filter-inspection / v4 /README.md
daipath's picture
新增 v4 模型包版(与 v3 并列,不改动任何现有文件):模型可热插拔,manifest 驱动预处理,8 步加载闸门
36387e3 verified
|
Raw
History Blame Contribute Delete
23.7 kB

滤光片外观质检 · 模型包版 (filter-predict v4)

模型不再打进程序。一个目录 = 一个模型包,拷到程序旁边的 models/ 里就能用; 换模型 = 拷一个目录 + 改一行 ACTIVE.json不用重新打包程序

推理端只依赖 onnxruntime + numpy + pillow(xlsx 输出另需可选的 openpyxl)。 没有 torch / timm / albumentations / opencv。

首次部署必须把 models/ 目录一起拷贝。 程序里只内置了一个 17MB 的判废兜底包, 85MB 的类型包一律外置;只拷 exe 的话 --with-type 会以退出码 3 失败。


1. 目录结构

安装目录/
├── filter-predict-v4                    # PyInstaller onefile
├── models/
│   ├── ACTIVE.json                      # 角色 -> 包 的指针,--activate 原子写
│   ├── reject-mnv3-gwz-320-v1/          # 判废:出厂 mobilenetv3 / gwz(= v3 的同一权重)
│   │   ├── model.onnx
│   │   └── pack.json                    # manifest:预处理/输出语义/阈值/自检 全在这里
│   ├── reject-effb0-imnet-320-v1/       # 判废:test AUROC 最高的 efficientnet_b0 / imnet
│   └── type-r34-gwz-320-v1/             # 缺陷类型:resnet34 / gwz,4 类
└── 结果/

源码侧:

release2/
├── predict.py                # CLI 与主流程
├── filterpack/
│   ├── errors.py             # 退出码
│   ├── preprocess.py         # 六张注册表 + 像素链(换新预处理只改这里)
│   ├── fixture.py            # golden fixture 的 7 个合成图定义 + 容差上限
│   ├── manifest.py           # schema 校验 + v1 升级
│   ├── pack.py               # 发现/选择/加载闸门/自检/打分
│   └── report.py             # 契约横幅 + 结果落盘
├── tools/
│   ├── make_pack.py          # 构建机工具:from-train / from-v1 / calibrate / regen
│   │                         # (依赖 torch/timm/cv2,不打进 exe)
│   └── smoke_test.py         # 冒烟 + 故意做坏事,42 个用例
├── models/                   # 三个可用模型包
├── filter-predict.spec       # PyInstaller
├── requirements.txt
├── README.md                 # 本文
└── 模型包手册.md              # manifest 每个字段的含义 + 常见错误速查

2. 常用命令

filter-predict-v4 --list                              # 有哪些包、什么预处理、阈值从哪来
filter-predict-v4 -i imgs/ -o 结果.xlsx                # 判废
filter-predict-v4 -i imgs/ -o 结果.xlsx --with-type    # 判废 + 缺陷类型
filter-predict-v4 -i imgs/ -o 结果.xlsx --preset 保守  # 用包里标定好的预设阈值
filter-predict-v4 --show-presets reject-mnv3-gwz-320-v1
filter-predict-v4 --verify                            # 全部包做 8 步全闸门校验
filter-predict-v4 --info reject-effb0-imnet-320-v1    # 打印完整 manifest(含溯源)
filter-predict-v4 --compare A B -i 回归集/ -o 对拍.xlsx  # 换模型前的对拍
filter-predict-v4 --activate reject=<包id> --note "张三 回归通过"

每次运行都会先把契约打印出来,并把同一份内容写进结果文件 (xlsx 的「运行信息」sheet / csv 的 <输出名>.run.txt):

[判废] reject-mnv3-gwz-320-v1   [外置 (exe旁 models/)]   <- ACTIVE.json
  身份   version=1.0.0  sha256=b4055cdc139970e0…  pp_engine=fpp/2
  路径   /opt/filter/models/reject-mnv3-gwz-320-v1/model.onnx  (16.81 MB, 大小校验 ✔)
  预处理 size=320(与onnx图一致)  geom=longest_max_size+pad  interp=cv2_linear
         pad=center/0  norm=gwz(after_pad)  decode=rgb8  RGB/NCHW/float32
  输出   2类 ['良品', '不良']  good_index=0  activation=softmax  score=1-P[good]
  判定   threshold=0.4591   <- manifest decision.threshold
  自检   ✔ 7/7(与打包时逐字节一致)  canvas exact  norm 最大 4.7e-07  logits 最大 4.8e-07  score 最大 4.0e-07

