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