omr_compVis / omr_processor.py
Carlos280906's picture
finalisasi 2
0a8c823
Raw
History Blame Contribute Delete
29.2 kB
import cv2
import numpy as np
from dataclasses import dataclass, field
from typing import Optional
import base64
from itertools import combinations
@dataclass
class BubbleResult:
question_number: int
detected_answer: Optional[str]
correct_answer: Optional[str]
is_correct: bool
confidence: float
@dataclass
class OMRResult:
total_questions: int
correct_count: int
wrong_count: int
empty_count: int
score: float
percentage: float
details: list = field(default_factory=list)
processed_image_b64: Optional[str] = None
error: Optional[str] = None
class OMRProcessor:
CHOICE_LABELS = ['A', 'B', 'C', 'D', 'E']
def __init__(
self,
num_questions: int = 50,
num_choices: int = 5,
num_column_blocks: int = 5,
bubble_fill_threshold: float = 0.38,
min_bubble_size_ratio: float = 0.015,
max_bubble_size_ratio: float = 0.12,
debug: bool = False,
min_mark_density: float = 0.35,
min_mark_coverage_ratio: float = 0.45
):
self.num_questions = num_questions
self.num_choices = num_choices
self.num_column_blocks = num_column_blocks
self.choice_labels = self.CHOICE_LABELS[:num_choices]
self.bubble_fill_threshold = bubble_fill_threshold
self.min_bubble_size_ratio = min_bubble_size_ratio
self.max_bubble_size_ratio = max_bubble_size_ratio
self.debug = debug
self.questions_per_block = num_questions // num_column_blocks
self.min_mark_density = min_mark_density
self.min_mark_coverage_ratio = min_mark_coverage_ratio
def process(
self,
image_input,
answer_key: dict,
return_preview: bool = True
) -> OMRResult:
try:
image = self._load_image(image_input)
image = self._deskew(image)
gray, blurred, thresh = self._preprocess(image)
warped, warped_thresh = self._detect_answer_sheet(image, gray, thresh)
detected_answers, annotated = self._detect_bubbles(
warped, warped_thresh, answer_key, return_preview
)
result = self._calculate_score(detected_answers, answer_key)
if return_preview and annotated is not None:
result.processed_image_b64 = self._encode_image(annotated)
return result
except SheetNotFoundError as e:
return OMRResult(
total_questions=self.num_questions,
correct_count=0, wrong_count=0, empty_count=self.num_questions,
score=0.0, percentage=0.0,
error=f"Lembar jawaban tidak terdeteksi: {str(e)}"
)
except Exception as e:
import traceback
return OMRResult(
total_questions=self.num_questions,
correct_count=0, wrong_count=0, empty_count=self.num_questions,
score=0.0, percentage=0.0,
error=f"Error: {str(e)}\n{traceback.format_exc()}"
)
def process_answer_key_image(self, image_input) -> dict:
try:
image = self._load_image(image_input)
image = self._deskew(image)
gray, blurred, thresh = self._preprocess(image)
warped, warped_thresh = self._detect_answer_sheet(image, gray, thresh)
answer_key, _ = self._detect_bubbles(warped, warped_thresh, {}, False)
return answer_key
except Exception as e:
raise ValueError(f"Gagal membaca kunci jawaban: {str(e)}")
def _load_image(self, image_input) -> np.ndarray:
if isinstance(image_input, np.ndarray):
return image_input
elif isinstance(image_input, (bytes, bytearray)):
arr = np.frombuffer(image_input, np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("Tidak dapat membaca image bytes.")
return img
elif isinstance(image_input, str):
img = cv2.imread(image_input)
if img is None:
raise ValueError(f"File tidak ditemukan: {image_input}")
return img
else:
raise TypeError(f"Tipe input tidak didukung: {type(image_input)}")
def _deskew(self, image: np.ndarray) -> np.ndarray:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
angle = self._estimate_angle_hough(blurred)
if angle is None:
angle = self._estimate_angle_contour(blurred)
if angle is None or abs(angle) < 0.5 or abs(angle) > 20.0:
if self.debug and angle is not None:
print(f"[DESKEW] Sudut diabaikan: {angle:.2f}°")
return image
if self.debug:
print(f"[DESKEW] Koreksi rotasi: {angle:.2f}°")
h, w = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
corrected = cv2.warpAffine(
image, M, (w, h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE
)
return corrected
def _estimate_angle_hough(self, gray_blurred: np.ndarray):
edges = cv2.Canny(gray_blurred, 50, 150, apertureSize=3)
h, w = gray_blurred.shape[:2]
min_line_len = int(w * 0.25)
lines = cv2.HoughLinesP(
edges, 1, np.pi / 180,
threshold=80,
minLineLength=min_line_len,
maxLineGap=20
)
if lines is None:
return None
angles = []
for line in lines:
x1, y1, x2, y2 = line[0]
dx = x2 - x1
dy = y2 - y1
if dx == 0:
continue
angle = np.degrees(np.arctan2(dy, dx))
if abs(angle) <= 20.0:
angles.append(angle)
if len(angles) < 5:
return None
return float(np.median(angles))
def _estimate_angle_contour(self, gray_blurred: np.ndarray):
_, thresh = cv2.threshold(
gray_blurred, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
)
contours, _ = cv2.findContours(
thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
h, w = gray_blurred.shape[:2]
img_area = h * w
angles = []
for c in contours:
area = cv2.contourArea(c)
if area < img_area * 0.005:
continue
rect = cv2.minAreaRect(c)
angle = rect[2]
if angle < -45:
angle += 90
if abs(angle) <= 20.0:
angles.append(angle)
if len(angles) < 3:
return None
return float(np.median(angles))
def _preprocess(self, image: np.ndarray):
h, w = image.shape[:2]
if w > 1600:
scale = 1600 / w
image = cv2.resize(image, (1600, int(h * scale)))
elif w < 800:
scale = 800 / w
image = cv2.resize(image, (800, int(h * scale)))
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
thresh = cv2.adaptiveThreshold(
blurred, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,
blockSize=11,
C=2
)
return gray, blurred, thresh
def _detect_answer_sheet(self, image, gray, thresh):
contours, _ = cv2.findContours(
thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
contours = sorted(contours, key=cv2.contourArea, reverse=True)
sheet_contour = None
for c in contours[:10]:
peri = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
if len(approx) == 4:
area = cv2.contourArea(c)
img_area = image.shape[0] * image.shape[1]
if area > img_area * 0.15:
sheet_contour = approx
break
if sheet_contour is None:
h, w = image.shape[:2]
sheet_contour = np.array([
[[0, 0]], [[w - 1, 0]], [[w - 1, h - 1]], [[0, h - 1]]
], dtype=np.int32)
warped = self._four_point_transform(image, sheet_contour.reshape(4, 2))
warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
_, warped_thresh = cv2.threshold(
warped_gray, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
)
return warped, warped_thresh
def _four_point_transform(self, image, pts):
rect = self._order_points(pts)
(tl, tr, br, bl) = rect
widthA = np.linalg.norm(br - bl)
widthB = np.linalg.norm(tr - tl)
maxWidth = max(int(widthA), int(widthB))
heightA = np.linalg.norm(tr - br)
heightB = np.linalg.norm(tl - bl)
maxHeight = max(int(heightA), int(heightB))
dst = np.array([
[0, 0], [maxWidth - 1, 0],
[maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]
], dtype=np.float32)
M = cv2.getPerspectiveTransform(rect, dst)
return cv2.warpPerspective(image, M, (maxWidth, maxHeight))
def _order_points(self, pts):
rect = np.zeros((4, 2), dtype=np.float32)
s = pts.sum(axis=1)
rect[0] = pts[np.argmin(s)]
rect[2] = pts[np.argmax(s)]
diff = np.diff(pts, axis=1)
rect[1] = pts[np.argmin(diff)]
rect[3] = pts[np.argmax(diff)]
return rect
def _detect_bubbles(self, warped, warped_thresh, answer_key, annotate):
h, w = warped.shape[:2]
min_dim = min(w, h)
contours, _ = cv2.findContours(
warped_thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
bubble_contours = []
for c in contours:
(x, y, bw, bh) = cv2.boundingRect(c)
area = cv2.contourArea(c)
perimeter = cv2.arcLength(c, True)
if area <= 0 or perimeter <= 0:
continue
ar = bw / float(bh)
min_b = min(bw, bh)
extent = area / float(bw * bh)
circularity = (4.0 * np.pi * area) / (perimeter * perimeter)
if (0.70 <= ar <= 1.30) and \
(min_dim * self.min_bubble_size_ratio <= min_b <= min_dim * self.max_bubble_size_ratio) and \
(0.45 <= circularity <= 1.25) and \
(extent >= 0.40):
cx_center = x + bw // 2
cy_center = y + bh // 2
bubble_contours.append((cx_center, cy_center, bw, bh))
bubble_contours = self._filter_by_dominant_bubble_size(bubble_contours)
if self.debug:
print(f"[DEBUG] Kandidat bubble: {len(bubble_contours)}")
row_tolerance = max(h * 0.03, 25)
rows = self._cluster_bubbles_by_y(bubble_contours, row_tolerance)
if self.debug:
for i, row in enumerate(rows):
ys = [b[1] for b in row]
print(f"[DEBUG] Row {i+1}: {len(row)} bubbles, Y~{int(np.mean(ys))}")
min_bubbles_per_row = int(self.num_choices * self.num_column_blocks * 0.8)
question_rows = [r for r in rows if len(r) >= min_bubbles_per_row]
if self.debug:
print(f"[DEBUG] Question rows (setelah filter header): {len(question_rows)}")
if len(question_rows) < self.questions_per_block:
if self.debug:
print("[DEBUG] Fallback ke grid-based detection")
return self._grid_based_detection(warped, warped_thresh, answer_key, annotate)
question_rows = question_rows[:self.questions_per_block]
all_cx = sorted([b[0] for row in question_rows for b in row])
col_block_boundaries = self._find_column_boundaries(all_cx, self.num_column_blocks)
if self.debug:
print(f"[DEBUG] Column boundaries: {col_block_boundaries}")
detected_answers = {}
annotated = warped.copy() if annotate else None
for row_i, row in enumerate(question_rows):
row_sorted = sorted(row, key=lambda b: b[0])
col_groups = [[] for _ in range(self.num_column_blocks)]
for b in row_sorted:
for si in range(self.num_column_blocks):
if col_block_boundaries[si] <= b[0] < col_block_boundaries[si + 1]:
col_groups[si].append(b)
break
for sec_i, sec_bubbles in enumerate(col_groups):
q_num = sec_i * self.questions_per_block + row_i + 1
if q_num > self.num_questions:
continue
sec_sorted = self._select_choice_bubbles(sec_bubbles)
if len(sec_sorted) < self.num_choices:
if self.debug:
print(f"[DEBUG] Q{q_num}: hanya {len(sec_sorted)} bubble terdeteksi")
detected_answers[q_num] = None
continue
fill_scores = []
fill_densities = []
coverage_ratios = []
for b in sec_sorted:
cx, cy, bw, bh = b
score, density, coverage_ratio = self._measure_bubble_fill(
warped_thresh, cx, cy, bw, bh
)
fill_scores.append(score)
fill_densities.append(density)
coverage_ratios.append(coverage_ratio)
max_val = max(fill_scores)
max_idx = fill_scores.index(max_val)
other_vals = [v for i, v in enumerate(fill_scores) if i != max_idx]
other_avg = float(np.mean(other_vals)) if other_vals else 0.0
fill_ratio = max_val / (max_val + other_avg + 1e-6)
if (
fill_ratio >= self.bubble_fill_threshold and
fill_densities[max_idx] >= self.min_mark_density and
coverage_ratios[max_idx] >= self.min_mark_coverage_ratio
):
chosen = self.choice_labels[max_idx]
confidence = float(fill_ratio)
else:
chosen = None
confidence = 0.0
detected_answers[q_num] = chosen
if annotate and annotated is not None:
correct_ans = answer_key.get(q_num)
for i, b in enumerate(sec_sorted):
cx, cy, bw, bh = b
radius = min(bw, bh) // 2 + 3
if i == max_idx and chosen is not None:
if correct_ans and chosen == correct_ans:
color = (0, 200, 0)
elif correct_ans:
color = (0, 0, 220)
else:
color = (200, 160, 0)
cv2.circle(annotated, (cx, cy), radius, color, 2)
if sec_sorted:
x0 = sec_sorted[0][0]
y0 = sec_sorted[0][1]
cv2.putText(
annotated, str(q_num),
(max(0, x0 - 30), y0 + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.32, (80, 80, 80), 1
)
return detected_answers, annotated
def _filter_by_dominant_bubble_size(self, bubbles):
"""Keep contours close to the dominant printed bubble size."""
if len(bubbles) < self.num_choices:
return bubbles
sizes = np.array([min(b[2], b[3]) for b in bubbles], dtype=np.float32)
median_size = float(np.median(sizes))
tolerance = max(4.0, median_size * 0.35)
filtered = [
b for b in bubbles
if abs(min(b[2], b[3]) - median_size) <= tolerance
]
return filtered if len(filtered) >= self.num_choices else bubbles
def _select_choice_bubbles(self, sec_bubbles):
candidates = sorted(sec_bubbles, key=lambda b: b[0])
if len(candidates) <= self.num_choices:
return candidates if len(candidates) == self.num_choices else []
index_groups = (
combinations(range(len(candidates)), self.num_choices)
if len(candidates) <= 12
else (range(i, i + self.num_choices)
for i in range(0, len(candidates) - self.num_choices + 1))
)
best_group = None
best_score = None
all_x_span = candidates[-1][0] - candidates[0][0] + 1e-6
for indexes in index_groups:
group = [candidates[i] for i in indexes]
xs = np.array([b[0] for b in group], dtype=np.float32)
ys = np.array([b[1] for b in group], dtype=np.float32)
sizes = np.array([min(b[2], b[3]) for b in group], dtype=np.float32)
gaps = np.diff(xs)
if len(gaps) == 0 or np.any(gaps <= 0):
continue
mean_gap = float(np.mean(gaps))
median_size = float(np.median(sizes))
if mean_gap < median_size * 1.05:
continue
gap_cv = float(np.std(gaps) / (mean_gap + 1e-6))
size_cv = float(np.std(sizes) / (float(np.mean(sizes)) + 1e-6))
y_span = float((np.max(ys) - np.min(ys)) / (median_size + 1e-6))
if gap_cv > 0.55 or size_cv > 0.40 or y_span > 0.90:
continue
left_skip = float((group[0][0] - candidates[0][0]) / all_x_span)
score = gap_cv * 4.0 + size_cv * 1.5 + y_span + max(0.0, 0.08 - left_skip)
if best_score is None or score < best_score:
best_score = score
best_group = group
if best_group is not None:
return sorted(best_group, key=lambda b: b[0])
return candidates[-self.num_choices:]
def _measure_bubble_fill(self, thresh_img, cx, cy, bw, bh):
h, w = thresh_img.shape[:2]
outer_radius = max(1, min(bw, bh) // 2)
inner_radius = max(1, int(outer_radius * 0.72))
x1 = max(0, cx - inner_radius)
x2 = min(w, cx + inner_radius + 1)
y1 = max(0, cy - inner_radius)
y2 = min(h, cy + inner_radius + 1)
roi = thresh_img[y1:y2, x1:x2]
if roi.size == 0:
return 0.0, 0.0, 0.0
mask = np.zeros(roi.shape[:2], dtype=np.uint8)
cv2.circle(mask, (cx - x1, cy - y1), inner_radius, 255, -1)
mask_area = cv2.countNonZero(mask)
if mask_area == 0:
return 0.0, 0.0, 0.0
marked = cv2.bitwise_and(roi, roi, mask=mask)
density = cv2.countNonZero(marked) / float(mask_area)
grid_size = 5
solid_cells = 0
valid_cells = 0
min_cell_area = max(3, mask_area / float(grid_size * grid_size) * 0.35)
for gy in range(grid_size):
y_start = int(round(gy * roi.shape[0] / grid_size))
y_end = int(round((gy + 1) * roi.shape[0] / grid_size))
for gx in range(grid_size):
x_start = int(round(gx * roi.shape[1] / grid_size))
x_end = int(round((gx + 1) * roi.shape[1] / grid_size))
cell_mask = mask[y_start:y_end, x_start:x_end]
cell_marked = marked[y_start:y_end, x_start:x_end]
cell_area = cv2.countNonZero(cell_mask)
if cell_area < min_cell_area:
continue
valid_cells += 1
cell_density = cv2.countNonZero(cell_marked) / float(cell_area)
if cell_density >= 0.35:
solid_cells += 1
coverage_ratio = solid_cells / float(valid_cells) if valid_cells else 0.0
score = (density * 0.55) + (coverage_ratio * 0.45)
return float(score), float(density), float(coverage_ratio)
def _cluster_bubbles_by_y(self, bubbles, tolerance):
if not bubbles:
return []
xs = np.array([b[0] for b in bubbles], dtype=np.float64)
ys = np.array([b[1] for b in bubbles], dtype=np.float64)
slope = 0.0
if len(bubbles) >= 10:
x_mean = float(np.mean(xs))
y_mean = float(np.mean(ys))
denom = float(np.sum((xs - x_mean) ** 2))
if denom > 1e-6:
slope = float(np.sum((xs - x_mean) * (ys - y_mean)) / denom)
max_slope = np.tan(np.radians(20))
slope = max(-max_slope, min(max_slope, slope))
if self.debug and abs(slope) > 0.005:
angle_deg = np.degrees(np.arctan(slope))
print(f"[CLUSTER] Estimasi kemiringan baris: {angle_deg:.2f}°")
x_ref = float(np.mean(xs))
projected_ys = ys - slope * (xs - x_ref)
order = np.argsort(projected_ys)
bubbles_sorted = [bubbles[i] for i in order]
proj_sorted = projected_ys[order]
rows = []
current = [bubbles_sorted[0]]
current_proj = [proj_sorted[0]]
for idx in range(1, len(bubbles_sorted)):
b = bubbles_sorted[idx]
py = proj_sorted[idx]
avg_proj = float(np.mean(current_proj))
if abs(py - avg_proj) <= tolerance:
current.append(b)
current_proj.append(py)
else:
rows.append(current)
current = [b]
current_proj = [py]
rows.append(current)
return rows
def _find_column_boundaries(self, all_cx_sorted, num_blocks):
if len(all_cx_sorted) < 2:
w_approx = all_cx_sorted[-1] * 2 if all_cx_sorted else 1000
step = w_approx // num_blocks
return [i * step for i in range(num_blocks + 1)]
col_group_centers = []
cur_group = [all_cx_sorted[0]]
for x in all_cx_sorted[1:]:
if x - cur_group[-1] <= 35:
cur_group.append(x)
else:
col_group_centers.append(int(np.mean(cur_group)))
cur_group = [x]
col_group_centers.append(int(np.mean(cur_group)))
if len(col_group_centers) < 2:
step = (all_cx_sorted[-1] - all_cx_sorted[0]) // num_blocks
start = all_cx_sorted[0]
return [0] + [start + i * step for i in range(1, num_blocks)] + [all_cx_sorted[-1] + 100]
gaps = []
for i in range(1, len(col_group_centers)):
gap = col_group_centers[i] - col_group_centers[i - 1]
gaps.append((gap, col_group_centers[i - 1], col_group_centers[i]))
gaps_sorted = sorted(gaps, reverse=True)
separator_xs = sorted([
g[1] + (g[2] - g[1]) // 2
for g in gaps_sorted[:num_blocks - 1]
])
boundaries = [0] + separator_xs + [all_cx_sorted[-1] + 100]
return boundaries
def _grid_based_detection(self, warped, warped_thresh, answer_key, annotate):
h, w = warped.shape[:2]
margin_top = int(h * 0.12)
margin_bottom = int(h * 0.05)
margin_left = int(w * 0.05)
margin_right = int(w * 0.05)
grid_h = h - margin_top - margin_bottom
grid_w = w - margin_left - margin_right
block_w = grid_w / self.num_column_blocks
row_step = grid_h / self.questions_per_block
col_step = block_w / self.num_choices
bubble_r = int(min(row_step, col_step) * 0.35)
detected_answers = {}
annotated = warped.copy() if annotate else None
for q in range(self.num_questions):
question_num = q + 1
sec_i = (q) // self.questions_per_block
row_i = (q) % self.questions_per_block
cy_center = int(margin_top + (row_i + 0.5) * row_step)
block_start_x = margin_left + sec_i * int(block_w)
intensities = []
fill_densities = []
coverage_ratios = []
for c in range(self.num_choices):
cx_center = int(block_start_x + (c + 0.5) * col_step)
score, density, coverage_ratio = self._measure_bubble_fill(
warped_thresh,
cx_center,
cy_center,
bubble_r * 2,
bubble_r * 2
)
intensities.append(score)
fill_densities.append(density)
coverage_ratios.append(coverage_ratio)
if max(intensities) <= 0:
detected_answers[question_num] = None
continue
max_idx = intensities.index(max(intensities))
other_avg = np.mean([v for i, v in enumerate(intensities) if i != max_idx]) \
if len(intensities) > 1 else 0
fill_ratio = intensities[max_idx] / (intensities[max_idx] + other_avg + 1e-6)
detected_answers[question_num] = (
self.choice_labels[max_idx]
if (
fill_ratio >= self.bubble_fill_threshold and
fill_densities[max_idx] >= self.min_mark_density and
coverage_ratios[max_idx] >= self.min_mark_coverage_ratio
)
else None
)
return detected_answers, annotated
def _calculate_score(self, detected: dict, answer_key: dict) -> OMRResult:
correct = 0
wrong = 0
empty = 0
details = []
for q_num in range(1, self.num_questions + 1):
detected_ans = detected.get(q_num)
correct_ans = answer_key.get(q_num)
if detected_ans is None:
empty += 1
is_correct = False
confidence = 0.0
elif correct_ans is None:
wrong += 1
is_correct = False
confidence = 1.0
elif detected_ans == correct_ans:
correct += 1
is_correct = True
confidence = 1.0
else:
wrong += 1
is_correct = False
confidence = 1.0
details.append(BubbleResult(
question_number=q_num,
detected_answer=detected_ans,
correct_answer=correct_ans,
is_correct=is_correct,
confidence=confidence
))
total_with_key = sum(1 for q in range(1, self.num_questions + 1) if answer_key.get(q))
if total_with_key == 0:
total_with_key = self.num_questions
score = (correct / total_with_key) * 100
return OMRResult(
total_questions=self.num_questions,
correct_count=correct,
wrong_count=wrong,
empty_count=empty,
score=round(score, 2),
percentage=round(score, 2),
details=details
)
def _encode_image(self, image: np.ndarray) -> str:
_, buffer = cv2.imencode('.png', image)
return base64.b64encode(buffer).decode('utf-8')
class SheetNotFoundError(Exception):
pass
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python omr_processor.py <image_path> [answer_key_image_path]")
print(" python omr_processor.py <image_path> --key A,B,C,D,...")
sys.exit(1)
processor = OMRProcessor(
num_questions=50,
num_choices=5,
num_column_blocks=5,
bubble_fill_threshold=0.38,
debug=True
)
image_path = sys.argv[1]
answer_key = {}
if len(sys.argv) >= 4 and sys.argv[2] == "--key":
labels_str = sys.argv[3].upper().split(',')
for i, label in enumerate(labels_str):
label = label.strip()
if label:
answer_key[i + 1] = label
print(f"Kunci jawaban dari argumen: {answer_key}")
elif len(sys.argv) >= 3 and sys.argv[2] != "--key":
key_image_path = sys.argv[2]
print(f"Membaca kunci jawaban dari: {key_image_path}")
answer_key = processor.process_answer_key_image(key_image_path)
print(f"Kunci jawaban terdeteksi: {answer_key}")
print(f"\nMemproses: {image_path}")
result = processor.process(image_path, answer_key, return_preview=False)
if result.error:
print(f"\nERROR: {result.error}")
else:
print(f"\n{'='*40}")
print(f"Total Soal : {result.total_questions}")
print(f"Benar : {result.correct_count}")
print(f"Salah : {result.wrong_count}")
print(f"Kosong : {result.empty_count}")
print(f"Skor : {result.score:.2f}")
print(f"{'='*40}")
print("\nDetail per soal:")
for d in result.details:
status = "✓" if d.is_correct else ("○" if d.detected_answer is None else "✗")
print(f" Q{d.question_number:2d}: Jawab={d.detected_answer or '-':>1} "
f"Kunci={d.correct_answer or '-':>1} {status}")