「自检 ✔」的措辞是精确的:它证明的是目标机的像素链+权重 == 打包时的像素链+权重, 不是"像素链 == 训练真相"。后者由构建机上的 train_parity 闸门保证,结果记在 provenance.train_parity 里(本仓三个包当前都是 canvas_u8_max_abs_err=0, logit_max_abs_err=0.0,即与 albumentations+cv2 训练链逐字节相同)。

为什么每次都要打印这一段:预处理配错是完全静默的失败模式。实测把最优模型 (efficientnet_b0 / imnet)按老程序写死的 gwz 跑,漏检从 7 涨到 23,而误报两边都是 3 —— 操作员看到的误报率一模一样,只有漏检在偷偷涨。光看结果表永远发现不了,只能靠加载时把契约摆到台面上。


3. 换模型的标准流程(禁止就地覆盖任何 .onnx)

# 1) 构建机生成包(自带 train_parity 闸门 + 自校验)
/workspace/venvs/seg/bin/python tools/make_pack.py from-train <训练目录> \
    --out /tmp/reject-新包-v2 --id reject-新包-v2 --role reject --parity-images 200

# 2) 在 val 上标定阈值 + 预设表(test 只评一次),有工具,不要手填
#    先 dry-run 看数字,确认了再 --write
/workspace/venvs/seg/bin/python tools/make_pack.py calibrate /tmp/reject-新包-v2 \
    --split-dir ../splits/splitA --calib-set val --holdout test --pick 平衡
/workspace/venvs/seg/bin/python tools/make_pack.py calibrate /tmp/reject-新包-v2 \
    --split-dir ../splits/splitA --calib-set val --holdout test --pick 平衡 --write
#    不标定的话,这个包一投产就会被退出码 8 拒绝——这是刻意的。
#    阈值只允许落在 [0,1]:手滑写成 8176 会被退出码 4 当场挡住(曾经是静默全判良品)。
#    随时可以复测现有阈值到底对应多少误报/漏检(不改任何数字):
/workspace/venvs/seg/bin/python tools/make_pack.py calibrate /opt/filter/models/reject-新包-v2 --check

# 3) 传到产线机的【新目录】,绝不覆盖任何正在用的 .onnx / .onnx.data
rsync -a /tmp/reject-新包-v2/ /opt/filter/models/reject-新包-v2/

# 4) 目标机校验(不切换)
filter-predict-v4 --verify reject-新包-v2

# 5) 与现役包对拍(切换前必做)
filter-predict-v4 --compare reject-mnv3-gwz-320-v1 reject-新包-v2 -i /data/回归集/ -o 对拍.xlsx

# 6) 原子切换
filter-predict-v4 --activate reject=reject-新包-v2 --note "张三 2026-08-05 回归通过"

# 回滚:旧目录还在,一条命令
filter-predict-v4 --activate reject=reject-mnv3-gwz-320-v1 --note "回滚:误报上升"

为什么禁止就地覆盖:session 存活时覆写 .onnx.data(被 mmap 59 段)会让同一个 session 的输出静默改变(实测最大差 0.369)且不报错;Windows 上 mmap 锁文件会直接写失败。 先写新目录、再切指针,是唯一安全的做法。


4. 五道闸门:换错模型一定会大声失败

加载顺序 G0 结构 → G1 manifest → G2 文件完整性 → G3 运行时 → G4 建 session → G5 onnx 图契约 → G6 golden 自检 → G7 阈值 → G8 打印契约。任一不过就带编号退出,绝不降级出结果

