Skip to content

Releases: DC1024/answer-sheet-builder

v1.4.1

Choose a tag to compare

@github-actions github-actions released this 27 Sep 11:12

Fixed / 修复

  • Windows 免安装版:「下载更新 → 更新并重启」之后什么都不发生,手动打开还是旧版本号。这条链路上叠了三个坑,任一个都足以让更新彻底失效:
    Windows portable build: after "download update → install and restart" nothing happened, and manually reopening still showed the old version. Three separate faults were stacked on this one path, any of which alone was enough to break the update completely:
    • 引导程序根本起不来(根因)。免安装版是 PyInstaller onedir 产物 —— exe 必须和同级的 _internal\python313.dll 一起才能跑。而自更新把 sys.executable 单独复制到 %TEMP%\asb-update-bootstrap.exe 再去执行,这个裸 exe 找不到 _internal,进程在加载 Python DLL 时就死了(Failed to load Python DLL ...),于是没有任何人去处理暂存包。现在改为直接拿暂存目录里那份新版的 exe 当引导程序:它自带完整的 _internal,能原地跑,又正好在安装目录之外(替换安装目录不会被自己锁住),还省掉了再复制一份几百 MB 的文件。
      The bootstrap could not start at all (root cause). The portable build is a PyInstaller onedir artifact — the exe only runs alongside its sibling _internal\python313.dll. The updater copied sys.executable alone to %TEMP%\asb-update-bootstrap.exe and ran that; the bare exe could not find _internal and died while loading the Python DLL (Failed to load Python DLL ...), so nobody ever processed the staged update. It now runs the new version's own exe from the staging directory as the bootstrap: it carries its full _internal, runs in place, sits outside the install directory (so replacing that directory is not blocked by its own file lock), and avoids copying several hundred MB again.
    • 旧服务退得太早,引导程序还来不及动手就先撞上文件锁。原来触发后固定 0.6 秒就 os._exit(0),而引导程序要先把几百 MB 的 onedir 复制一遍才真正落地 —— 真正的危险窗口是「引导程序开始复制」之前。现在两个进程之间做握手:引导程序启动后第一件事是回填 update_boot.json 的 reportedAt,服务轮询到这个标记才退出;轮询不到就带原因把「安装失败」返回给界面,而不是静默退出留下一个没人处理的暂存包。服务退出后,引导程序还会等它确实消失(OpenProcess + WaitForSingleObject,而不是在 Windows 上会真杀进程的 os.kill(pid, 0))才开始替换。
      The old service exited too early — the bootstrap hit the file lock before it could act. The old code always os._exit(0) after a fixed 0.6s, while the bootstrap has to copy the whole several-hundred-MB onedir before it can land anything; the dangerous window is before it starts copying. The two processes now shake hands: the bootstrap's first act is to fill in reportedAt in update_boot.json, and the service polls for that marker before exiting; if it never appears the service returns a "install failed" reason to the UI instead of silently exiting and leaving an unprocessed staging directory. After the service exits, the bootstrap also waits for it to actually disappear (OpenProcess + WaitForSingleObject, rather than os.kill(pid, 0), which on Windows really does kill the process) before replacing anything.
    • 失败了完全不留痕。引导程序是在服务已经退出之后跑的,它崩了就没有任何界面能看到 —— 用户看到的只是「点了没反应」。现在它把每一步写进 <数据目录>\update.log,失败时留一条 update_boot.json 事故记录(想装的版本 / 退出码 / 人话原因);服务下次起来时 /api/update/last-boot 把这条读给界面显示一次(读过即清,不会每次开都弹旧事故)。
      No trace was left when it failed. The bootstrap runs after the service has exited, so if it crashed there was no UI left to show it — the user just saw "nothing happened". It now writes every step to <data dir>\update.log and, on failure, leaves a record in update_boot.json (target version / exit code / plain-language reason); on the next start the service reads it back once via /api/update/last-boot and shows it (then clears it, so a past incident is not re-announced on every launch).
  • 界面上的「正在重启…」其实是盲等 4 秒。老实现是 setTimeout(reload, 4000) —— 如果窗口根本没起来,4 秒后刷出来的还是老页面,用户看到的就是「没反应、版本号也没变」。现在改成轮询 /api/health 等版本号真的变成新版本再刷新,并区分三种结局:变了 → 刷新;服务活着但版本没变 → 明确提示「服务已重启,但版本仍是 vX(未换成 vY),详见 update.log」;一直连不上 → 提示手动双击启动。页面上「下载更新」时显示的下载进度在重装后也能直接看到已下载状态。
    The UI's "restarting…" was really a blind 4-second wait. The old code did setTimeout(reload, 4000) — if the window never came up, the reload just redisplayed the old page, which is precisely what "nothing happened / version unchanged" looks like. It now polls /api/health until the version really changes before reloading, and distinguishes three outcomes: changed → reload; service alive but version unchanged → explicitly report "the service restarted but is still vX (not vY), see update.log"; never reachable → prompt the user to start it manually.
  • 安装时不会再把安装目录覆盖成空壳。落地前先确认暂存那一层里真的存在 exe(压缩包多包了一层就往下找一层);找不到就放弃并说明原因,而不是拿一个只有目录结构的空框架去替换整个安装目录。
    Installing can no longer replace the install directory with an empty shell. Before landing, it verifies that the staging level actually contains an exe (descending a level if the archive is double-wrapped); if not it aborts with a reason instead of overwriting the whole install directory with a skeleton of directories.
  • 落地成功后不再一直留着几百 MB 的备份和暂存包。备份 .bak 会保留到新版本确实启动起来(万一新版起不来还能人工改名回滚),此后由新进程自动清理 .bak 与 data\updates\。
    A few hundred MB of backup and staging data are no longer left behind forever after a successful install. The .bak backup is kept until the new version has actually started (so a broken new build can still be rolled back by renaming it); after that the new process cleans up .bak and data\updates\ automatically.
  • 测试:新增 scanner/tests/test_selfupdate.py(引导程序选谁 / 落地替换 / 自我锁定保护 / 事故留痕 / 参数传递 / 端到端,56 项)与 scanner/tests/test_bootstrap_launcher.py(引导程序控制流:报到 → 等旧进程 → 落地 → 拉起,含等待超时、暂存缺失、拉起失败三种失败分支与日志断言,31 项)。后者补上的是此前完全没有覆盖的一段 —— 它就藏在打包目录里、又只在服务退出后才跑,人工点界面观察不到。另外在真实发布产物上实测确认了三件事:单独复制出来的 exe 确实起不来(复现原 bug)、运行中的 onedir 目录可以被复制和改名(所以拿它当引导程序是安全的)、exe 从启动到能应答只要 1.5 秒(所以握手超时给 25 秒足够宽裕)。
    Tests: new scanner/tests/test_selfupdate.py (which bootstrap gets picked / landing the replace / self-lock protection / failure records / argument passing / end to end, 56 assertions) and scanner/tests/test_bootstrap_launcher.py (the bootstrap's control flow: report in → wait for the old process → land → relaunch, including the wait-timeout, missing-staging and relaunch-failure branches plus log assertions, 31 assertions). The latter covers a stretch that previously had no coverage at all — it hides in the packaging directory and only runs after the service has exited, so clicking through the UI can never observe it. Three things were also verified empirically against the real release artifact: an exe copied out on its own really does fail to start (reproducing the original bug), a running onedir directory can be copied and renamed (so using it as the bootstrap is safe), and the exe takes only 1.5 s from launch to answering (so the 25 s handshake timeout is comfortably generous).

v1.4.0 — 主观题(解答题 / 填空题)人工阅卷

Choose a tag to compare

@DC1024 DC1024 released this 27 Sep 06:34

[1.4.0] - 2026-09-27

Added / 新增

  • 主观题(解答题 / 填空题)人工阅卷 —— 从制卡端一路做到成绩单与导出。以前这套工具只能判选择题:卷子上有解答题,老师就得把这份卷子从流水线里拿出去、手工加进总分,成绩单和导出的 CSV 里也永远只有客观分。现在主观题是一整条链路:
    Subjective-question (free-response / fill-in-the-blank) manual grading — end to end, from the card designer all the way to the gradebook and CSV export. Until now the tool could only mark multiple-choice: if a paper had free-response questions, the teacher had to pull that sheet out of the pipeline and add the marks by hand, and neither the gradebook nor the exported CSV ever had anything but objective scores. Subjective questions are now one connected pipeline:
    • 制卡端:选择题 / 填空题 / 解答题块都多了一组「人工阅卷」配置 —— 勾上之后填满分,可选再拆小问(每个小问各自给分)。导出阅卷模板时,勾过的题才会带上 points 和整题一块的作答区坐标 region(mm)。没勾的题一个字节都不会多出来,纯选择题的老模板导出结果和 1.3.2 完全一致。
      Card designer: the multiple-choice, fill-in-the-blank and free-response blocks all gained a "manual grading" group — tick it, set the max score, and optionally split it into sub-questions (each scored separately). When exporting the OMR template only the ticked questions carry points and a single whole-question region region (in mm). Unticked questions add not a single byte, so a pure multiple-choice template exports byte-identically to 1.3.2.
    • 扫描端:识别时按模板的 region 从校正后的干净图上裁出每道主观题的作答区,落盘成 DATA/<rid>_q<题号>.jpg,用 GET /api/region/<rid>/<题号>.jpg 取;题号没裁过就老老实实 404,不猜不编。批量上传时每个页记录里带着 regions 列表,工作台据此知道哪道题有裁剪图。
      Scanner: during recognition, each subjective question's answer area is cropped from the clean deskewed image using the template's region, written to DATA/<rid>_q<no>.jpg, and served at GET /api/region/<rid>/<no>.jpg; an uncropped question number returns an honest 404 rather than guessing. Batch uploads record a regions list on every page, which is how the workbench knows a crop exists.
    • 两种给分模式,由模板决定:设了小问就按小问逐项给分再求和,没设就是整题给一个分。这条规则在制卡端 grading.js、扫描端 scoring.py、工作台 UI 三处逐字对齐 —— 三边各写一套口径迟早会分叉,那正是老师最不能接受的那种错。每项分值都会夹到自己的满分([0, 满分],scoring._num 顺手挡掉 NaN / ±inf),老师手滑把 8 打成 80 不会把整份卷子的总分炸掉。
      Two scoring modes, decided by the template: if sub-questions are defined, each is scored and summed; otherwise the whole question gets one mark. This rule is kept character-for-character identical in the designer's grading.js, the scanner's scoring.py, and the workbench UI — three independently-written versions of the same rule would eventually drift, and that is exactly the kind of error a teacher cannot tolerate. Every mark is clamped to its own max ([0, max], with scoring._num screening out NaN / ±inf), so fat-fingering 8 into 80 cannot blow up the whole paper's total.
    • 成绩单与导出:有主观题的考试,成绩单自动从两列(原始分 / 复核分)变成三列(客观分 / 主观分 / 总分),顶部汇总条写明「满分:客观 19 + 主观 16 = 35」;导出的 CSV 同样分三列,并在 X-Score-Max 头里给出 auto/subjective/total。纯选择题考试的行为一个字没变 —— 仍是两列、仍不带 X-Score-Max,老用户的脑子和脚本都不用改。主观题还会从客观题分布统计里排除(拿主观题的「填涂」去做选项分布本来就没有意义)。
      Gradebook and export: an exam with subjective questions automatically switches from two columns (raw / reviewed) to three (objective / subjective / total), and the summary bar spells out "max: objective 19 + subjective 16 = 35"; the CSV export splits the same way and reports auto/subjective/total in the X-Score-Max header. A pure multiple-choice exam behaves exactly as before — still two columns, still no X-Score-Max header, so neither the teacher's habits nor any existing script needs to change. Subjective questions are also excluded from the bubble-distribution statistics, where a "fill" pattern would be meaningless.
  • 阅卷工作台:主观题打分卡片 + 裁剪图/整页一键切换。每道主观题一张卡片:题目满分、每个小问一个输入框、卡片右上角「本题得分 X / 满分」小计(满分变绿、零分变红)。右边看图区默认显示这一题的作答区裁剪图(够大,看得清字),一键切「整页校对」回整张卷子核对;点别的题卡片就切到那一题。打分、改判、复核分任一改动,弹窗底部合计预览立刻重算,格式是 客观 19 + 主观 13 = 32 / 35 —— 老师要能一眼看出「这 32 分里有 13 分是我手打的」。
    Grading workbench: subjective scoring cards with one-click crop/full-page switching. Each subjective question gets a card: its max score, one input per sub-question, and a "this question: X / max" subtotal in the corner (green at full marks, red at zero). The image pane on the right defaults to that question's cropped answer area (large enough to actually read the handwriting) with a one-click switch to full-page review; clicking another question's card jumps to it. Any change to scores, overrides or the manual total immediately recomputes the footer preview, shown as objective 19 + subjective 13 = 32 / 35 — the teacher must be able to see at a glance that 13 of those 32 marks were typed by hand.
  • 测试:新增扫描端 tests/test_subjective.py(模板解析 → 裁剪落盘 → 路由 → 打分落库 → 成绩单 / 导出 / 夹取 / NaN / 老考试隔离,端到端一条龙),制卡端 dev/verify_subjective.cjs + dev/check_subjective_template.py,以及工作台 UI 的 dev/verify_subjective_ui.cjs(真 Flask 服务 + 真 Chromium 点 43 项:三列表头、两种给分模式、裁剪/整页切换、实时合计、保存后重开还在、未打分不计入总分)。
    Tests: new scanner-side tests/test_subjective.py (template parsing → crop persistence → route → score persistence → gradebook / export / clamping / NaN / legacy-exam isolation, end to end), designer-side dev/verify_subjective.cjs + dev/check_subjective_template.py, and workbench UI dev/verify_subjective_ui.cjs (real Flask server + real Chromium, 43 assertions: three-column header, both scoring modes, crop/full-page switching, live totals, scores surviving a save-and-reopen, and ungraded questions not counting toward the total).

Fixed / 修复

  • 制卡端:填空题配置「人工阅卷」时面板直接报错、并且会吃掉小问树。normalizeGrade 原来用 .map((s) => ({label, points})) 重建 subs,把填空题嵌套的 blanks / 子级结构整个丢掉,于是配置面板一打开就 Cannot read properties of undefined (reading 'forEach')。改为就地改 subs(只做 String(label) / 数值化),嵌套原样保留。
    Designer: the fill-in-the-blank panel crashed when configuring manual grading, and silently ate the sub-question tree. normalizeGrade used to rebuild subs via .map((s) => ({label, points})), dropping fill-in-the-blank's nested blanks / child structure entirely, so the config panel threw Cannot read properties of undefined (reading 'forEach') the moment it opened. Now it mutates subs in place (only String(label) / numeric coercion), preserving nesting as-is.
  • 阅卷工作台:切到「整页校对」后,再点「当前题裁剪」没反应。整页模式下当前题号会被清成 null,切回裁剪时不知道该回哪一题,于是按钮点了像是坏了。改为记住最近看过的那道主观题(GBIMG.lastQ),「当前题裁剪」永远回得到那一题。
    Grading workbench: after switching to "full-page review", the "current question crop" button did nothing. Full-page mode clears the current question number to null, so switching back had no idea which question to return to and the button just looked broken. It now remembers the last subjective question viewed (GBIMG.lastQ), so "current question crop" always goes back to it.
  • 工作台的图片缩放复位钩子在切图时丢失。setGbImg 原来用 Object.assign({}, GBIMG, …) 换了一个新对象,而看图器把「默认缩放复位」的钩子挂在这个对象上 —— 一换对象钩子就没了,切图后缩放会停在上一张的值。改为原地修改 GBIMG。
    The workbench's zoom-reset hook was lost on every image switch. setGbImg used Object.assign({}, GBIMG, …), producing a new object, while the viewer attached its "reset to default zoom" hook to that object — so the hook vanished and the zoom stayed at the previous image's value. It now mutates GBIMG in place.
  • dev/verify_settings.cjs 把当前版本号写死成 v1.0.3,导致每次发版这个脚本都会变红,而人的第一反应是「测试坏了」而不是「版本号改对了」。改为从 assets/js/core/version.js 读 APP_VERSION —— 于是它只验真正该验的那件事:页面显示的版本号和源码一致。
    dev/verify_settings.cjs hardcoded the current version as v1.0.3, so the script went red on every release and the natural reaction was "the test is broken" rather than "I bumped the version correctly". It now reads APP_VERSION from assets/js/core/version.js, so it only checks the thing that actually matters: that the version shown on the page matches the source.
  • dev/verify_writebox.cjs 的路径从来就没对过,脚本永远跑不到底:它把产物写到 dev/scanner/tests/fixtures/real30/(dev/scanner/ 既不是 scanner/、又不在 .gitignore 里),Python 那一步的解释器也指向 dev/scanner/.venv/...(不存在);而 execFileSync 在本机沙箱里起不了任何子进程,所以「跑过了」这件事本身就不成立。产物改落 dev/.cache/writebox/,并把扫描端那一步拆成 dev/check_writebox_template.py(与 verify_subjective.cjs / check_subjective_template.py 同一套两步写法)。现在两步都是真的绿。
    dev/verify_writebox.cjs had wrong paths from the start, so the script could never finish: it wrote its artifacts to dev/scanner/tests/fixtures/real30/ (dev/scanner/ is neither scanner/ nor gitignored), and the interpreter for its Python step pointed at dev/scanner/.venv/..., which does not exist; on top of that execFileSync cannot start any child process in this sandbox, so "it ran" was never true. Artifacts now go to dev/.cache/writebox/, and the scanner step is split out into dev/check_writebox_template.py — the same two-step pattern as verify_subjective.cjs / check_subjective_template.py. Both steps are now genuinely green.
  • 验证脚本把 favicon.ico 的 404 当成 JS 错误(dev/verify_writebox.cjs)。静态服务没有 favicon,浏览器会自动去要一个,那条 Failed to load resource: 404 是环境噪音 —— 混进「无页面 JS 错误」这条断言里,会让人习惯性忽略这一行,真出 JS 错误时反而看不见。已按同一口径过滤(dev/verify_subjective.cjs 早就这么做了)。
    Verifiers counted the favicon.ico 404 as a JS error (dev/verify_writebox.cjs). A static server has no favicon and the browser asks for one anyway; that Failed to load resource: 404 is environmental noise, and letting it into the "no page JS errors" assertion trains people to ignore the line — so a real JS error would slip past. Filtered by the same rule dev/verify_subjective.cjs already used.

v1.3.2

Choose a tag to compare

@github-actions github-actions released this 27 Sep 05:48
v1.3.2:修复细笔画手写卷整卷读不出(83.0%→96.0%,弃答 44→0)

v1.3.1

Choose a tag to compare

@github-actions github-actions released this 27 Sep 04:52
v1.3.1: 修复检查更新非 JSON 错误 + 免安装版手写 CNN 静默失效

v1.3.0

Choose a tag to compare

@github-actions github-actions released this 27 Sep 04:20
v1.3.0 - 扫描端自动下载并安装更新(Windows 免安装版自更新,含构建依赖修复)

v1.1.0

Choose a tag to compare

@DC1024 DC1024 released this 26 Sep 15:14

新增

扫描端 · 人工修正(改机器读错的答案 / 改卷面考号)

  • 阅卷工作台逐题复核里每题可直接改答案,机器原读标红显示;改完即落库,并穿透进判分、班级分布统计、CSV 导出与成绩单。
  • 原机器读数(machine)始终保留,供误判率统计使用。
  • 卷面考号也能改(/api/fix/sid)—— 机器读错考号等于整份卷子归错人,改完即时生效。
  • 重传扫描件时人工修正按考号搬回(save_students(keep_manual)),不会白改。

Scanner · manual correction: edit any question's answer in the gradebook review modal (the machine's original read is shown in red); the correction persists and propagates into scoring, class distribution stats, CSV export and the score sheet. The original machine reading is always retained for misjudgment-rate accounting. The scanned candidate ID is also editable.

