# 模型包(model pack)手册
面向两种人:
* **产线/运维** —— 只看第 1、5、6 节:怎么换模型、报错了怎么办。
* **算法/构建机** —— 看全部:manifest 每个字段什么意思、怎么生成新包。
---
## 1. 换模型:最短路径
```bash
# 情况 A:算法给了你一个做好的包目录(里面有 model.onnx + pack.json)
rsync -a 新包-v2/ /opt/filter/models/新包-v2/ # 拷成【新目录】,绝不覆盖旧的
filter-predict-v4 --verify 新包-v2 # 8 步全闸门校验,必须全绿
filter-predict-v4 --compare 现役包 新包-v2 -i /data/回归集/ -o 对拍.xlsx # 看有多少张判定会变
filter-predict-v4 --activate reject=新包-v2 --note "张三 2026-08-05 回归通过"
# 回滚:旧目录还在
filter-predict-v4 --activate reject=旧包-v1 --note "回滚:误报上升"
```
`--activate` 自己会先把**全部闸门跑一遍**(结构 / manifest / 完整性 / 图契约 / golden 自检),
不过就拒绝切换并原样保留 `ACTIVE.json`;写完还会**回读校验**一次,确认程序真的会读到刚写的那份。
(切换是这套工具里最危险的动作:如果它只是"写了个文件就报 ✔",故障要等下一次真跑才暴露,
而那多半是夜班交接之后最不合适的时刻。曾经还有两种"写了但读不回来"的形态:
回滚到**内置兜底包**时写进 onefile 的临时解包目录、激活 **v1 平铺包**时写到 `HERE/` 而不是
`HERE/models/` —— 两种都会打印 ✔、退出码 0,产线却继续跑着旧模型。)
三条铁律:
1. **绝不就地覆盖任何 `.onnx` / `.onnx.data`。** 程序运行中覆写会静默改变输出(实测最大 0.369),
Windows 上还会因为文件被 mmap 锁住直接失败。永远是"新目录 → 校验 → 切指针"。
2. **绝不只换 `.onnx` 不换 `pack.json`。** 预处理参数(norm/size)和阈值都在 pack.json 里,
换了权重不换它 = 用旧模型的配方跑新模型。程序会用文件大小和 golden fixture 拦住你,
但正确的做法是整个目录一起换。
3. **绝不把 v2 包里的 onnx 拷到老 v3 exe 旁边改名成 `filter_binary.onnx`。**
老 exe 里 norm 是写死的 gwz,跑 imnet 模型会静默降级(零误报工作点检出率 78.57% → 65.62%)。
---
## 2. 一个包长什么样
```
models/reject-effb0-imnet-320-v1/
├── model.onnx # 权重。文件名由 pack.json 的 model.file 指名,不靠猜
├── model.onnx.data # 可选:外置权重(dynamo 导出),必须与 .onnx 同目录
└── pack.json # manifest:预处理、输出语义、阈值、自检基准,全在这里
```
* 目录名必须等于 `pack.id`,不等就会被跳过并在 `--list` 里报出来(防 rsync 改名)。
* manifest 文件名固定 `pack.json`,**永远不从 onnx 路径推导**
(`'/opt/v2.onnx-pack/model.onnx'.replace('.onnx','.json')` 会把目录名也改坏——v3 就有这个 bug)。
* 也**永远不叫** `filter_binary.json`:即使有人把包摊平拷到老 exe 旁边,老 exe 也读不到它,
不会拿 v2 的字段配 v1 的逻辑跑出半错的结果。
* 不允许给 `.onnx` 做文件级符号链接(外置权重会让 ORT 报 `escapes model directory`);
目录级软链、`models/` 整体软链、硬链接都可以。
---
## 3. manifest 字段全表
只有 `_` 开头的键可以随便加(留给人写注释)。**其余未知的键一律报错退出码 4** ——
拼错的字段如果被忽略,就等于配置没生效而程序不报错,那正是这套设计要根除的失败模式。
### 3.1 顶层
| 字段 | 必填 | 说明 |
|---|---|---|
| `schema` | ✔ | 固定 `filter-model-pack/2`。主版本大于程序支持的 → 退出码 4「请升级程序」 |
| `pack` | ✔ | 身份 |
| `runtime` | | 运行时要求 |
| `model` | ✔ | 权重文件与完整性 |
| `input` | ✔ | **像素链,必须逐字节复现训练** |
| `output` | ✔ | 输出张量语义 |
| `decision` | ✔ | 阈值与判定 |
| `selftest` | ✔ | golden fixture |
| `provenance` | | **完全不透明**,程序一个键都不解析,只在 `--info` 原样打印 |
### 3.2 `pack`
| 字段 | 必填 | 说明 |
|---|---|---|
| `id` | ✔ | 必须等于目录名 |
| `version` | ✔ | 随意的版本串,进审计日志 |
| `role` | ✔ | `reject`(判废,2 类)或 `type`(缺陷类型,≥3 类) |
| `description` | | 人看的一句话 |
### 3.3 `runtime`
| 字段 | 说明 |
|---|---|
| `pp_engine` | 生成该包时的像素链引擎,当前唯一合法值 **`fpp/2`**。不符 → 退出码 4,要求在构建机 `make_pack regen`。
`fpp/1` 的包**加载不了**:fpp/2 修正了 resize 垂直边界系数(放大分支曾与 cv2 差 1 LSB),`canvas_sha256` 全部作废 |
| `min_onnxruntime` | 如 `"1.16"`。dynamo 导出的包要写 `"1.22"`(ir_version 10 / opset 18) |
### 3.4 `model`
| 字段 | 必填 | 说明 |
|---|---|---|
| `file` | ✔ | 相对包目录的文件名,不含路径分隔符 |
| `bytes` | ✔ | 文件字节数。**每次运行都校验**(`os.path.getsize`,免费) |
| `sha256` | ✔ | 64 位十六进制。**只在 `--verify` 和自检失败时计算**(85MB 约 170ms)。
理由:fixture 通过就已经在行为上证明了权重字节正确(比哈希更强);
fixture 失败时才需要哈希来区分"onnx 被换了"和"manifest 被改了" |
| `external_data` | | `[{file, bytes, sha256}]`,外置权重清单 |
### 3.5 `input`(最关键的一块)
| 字段 | 取值 | 说明 |
|---|---|---|
| `size` | 正整数 | 与 onnx 图交叉校验,不一致 → 退出码 6 |
| `decode` | `rgb8` | 只接受 8 位 RGB。16 位灰度 PNG 上 PIL 与训练侧 cv2 的像素最大差 **254**,所以宁可退出码 9 |
| `color_order` | `RGB` \| `BGR` | 训练是 `cv2.imread` + `BGR2RGB`,所以是 RGB |
| `layout` | `NCHW` \| `NHWC` | 写错 ORT **不会报错**,只会算错 |
| `dtype` | `float32` \| `float16` | 写错 ORT 会报错(安全) |
| `geometry.mode` | `longest_max_size+pad` \| `resize_shortest+crop` \| `stretch` | 前者 = `A.LongestMaxSize` + `A.PadIfNeeded`(保长宽比,**也会放大**:409 张 test 里 164 张被放大) |
| `geometry.interpolation` | `cv2_linear` \| `pil_bilinear` \| `pil_bicubic` \| `nearest` | **枚举命名的是内核语义而不是笼统的 "bilinear"**:cv2 是半像素中心且下采样不低通,PIL 是 support 随 scale 放大且下采样带抗锯齿,两者不是一回事。本项目 42 个模型全部是 `cv2_linear` |
| `geometry.pad_position` | `center` \| `top_left` | 训练是 center |
| `geometry.pad_value` | 0..255 | 训练是 0(黑边) |
| `geometry.crop_pct` | 浮点 | 仅 `resize_shortest+crop` 时必填 |
| `norm.name` | `gwz` \| `imnet` \| `zscore` \| `none` \| `scale01` | **v3 把这个字段完全忽略、写死 gwz**,代价见下 |
| `norm.stage` | `after_pad` | 训练真相:**补零之后才算 mean/std,黑边计入统计**。写成"先归一化再补零"会让 AUROC 0.9920→0.9911、翻转 1/409,且不报错 |
| `norm.mean` / `norm.std` | 数组 | 仅 `imnet` 允许,值域 0-1,运行时自动 ×255(输入域是 0-255 而不是 0-1) |
三种归一化的确切定义(与 `cls_train2.py:79-96` 逐行一致):
```python
gwz : f = img.float(); m = f.reshape(-1,3).mean(0) # 灰世界通道均值(含补零像素)
f *= m.mean()/max(m,1e-6)
f = (f - f.mean()) / max(f.std(), 1e-6) # z-score 是【全局标量】,不是 per-channel
imnet : (f - [.485,.456,.406]*255) / ([.229,.224,.225]*255)
zscore : (f - f.mean()) / max(f.std(), 1e-6)
```
> **norm 写错的实测代价**(这就是为什么它必须由 manifest 驱动且缺失即拒绝):
> * imnet 模型走 gwz:test AUROC 0.9976 → 0.9940,零误报工作点检出率 78.57% → 65.62%,漏检 48 → 77
> * gwz 模型走 imnet:test AUROC 0.9914 → **0.3542**(排序反转,Spearman −0.167),
> 出货阈值下 185 张良品误报 141 张 —— 而程序不会有任何异常表现
### 3.6 `output`
| 字段 | 说明 |
|---|---|
| `tensor` | 输出下标或名字。7 个已导出模型都是单输出,但"logits + 特征"双输出的模型是存在的 |
| `activation` | `softmax` \| `sigmoid` \| `none`。**onnx 图里如果已经内置 softmax,这里必须写 `none`**:
双重 softmax 会把二分类概率压进 [0.269, 0.731],7 个预设里 3 个直接失效(最严→检出 0%,高召回/极高召回→误报 100%),
而 AUROC 完全不变(单调变换),离线评测根本查不出来 |
| `classes` | **顺序即语义**。cls4 = `["良品","划痕","麻点","崩边"]`,对应训练的 `CLS4={'good':0,'划':1,'麻':2,'崩':3,'侧面崩':3}`(原始 5 个标签压成 4 类) |
| `good_index` | 良品在 `classes` 里的下标。v3 隐含假设 =0;类别若按字母序排就会错 |
### 3.7 `decision`
| 字段 | 说明 |
|---|---|
| `score_rule` | `1-P[good]` \| `sum(P[bad])` \| `P[bad_index]`。softmax 下前两式恒等(实测差 1.19e-07),sigmoid 多标签下立刻分裂 |
| `bad_indices` / `bad_index` | 对应上面后两种规则 |
| `threshold` | 判废阈值,**必须是 `[0,1]` 内的数字或 `null`**。未标定就写 `null` —— 程序会以退出码 8 拒绝启动,**绝不兜底 0.5**。
写成 `8176` / `1.5` / `-1` / 字符串 `"0.8176"` 一律退出码 4:实测 `8176` 会让 8 步闸门全绿、退出码 0、把所有产品判成良品(**100% 漏检零告警**) |
| `presets` | `{名字: {threshold, ...}}`,`--preset 保守` 用。每个 `threshold` 同样过 `[0,1]` 闸。除 `threshold` 外的键程序不解析、原样打印 |
| `calibration` | 不透明溯源:在哪个 split、多少良/不良、什么口径、什么时候标的 |
| `type_rule` | `{mode: "argmax", min_conf: 0.0}`,类型模型用。`min_conf` 也必须在 `[0,1]`(>1 会把每张都判「不确定」) |
> **阈值必须跟模型走,不跟代码走。** 出厂 mnv3/gwz 的平衡阈值是 0.4591,
> efficientnet_b0/imnet 的同口径阈值是 **0.5883** —— 把前者套到后者身上没有任何意义。
### 3.8 `selftest`
```json
"selftest": {
"generator": "randomstate",
"tolerance": {"canvas": "exact", "norm_stats": 1e-4, "logits": 1e-3, "score": 1e-4},
"cases": [{"name": "down_307x345", "gen": "randomstate", "seed": 20260731, "shape": [345,307,3],
"canvas_sha256": "...", "norm_stats": {...}, "logits": [...], "score": 0.937263}, ...]
}
```
* **必须恰好 7 个 case,名字和顺序固定**(名字一律是【宽x高】,与 `shape=[H,W,3]` 相反):
| # | name | seed | shape | 覆盖 |
|---|---|---|---|---|
| 1 | `down_307x345` | 20260731 | [345,307,3] | 两轴缩小 |
| 2 | `same_320x320` | 20260732 | [320,320,3] | 不缩放(resize 被跳过) |
| 3 | `down_340x359` | 20260733 | [359,340,3] | 两轴缩小 |
| 4 | `wide_400x200` | 20260734 | [200,400,3] | 极端宽高比 / 大面积补零 |
| 5 | `cast_251x337` | 20260735 | [337,251,3] `gain=[1.0,0.55,0.30]` | 强色偏 |
| 6 | `up_160x180` | 20260736 | [180,160,3] | **放大** 1.78x |
| 7 | `near_317x316` | 20260737 | [316,317,3] | **放大** 1.0032x(真实产品图最常见形态) |
第 5 个强色偏图是**实测出来的必需项**:只用中性图时 gwz↔zscore 错配的偏差只有
0.0117~0.0413,会淹在容差里;加上色偏图后升到 0.94~1.09(提升 23~92 倍)。
灰世界白平衡在色彩中性的图上本来就近似恒等。反过来色偏图对插值内核最不敏感,所以两类缺一不可。
第 6、7 个是这一轮对抗验证补的:原来 5 个 case 全是"缩小/不缩放",
而 resize 的边界系数 bug **只在放大分支发作**(320px 档下真实产品图约 40% 走放大分支),
于是 S1 这道零容差闸门当时对它完全是盲的 —— fixture 值本身是真的,但**覆盖面**不够。
冒烟用例 42 会把老实现塞回沙盒,确认现在能以退出码 7 / S1 报出这两个 case。
* **`tolerance` 只允许比默认更严,写松一律退出码 4。** 默认同时就是上限
(`norm_stats 1e-4 / logits 1e-3 / score 1e-4`,`canvas` 只能是 `"exact"`)。
危险路径不是攻击,而是"新包 S3 不过、工期紧,先把容差调大让它跑起来":
实测把 `logits`/`score` 改成 `1e9` 后,一个权重与 manifest 根本不符的包
(logits 最大偏差 **1.5e+03**)会打印「自检 ✔」并正常出结果、退出码 0;
而 `sha256` 按设计只在自检失败时才算,等于把最后一道归因也一起关掉。
* 用 legacy `np.random.RandomState` 而**不是** `default_rng`:NEP 19 只承诺 `RandomState`
的随机流永久稳定,`Generator` 的流允许在 feature release 里改变。fixture 要在 manifest 里躺好几年。
* fixture 值**不是人填的,人也填不出来**,一律由 `tools/make_pack.py` 生成。
* 自检永远在 `CPUExecutionProvider` 上跑(CUDA 的偏差会超 1e-3,否则就变成"换了显卡就自检失败")。
* fixture 图**不落临时文件**(只读/受限的产线机器上写 temp 会失败)。
---
## 4. 生成一个新包(构建机)
```bash
# 从训练目录(首选:能填满 provenance,并跑 train_parity 闸门)
/workspace/venvs/seg/bin/python tools/make_pack.py from-train \
../splits/splitA/cls2/bin_efficientnet_b0_mid_imnet_ce_320 \
--out models/reject-effb0-imnet-320-v1 --id reject-effb0-imnet-320-v1 --role reject \
--parity-images 150 --parity-set val
# 从 v1 的 onnx + json 转换
/workspace/venvs/seg/bin/python tools/make_pack.py from-v1 \
../release/filter_binary.onnx ../release/filter_binary.json \
--out models/reject-mnv3-gwz-320-v1 --id reject-mnv3-gwz-320-v1 --role reject --parity-images 150
# 在 val 上标定阈值 + 预设表(新包投产前【必须】做这一步;不给 --write 就是 dry-run)
/workspace/venvs/seg/bin/python tools/make_pack.py calibrate models/reject-x \
--split-dir ../splits/splitA --calib-set val --holdout test \
--ladder 0,1,2,3,5,8,15 --names 最严,保守,偏严,平衡,宽松,高召回,极高召回 \
--pick 平衡 --write
# 只复测【现有】阈值到底对应多少误报/漏检,不改任何数字
/workspace/venvs/seg/bin/python tools/make_pack.py calibrate models/reject-x --check
# 像素链引擎升级后重算 fixture(唯一允许重算的入口,只在构建机上)
/workspace/venvs/seg/bin/python tools/make_pack.py regen models/reject-x --parity-images 400
```
`from-train` 干六件事:导出 onnx(记录 PyTorch↔ONNX 误差)→ 从 `summary.json` 填 manifest →
算 bytes/sha256 → 跑 **train_parity 闸门** → 生成 7 个 fixture → 调 `predict.py --verify` 自校一遍。
### 4.1 标定阈值(`calibrate`)
这是整条流程里**唯一决定良品/不良的数字**,所以它有工具、有口径、有校验,不靠人手填:
* 口径与 `cls_eval_all.py:113` 完全一致:**val 上取满足误报 ≤K 的最小阈值**,
候选值取自 val 上实际出现过的分值(含不良样本的分值),再向上取整到 6 位小数
(向上只会让误报更少,所以 `fp<=K` 依然成立,而写进 json 的数字与实际用的数字完全一致)。
* holdout(test)**只评一次**,结果写进每个预设的 `test_fp` / `test_recall`。
* 打分走的是**产线实际那条计算路径**(本包的 manifest 预处理 + ONNX + CPU),
不是 torch、不是 GPU —— 阈值必须标定在会真正跑它的那条路径上。
* **不要拿结果 CSV 里的"不良概率"反推阈值**:那一列只有 4 位小数,精度 1e-4,
而且没有任何复核。`calibrate` 内部用全精度分值。
* 写回后自动调 `predict.py --verify` 复校一遍;阈值超出 `[0,1]` 会在写回前就被 manifest 校验挡住。
**train_parity 闸门**把纯 numpy 像素链与训练侧 `albumentations + cv2` 逐图对拍,
记录 `canvas_u8_max_abs_err` / `logit_max_abs_err` / 判定翻转数 / ΔAUROC,
翻转 ≠ 0 或 |ΔAUROC| > 0.001 就**拒绝生成包**。
> 这是整套机制里唯一能接住"包从一开始就配错但自洽"的地方。
> golden fixture 证明的是"目标机 == 构建机",证明不了"构建机 == 训练真相"。
> 后者只能在有 torch + cv2 的构建机上验,目标机复验不了 —— 这是"推理端不许装 cv2"的必然分工。
`--role reject` 且没给 `--threshold` 时,`decision.threshold` 写 `null`,
`--verify` 会在第 8 步如实报红。**新包默认没有阈值,必须先在 val 上标定。这是刻意的。**
---
## 5. v1 兼容(老的 `filter_binary.onnx` + `filter_binary.json`)
把老的一对文件直接放在程序旁边就能跑(`HERE/*.onnx` 平铺扫描)。程序会在内存里把它升级成 v2,
**永不改写源文件**,并且每次运行都打印降级警告:
```
⚠ v1 兼容模式(降级):该包没有 pack.json,以下保护不可用:
· model.sha256 完整性 —— 无法检测「只换了 .onnx」(最致命的静默失败)
· golden fixture 自检 —— 无法检测 norm / 几何管线错配
```
映射规则(每一项都不猜):
| v2 字段 | v1 来源 | 缺失时 |
|---|---|---|
| `pack.role` | `task=='bin'→reject`,`cls4→type`;否则按类别数 | 都判断不了 → 退出码 3 |
| `input.size` | `size` | 没有则从 onnx 图取;都没有 → 退出码 4 |
| `input.norm.name` | **读 v1 的 `norm` 字段**(它本来就有而且是对的,只是被 v3 忽略了) | 连键都没有才落 `gwz` + 打警告,与 `cls_eval_all.py:129` / `export_onnx.py:41` 的 `.get('norm','gwz')` 同一默认(42 个 summary.json 里有 23 个没写 norm 键) |
| `geometry.interpolation` | — | **`cv2_linear`**(训练真相),不是 v3 当时的 PIL —— 唯一的行为变更,见 README 第 6 节 |
| `output.activation` / `good_index` / `score_rule` | — | `softmax` / `0` / `1-P[good]`,精确复刻 v3 的行为 |
| `decision.threshold` | `threshold` | 缺失 → 退出码 8,**绝不兜底 0.5** |
| `decision.presets` | `presets` | 原样搬 |
产线建议常开 `--strict`:直接拒绝一切 v1 降级包(退出码 3)。
---
## 6. 常见错误速查
| 报错 | 真正的原因 | 怎么办 |
|---|---|---|
| `[退出码3] role=reject 有 N 个候选包` | 拷进了第二个判废包,程序拒绝替你猜 | `--activate reject=<包id>` 或 `--model <包id>` |
| `[退出码3] 没有可用的 role=type 模型包` | 只拷了 exe 没拷 `models/`,或类型包没拷 | 把 `models/` 整个拷过来 |
| `[退出码4] input 里有本版本不认识的键 ['nrom']` | 手改 manifest 拼错了字段名 | 按报错里的允许列表改 |
| `[退出码4] norm.name = 'xxx' 本版本不支持` | manifest 用了新预处理,但程序是旧的 | **升级程序**,不要改 manifest 去迁就旧程序 |
| `[退出码4] schema 版本 filter-model-pack/3 高于本程序支持的` | 包比程序新 | 升级程序 |
| `[退出码5] model.onnx 大小与 manifest 不符` | **只换了 onnx 没换 pack.json** | 整个包目录一起换 |
| `[退出码6] manifest 与 onnx 图不一致` | json 和 onnx 不是一套 | 看报错里摊开的两边数值 |
| `[退出码7] 自检失败 · S1 几何` | resize 内核 / round 语义 / pad 位置或取值 / 解码变了 | 检查 `geometry.*`;或程序的像素链引擎变了 |
| `[退出码7] 自检失败 · S2 归一化` | `norm.name` / `norm.stage` / imnet 常数是否 ×255 | 检查 `input.norm`,**结果不可信** |
| `[退出码7] 自检失败 · S3 权重` | onnx 与 pack.json 不是同一个模型(Δ>1);或 activation 被叠加了两次(Δ 0.1~1) | 看报错里的 sha256 归因 |
| `[退出码7] 自检失败 · S4 判定语义` | `good_index` / `activation` / `score_rule` 配错 | 三个字段只有这一处能抓住 |
| `[退出码8] 没有标定过的判废阈值` | 新包还没标定 | `make_pack.py calibrate <包> --write`;临时可用 `--threshold` |
| `[退出码4] decision.threshold = 8176.0 超出 [0,1]` | 手填阈值时小数点手滑 | 用 `calibrate` 标,别手算。曾经这会静默把全部产品判成良品 |
| `[退出码4] selftest.tolerance.logits 比本程序的上限更松` | 有人为了让包"跑起来"把容差调大了 | 去查自检为什么不过(S1~S4),容差只许更严 |
| `[退出码4] JSON 解析失败` | pack.json 不是合法 JSON | **UTF-8 BOM 是允许的**(Windows 记事本另存没问题),别的语法错要改 |
| `[退出码4] 本程序的像素链引擎是 'fpp/2',而该模型包生成于 'fpp/1'` | 包比程序旧一个引擎版本 | 构建机上 `make_pack.py regen <包> --parity-images 400` |
| `[退出码6] 模型包 X 不能用作【判废】模型` | 把类型包塞进了 `-m`(或反过来把判废包塞进 `--type-model`) | `--list` 看 role,换成对的包 |
| `[退出码2] --verify 不接受 -m/--model` | 写成了 `-m --verify` | 写 `--verify ` |
| `[退出码2] --threshold 1.5 超出 [0,1]` | 命令行阈值手滑 | 判废分是概率,只能 0~1 |
| `[退出码9] PIL 解出来是 I;16` | 采图端出了 16 位图 | 改成 8 位 RGB PNG |
| `[退出码10] onnxruntime 无法加载` | 外置权重 `.onnx.data` 没拷 / ORT 太老 / 给 .onnx 做了文件软链 | 见第 2 节的链接规则 |
| 自检全部 Δ<1e-5 却报错 | `selftest.tolerance` 被人改小了 | 噪声底实测 8.1e-06,不要设到它以下 |
### 残余风险(必须知道)
* **单字节改动不一定被运行时抓住**:实测在 16MB 的 onnx 里翻掉某一个字节,logits 可能完全不变
(那个字节没被用到)。这种情况只有 `--verify` 的 sha256 能发现。运行时校验的是**行为**
(fixture)而不是字节 —— 行为一致的字节差异不影响结果,但换模型前跑一次 `--verify` 仍然是纪律。
* **`classes` 名字对调没有任何自动防线**:argmax 照样全绿,只是标签张冠李戴。靠 `--info` 人工核。
* **sha256 防的是事故不是攻击**:能改 onnx 的人也能改 pack.json。要防攻击得签名,本版本没做。
* **`--check` 出来的数字可能和包里原来记的对不上,而两者都没错**:
出厂包 `reject-mnv3-gwz-320-v1` 的 preset 表源自 v1 时代**在 GPU 上用 best.pt** 评的结果,
与"CPU + ONNX"这条产线路径不是同一条计算路径(用 torch+cv2 在 CPU 上独立复算,
数字与本程序实测逐项相同,差 ~3e-6)。现在包里记的是**本程序实测值**,阈值一个没改。
换句话说:**辅助统计只在它被标定的那条计算路径上成立**,换路径就要 `--check` 重测。
---
## 7. 新增一种预处理方式(这就是"换模型不重打包"的边界)
manifest 只能引用程序**已经实现**的注册表项。想加一种新的归一化 `foo`:
1. 在 `filterpack/preprocess.py` 的 `NORM` 表里加一个 `'foo': norm_foo`;
2. 升 schema 的**次版本号**(`filter-model-pack/2.1`)—— 主版本不变,老程序读到 `2.1` 仍会
尝试加载,但遇到 `norm.name='foo'` 会以退出码 4 明确告诉用户"请升级程序";
3. 重新打包**程序**(这一步不可避免:新代码必须进二进制)。
也就是说:**换模型、换阈值、换类别、换 size、换现有的任意一种预处理组合,都不用重新打包;
只有"发明一种程序里还没有的预处理算法"才需要。**