场景 老 predict.py (v3) v4
换成 imnet 模型只换 onnx 静默跑完,零告警 退出码 5(大小不符)或 7(自检 + sha256 归因)
换 onnx 且照抄旧 json(norm 还写 gwz) 同上静默 退出码 7(S2 归一化:absmean 0.8166 vs 0.8721)
448 模型但 json 写 320 ORT 报 InvalidArgument(看不懂) 退出码 6,两边摊开写清楚
四分类模型当判废模型 静默跑完 退出码 6(2 类 vs 4 类)+ role 校验
完全不带 json 回落 320 + 阈值静默变 0.5 退出码 3 / 8
手改 manifest 把 imnet 改成 gwz 退出码 7
插值后端被换 无感知 退出码 7(S1 canvas 逐字节,零容差)
16 位灰度 PNG 进产线 PIL 与 cv2 差 254,静默出错 退出码 9(逐图)
多个同 role 包 退出码 3,列出候选,绝不静默选第一个
阈值手滑写成 8176 / 1.5 / -1 退出码 4(manifest)/ 2(命令行),见下
把类型包塞进判废槽位(-m 四分类包 退出码 6(槽位校验,见下)
selftest.tolerance 调大让包"跑起来" 退出码 4(容差只许更严)

阈值范围闸decision.thresholdpresets[*].threshold--threshold 都必须落在 [0,1] (判废分是概率),类型也不放过(字符串 "0.8176" 一样被拒)。理由是这个项目的立场—— "没标定过的阈值宁可退出码 8 也绝不兜底 0.5"——不能只管缺失不管离谱: 实测把某个包的 threshold 改成 8176,程序 8 步闸门全绿、横幅照打「自检 ✔」、退出码 0, 把送进去的真不良品全判成良品,整条产线 100% 漏检而没有任何告警

槽位校验pack.role 只说明包对自己自洽,不说明它被放进了哪个槽。 -m <四分类类型包> 时老实现全部闸门通过、把类型模型当判废模型跑完(退出码 0); 反向 --type-model <判废包> 会在缺陷类型列里写出「不良」这种不存在的缺陷类型。 现在两条路径都在推理前退出码 6,并把"槽位要求 / 该包实际"两边摊开。

golden fixture(自检的心脏)

manifest 里存 7 个合成图的 seed + 四级摘要,不存图片(零字节,也不会把真实产品图 带进 HF 仓库和客户机器)。每次运行都跑一遍(7 次前向约 0.3s,不缓存):

记录 容差 独占抓住
S1 canvas_sha256(几何之后、归一化之前的 uint8 画布) 逐字节 resize 内核 / round 语义 / pad 位置/取值 / 解码
S2 norm_stats {absmean,min,max} 1e-4 norm.name / norm.stage / imnet 常数是否 ×255
S3 logits(未过激活) 1e-3 权重被换、onnx 导出串了
S4 score(激活 + score_rule 之后) 1e-4 good_index / activation / score_rule

失败定位是确定性的:canvas 不符→几何;canvas 对而 norm 不符→归一化;两者都对而 logits 不符→权重; 前三级都对而 score 不符→输出语义。程序会在报错里直接给出这张速查表。

容差不是拍脑袋的:数值复现噪声底实测 8.1e-06,最弱的真实故障信号(cv2→pil)是 0.351, logits=1e-3 在噪声之上 123 倍、在信号之下 350 倍。 这既是默认值也是上限:manifest 里的 selftest.tolerance 只允许比它更严,写松一律退出码 4。 现实里的危险路径不是有人攻击,而是"新包 S3 不过、工期紧,先把容差调大让它跑起来"—— 实测把 logits/score 容差改成 1e9 后,一个权重与 manifest 根本不符的包(logits 最大偏差 1.5e+03)会打印「自检 ✔」正常出结果,而 sha256 按设计只在自检失败时才算,等于把最后一道归因也关掉。

7 个 fixture 的覆盖面(少一个都不行,每一个都是实测挑出来的):

# name 覆盖
1 down_307x345 两轴缩小
2 same_320x320 不缩放(resize 被跳过的分支)
3 down_340x359 两轴缩小(近似等比)
4 wide_400x200 极端宽高比 / 大面积补零(norm.stage=after_pad 全靠它)
5 cast_251x337 强色偏 gain=[1.0,0.55,0.30]:分离 gwz / zscore(中性图上两者近似恒等,max|Δ| 只有 0.0117,会淹在容差里;色偏图上是 1.086)
6 up_160x180 放大 1.78x
7 near_317x316 放大 1.0032x(真实产品图最常见的形态:只差几个像素)

6、7 是这一轮对抗验证补上的:原来的 5 个 fixture 全是"缩小/不缩放",而 resize 的边界系数 bug 恰好只在放大分支发作,320px 档下真实产品图约 40% 走放大分支—— 也就是说 S1 这道零容差闸门当时对它是完全盲的。冒烟用例 42 把老实现的行为塞回沙盒, 确认现在会以退出码 7 / S1 几何 报出 up_160x180near_317x316


5. 退出码(产线脚本按这个判)

含义 典型处理
0 正常
2 命令行用法错 看提示改命令
3 找不到包 / 角色候选歧义 / --with-type 无类型包 --list 看看,--activate 指定
4 manifest 非法:版本过高、未知枚举、缺字段、拼错的键 按报错改 pack.json,或升级程序
5 文件完整性:onnx 不存在 / 大小不符 / 外置权重缺失 十有八九是只换了 onnx 没换 pack.json
6 manifest 与 onnx 图不一致 换模型时 json 和 onnx 没配套
7 golden fixture 自检失败 看报错里的 S1~S4 定位,结果不可信
8 没有可用阈值 先标定,或显式 --threshold / --preset
9 图像解码策略不符(16 位图等) 采图端改成 8 位 RGB
10 onnxruntime 建 session 失败 外置权重没拷全 / ORT 版本太老
11 输入路径下没有图片
12 结果写入失败 磁盘/权限

6. 发版说明:v3 → v4 的唯一行为变更

同一个模型对同一批图的判定会有约 0.25% 的变化。这是修复,不是回归。

v3 的 predict.py 用 PIL BILINEAR 缩放,而 42 个模型全部是 albumentations(cv2.INTER_LINEAR) 训练的。v4 用 ~25 行纯 numpy 位级复刻了 cv2 的 8U INTER_LINEAR(不引入 cv2 依赖), 方向是回到训练侧。本次在 splitA test 409 张上实测(全量逐图,非抽样):

v3 (PIL) v4 (cv2_linear)
test AUROC 0.991397 0.991964(= 训练侧 cv2 链的值)
@0.4591 FP=7 FN=3 FP=8 FN=3
判定变化 1 / 409 = 0.24%
filter_binary.json 7 个预设的 test_fp 吻合 2/7 6/7

也就是说:已经写给客户的"保守 = test 误报 8"这个承诺,在 v3 下其实不成立(v3 是 7), v4 让整张标定表重新可精确复现。

6.1 像素链引擎 fpp/1 → fpp/2(本轮修正,所有模型包必须 regen

对抗验证证伪了 fpp/1 的"逐字节复刻 cv2"这句话:放大分支上首/末行与 cv2 差 1 LSB。 根因是 cv2 只在【水平】建表时把边界系数钳成 (2048,0),【垂直】方向不钳—— 越界保护在 resizeGeneric_ 里对行号做 clip(sy0+k, 0, h),于是首/末行是 "同一行 ×(b0,b1)"而不是"同一行 ×(2048,0)";而 b0b1 各自独立取整、b0+b1 未必等于 2048, 两式的 >>16 截断能差 1。fpp/1 对 y 也钳了,所以只在放大时错。

fpp/1 fpp/2
合成扫描(1260 组 形状×图案,含 1×N / 2×2 等极端形状)vs cv2 5.0 93 组不符(max|Δ|=1) 0 组不符
splitA test 409 张 canvas vs albumentations+cv2 164 张不符(320px 档) 0 张不符
全量 3611 张 canvas(其中放大分支 1477 张) 0 张不符
train_parity(400 张 val,与训练链对拍) canvas 差 1、logit 差 0.0081 canvas 0 / logit 0.0 / score 0.0
判定影响(reject-mnv3 @0.4591) 翻转 0/409,FP/FN/AUROC 完全不变

也就是说这次修正不改变任何一张图的判定,改变的是"这句话到底成不成立"。 代价是 canvas_sha256 全部作废:fpp/1 的包在 v4 新版本下会以退出码 4 拒绝加载, 必须在构建机上 make_pack regen <包目录> --parity-images 400。这正是 pp_engine 这个字段 存在的意义——像素链变了就该重新验收,而不是静默兼容。

6.2 出厂包 preset 表里的辅助数字被重算了(阈值一个没改)

reject-mnv3-gwz-320-v1 的 7 个阈值沿用 filter_binary.json 原值(0.7870…0.1215,一个没动), 但 val_fp / test_fp / test_recall 三列改成了本程序实测的值:

预设 v1 表 val_fp 实测 v1 表 test_fp 实测
最严 / 保守 / 平衡 0 / 1 / 3 0 / 1 / 3 ✔ 4 / 8 / 8 4 / 8 / 8 ✔
偏严 2 1 8 8 ✔
宽松 5 3 9 8
高召回 8 6 11 11 ✔
极高召回 15 14 17 17 ✔

这不是像素链的问题。 用训练侧 albumentations+cv2 + best.pt(torch,CPU)独立复算, 得到的数字与本实测逐项相同(val 良品分值前 6 名:torch/cv2 = 0.740242/0.470387/0.463196/0.348750/0.347691/0.315516,本链 = 0.740245/0.470386/0.463196/0.348748/0.347690/0.315516,差 ~3e-6)。 v1 那张表是当年在 GPU 上用 best.pt 评出来的(cls_eval_all.pym.cuda() + channels_last), 与"CPU + ONNX"这条产线实际路径不是同一条计算路径。阈值本身仍是有效工作点: @0.4591 实测 val_fp=3(与记录一致)、test FP=8 / FN=3

对照组:reject-effb0-imnet-320-v1 的表本来就是用这条 CPU+ONNX 路径标的, calibrate --check 复测 7/7 逐项相同,fpp/1→fpp/2 也没有移动它的任何一个工作点。

换到别的 size 代价更大:224px 模型在零误报阈值下 PIL 链翻转 7 张(1.7%),numpy 位级链翻转 0 张。

任何已经基于 v3 输出建立了历史记录 / SPC 基线的产线,换上 v4 后基线会有一个微小台阶, 需要重新走一遍验收。

想逐位保持 v3 的旧行为:在 pack.json 里显式写 "interpolation": "pil_bilinear", 注册表里两个实现都在。实测这样跑出来的分数与 v3 逐图完全相同(409 张 max|Δ|=0.000000)。

其他变更:

  • --threshold 不再有 0.5 兜底 —— 没标定过的包会以退出码 8 拒绝启动。
  • --with-type 找不到类型包不再静默跳过 —— 退出码 3。
  • 结果表固定新增 模型包 / 阈值 两列;xlsx 多一个「运行信息」sheet。

7. 对已分发二进制(v3 / HF daipath/filter-inspection)的承诺

  • release/ 一个字节都不动,HF 仓库和 filter-predict-v3 继续有效。
  • v3 只认 HERE/filter_binary.onnx / HERE/filter_4class.onnx 且不递归;v2 包全在 HERE/models/ 子目录里,v3 物理上看不见。同一个安装目录可以并存。
  • 新二进制叫 filter-predict-v4,不覆盖 v3。v3 的数值行为不做任何回溯性修改 —— 偷偷改已分发二进制的行为,正是这套设计要根除的失败模式。
  • 唯一的禁令:不要把 v2 包里的 onnx 单独拷到老 exe 旁边改名成 filter_binary.onnx。 老 exe 会用写死的 gwz 去跑 imnet 模型,静默降级(零误报工作点检出率 78.57% → 65.62%, 漏检 48 → 77)。这条防线是流程加文档,不是代码 —— 老二进制已经出去了。

8. 打包

/workspace/venvs/pkg/bin/pyinstaller filter-predict.spec --distpath dist --workpath build

只内置 17MB 的判废兜底包,且**内置目录名等于 pack.id**(_MEIxxx/models/reject-mnv3-gwz-320-v1), 这样 exe 旁边放一个同 id 的包就会自然把它遮蔽掉——--list 会显示「↓ 被覆盖」。 85MB 的类型包一律外置:实测内置 102MB 模型的代价是每次调用 +1.7s 解包 + /tmp 峰值 357MB(外置 163MB)。 .onnx.data(外置权重)必须一并打进去,否则运行时直接 FAIL。

本次实测产物:56.6 MB(v3 是 185MB),冷启动 1.0s,22 张图 0.3s(判废)/ 0.9s(判废+类型)。 已验证的 frozen 场景:中文+空格路径、经符号链接调用、PATH 裸名调用、FILTER_MODEL_DIR、 拷贝 models/ 后热插拔、--activate 现场切换(含回滚到内置兜底包)。exe 里不含 torch / cv2 的任何痕迹。

spec 里所有路径都必须相对 SPECPATH 拼绝对路径。 spec 是被 exec 的普通 Python, 裸相对路径按【调用 pyinstaller 时的 cwd】解析,而 datas 元组里的相对源路径又由 PyInstaller 按 spec 所在目录解析——两套规则。实测:在别的目录下敲 pyinstaller .../filter-predict.spec, 若那个 cwd 下恰好也有 models/reject-mnv3-gwz-320-v1/,旧写法的 os.listdir 会列出 那个目录的文件名,产出一个构建成功、运行时却退出码 5 的 exe。 现在换成绝对路径 + 构建前断言 model.onnx / pack.json 存在;已在"错误 cwd + 同名诱饵目录" 下实测构建通过且内置包完好。


9. 已知残余风险(诚实清单)

  1. 包从一开始就配错但自洽:如果 make_pack 读错 summary.json(42 个里有 23 个根本没有 norm 键,只能按上游默认落 gwz),生成的包所有闸门全绿——因为 fixture 就是用这条错误管线算的。 自检证明的是"目标机 == 构建机",不是"构建机 == 训练真相"。 后者由构建期的 train_parity 闸门(对拍 albumentations+cv2)保证,而它只能在有 torch+cv2 的构建机上跑。 这是"推理端不许装 cv2"这条硬约束的必然分工,不是能设计掉的。
  2. classes 名字对调("划痕"与"麻点"互换)没有任何自动防线:argmax 照样通过全部自检, 只是标签张冠李戴。缓解:make_pack 自动生成而不是手填,--info 打印出来让人核。
  3. sha256 不是安全边界:能改 model.onnx 的人也能改 pack.json。这套机制防的是事故 (rsync 拷错、手滑覆盖、照抄旧 json),不是攻击。防攻击要签名,本版本没做。
  4. fixture 是合成噪声图,覆盖的是"分支"不是"分布":现在 7 个 case 覆盖了 缩小 / 不缩放 / 极端宽高比 / 强色偏 / 大倍率放大 / 近似等尺寸放大六种几何分支 (fpp/1 时代只有前 4 类,对放大分支是盲的,这一轮才补上)。但它们仍然不是真实产品图: 通过 ≠ 模型在真实图上判得对,只等于"像素链和权重跟打包时一致"。
  5. 每次运行 +0.3s 自检(7 个 fixture)、每张图 +6ms resize(22 张的作业整体约 +15%)。 单图一进程的调用方式会明显感到自检税,应改用批量模式。
  6. **pp_engine 升级会作废所有包的 canvas_sha256**,需要在构建机上 make_pack regen。 本二进制只实现一个引擎,版本不符即硬失败——像素链变更本来就是需要重新验收的行为变更。 本轮 fpp/1 → fpp/2 就是一次这样的变更:三个出厂包都已 regen,第三方手里的 fpp/1 包必须重做。
  7. 阈值的辅助统计只在它被标定的那条计算路径上成立:出厂包 preset 表里的 val_fp / test_fp / test_recall 是"本程序 + 本 onnx + CPU"实测的。 同一权重换到 GPU / torch 上评,数字会有出入(见 §6.2 的实测对照,这就是 v1 那张表的来历)。 换机器、换 EP、换引擎之后想知道现在到底对应多少误报,跑 make_pack.py calibrate <包> --check(它不改任何数字)。
  8. --activate 的闸门跑在切换的那一刻,不是产线运行时的那一刻: 切换之后再有人去动包目录,仍然要等下一次运行被自检拦住(退出码 7)——那已经是停线。 models/ 目录权限该收就收。
  9. 内置兜底包是次优的 gwz mnv3,不是最优的 effb0/imnet(内置哪个都会有人直接用兜底包)。 两个包都在 models/ 里,用 --compare + --activate 切换。