扫描端 · 识别方案标识 + 按方案误判率

  • 每题标注用的是哪套方案:结构特征规则(纯 OpenCV,零模型)还是 CNN(手写 A–D 交叉验证),一份卷子两套都用时显示「混合」。
  • ④ 汇总统计新增「识别方案与误判率」面板,按方案统计「被老师亲手改掉的比例」,并原样展示有偏样本提醒(只有老师看过并动手改的题才进统计,不等于全量准确率)。

Scanner · scheme label + per-scheme misjudgment rate: every question is tagged rule / CNN / mixed; a new panel reports, per scheme, the share of questions the teacher manually corrected, with the biased-sample caveat shown verbatim.

  • 新增离线测试 scanner/tests/test_fixes.py:覆盖修正规整 / 生效 / 落库 / 穿透统计导出 / 重传不丢 / 考号改名拒绝 / 只读权限 / 分桶口径。

变更

  • 库结构升级 SCHEMA_VERSION = 3:新增 fixes、fixedQnos 与 machine_* 原读字段。

修复

  • 无模板(或题号集为空)考试的工作台弹窗一题都渲染不出来(方案徽标 / 机读标红 / 改答案输入框全空)——由真实浏览器点按 E2E 抓出,_qnos 改为回退到库里已存的学生答案题号。

