felixkky commited on
Commit
511f7f8
·
verified ·
1 Parent(s): e97977b

Upload 13 files

Browse files
.gitattributes CHANGED
@@ -36,3 +36,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
36
  template.jpg filter=lfs diff=lfs merge=lfs -text
37
  template.png filter=lfs diff=lfs merge=lfs -text
38
  template[[:space:]](8).jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
36
  template.jpg filter=lfs diff=lfs merge=lfs -text
37
  template.png filter=lfs diff=lfs merge=lfs -text
38
  template[[:space:]](8).jpg filter=lfs diff=lfs merge=lfs -text
39
+ docs/template_coordinate_overlay.jpg filter=lfs diff=lfs merge=lfs -text
40
+ template_A4_print.pdf filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ OMRChecker/
2
+ generated/
3
+ mock_libs/
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ .DS_Store
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: OMRChecker API
3
  emoji: 📊
4
  colorFrom: blue
5
  colorTo: indigo
@@ -10,10 +10,51 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- # OMRChecker API
14
 
15
- DSE OMR recognition backend for the HKDSE Biology assessment system.
16
 
17
- The Space lazily downloads the OMRChecker source on the first scan. Python packages are installed during the Hugging Face build from `requirements.txt`; no runtime `pip install` is performed.
 
 
 
 
18
 
19
- The Gradio/FastAPI/Starlette versions are pinned because Gradio 4.44.1 still uses Starlette's legacy `TemplateResponse(name, context)` call form.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DSE OMR Mobile Scanner
3
  emoji: 📊
4
  colorFrom: blue
5
  colorTo: indigo
 
10
  pinned: false
11
  ---
12
 
13
+ # DSE OMR 手機相片辨識系統
14
 
15
+ 這個 Hugging Face Space 專案把以下流程整合在同一個 `app.py`:
16
 
17
+ 1. 上傳學生以手機拍攝的答題紙。
18
+ 2. 嘗試偵測答案區四個大型黑白定位標記。
19
+ 3. 將相片透視校正至 `3093 × 4374`。
20
+ 4. 交由 OMRChecker 執行三輪靈敏度掃描。
21
+ 5. 輸出融合答案 CSV、校正圖片、OMR 劃記圖及執行紀錄。
22
 
23
+ ## 上傳到 Hugging Face
24
+
25
+ 1. 在電腦解壓本 ZIP。
26
+ 2. 建立一個新的 **Gradio Space**。
27
+ 3. 把解壓後的全部檔案及資料夾一次拖入 Space 根目錄。
28
+ 4. 等待 Build 完成。
29
+ 5. 第一次辨識時,系統會下載 OMRChecker 原始碼,因此會比之後稍慢。
30
+
31
+ > Hugging Face 不會自動執行 ZIP 內的專案。請先解壓,然後一次上傳解壓後的內容。
32
+
33
+ ## 根目錄必要檔案
34
+
35
+ - `app.py`
36
+ - `requirements.txt`
37
+ - `packages.txt`
38
+ - `README.md`
39
+ - `template.jpg`
40
+ - `template.json`
41
+ - `marker_config.json`
42
+
43
+ ## 其他檔案
44
+
45
+ - `template_A4_print.pdf`:供列印的答題紙。
46
+ - `tools/add_markers_and_warp_colab.py`:Colab 版本的標記與透視校正工具。
47
+ - `docs/template_coordinate_overlay.jpg`:答案座標核對圖。
48
+ - `validate_project.py`:部署前檢查必要檔案、圖片尺寸及 JSON。
49
+
50
+ ## 拍攝要求
51
+
52
+ - 四個大型定位標記必須完整入鏡。
53
+ - 保持直向拍攝。
54
+ - 避免強烈反光、陰影及模糊。
55
+ - 紙張盡量攤平。
56
+ - 低信心或分歧答案仍應由學生核對。
57
+
58
+ ## 回退機制
59
+
60
+ 若四角定位偵測失敗,系統不會立即終止,而會回退至 OMRChecker 的 `FeatureBasedAlignment` 流程,並在執行摘要顯示原因。
app.py CHANGED
@@ -18,6 +18,7 @@ from typing import Any
18
  import cv2
19
  import gradio as gr
20
  import pandas as pd
 
21
  import fastapi
22
  import starlette
23
 
@@ -66,6 +67,7 @@ ROOT_DIR = Path(__file__).resolve().parent
66
  OMR_DIR = ROOT_DIR / "OMRChecker"
67
  MOCK_DIR = ROOT_DIR / "mock_libs"
68
  GENERATED_DIR = ROOT_DIR / "generated"
 
69
 
70
  OMR_REPO_URL = "https://github.com/Udayraj123/OMRChecker.git"
71
  OMR_REPO_BRANCH = "master"
@@ -245,6 +247,251 @@ def _load_template_content(template_content: str | None) -> tuple[dict[str, Any]
245
  raise ValueError(f"{template_path.name} JSON 格式不正確:{error}") from error
246
 
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  def _expand_question_label(label: Any) -> list[int]:
249
  text = str(label).strip()
250
 
@@ -462,9 +709,10 @@ def _format_answer_summary(final_answers: dict[int, str]) -> str:
462
  return "\n".join(lines)
463
 
464
 
 
465
  def process_omr(image_file: str | None, template_content: str | None = None):
466
  if image_file is None:
467
- return None, None, "錯誤:請先上傳答案卡圖片。"
468
 
469
  with PROCESS_LOCK:
470
  request_root: Path | None = None
@@ -478,9 +726,7 @@ def process_omr(image_file: str | None, template_content: str | None = None):
478
  raise ValueError("OpenCV 無法讀取上傳圖片。")
479
 
480
  image_height, image_width = image.shape[:2]
481
- dimension_info = (
482
- f"【圖片載入成功】解析度:{image_width} × {image_height} px"
483
- )
484
 
485
  base_template, _ = _load_template_content(template_content)
486
  question_numbers = _question_numbers_from_template(base_template)
@@ -493,13 +739,55 @@ def process_omr(image_file: str | None, template_content: str | None = None):
493
  result_dir = GENERATED_DIR / request_id
494
  result_dir.mkdir(parents=True, exist_ok=True)
495
 
496
- scans: list[dict[str, Any]] = []
497
  logs = [
498
  dimension_info,
499
  f"【Template】題號範圍:q{question_numbers[0]}–q{question_numbers[-1]},共 {question_count} 題",
500
- "--- 開始三輪多重採樣 ---",
501
  ]
502
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  for index, profile in enumerate(SENSITIVITY_PROFILES, start=1):
504
  profile_dir = request_root / f"profile_{index}"
505
  images_dir = profile_dir / "images"
@@ -515,8 +803,7 @@ def process_omr(image_file: str | None, template_content: str | None = None):
515
  )
516
  _write_headless_config(profile_dir)
517
  _copy_alignment_assets(current_template, profile_dir)
518
-
519
- shutil.copy2(image_file, images_dir / "sheet.jpg")
520
 
521
  environment = os.environ.copy()
