Repository navigation
v1.0.2 — 阅卷模板导出 + 配套扫描识别服务
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。
🐛 顺带修掉两处
- 判分白送分:
未作答(None) == 未给标准答案(None)会被算成答对。
已抽成stats.score()并加回归用例,接口与统计共用同一条口径。 - 浅涂漏判:
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.