Windows 免安装版

CI 会自动构建并把两个 zip 挂到本 Release(稍后刷新即可看到):

  • 答题卡制作器(制卡端)
  • 答题卡扫描服务(扫描端,含可选手写 CNN)

解压后双击 exe 即用,不需要装 Python、也不需要 Docker;数据库与校对图落在 %LOCALAPPDATA%\asb-scanner\data。

Windows portable builds (attached by CI): the sheet builder and the scanner. Unzip and double-click the exe — no Python and no Docker required.

完整变更见 CHANGELOG.md。

v1.0.3

Choose a tag to compare

@github-actions github-actions released this 26 Sep 07:21
v1.0.3 · 设置与检查更新 + Windows 免安装版

- 制卡端 ⚙ 设置 / 扫描端 ⑧ 设置:自动检查更新可开关、可手动查、显示版本号;「查不到」不算错误
- Windows 免安装版(制卡端 + 扫描端)随 Release 发布;推 v* 自动编译并挂载
- /api/health 新增 cnn 字段,产物自证手写 CNN 是否装入
- 修复:waitress ident 中文导致每个响应 500;设置结果区被 display:flex 盖住;test_real30 写死张数

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

Choose a tag to compare

@DC1024 DC1024 released this 24 Sep 18:26

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.

v1.0.1