522
  environment["PYTHONPATH"] = (
@@ -595,17 +882,13 @@ def process_omr(image_file: str | None, template_content: str | None = None):
595
  final_answers[number] = final_answer
596
 
597
  if len(vote_counts) > 1:
598
- logs.append(
599
- f"q{number} 分歧:{dict(vote_counts)} → {final_answer}"
600
- )
601
 
602
  final_valid_count = sum(answer != "—" for answer in final_answers.values())
603
  logs.append(
604
  f"融合後辨識:{final_valid_count}/{question_count} 題;空白 {question_count - final_valid_count} 題。"
605
  )
606
 
607
- # Return a CSV that matches the fused answers, rather than a raw
608
- # CSV from only one sensitivity profile.
609
  fused_csv = result_dir / "omr_fused_answers.csv"
610
  pd.DataFrame(
611
  [{f"q{number}": final_answers[number] for number in question_numbers}]
@@ -615,9 +898,7 @@ def process_omr(image_file: str | None, template_content: str | None = None):
615
  checked_image: Path | None = None
616
  if best_scan["image"] and Path(best_scan["image"]).is_file():
617
  source_image = Path(best_scan["image"])
618
- checked_image = result_dir / (
619
- "checked_omr" + source_image.suffix.lower()
620
- )
621
  shutil.copy2(source_image, checked_image)
622
 
623
  combined_log = (
@@ -630,14 +911,15 @@ def process_omr(image_file: str | None, template_content: str | None = None):
630
 
631
  return (
632
  str(fused_csv),
 
633
  str(checked_image) if checked_image else None,
634
  combined_log,
635
  )
636
 
637
  except subprocess.TimeoutExpired:
638
- return None, None, "錯誤:OMRChecker 單輪處理超過 180 秒,已中止。"
639
  except Exception as error:
640
- return None, None, f"錯誤:{type(error).__name__}: {error}"
641
  finally:
642
  if request_root is not None:
643
  shutil.rmtree(request_root, ignore_errors=True)
@@ -646,7 +928,7 @@ def process_omr(image_file: str | None, template_content: str | None = None):
646
  interface = gr.Interface(
647
  fn=process_omr,
648
  inputs=[
649
- gr.Image(type="filepath", label="1. 上傳 OMR 答案卡片 (JPG/PNG)"),
650
  gr.Textbox(
651
  lines=6,
652
  label="2. [選填] Template JSON 配置",
@@ -655,13 +937,15 @@ interface = gr.Interface(
655
  ],
656
  outputs=[
657
  gr.File(label="下載融合辨識結果 (CSV)"),
658
- gr.Image(label="視覺化劃記檢視圖片"),
659
- gr.Textbox(label="辨識答案與執行摘要", lines=18),
 
660
  ],
661
- title="DSE OMR 雲端辨識系統 API 後端",
662
  description=(
663
- "以 OMRChecker 執行三輪靈敏度掃描及多數決。"
664
- "正式提交前仍須由學生在前端人工核對。"
 
665
  ),
666
  )
667
 
@@ -670,4 +954,4 @@ if __name__ == "__main__":
670
  server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
671
  server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
672
  show_error=True,
673
- )
 
18
  import cv2
19
  import gradio as gr
20
  import pandas as pd
21
+ import numpy as np
22
  import fastapi
23
  import starlette
24
 
 
67
  OMR_DIR = ROOT_DIR / "OMRChecker"
68
  MOCK_DIR = ROOT_DIR / "mock_libs"
69
  GENERATED_DIR = ROOT_DIR / "generated"
70
+ MARKER_CONFIG_PATH = ROOT_DIR / "marker_config.json"
71
 
72
  OMR_REPO_URL = "https://github.com/Udayraj123/OMRChecker.git"
73
  OMR_REPO_BRANCH = "master"
 
247
  raise ValueError(f"{template_path.name} JSON 格式不正確:{error}") from error
248
 
249
 
250
+
251
+ def _load_marker_config() -> dict[str, Any] | None:
252
+ """Load the optional four-corner marker configuration.
253
+
254
+ The Space can still run without marker_config.json, in which case the
255
+ original OMRChecker FeatureBasedAlignment path is used as a fallback.
256
+ """
257
+ if not MARKER_CONFIG_PATH.is_file():
258
+ return None
259
+
260
+ try:
261
+ data = json.loads(MARKER_CONFIG_PATH.read_text(encoding="utf-8"))
262
+ except (OSError, json.JSONDecodeError) as error:
263
+ raise ValueError(f"marker_config.json 無法讀取:{error}") from error
264
+
265
+ dimensions = data.get("pageDimensions")
266
+ markers = data.get("largeCornerMarkers")
267
+ if (
268
+ not isinstance(dimensions, list)
269
+ or len(dimensions) != 2
270
+ or not isinstance(markers, dict)
271
+ ):
272
+ raise ValueError("marker_config.json 缺少 pageDimensions 或 largeCornerMarkers。")
273
+
274
+ required = {"top_left", "top_right", "bottom_left", "bottom_right"}
275
+ if not required.issubset(markers):
276
+ raise ValueError("marker_config.json 的四個大型定位點不完整。")
277
+
278
+ return data
279
+
280
+
281
+ def _resize_for_detection(image: np.ndarray, max_dimension: int = 1800) -> tuple[np.ndarray, float]:
282
+ """Downscale only for marker detection; return scale back to original."""
283
+ height, width = image.shape[:2]
284
+ longest = max(height, width)
285
+ if longest <= max_dimension:
286
+ return image.copy(), 1.0
287
+
288
+ ratio = max_dimension / float(longest)
289
+ resized = cv2.resize(
290
+ image,
291
+ (max(1, int(round(width * ratio))), max(1, int(round(height * ratio)))),
292
+ interpolation=cv2.INTER_AREA,
293
+ )
294
+ return resized, 1.0 / ratio
295
+
296
+
297
+ def _square_marker_candidates(image: np.ndarray) -> list[dict[str, Any]]:
298
+ """Find high-contrast square candidates, including nested square markers."""
299
+ detection_image, scale_back = _resize_for_detection(image)
300
+ gray = cv2.cvtColor(detection_image, cv2.COLOR_BGR2GRAY)
301
+ gray = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)
302
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
303
+ _, binary = cv2.threshold(
304
+ blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
305
+ )
306
+
307
+ contours, hierarchy = cv2.findContours(
308
+ binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
309
+ )
310
+ hierarchy_row = hierarchy[0] if hierarchy is not None else None
311
+ height, width = gray.shape[:2]
312
+ image_area = float(height * width)
313
+ min_area = max(35.0, image_area * 0.000015)
314
+ max_area = image_area * 0.025
315
+
316
+ candidates: list[dict[str, Any]] = []
317
+ for index, contour in enumerate(contours):
318
+ area = float(cv2.contourArea(contour))
319
+ if area < min_area or area > max_area:
320
+ continue
321
+
322
+ perimeter = cv2.arcLength(contour, True)
323
+ if perimeter <= 0:
324
+ continue
325
+ polygon = cv2.approxPolyDP(contour, 0.04 * perimeter, True)
326
+ x, y, box_width, box_height = cv2.boundingRect(contour)
327
+ if box_width < 6 or box_height < 6:
328
+ continue
329
+
330
+ aspect = box_width / float(box_height)
331
+ fill_ratio = area / float(box_width * box_height)
332
+ if not (0.68 <= aspect <= 1.32):
333
+ continue
334
+ if fill_ratio < 0.35:
335
+ continue
336
+ if len(polygon) < 4 or len(polygon) > 8:
337
+ continue
338
+
339
+ nested = False
340
+ if hierarchy_row is not None:
341
+ nested = hierarchy_row[index][2] >= 0 or hierarchy_row[index][3] >= 0
342
+
343
+ center_x = (x + box_width / 2.0) * scale_back
344
+ center_y = (y + box_height / 2.0) * scale_back
345
+ candidates.append(
346
+ {
347
+ "center": (center_x, center_y),
348
+ "area": area * scale_back * scale_back,
349
+ "nested": nested,
350
+ "aspect": aspect,
351
+ "fill_ratio": fill_ratio,
352
+ }
353
+ )
354
+
355
+ return candidates
356
+
357
+
358
+ def _detect_large_corner_markers(
359
+ image: np.ndarray, marker_config: dict[str, Any]
360
+ ) -> tuple[np.ndarray, np.ndarray]:
361
+ """Detect and order the four large markers as TL, TR, BR, BL.
362
+
363
+ The scoring uses the expected normalized positions from marker_config.json,
364
+ while allowing moderate phone-camera perspective and surrounding margins.
365
+ """
366
+ candidates = _square_marker_candidates(image)
367
+ if len(candidates) < 4:
368
+ raise RuntimeError(f"只找到 {len(candidates)} 個方形候選,未��取得四角定位點。")
369
+
370
+ image_height, image_width = image.shape[:2]
371
+ page_width, page_height = map(float, marker_config["pageDimensions"])
372
+ expected_raw = marker_config["largeCornerMarkers"]
373
+ expected = {
374
+ "top_left": (
375
+ expected_raw["top_left"][0] / page_width,
376
+ expected_raw["top_left"][1] / page_height,
377
+ ),
378
+ "top_right": (
379
+ expected_raw["top_right"][0] / page_width,
380
+ expected_raw["top_right"][1] / page_height,
381
+ ),
382
+ "bottom_right": (
383
+ expected_raw["bottom_right"][0] / page_width,
384
+ expected_raw["bottom_right"][1] / page_height,
385
+ ),
386
+ "bottom_left": (
387
+ expected_raw["bottom_left"][0] / page_width,
388
+ expected_raw["bottom_left"][1] / page_height,
389
+ ),
390
+ }
391
+
392
+ largest_area = max(candidate["area"] for candidate in candidates)
393
+ selected: dict[str, tuple[float, float]] = {}
394
+ used_indices: set[int] = set()
395
+
396
+ # Broad region constraints reject the DSE logo and the smaller group marks.
397
+ regions = {
398
+ "top_left": lambda nx, ny: nx < 0.62 and 0.15 < ny < 0.68,
399
+ "top_right": lambda nx, ny: nx > 0.50 and 0.15 < ny < 0.68,
400
+ "bottom_right": lambda nx, ny: nx > 0.50 and ny > 0.58,
401
+ "bottom_left": lambda nx, ny: nx < 0.62 and ny > 0.58,
402
+ }
403
+
404
+ for marker_name in ("top_left", "top_right", "bottom_right", "bottom_left"):
405
+ expected_x, expected_y = expected[marker_name]
406
+ best_index: int | None = None
407
+ best_score = float("inf")
408
+
409
+ for index, candidate in enumerate(candidates):
410
+ if index in used_indices:
411
+ continue
412
+ center_x, center_y = candidate["center"]
413
+ normalized_x = center_x / float(image_width)
414
+ normalized_y = center_y / float(image_height)
415
+ if not regions[marker_name](normalized_x, normalized_y):
416
+ continue
417
+
418
+ distance = ((normalized_x - expected_x) ** 2 + (normalized_y - expected_y) ** 2) ** 0.5
419
+ size_ratio = candidate["area"] / max(largest_area, 1.0)
420
+ nested_penalty = 0.0 if candidate["nested"] else 0.06
421
+ size_penalty = 0.08 * (1.0 - min(size_ratio, 1.0))
422
+ score = distance + nested_penalty + size_penalty
423
+
424
+ if score < best_score:
425
+ best_score = score
426
+ best_index = index
427
+
428
+ if best_index is None:
429
+ raise RuntimeError(f"未能在合理範圍內找到 {marker_name} 定位點。")
430
+
431
+ used_indices.add(best_index)
432
+ selected[marker_name] = candidates[best_index]["center"]
433
+
434
+ points = np.array(
435
+ [
436
+ selected["top_left"],
437
+ selected["top_right"],
438
+ selected["bottom_right"],
439
+ selected["bottom_left"],
440
+ ],
441
+ dtype=np.float32,
442
+ )
443
+
444
+ # Reject degenerate quadrilaterals before perspective transformation.
445
+ polygon_area = abs(float(cv2.contourArea(points.reshape(-1, 1, 2))))
446
+ if polygon_area < image_width * image_height * 0.08:
447
+ raise RuntimeError("四個定位點形成的區域過小,可能誤認了小標記。")
448
+
449
+ debug_image = image.copy()
450
+ labels = ("TL", "TR", "BR", "BL")
451
+ for label, (point_x, point_y) in zip(labels, points):
452
+ position = (int(round(point_x)), int(round(point_y)))
453
+ cv2.circle(debug_image, position, 18, (0, 255, 0), 5)
454
+ cv2.putText(
455
+ debug_image,
456
+ label,
457
+ (position[0] + 18, position[1] - 18),
458
+ cv2.FONT_HERSHEY_SIMPLEX,
459
+ 1.0,
460
+ (0, 120, 0),
461
+ 3,
462
+ cv2.LINE_AA,
463
+ )
464
+
465
+ return points, debug_image
466
+
467
+
468
+ def _warp_with_large_markers(
469
+ image: np.ndarray, marker_config: dict[str, Any]
470
+ ) -> tuple[np.ndarray, np.ndarray]:
471
+ source_points, debug_image = _detect_large_corner_markers(image, marker_config)
472
+ page_width, page_height = map(int, marker_config["pageDimensions"])
473
+ marker_points = marker_config["largeCornerMarkers"]
474
+ destination_points = np.array(
475
+ [
476
+ marker_points["top_left"],
477
+ marker_points["top_right"],
478
+ marker_points["bottom_right"],
479
+ marker_points["bottom_left"],
480
+ ],
481
+ dtype=np.float32,
482
+ )
483
+
484
+ transform = cv2.getPerspectiveTransform(source_points, destination_points)
485
+ warped = cv2.warpPerspective(
486
+ image,
487
+ transform,
488
+ (page_width, page_height),
489
+ flags=cv2.INTER_CUBIC,
490
+ borderMode=cv2.BORDER_CONSTANT,
491
+ borderValue=(255, 255, 255),
492
+ )
493
+ return warped, debug_image
494
+
495
  def _expand_question_label(label: Any) -> list[int]:
496
  text = str(label).strip()
497
 
 
709
  return "\n".join(lines)
710
 
711
 
712
+
713
  def process_omr(image_file: str | None, template_content: str | None = None):
714
  if image_file is None:
715
+ return None, None, None, "錯誤:請先上傳答案卡圖片。"
716
 
717
  with PROCESS_LOCK:
718
  request_root: Path | None = None
 
726
  raise ValueError("OpenCV 無法讀取上傳圖片。")
727
 
728
  image_height, image_width = image.shape[:2]
729
+ dimension_info = f"【圖片載入成功】解析度:{image_width} × {image_height} px"
 
 
730
 
731
  base_template, _ = _load_template_content(template_content)
732
  question_numbers = _question_numbers_from_template(base_template)
 
739
  result_dir = GENERATED_DIR / request_id
740
  result_dir.mkdir(parents=True, exist_ok=True)
741
 
 
742
  logs = [
743
  dimension_info,
744
  f"【Template】題號範圍:q{question_numbers[0]}–q{question_numbers[-1]},共 {question_count} 題",
 
745
  ]
746
 
747
+ # -------------------------------------------------------
748
+ # Stage 1: marker-based perspective correction.
749
+ # If marker detection fails, keep the original image and allow
750
+ # OMRChecker FeatureBasedAlignment to attempt recovery.
751
+ # -------------------------------------------------------
752
+ omr_input_path = Path(image_file)
753
+ corrected_preview: Path | None = None
754
+ marker_debug_path: Path | None = None
755
+ marker_config = _load_marker_config()
756
+
757
+ if marker_config is not None:
758
+ try:
759
+ warped, marker_debug = _warp_with_large_markers(image, marker_config)
760
+ corrected_preview = result_dir / "01_perspective_corrected.jpg"
761
+ marker_debug_path = result_dir / "00_detected_markers.jpg"
762
+ cv2.imwrite(
763
+ str(corrected_preview),
764
+ warped,
765
+ [int(cv2.IMWRITE_JPEG_QUALITY), 95],
766
+ )
767
+ cv2.imwrite(
768
+ str(marker_debug_path),
769
+ marker_debug,
770
+ [int(cv2.IMWRITE_JPEG_QUALITY), 92],
771
+ )
772
+ omr_input_path = corrected_preview
773
+ logs.append("【四角校正】成功偵測四個大型定位點,已完成透視校正。")
774
+ except Exception as marker_error:
775
+ fallback_path = result_dir / "01_original_fallback.jpg"
776
+ shutil.copy2(image_file, fallback_path)
777
+ corrected_preview = fallback_path
778
+ logs.append(
779
+ "【四角校正】未成功,已回退至 OMRChecker FeatureBasedAlignment:"
780
+ f"{type(marker_error).__name__}: {marker_error}"
781
+ )
782
+ else:
783
+ fallback_path = result_dir / "01_original_no_marker_config.jpg"
784
+ shutil.copy2(image_file, fallback_path)
785
+ corrected_preview = fallback_path
786
+ logs.append("【四角校正】沒有 marker_config.json,使用原始對齊流程。")
787
+
788
+ scans: list[dict[str, Any]] = []
789
+ logs.append("--- 開始三輪多重採樣 ---")
790
+
791
  for index, profile in enumerate(SENSITIVITY_PROFILES, start=1):
792
  profile_dir = request_root / f"profile_{index}"
793
  images_dir = profile_dir / "images"
 
803
  )
804
  _write_headless_config(profile_dir)
805
  _copy_alignment_assets(current_template, profile_dir)
806
+ shutil.copy2(omr_input_path, images_dir / "sheet.jpg")
 
807
 
808
  environment = os.environ.copy()
809
  environment["PYTHONPATH"] = (
 
882
  final_answers[number] = final_answer
883
 
884
  if len(vote_counts) > 1:
885
+ logs.append(f"q{number} 分歧:{dict(vote_counts)} → {final_answer}")
 
 
886
 
887
  final_valid_count = sum(answer != "—" for answer in final_answers.values())
888
  logs.append(
889
  f"融合後辨識:{final_valid_count}/{question_count} 題;空白 {question_count - final_valid_count} 題。"
890
  )
891
 
 
 
892
  fused_csv = result_dir / "omr_fused_answers.csv"
893
  pd.DataFrame(
894
  [{f"q{number}": final_answers[number] for number in question_numbers}]
 
898
  checked_image: Path | None = None
899
  if best_scan["image"] and Path(best_scan["image"]).is_file():
900
  source_image = Path(best_scan["image"])
901
+ checked_image = result_dir / ("checked_omr" + source_image.suffix.lower())
 
 
902
  shutil.copy2(source_image, checked_image)
903
 
904
  combined_log = (
 
911
 
912
  return (
913
  str(fused_csv),
914
+ str(corrected_preview) if corrected_preview else None,
915
  str(checked_image) if checked_image else None,
916
  combined_log,
917
  )
918
 
919
  except subprocess.TimeoutExpired:
920
+ return None, None, None, "錯誤:OMRChecker 單輪處理超過 180 秒,已中止。"
921
  except Exception as error:
922
+ return None, None, None, f"錯誤:{type(error).__name__}: {error}"
923
  finally:
924
  if request_root is not None:
925
  shutil.rmtree(request_root, ignore_errors=True)
 
928
  interface = gr.Interface(
929
  fn=process_omr,
930
  inputs=[
931
+ gr.Image(type="filepath", label="1. 上傳已填寫的 OMR 答案卡片 (JPG/PNG)"),
932
  gr.Textbox(
933
  lines=6,
934
  label="2. [選填] Template JSON 配置",
 
937
  ],
938
  outputs=[
939
  gr.File(label="下載融合辨識結果 (CSV)"),
940
+ gr.Image(label="四角校正後圖片/回退原圖"),
941
+ gr.Image(label="OMRChecker 視覺化劃記檢視"),
942
+ gr.Textbox(label="辨識答案與執行摘要", lines=20),
943
  ],
944
+ title="DSE OMR 手機相片辨識系統",
945
  description=(
946
+ "先嘗試偵測答案區四個大型定位標記並作透視校正,"
947
+ "再以 OMRChecker 執行三輪靈敏度掃描及融合。"
948
+ "正式提交前仍建議學生核對辨識結果。"
949
  ),
950
  )
951
 
 
954
  server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
955
  server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
956
  show_error=True,
957
+ )
docs/template_coordinate_overlay.jpg ADDED

Git LFS Details

  • SHA256: 1cf0df0ac58e75f9db078994caeab2f723ac082cadd35d1f5f37c1943257fe09
  • Pointer size: 132 Bytes
  • Size of remote file: 1.97 MB
marker_config.json ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "templateVersion": "DSE-OMR-v2-marked",
3
+ "pageDimensions": [
4
+ 3093,
5
+ 4374
6
+ ],
7
+ "answerRegionBounds": {
8
+ "left": 829,
9
+ "top": 1598,
10
+ "right": 2917,
11
+ "bottom": 4170
12
+ },
13
+ "largeCornerMarkers": {
14
+ "top_left": [
15
+ 790,
16
+ 1555
17
+ ],
18
+ "top_right": [
19
+ 2960,
20
+ 1555
21
+ ],
22
+ "bottom_left": [
23
+ 790,
24
+ 4220
25
+ ],
26
+ "bottom_right": [
27
+ 2960,
28
+ 4220
29
+ ]
30
+ },
31
+ "largeMarkerOuterSize": 72,
32
+ "smallGroupMarkers": [
33
+ [
34
+ 1310,
35
+ 2122
36
+ ],
37
+ [
38
+ 1310,
39
+ 2637
40
+ ],
41
+ [
42
+ 1310,
43
+ 3152
44
+ ],
45
+ [
46
+ 1310,
47
+ 3667
48
+ ],
49
+ [
50
+ 1830,
51
+ 2122
52
+ ],
53
+ [
54
+ 1830,
55
+ 2637
56
+ ],
57
+ [
58
+ 1830,
59
+ 3152
60
+ ],
61
+ [
62
+ 1830,
63
+ 3667
64
+ ],
65
+ [
66
+ 2350,
67
+ 2122
68
+ ],
69
+ [
70
+ 2350,
71
+ 2637
72
+ ],
73
+ [
74
+ 2350,
75
+ 3152
76
+ ],
77
+ [
78
+ 2350,
79
+ 3667
80
+ ],
81
+ [
82
+ 2870,
83
+ 2122
84
+ ],
85
+ [
86
+ 2870,
87
+ 2637
88
+ ],
89
+ [
90
+ 2870,
91
+ 3152
92
+ ],
93
+ [
94
+ 2870,
95
+ 3667
96
+ ]
97
+ ],
98
+ "smallMarkerOuterSize": 34,
99
+ "notes": [
100
+ "Large markers are for four-point perspective correction before OMRChecker.",
101
+ "Small markers are placed in the blank gaps after every five questions and outside A-D bubble regions.",
102
+ "The OMRChecker template.json itself does not read marker_config.json; app.py uses it during pre-warping."
103
+ ]
104
+ }
project_manifest.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ ".gitignore": {
3
+ "size_bytes": 69,
4
+ "sha256": "220f22568b058b917b79feb609ec12fc63085941efafd3698cf4575c0f6ad05f"
5
+ },
6
+ "README.md": {
7
+ "size_bytes": 1800,
8
+ "sha256": "476f5b35cc85679dbc72d05326b950910a297a13cb772ed48e12e63c1ec09bb8"
9
+ },
10
+ "app.py": {
11
+ "size_bytes": 32997,
12
+ "sha256": "b9c1bbdcb8166332922be3d27edc6586d1699cc7a2077eff143cfd71d859e146"
13
+ },
14
+ "docs/template_coordinate_overlay.jpg": {
15
+ "size_bytes": 1972251,
16
+ "sha256": "1cf0df0ac58e75f9db078994caeab2f723ac082cadd35d1f5f37c1943257fe09"
17
+ },
18
+ "marker_config.json": {
19
+ "size_bytes": 1406,
20
+ "sha256": "222fc1a1b2df9056d5f2af4677828397c31923e1388055f87638d66187997887"
21
+ },
22
+ "packages.txt": {
23
+ "size_bytes": 32,
24
+ "sha256": "4de86580e7ebc03a7790808fa6655cac191045bd030c15d4cc07acf03e6706f1"
25
+ },
26
+ "requirements.txt": {
27
+ "size_bytes": 576,
28
+ "sha256": "b7411f9ebbf827d2272fb789c10c2d03ae27e7f3f908d0d0f4da5b4103497e22"
29
+ },
30
+ "template.jpg": {
31
+ "size_bytes": 2201218,
32
+ "sha256": "6444e117c802ca2ceb3e12a60bcb219b54c732f3c299dad874c5a4c8ea66b82a"
33
+ },
34
+ "template.json": {
35
+ "size_bytes": 4723,
36
+ "sha256": "d49c9a881225afae81cae9521a85a68c044d5d86844b3774cc9130de1d484bd2"
37
+ },
38
+ "template_A4_print.pdf": {
39
+ "size_bytes": 717825,
40
+ "sha256": "dc11564a20df544e5523bdaf353c56455f9311e1f4d5301f3abb1e3a79079940"
41
+ },
42
+ "tools/add_markers_and_warp_colab.py": {
43
+ "size_bytes": 6825,
44
+ "sha256": "5182f2ca645dbdac0479b1fdb7d0a09c6551656047086f726ac6cb9a95ce0c85"
45
+ },
46
+ "validate_project.py": {
47
+ "size_bytes": 1236,
48
+ "sha256": "b6a88ebed905bb132b0342f940e3a80c90be83f08dcb97256b4e7347ef06d772"
49
+ }
50
+ }
template.jpg CHANGED

Git LFS Details

  • SHA256: a777806b885e1fbafcc1d23ea257ace2c6fa6735c85df3773daa6489fa4fb735
  • Pointer size: 132 Bytes
  • Size of remote file: 2.96 MB

Git LFS Details

  • SHA256: 6444e117c802ca2ceb3e12a60bcb219b54c732f3c299dad874c5a4c8ea66b82a
  • Pointer size: 132 Bytes
  • Size of remote file: 2.2 MB
template.json CHANGED
@@ -1,270 +1,270 @@
1
- {
2
- "pageDimensions": [
3
- 3093,
4
- 4374
5
- ],
6
- "bubbleDimensions": [
7
- 60,
8
- 30
9
- ],
10
- "preProcessors": [
11
- {
12
- "name": "FeatureBasedAlignment",
13
- "options": {
14
- "reference": "template.jpg",
15
- "maxFeatures": 1000,
16
- "2d": true
17
- }
18
- },
19
- {
20
- "name": "Levels",
21
- "options": {
22
- "low": 0.1,
23
- "high": 0.9
24
- }
25
- }
26
- ],
27
- "customLabels": {},
28
- "fieldBlocks": {
29
- "MCQBlock_Col1_1": {
30
- "fieldType": "QTYPE_MCQ4",
31
- "origin": [
32
- 990,
33
- 1680
34
- ],
35
- "fieldLabels": [
36
- "q1..5"
37
- ],
38
- "bubblesGap": 75,
39
- "labelsGap": 92
40
- },
41
- "MCQBlock_Col1_2": {
42
- "fieldType": "QTYPE_MCQ4",
43
- "origin": [
44
- 990,
45
- 2195
46
- ],
47
- "fieldLabels": [
48
- "q6..10"
49
- ],
50
- "bubblesGap": 75,
51
- "labelsGap": 92
52
- },
53
- "MCQBlock_Col1_3": {
54
- "fieldType": "QTYPE_MCQ4",
55
- "origin": [
56
- 990,
57
- 2710
58
- ],
59
- "fieldLabels": [
60
- "q11..15"
61
- ],
62
- "bubblesGap": 75,
63
- "labelsGap": 92
64
- },
65
- "MCQBlock_Col1_4": {
66
- "fieldType": "QTYPE_MCQ4",
67
- "origin": [
68
- 990,
69
- 3225
70
- ],
71
- "fieldLabels": [
72
- "q16..20"
73
- ],
74
- "bubblesGap": 75,
75
- "labelsGap": 92
76
- },
77
- "MCQBlock_Col1_5": {
78
- "fieldType": "QTYPE_MCQ4",
79
- "origin": [
80
- 990,
81
- 3740
82
- ],
83
- "fieldLabels": [
84
- "q21..25"
85
- ],
86
- "bubblesGap": 75,
87
- "labelsGap": 92
88
- },
89
- "MCQBlock_Col2_1": {
90
- "fieldType": "QTYPE_MCQ4",
91
- "origin": [
92
- 1510,
93
- 1680
94
- ],
95
- "fieldLabels": [
96
- "q26..30"
97
- ],
98
- "bubblesGap": 75,
99
- "labelsGap": 92
100
- },
101
- "MCQBlock_Col2_2": {
102
- "fieldType": "QTYPE_MCQ4",
103
- "origin": [
104
- 1510,
105
- 2195
106
- ],
107
- "fieldLabels": [
108
- "q31..35"
109
- ],
110
- "bubblesGap": 75,
111
- "labelsGap": 92
112
- },
113
- "MCQBlock_Col2_3": {
114
- "fieldType": "QTYPE_MCQ4",
115
- "origin": [
116
- 1510,
117
- 2710
118
- ],
119
- "fieldLabels": [
120
- "q36..40"
121
- ],
122
- "bubblesGap": 75,
123
- "labelsGap": 92
124
- },
125
- "MCQBlock_Col2_4": {
126
- "fieldType": "QTYPE_MCQ4",
127
- "origin": [
128
- 1510,
129
- 3225
130
- ],
131
- "fieldLabels": [
132
- "q41..45"
133
- ],
134
- "bubblesGap": 75,
135
- "labelsGap": 92
136
- },
137
- "MCQBlock_Col2_5": {
138
- "fieldType": "QTYPE_MCQ4",
139
- "origin": [
140
- 1510,
141
- 3740
142
- ],
143
- "fieldLabels": [
144
- "q46..50"
145
- ],
146
- "bubblesGap": 75,
147
- "labelsGap": 92
148
- },
149
- "MCQBlock_Col3_1": {
150
- "fieldType": "QTYPE_MCQ4",
151
- "origin": [
152
- 2030,
153
- 1680
154
- ],
155
- "fieldLabels": [
156
- "q51..55"
157
- ],
158
- "bubblesGap": 75,
159
- "labelsGap": 92
160
- },
161
- "MCQBlock_Col3_2": {
162
- "fieldType": "QTYPE_MCQ4",
163
- "origin": [
164
- 2030,
165
- 2195
166
- ],
167
- "fieldLabels": [
168
- "q56..60"
169
- ],
170
- "bubblesGap": 75,
171
- "labelsGap": 92
172
- },
173
- "MCQBlock_Col3_3": {
174
- "fieldType": "QTYPE_MCQ4",
175
- "origin": [
176
- 2030,
177
- 2710
178
- ],
179
- "fieldLabels": [
180
- "q61..65"
181
- ],
182
- "bubblesGap": 75,
183
- "labelsGap": 92
184
- },
185
- "MCQBlock_Col3_4": {
186
- "fieldType": "QTYPE_MCQ4",
187
- "origin": [
188
- 2030,
189
- 3225
190
- ],
191
- "fieldLabels": [
192
- "q66..70"
193
- ],
194
- "bubblesGap": 75,
195
- "labelsGap": 92
196
- },
197
- "MCQBlock_Col3_5": {
198
- "fieldType": "QTYPE_MCQ4",
199
- "origin": [
200
- 2030,
201
- 3740
202
- ],
203
- "fieldLabels": [
204
- "q71..75"
205
- ],
206
- "bubblesGap": 75,
207
- "labelsGap": 92
208
- },
209
- "MCQBlock_Col4_1": {
210
- "fieldType": "QTYPE_MCQ4",
211
- "origin": [
212
- 2550,
213
- 1680
214
- ],
215
- "fieldLabels": [
216
- "q76..80"
217
- ],
218
- "bubblesGap": 75,
219
- "labelsGap": 92
220
- },
221
- "MCQBlock_Col4_2": {
222
- "fieldType": "QTYPE_MCQ4",
223
- "origin": [
224
- 2550,
225
- 2195
226
- ],
227
- "fieldLabels": [
228
- "q81..85"
229
- ],
230
- "bubblesGap": 75,
231
- "labelsGap": 92
232
- },
233
- "MCQBlock_Col4_3": {
234
- "fieldType": "QTYPE_MCQ4",
235
- "origin": [
236
- 2550,
237
- 2710
238
- ],
239
- "fieldLabels": [
240
- "q86..90"
241
- ],
242
- "bubblesGap": 75,
243
- "labelsGap": 92
244
- },
245
- "MCQBlock_Col4_4": {
246
- "fieldType": "QTYPE_MCQ4",
247
- "origin": [
248
- 2550,
249
- 3225
250
- ],
251
- "fieldLabels": [
252
- "q91..95"
253
- ],
254
- "bubblesGap": 75,
255
- "labelsGap": 92
256
- },
257
- "MCQBlock_Col4_5": {
258
- "fieldType": "QTYPE_MCQ4",
259
- "origin": [
260
- 2550,
261
- 3740
262
- ],
263
- "fieldLabels": [
264
- "q96..100"
265
- ],
266
- "bubblesGap": 75,
267
- "labelsGap": 92
268
- }
269
- }
270
- }
 
1
+ {
2
+ "pageDimensions": [
3
+ 3093,
4
+ 4374
5
+ ],
6
+ "bubbleDimensions": [
7
+ 60,
8
+ 30
9
+ ],
10
+ "preProcessors": [
11
+ {
12
+ "name": "FeatureBasedAlignment",
13
+ "options": {
14
+ "reference": "template.jpg",
15
+ "maxFeatures": 2000,
16
+ "2d": true
17
+ }
18
+ },
19
+ {
20
+ "name": "Levels",
21
+ "options": {
22
+ "low": 0.1,
23
+ "high": 0.9
24
+ }
25
+ }
26
+ ],
27
+ "customLabels": {},
28
+ "fieldBlocks": {
29
+ "MCQBlock_Col1_1": {
30
+ "fieldType": "QTYPE_MCQ4",
31
+ "origin": [
32
+ 990,
33
+ 1680
34
+ ],
35
+ "fieldLabels": [
36
+ "q1..5"
37
+ ],
38
+ "bubblesGap": 75,
39
+ "labelsGap": 92
40
+ },
41
+ "MCQBlock_Col1_2": {
42
+ "fieldType": "QTYPE_MCQ4",
43
+ "origin": [
44
+ 990,
45
+ 2195
46
+ ],
47
+ "fieldLabels": [
48
+ "q6..10"
49
+ ],
50
+ "bubblesGap": 75,
51
+ "labelsGap": 92
52
+ },
53
+ "MCQBlock_Col1_3": {
54
+ "fieldType": "QTYPE_MCQ4",
55
+ "origin": [
56
+ 990,
57
+ 2710
58
+ ],
59
+ "fieldLabels": [
60
+ "q11..15"
61
+ ],
62
+ "bubblesGap": 75,
63
+ "labelsGap": 92
64
+ },
65
+ "MCQBlock_Col1_4": {
66
+ "fieldType": "QTYPE_MCQ4",
67
+ "origin": [
68
+ 990,
69
+ 3225
70
+ ],
71
+ "fieldLabels": [
72
+ "q16..20"
73
+ ],
74
+ "bubblesGap": 75,
75
+ "labelsGap": 92
76
+ },
77
+ "MCQBlock_Col1_5": {
78
+ "fieldType": "QTYPE_MCQ4",
79
+ "origin": [
80
+ 990,
81
+ 3740
82
+ ],
83
+ "fieldLabels": [
84
+ "q21..25"
85
+ ],
86
+ "bubblesGap": 75,
87
+ "labelsGap": 92
88
+ },
89
+ "MCQBlock_Col2_1": {
90
+ "fieldType": "QTYPE_MCQ4",
91
+ "origin": [
92
+ 1510,
93
+ 1680
94
+ ],
95
+ "fieldLabels": [
96
+ "q26..30"
97
+ ],
98
+ "bubblesGap": 75,
99
+ "labelsGap": 92
100
+ },
101
+ "MCQBlock_Col2_2": {
102
+ "fieldType": "QTYPE_MCQ4",
103
+ "origin": [
104
+ 1510,
105
+ 2195
106
+ ],
107
+ "fieldLabels": [
108
+ "q31..35"
109
+ ],
110
+ "bubblesGap": 75,
111
+ "labelsGap": 92
112
+ },
113
+ "MCQBlock_Col2_3": {
114
+ "fieldType": "QTYPE_MCQ4",
115
+ "origin": [
116
+ 1510,
117
+ 2710
118
+ ],
119
+ "fieldLabels": [
120
+ "q36..40"
121
+ ],
122
+ "bubblesGap": 75,
123
+ "labelsGap": 92
124
+ },
125
+ "MCQBlock_Col2_4": {
126
+ "fieldType": "QTYPE_MCQ4",
127
+ "origin": [
128
+ 1510,
129
+ 3225
130
+ ],
131
+ "fieldLabels": [
132
+ "q41..45"
133
+ ],
134
+ "bubblesGap": 75,
135
+ "labelsGap": 92
136
+ },
137
+ "MCQBlock_Col2_5": {
138
+ "fieldType": "QTYPE_MCQ4",
139
+ "origin": [
140
+ 1510,
141
+ 3740
142
+ ],
143
+ "fieldLabels": [
144
+ "q46..50"
145
+ ],
146
+ "bubblesGap": 75,
147
+ "labelsGap": 92
148
+ },
149
+ "MCQBlock_Col3_1": {
150
+ "fieldType": "QTYPE_MCQ4",
151
+ "origin": [
152
+ 2030,
153
+ 1680
154
+ ],
155
+ "fieldLabels": [
156
+ "q51..55"
157
+ ],
158
+ "bubblesGap": 75,
159
+ "labelsGap": 92
160
+ },
161
+ "MCQBlock_Col3_2": {
162
+ "fieldType": "QTYPE_MCQ4",
163
+ "origin": [
164
+ 2030,
165
+ 2195
166
+ ],
167
+ "fieldLabels": [
168
+ "q56..60"
169
+ ],
170
+ "bubblesGap": 75,
171
+ "labelsGap": 92
172
+ },
173
+ "MCQBlock_Col3_3": {
174
+ "fieldType": "QTYPE_MCQ4",
175
+ "origin": [
176
+ 2030,
177
+ 2710
178
+ ],
179
+ "fieldLabels": [
180
+ "q61..65"
181
+ ],
182
+ "bubblesGap": 75,
183
+ "labelsGap": 92
184
+ },
185
+ "MCQBlock_Col3_4": {
186
+ "fieldType": "QTYPE_MCQ4",
187
+ "origin": [
188
+ 2030,
189
+ 3225
190
+ ],
191
+ "fieldLabels": [
192
+ "q66..70"
193
+ ],
194
+ "bubblesGap": 75,
195
+ "labelsGap": 92
196
+ },
197
+ "MCQBlock_Col3_5": {
198
+ "fieldType": "QTYPE_MCQ4",
199
+ "origin": [
200
+ 2030,
201
+ 3740
202
+ ],
203
+ "fieldLabels": [
204
+ "q71..75"
205
+ ],
206
+ "bubblesGap": 75,
207
+ "labelsGap": 92
208
+ },
209
+ "MCQBlock_Col4_1": {
210
+ "fieldType": "QTYPE_MCQ4",
211
+ "origin": [
212
+ 2550,
213
+ 1680
214
+ ],
215
+ "fieldLabels": [
216
+ "q76..80"
217
+ ],
218
+ "bubblesGap": 75,
219
+ "labelsGap": 92
220
+ },
221
+ "MCQBlock_Col4_2": {
222
+ "fieldType": "QTYPE_MCQ4",
223
+ "origin": [
224
+ 2550,
225
+ 2195
226
+ ],
227
+ "fieldLabels": [
228
+ "q81..85"
229
+ ],
230
+ "bubblesGap": 75,
231
+ "labelsGap": 92
232
+ },
233
+ "MCQBlock_Col4_3": {
234
+ "fieldType": "QTYPE_MCQ4",
235
+ "origin": [
236
+ 2550,
237
+ 2710
238
+ ],
239
+ "fieldLabels": [
240
+ "q86..90"
241
+ ],
242
+ "bubblesGap": 75,
243
+ "labelsGap": 92
244
+ },
245
+ "MCQBlock_Col4_4": {
246
+ "fieldType": "QTYPE_MCQ4",
247
+ "origin": [
248
+ 2550,
249
+ 3225
250
+ ],
251
+ "fieldLabels": [
252
+ "q91..95"
253
+ ],
254
+ "bubblesGap": 75,
255
+ "labelsGap": 92
256
+ },
257
+ "MCQBlock_Col4_5": {
258
+ "fieldType": "QTYPE_MCQ4",
259
+ "origin": [
260
+ 2550,
261
+ 3740
262
+ ],
263
+ "fieldLabels": [
264
+ "q96..100"
265
+ ],
266
+ "bubblesGap": 75,
267
+ "labelsGap": 92
268
+ }
269
+ }
270
+ }
template_A4_print.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dc11564a20df544e5523bdaf353c56455f9311e1f4d5301f3abb1e3a79079940
3
+ size 717825
tools/add_markers_and_warp_colab.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Colab-ready utility for HKDSE-style OMR templates.
4
+
5
+ What this script can do:
6
+ 1) Add large corner markers + small in-between-group markers to the original template image.
7
+ 2) Detect the 4 large corner markers from a phone photo.
8
+ 3) Perspective-warp the answer sheet back to the template size (3093 x 4374).
9
+
10
+ Recommended Colab usage:
11
+ - Upload the ORIGINAL unmarked template as: template_original.jpg
12
+ - Run add_markers_to_template() once to generate:
13
+ template_marked_3093x4374.jpg
14
+ template_marked_A4.pdf
15
+ - Later, for student phone photos, call warp_sheet_from_photo('student_photo.jpg')
16
+ to get a corrected sheet image before OMR reading.
17
+ """
18
+
19
+ from pathlib import Path
20
+ from PIL import Image, ImageDraw
21
+ import cv2
22
+ import numpy as np
23
+
24
+ TEMPLATE_SIZE = (3093, 4374) # width, height
25
+ CORNER_MARKERS = [
26
+ (961, 1726), # top-left
27
+ (3043, 1726), # top-right
28
+ (961, 4255), # bottom-left
29
+ (3043, 4255), # bottom-right
30
+ ]
31
+ SMALL_MARKERS = [
32
+ # x, y centers of the small double-square markers in the blank gap
33
+ # between q5/6, q10/11, q15/16, q20/21 for each of the 4 columns.
34
+ (1299, 2323), (1299, 2842), (1299, 3361), (1299, 3880),
35
+ (1818, 2323), (1818, 2842), (1818, 3361), (1818, 3880),
36
+ (2338, 2323), (2338, 2842), (2338, 3361), (2338, 3880),
37
+ (2857, 2323), (2857, 2842), (2857, 3361), (2857, 3880),
38
+ ]
39
+
40
+ BLACK = (0, 0, 0)
41
+ WHITE = (255, 255, 255)
42
+
43
+
44
+ def draw_corner_marker(draw, cx, cy, outer=72, border=4, fill_margin=10):
45
+ """Large high-contrast square marker for 4-point detection."""
46
+ x0 = int(cx - outer / 2)
47
+ y0 = int(cy - outer / 2)
48
+ x1 = x0 + outer
49
+ y1 = y0 + outer
50
+ draw.rectangle([x0, y0, x1, y1], outline=BLACK, width=border, fill=WHITE)
51
+ draw.rectangle([x0 + fill_margin, y0 + fill_margin, x1 - fill_margin, y1 - fill_margin], fill=BLACK)
52
+
53
+
54
+ def draw_small_double_square(draw, cx, cy, outer=34, border=2, fill_margin=5, core=9):
55
+ """Small double-square marker placed in the blank space after each 5 questions."""
56
+ x0 = int(cx - outer / 2)
57
+ y0 = int(cy - outer / 2)
58
+ x1 = x0 + outer
59
+ y1 = y0 + outer
60
+ draw.rectangle([x0, y0, x1, y1], outline=BLACK, width=border, fill=WHITE)
61
+ draw.rectangle([x0 + fill_margin, y0 + fill_margin, x1 - fill_margin, y1 - fill_margin], outline=BLACK, width=border, fill=WHITE)
62
+ c0x = int(cx - core / 2)
63
+ c0y = int(cy - core / 2)
64
+ c1x = c0x + core
65
+ c1y = c0y + core
66
+ draw.rectangle([c0x, c0y, c1x, c1y], fill=BLACK)
67
+
68
+
69
+ def add_markers_to_template(input_path='template_original.jpg',
70
+ output_img='template_marked_3093x4374.jpg',
71
+ output_pdf='template_marked_A4.pdf'):
72
+ img = Image.open(input_path).convert('RGB')
73
+ if img.size != TEMPLATE_SIZE:
74
+ raise ValueError(f'Input size must be {TEMPLATE_SIZE}, got {img.size}')
75
+
76
+ draw = ImageDraw.Draw(img)
77
+ for cx, cy in CORNER_MARKERS:
78
+ draw_corner_marker(draw, cx, cy)
79
+ for cx, cy in SMALL_MARKERS:
80
+ draw_small_double_square(draw, cx, cy)
81
+
82
+ img.save(output_img, quality=95, subsampling=0)
83
+ img.save(output_pdf, resolution=300.0)
84
+ print('Saved:', output_img)
85
+ print('Saved:', output_pdf)
86
+
87
+
88
+ def order_points(pts):
89
+ pts = np.array(pts, dtype=np.float32)
90
+ s = pts.sum(axis=1)
91
+ diff = np.diff(pts, axis=1)
92
+ tl = pts[np.argmin(s)]
93
+ br = pts[np.argmax(s)]
94
+ tr = pts[np.argmin(diff)]
95
+ bl = pts[np.argmax(diff)]
96
+ return np.array([tl, tr, br, bl], dtype=np.float32)
97
+
98
+
99
+ def detect_corner_markers(image_bgr, debug=False):
100
+ """Detect the 4 large black corner markers from a phone photo.
101
+ Returns 4 ordered points: TL, TR, BR, BL.
102
+ """
103
+ gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
104
+ blur = cv2.GaussianBlur(gray, (5,5), 0)
105
+ # Strong threshold for black markers
106
+ _, th = cv2.threshold(blur, 90, 255, cv2.THRESH_BINARY_INV)
107
+
108
+ contours, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
109
+ h, w = gray.shape[:2]
110
+ candidates = []
111
+ for c in contours:
112
+ area = cv2.contourArea(c)
113
+ if area < (h*w)*0.0002: # too small
114
+ continue
115
+ peri = cv2.arcLength(c, True)
116
+ approx = cv2.approxPolyDP(c, 0.05 * peri, True)
117
+ x, y, bw, bh = cv2.boundingRect(c)
118
+ aspect = bw / max(bh, 1)
119
+ if 0.7 <= aspect <= 1.3:
120
+ M = cv2.moments(c)
121
+ if M['m00'] == 0:
122
+ continue
123
+ cx = M['m10'] / M['m00']
124
+ cy = M['m01'] / M['m00']
125
+ # prioritize near the outer parts of the page
126
+ edge_score = min(cx, cy, w-cx, h-cy)
127
+ candidates.append((area, edge_score, (cx, cy), c))
128
+
129
+ if len(candidates) < 4:
130
+ raise RuntimeError('Could not find 4 corner markers.')
131
+
132
+ # Keep larger, more edge-localized blobs.
133
+ candidates.sort(key=lambda x: (-x[0], x[1]))
134
+ pts = [item[2] for item in candidates[:12]]
135
+
136
+ # choose one from each quadrant around image center
137
+ cx0, cy0 = w/2, h/2
138
+ quad = {'tl': None, 'tr': None, 'br': None, 'bl': None}
139
+ best = {'tl': 1e18, 'tr': 1e18, 'br': 1e18, 'bl': 1e18}
140
+ for pt in pts:
141
+ x, y = pt
142
+ d = 0
143
+ if x < cx0 and y < cy0:
144
+ d = (x)**2 + (y)**2; key='tl'
145
+ elif x >= cx0 and y < cy0:
146
+ d = (w-x)**2 + (y)**2; key='tr'
147
+ elif x >= cx0 and y >= cy0:
148
+ d = (w-x)**2 + (h-y)**2; key='br'
149
+ else:
150
+ d = (x)**2 + (h-y)**2; key='bl'
151
+ if d < best[key]:
152
+ best[key] = d
153
+ quad[key] = pt
154
+
155
+ if any(v is None for v in quad.values()):
156
+ raise RuntimeError('Detected markers are incomplete across quadrants.')
157
+
158
+ return np.array([quad['tl'], quad['tr'], quad['br'], quad['bl']], dtype=np.float32)
159
+
160
+
161
+ def warp_sheet_from_photo(photo_path, output_path='warped_sheet.jpg', debug_path=None):
162
+ img = cv2.imread(str(photo_path))
163
+ if img is None:
164
+ raise FileNotFoundError(photo_path)
165
+
166
+ src_pts = detect_corner_markers(img)
167
+ dst_pts = np.array([
168
+ [CORNER_MARKERS[0][0], CORNER_MARKERS[0][1]],
169
+ [CORNER_MARKERS[1][0], CORNER_MARKERS[1][1]],
170
+ [CORNER_MARKERS[3][0], CORNER_MARKERS[3][1]],
171
+ [CORNER_MARKERS[2][0], CORNER_MARKERS[2][1]],
172
+ ], dtype=np.float32)
173
+
174
+ M = cv2.getPerspectiveTransform(src_pts, dst_pts)
175
+ warped = cv2.warpPerspective(img, M, TEMPLATE_SIZE)
176
+ cv2.imwrite(str(output_path), warped)
177
+
178
+ if debug_path:
179
+ dbg = img.copy()
180
+ for x, y in src_pts.astype(int):
181
+ cv2.circle(dbg, (x, y), 16, (0, 255, 0), -1)
182
+ cv2.imwrite(str(debug_path), dbg)
183
+
184
+ print('Saved:', output_path)
185
+ return warped
186
+
187
+
188
+ if __name__ == '__main__':
189
+ # Example usage in Colab
190
+ # add_markers_to_template('template_original.jpg')
191
+ # warp_sheet_from_photo('student_photo.jpg', 'warped_sheet.jpg', 'debug_detected_markers.jpg')
192
+ pass
validate_project.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import json
3
+ import sys
4
+ from PIL import Image
5
+
6
+ ROOT = Path(__file__).resolve().parent
7
+ REQUIRED = [
8
+ "app.py",
9
+ "requirements.txt",
10
+ "packages.txt",
11
+ "README.md",
12
+ "template.jpg",
13
+ "template.json",
14
+ "marker_config.json",
15
+ ]
16
+
17
+ errors = []
18
+ for filename in REQUIRED:
19
+ if not (ROOT / filename).is_file():
20
+ errors.append(f"Missing: {filename}")
21
+
22
+ try:
23
+ image = Image.open(ROOT / "template.jpg")
24
+ if image.size != (3093, 4374):
25
+ errors.append(f"template.jpg size is {image.size}, expected (3093, 4374)")
26
+ except Exception as error:
27
+ errors.append(f"template.jpg cannot be read: {error}")
28
+
29
+ for filename in ("template.json", "marker_config.json"):
30
+ try:
31
+ data = json.loads((ROOT / filename).read_text(encoding="utf-8"))
32
+ if data.get("pageDimensions") != [3093, 4374]:
33
+ errors.append(f"{filename} pageDimensions is not [3093, 4374]")
34
+ except Exception as error:
35
+ errors.append(f"{filename} cannot be read: {error}")
36
+
37
+ if errors:
38
+ print("PROJECT VALIDATION FAILED")
39
+ for error in errors:
40
+ print("-", error)
41
+ sys.exit(1)
42
+
43
+ print("PROJECT VALIDATION PASSED")
44
+ print("All required files exist and template dimensions are correct.")