feat: update image processor logic

This commit is contained in:
2025-07-08 20:53:13 +07:00
parent 355a3a0b11
commit a01daea8d5
2 changed files with 303 additions and 27 deletions
+6 -1
View File
@@ -4,7 +4,12 @@
"Bash(npx create-next-app:*)",
"Bash(npm install:*)",
"Bash(mkdir:*)",
"Bash(npm run build:*)"
"Bash(npm run build:*)",
"Bash(find:*)",
"Bash(npm run dev:*)",
"Bash(timeout 8 npm run dev)",
"Bash(node:*)",
"Bash(NODE_ENV=development node test-env.js)"
],
"deny": []
}
@@ -1,9 +1,35 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
interface OpenCVRect {
x: number;
y: number;
width: number;
height: number;
}
interface OpenCVScalar {
[key: number]: number;
}
interface OpenCVMat {
delete: () => void;
roi: (rect: OpenCVRect) => OpenCVMat;
setTo: (scalar: OpenCVScalar) => void;
rows: number;
cols: number;
size: () => number;
get: (index: number) => OpenCVMat;
}
interface Bubble {
x: number;
y: number;
width: number;
height: number;
area: number;
circularity: number;
}
declare global {
@@ -13,13 +39,23 @@ declare global {
cvtColor: (src: OpenCVMat, dst: OpenCVMat, code: number) => void;
adaptiveThreshold: (src: OpenCVMat, dst: OpenCVMat, maxValue: number, adaptiveMethod: number, thresholdType: number, blockSize: number, C: number) => void;
findContours: (image: OpenCVMat, contours: OpenCVMat, hierarchy: OpenCVMat, mode: number, method: number) => void;
Mat: new () => OpenCVMat;
contourArea: (contour: OpenCVMat) => number;
arcLength: (contour: OpenCVMat, closed: boolean) => number;
boundingRect: (contour: OpenCVMat) => { x: number; y: number; width: number; height: number };
mean: (src: OpenCVMat, mask?: OpenCVMat) => { [key: number]: number };
circle: (img: OpenCVMat, center: { x: number; y: number }, radius: number, color: number[], thickness: number) => void;
bitwise_and: (src1: OpenCVMat, src2: OpenCVMat, dst: OpenCVMat, mask?: OpenCVMat) => void;
Mat: new (rows?: number, cols?: number, type?: number) => OpenCVMat;
MatVector: new () => OpenCVMat;
Rect: new (x: number, y: number, width: number, height: number) => OpenCVRect;
Scalar: new (...values: number[]) => OpenCVScalar;
COLOR_RGBA2GRAY: number;
ADAPTIVE_THRESH_GAUSSIAN_C: number;
THRESH_BINARY: number;
THRESH_BINARY_INV: number;
RETR_EXTERNAL: number;
CHAIN_APPROX_SIMPLE: number;
CV_8UC1: number;
onRuntimeInitialized: () => void;
};
}
@@ -65,7 +101,7 @@ export default function ImageProcessor({ imageFile, onProcessingComplete }: Imag
loadOpenCV();
}, []);
const processImage = async () => {
const processImage = useCallback(async () => {
if (!cvLoaded || !window.cv) {
console.error('OpenCV not loaded');
return;
@@ -101,9 +137,46 @@ export default function ImageProcessor({ imageFile, onProcessingComplete }: Imag
console.error('Error processing image:', error);
setProcessing(false);
}
}, [cvLoaded, imageFile, onProcessingComplete]);
const generateDefaultAnswers = (): ProcessingResult => {
// Default answers for development mode
const defaultPhanI = [
'A', 'B', 'C', 'D', 'A', 'B', 'C', 'D', 'A', 'B', // Questions 1-10
'C', 'D', 'A', 'B', 'C', 'D', 'A', 'B', 'C', 'D', // Questions 11-20
'A', 'B', 'C', 'D', 'A', 'B', 'C', 'D', 'A', 'B', // Questions 21-30
'C', 'D', 'A', 'B', 'C', 'D', 'A', 'B', 'C', 'D' // Questions 31-40
];
const defaultPhanII = [
{ a: true, b: false, c: true, d: false }, // Question 1
{ a: false, b: true, c: false, d: true }, // Question 2
{ a: true, b: true, c: false, d: false }, // Question 3
{ a: false, b: false, c: true, d: true }, // Question 4
{ a: true, b: false, c: false, d: true }, // Question 5
{ a: false, b: true, c: true, d: false }, // Question 6
{ a: true, b: true, c: true, d: false }, // Question 7
{ a: false, b: false, c: false, d: true } // Question 8
];
const defaultPhanIII = ['7', '3', '9', '1', '5', '2']; // Questions 1-6
return {
studentId: '123456789',
phanI: defaultPhanI,
phanII: defaultPhanII,
phanIII: defaultPhanIII,
confidence: 0.95,
};
};
const processWithOpenCV = (imageData: ImageData): ProcessingResult => {
// In development mode, return default answers for faster testing
if (process.env.NODE_ENV === 'development') {
console.log('🚀 Development mode: Using default answers for faster testing');
return generateDefaultAnswers();
}
const cv = window.cv;
// Create OpenCV Mat from ImageData
@@ -113,21 +186,29 @@ export default function ImageProcessor({ imageFile, onProcessingComplete }: Imag
const gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
// Apply adaptive threshold
// Apply adaptive threshold for bubble detection
const thresh = new cv.Mat();
cv.adaptiveThreshold(gray, thresh, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY, 11, 2);
cv.adaptiveThreshold(gray, thresh, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY_INV, 11, 2);
// Find contours for bubble detection
const contours = new cv.MatVector();
const hierarchy = new cv.Mat();
cv.findContours(thresh, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
// Mock bubble detection and recognition
const mockResult = {
studentId: generateRandomStudentId(),
phanI: generateRandomAnswers(40, ['A', 'B', 'C', 'D']),
phanII: generateRandomPhanII(8),
phanIII: generateRandomNumbers(6),
// Detect bubbles
const bubbles = detectBubbles(contours);
// Process different sections
const studentId = detectStudentId(bubbles, gray);
const phanI = detectSection1Answers(bubbles, gray);
const phanII = detectSection2Answers(bubbles, gray);
const phanIII = detectSection3Answers(bubbles, gray);
const result = {
studentId,
phanI,
phanII,
phanIII,
confidence: 0.85,
};
@@ -138,35 +219,222 @@ export default function ImageProcessor({ imageFile, onProcessingComplete }: Imag
contours.delete();
hierarchy.delete();
return mockResult;
return result;
};
const generateRandomStudentId = () => {
return Math.floor(Math.random() * 1000000000).toString().padStart(9, '0');
const detectBubbles = (contours: OpenCVMat): Bubble[] => {
const cv = window.cv;
const bubbles: Bubble[] = [];
for (let i = 0; i < contours.size(); i++) {
const contour = contours.get(i);
const area = cv.contourArea(contour);
// Filter by area (bubble size)
if (area > 50 && area < 500) {
const perimeter = cv.arcLength(contour, true);
const circularity = perimeter > 0 ? (4 * Math.PI * area) / (perimeter * perimeter) : 0;
// Check if shape is reasonably circular
if (circularity > 0.4) {
const boundingRect = cv.boundingRect(contour);
const aspectRatio = boundingRect.width / boundingRect.height;
// Check aspect ratio
if (aspectRatio >= 0.5 && aspectRatio <= 2.0) {
bubbles.push({
x: boundingRect.x,
y: boundingRect.y,
width: boundingRect.width,
height: boundingRect.height,
area: area,
circularity: circularity
});
}
}
}
}
return bubbles;
};
const generateRandomAnswers = (count: number, options: string[]) => {
return Array.from({ length: count }, () => options[Math.floor(Math.random() * options.length)]);
const isBubbleFilled = (bubble: Bubble, gray: OpenCVMat): number => {
const cv = window.cv;
// Extract bubble region
const roi = gray.roi(new cv.Rect(bubble.x, bubble.y, bubble.width, bubble.height));
// Create circular mask
const mask = new cv.Mat(bubble.height, bubble.width, cv.CV_8UC1);
mask.setTo({ 0: 0 });
const center = { x: bubble.width / 2, y: bubble.height / 2 };
const radius = Math.min(bubble.width, bubble.height) / 3;
cv.circle(mask, center, radius, [255], -1);
// Calculate mean intensity in masked region
const meanValue = cv.mean(roi, mask);
const fillConfidence = 1.0 - (meanValue[0] / 255.0);
// Clean up
roi.delete();
mask.delete();
return fillConfidence;
};
const generateRandomPhanII = (count: number) => {
return Array.from({ length: count }, () => ({
a: Math.random() > 0.5,
b: Math.random() > 0.5,
c: Math.random() > 0.5,
d: Math.random() > 0.5,
}));
const groupBubblesByRows = (bubbles: Bubble[], tolerance: number = 20): Bubble[][] => {
if (!bubbles.length) return [];
const sortedBubbles = bubbles.sort((a, b) => a.y - b.y);
const rows: Bubble[][] = [];
let currentRow: Bubble[] = [sortedBubbles[0]];
for (let i = 1; i < sortedBubbles.length; i++) {
if (Math.abs(sortedBubbles[i].y - currentRow[0].y) <= tolerance) {
currentRow.push(sortedBubbles[i]);
} else {
currentRow.sort((a, b) => a.x - b.x);
rows.push(currentRow);
currentRow = [sortedBubbles[i]];
}
}
if (currentRow.length) {
currentRow.sort((a, b) => a.x - b.x);
rows.push(currentRow);
}
return rows;
};
const generateRandomNumbers = (count: number) => {
return Array.from({ length: count }, () => (Math.random() * 100).toFixed(1));
const detectStudentId = (bubbles: Bubble[], gray: OpenCVMat): string => {
// Filter bubbles in student ID area (top section)
const idBubbles = bubbles.filter(b => b.y >= 80 && b.y <= 340);
// Group by columns for ID detection
const sortedBubbles = idBubbles.sort((a, b) => a.x - b.x);
const columns: Bubble[][] = [];
let currentCol: Bubble[] = [];
const colThreshold = 30;
for (const bubble of sortedBubbles) {
if (!currentCol.length || Math.abs(bubble.x - currentCol[0].x) <= colThreshold) {
currentCol.push(bubble);
} else {
currentCol.sort((a, b) => a.y - b.y);
columns.push(currentCol);
currentCol = [bubble];
}
}
if (currentCol.length) {
currentCol.sort((a, b) => a.y - b.y);
columns.push(currentCol);
}
// Extract digits from each column
let studentId = '';
for (const col of columns) {
for (let i = 0; i < col.length; i++) {
const confidence = isBubbleFilled(col[i], gray);
if (confidence > 0.6) {
studentId += i.toString();
break;
}
}
}
return studentId || 'UNKNOWN';
};
const detectSection1Answers = (bubbles: Bubble[], gray: OpenCVMat): string[] => {
// Filter bubbles for Section 1 (Multiple choice A,B,C,D)
const section1Bubbles = bubbles.filter(b => b.y >= 370 && b.y <= 600);
const rows = groupBubblesByRows(section1Bubbles);
const answers: string[] = [];
for (const row of rows) {
if (row.length >= 4) {
const options = row.slice(0, 4);
let bestConfidence = 0;
let bestAnswer = '';
for (let i = 0; i < options.length; i++) {
const confidence = isBubbleFilled(options[i], gray);
if (confidence > bestConfidence && confidence > 0.6) {
bestConfidence = confidence;
bestAnswer = String.fromCharCode(65 + i); // A, B, C, D
}
}
answers.push(bestAnswer);
}
}
return answers;
};
const detectSection2Answers = (bubbles: Bubble[], gray: OpenCVMat): Array<{ a: boolean; b: boolean; c: boolean; d: boolean }> => {
// Filter bubbles for Section 2 (True/False with sub-options)
const section2Bubbles = bubbles.filter(b => b.y >= 620 && b.y <= 760);
const rows = groupBubblesByRows(section2Bubbles);
const answers: Array<{ a: boolean; b: boolean; c: boolean; d: boolean }> = [];
for (const row of rows) {
if (row.length >= 4) {
const options = row.slice(0, 4);
const answer = { a: false, b: false, c: false, d: false };
for (let i = 0; i < options.length; i++) {
const confidence = isBubbleFilled(options[i], gray);
if (confidence > 0.6) {
const key = String.fromCharCode(97 + i) as 'a' | 'b' | 'c' | 'd'; // a, b, c, d
answer[key] = true;
}
}
answers.push(answer);
}
}
return answers;
};
const detectSection3Answers = (bubbles: Bubble[], gray: OpenCVMat): string[] => {
// Filter bubbles for Section 3 (Numerical 0-9)
const section3Bubbles = bubbles.filter(b => b.y >= 780 && b.y <= 1070);
const rows = groupBubblesByRows(section3Bubbles);
const answers: string[] = [];
for (const row of rows) {
if (row.length >= 10) {
const options = row.slice(0, 10);
let bestConfidence = 0;
let bestAnswer = '';
for (let i = 0; i < options.length; i++) {
const confidence = isBubbleFilled(options[i], gray);
if (confidence > bestConfidence && confidence > 0.6) {
bestConfidence = confidence;
bestAnswer = i.toString();
}
}
answers.push(bestAnswer);
}
}
return answers;
};
useEffect(() => {
if (cvLoaded && imageFile) {
processImage();
}
}, [cvLoaded, imageFile]);
}, [cvLoaded, imageFile, processImage]);
return (
<div className="flex items-center justify-center p-4">
@@ -181,6 +449,9 @@ export default function ImageProcessor({ imageFile, onProcessingComplete }: Imag
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mx-auto mb-2"></div>
<p className="text-sm text-gray-600">Đang xử nh...</p>
{process.env.NODE_ENV === 'development' && (
<p className="text-xs text-blue-500 mt-1">🚀 Dev mode: Using default answers</p>
)}
</div>
)}
</div>