Choose a tag to compare

@DC1024 DC1024 released this 24 Sep 16:50

答题卡生成器 v1.0.1 —— 纯前端、零依赖、完全免费开源的自制答题卡排版工具。单文件即可运行,也可用 Docker / GitHub Pages 部署。

v1.0.1 是 1.0.0 之后的第一个更新,主要集中在作答区贴图、撤销重做、分页与排版精度上。所有条目均通过无头浏览器(Chromium)量化验证后才合入。

✨ 新增

  • 解答题图片可直接贴进作答区:选中解答题模块后,把鼠标停在预览区某个作答区上按 Ctrl+V 即贴进该题(点选作答区也行,属性面板同步高亮该题卡;未悬停时贴第 1 题)。图片叠加在作答区内 —— 不占高度、不挤压横线,学生照常在旁边书写;九宫格定位(左上/上中/右上/左中/居中/右中/左下/下中/右下)+ 图片宽(%)可调。实测:九宫格 9 位置与期望角点偏差全部 0.00px,插图前后区块总高差 0.00px(不占高度、不影响分页)。
  • 撤销 / 重做:快照式历史(最多 100 步),连续输入按 0.7s 窗口合并;工具栏按钮 + Ctrl+Z / Ctrl+Y。
  • 复制 / 剪切 / 粘贴:Ctrl+C / Ctrl+X / Ctrl+V 复制模块;选中「图片」模块时 Ctrl+V 可直接粘贴剪贴板图片(本机压缩、仅存浏览器);选中「解答题」时贴进作答区。
  • 选择题「行间距(mm)」:填涂与手写两种样式都生效。
  • 填空题分层自动编号:小题 (1)(2) → 小小题 ①② → 更深 a) b),各层一眼可区分;旧模板里硬编码的 (1)(1)(1) 自动重算。

