Skip to content

v1.0.2 — 阅卷模板导出 + 配套扫描识别服务

Choose a tag to compare

@DC1024 DC1024 released this 24 Sep 18:26
· 42 commits to main since this release

v1.0.2 — 阅卷模板导出 + 配套扫描识别服务

排好卷子只是上半场。这一版把下半场也补上了:学生作答、收卷之后,答题卡能不能自动读回来。


🎯 导出「阅卷模板」(坐标契约)

工具栏新增 🎯 阅卷模板,一键导出 asb-omr-template-A4-20q-<时间戳>.json,
里面是每个填涂圈在纸面上的毫米坐标 —— 含四角定位点、纸张尺寸、题号与选项。

卷子是本工具排的,每个填涂圈在哪只有这里最清楚,让识别端去猜是没道理的。
卷子里没有填涂圈模式的选择题时会明确提示,不会导出一份空模板。

📷 配套扫描识别服务(scanner/)

上传扫描图 → 四角定位点透视矫正 → 按模板坐标逐圈采样 → 判定填涂 → 出统计。

能力 说明
双输入源 扫描仪 300dpi 平面图、手机斜拍图(自带明暗归一化)
存疑标注 ok 正常 / faint 浅涂 / multi 多涂难分 / blank 未填
校对图 每份卷子回吐叠加图:绿圈=已选、红圈=存疑,圈旁标注墨迹值
班级统计 每题选项分布 + 正确率 + 每份得分,一键导出 CSV
Web 界面 单文件、零外部依赖;docker compose up -d 即用

纯 OpenCV / NumPy,不下载任何模型,CPU 即可运行,可完全离线。

为什么判定用「相对基线」而不是绝对阈值

填涂圈里印着 A/B/C/D 字母,本身就有墨迹,实测未涂的框 ink 能到 0.15~0.25;
再叠加不同笔的深浅,绝对阈值完全不可靠。所以取本题所有选项的最小值为底噪,
用 rel = best − base 判「涂了没有」,再用灰度 ink 判「深涂还是浅涂」
(二值化会把中灰铅笔算成"白",只有灰度和能分得开)。

实测:涂实 = 0.827、空白 = 0.15~0.25、中灰浅涂 = 0.388 —— 阈值 0.5 正好卡在中间。

✅ 实测数据

6 份合成卷 × 20 题,两批共 228 个判定:

场景 平均正确率 异常标注
干净扫描(300dpi 级) 100.0%(114/114) 第 3 题未涂 → blank ✅
手机拍照合成(透视+明暗+模糊+噪声+JPEG) 100.0%(114/114) 第 8 题中灰浅涂 → faint ✅

服务级集成(上传模板 → 识别 → 校对图 → 统计 → CSV)21 项断言全通过。
已在 Docker 29.7.2 上构建启动,容器 healthy,接口 200。

🐛 顺带修掉两处

  1. 判分白送分:未作答(None) == 未给标准答案(None) 会被算成答对。
    已抽成 stats.score() 并加回归用例,接口与统计共用同一条口径。
  2. 浅涂漏判:recognize() 的 fill_min 默认值(0.35)与 decide()(0.5) 不一致,
    导致浅涂题在默认调用路径上被判成 ok。已对齐到 0.5。

🔧 开发辅助

测试素材(scanner/tests/fixtures/,840KB)来自真实制卡端渲染,不是手搓的 ——
dev/gen_omr_fixtures.cjs 用无头 Chromium 打开真实 app.html,调 exportOmrTemplate()
拿模板、用 CSS 模拟涂卡、逐份截图导出。这样「测试过了」才等于「真机对得上」。

python -m http.server 8080                  # 仓库根目录
node dev/gen_omr_fixtures.cjs               # 重新生成素材

cd scanner && python tests/test_omr.py      # 离线自检,不需要任何外部素材
python tests/e2e_service.py                 # 服务级集成,需要对跑起来的服务

v1.0.2 — Machine-readable template + companion scanner

The builder now exports asb-omr-template-*.json with the millimetre coordinates of every
bubble
, and a companion service under scanner/ reads scanned sheets back: corner-mark
perspective correction → per-bubble sampling on the template's mm grid → answer decisions
with doubtful-case flags (faint / multi / blank) → class statistics and CSV.

Pure OpenCV/NumPy, no model downloads, fully offline, CPU-only. Web UI and one-command
Docker deploy included.

Measured (6 sheets × 20 questions, 228 decisions): clean scan 100%,
simulated phone photo 100%; deliberately-unanswered Q3 flagged blank,
deliberately-light Q8 flagged faint. Service-level integration: 21/21 assertions pass.

Also fixed: score counting gave free marks for None == None (unanswered vs. no answer
key), and recognize()'s fill_min default (0.35) disagreed with decide()'s (0.5),
letting light fills slip through as ok.