# 滤光片外观质检 · 模型包版 (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. 常用命令 ```bash 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) ```bash # 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.threshold`、`presets[*].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_160x180` 与 `near_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)";而 `b0`、`b1` 各自独立取整、`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.py` 里 `m.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. 打包 ```bash /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` 切换。