🐛 修复

  • 作答区贴图在打印时压过边框:叠加层原用「绝对定位 + transform 居中 + max-height/overflow 裁切」,屏幕预览正常、Ctrl+P 打印预览里图片越出边框。重构为定位层铺满作答区 + flex 九宫格(去 transform)+ 盒子自带 overflow:hidden 兜底。实测(1:3.75 超高图、60% 宽、100mm 盒):屏幕 / 打印媒体 / 真实 PDF 三方几何完全一致,0.00mm 越界。
  • 新增题型不再重复题号:新加的选择/填空/解答题不再一律用默认起始号(1/11/13),改为自动接续已有总题数(实测 13 题卷面 → 新解答题 14、再选择题 15)。
  • 点解答题任意位置都能选中该题:此前只有空白区响应,点题号行「13.」无反应;现在整个作答盒(含题号行)都响应,提示与题卡均改用卷面题号。
  • 填空题「行间距」不均匀:该值同时被用作折行 line-height 与题间 margin,导致题间行距 = 设定值 ×2(16mm 实测题间 32.1mm)。现已一致(16mm → 16.06 / 16.06)。
  • 选择题「行间距」不作用于标题与第一行:grid 的 row-gap 只作用于行与行之间,标题→第一行恒 2.12mm;补 padding-top 后与行间距一致(16mm → 17.12 / 17.12)。
  • 考号填涂区限宽:默认不超过页宽 1/4(可调 10–60%),把宽度让给右侧手写栏。实测 A3 16 位 87.6 → 74.5mm(25% 页宽)。
  • 属性面板题卡排版:「第 1 题 + 当前」角标不再被拆成两行。

📦 部署

docker run -d -p 8080:80 --name asb ghcr.io/dc1024/answer-sheet-builder:1.0.1
# 或:ghcr.io/dc1024/answer-sheet-builder:1.0 / :latest / :sha-<commit>

在线体验:https://dc1024.github.io/answer-sheet-builder/


v1.0.1 of the Answer Sheet Builder — a pure front-end, zero-dependency, free and open-source answer-sheet layout tool.

Added

  • Paste images straight into a free-response answer area: select the block, hover an answer area and press Ctrl+V (clicking works too). The image overlays inside the box — no height taken, ruled lines untouched — with 9-grid positioning and width (%). Measured: 0.00px deviation on all 9 positions; 0.00px total block height change.
  • Undo / redo (snapshot history, 100 steps, 0.7s coalescing; Ctrl+Z / Ctrl+Y).
  • Copy / cut / paste blocks (Ctrl+C / Ctrl+X / Ctrl+V); paste a clipboard image into an Image block, or into an answer area.
  • Row spacing (mm) for choice questions; layered auto-numbering for fill-in-the-blank.

Fixed

  • Images no longer cross the answer-box border in print (transform-based clipping was unreliable in Chrome's print pipeline): screen / print media / real PDF now measure identically with 0.00mm overflow.
  • New blocks no longer duplicate question numbers — the starting number continues from the existing total (13-question sheet → new answer block starts at 14).
  • Clicking anywhere on an answer box selects that question (previously only the blank middle area responded).
  • Fill-in-blank row spacing was doubled between questions (16mm set → 32.1mm measured); now uniform.
  • Choice row spacing now also applies between the title and the first row (was a constant 2.12mm).
  • Exam-number grid capped at 1/4 page width (A3 16 digits: 87.6 → 74.5mm).
  • Properties-panel question-card layout no longer wraps mid-badge.

Deploy

docker run -d -p 8080:80 --name asb ghcr.io/dc1024/answer-sheet-builder:1.0.1

Live demo: https://dc1024.github.io/answer-sheet-builder/

v1.0.0 · 答题卡制作器 / Answer Sheet Builder

Choose a tag to compare

@DC1024 DC1024 released this 24 Sep 13:41

🎉 v1.0.0 — 首个正式版本 / First stable release

答题卡制作器 是一个纯前端的可视化工具,用来快速制作考试答题卡并打印 / 导出 PDF。

✨ 亮点 / Highlights

  • 🧩 模块化题型:选择题 / 填空题 / 解答题 / 考生信息栏 / 自定义编辑区,可添加、复制、删除、拖拽排序。
  • 📄 固定纸张分页:纸张尺寸固定,内容按「第一面 → 第二面 → 第二页第一面……」顺序填充;A3 每面双栏。
  • 🔤 填涂 / 手写:选择题支持中括号 [A] 填涂与横线手写;每行题数按纸张宽度自动折行,绝不溢出。
  • ✎ 多级填空:支持 11(1)、11(2)①/②;空格长度、行间距可调。
  • 📐 解答题横线:作答区可选空白或等距横线,间距与高度可调。
  • 🖨 打印 / 导出 PDF:支持双面长边翻转;JSON 模板导入导出。
  • 🐳 一键部署:纯静态站点,docker compose up -d 即用。

📦 部署 / Deploy

docker build -t answer-sheet-builder .
docker run -d --name asb -p 8080:80 answer-sheet-builder
# 打开 http://<server-ip>:8080

完整说明见 README · English

Full Changelog: https://github.com/DC1024/answer-sheet-builder/commits/v1.0.0