| # 完整复现指南 |
|
|
| 从零复现本项目的全部实验。按顺序执行即可。 |
|
|
| **总耗时**:数据准备约 20 分钟,全部训练约 14 小时(单张 RTX 4090)。 |
| 只想复现分类主线结论:约 3 小时。 |
|
|
| --- |
|
|
| ## 0. 硬件与环境 |
|
|
| 实测环境(结果就是在这上面跑出来的): |
|
|
| | | | |
| |---|---| |
| | GPU | NVIDIA RTX 4090 24 GB | |
| | CUDA | 12.8 | |
| | Python | 3.12.3 | |
| | OS | Linux 6.8(Ubuntu 24.04 容器) | |
|
|
| ### 建环境 |
|
|
| ```bash |
| python3.12 -m venv /workspace/venvs/seg |
| source /workspace/venvs/seg/bin/activate |
| pip install -U pip |
| |
| pip install torch==2.11.0 torchvision --index-url https://download.pytorch.org/whl/cu128 |
| pip install timm==1.0.28 segmentation-models-pytorch==0.5.0 albumentations==2.0.8 \ |
| opencv-python-headless==5.0.0 numpy==2.4.4 pandas==3.0.5 \ |
| scipy scikit-learn pillow openpyxl tqdm huggingface_hub |
| ``` |
|
|
| 版本敏感度:`albumentations` 2.x 的 `PadIfNeeded(fill=…)` 参数名与 1.x 不同(1.x 是 `value=`), |
| 脚本按 2.x 写。`timm` 需 ≥1.0 才有 `edgenext_small.usi_in1k` / `efficientvit_b1.r288_in1k`。 |
|
|
| ```bash |
| export HF_HOME=/workspace/hf_cache # timm 预训练权重缓存,避免重复下载 |
| export HF_HUB_DISABLE_XET=1 # 本容器里不加这条 HF 上传/下载会报 httpx 错 |
| ``` |
|
|
| --- |
|
|
| ## 1. 拿数据 |
|
|
| ```bash |
| cd /workspace/CV_Task/seg3/newdata |
| hf download daipath/filter-inspection-data --repo-type dataset --local-dir ./dl |
| ``` |
|
|
| 如果你要从**最原始的压缩包**开始(验证解包逻辑): |
|
|
| ```bash |
| bash fetch_and_unpack.sh # 下 样本7_27.zip 并递归解包到 raw/ |
| ``` |
|
|
| `unpack.py` 会做两件不显然的事: |
| 1. **修 GBK 文件名**。zip 里中文名以 cp437 存储,按 `cp437 → gbk → gb18030 → big5` 顺序回退解码。不修的话目录名全是乱码,`pkg` 字段就废了。 |
| 2. **防 zip-slip**。拒绝解到 `raw/` 之外的路径。 |
|
|
| 解包后应得到 **3728 张 PNG + 1977 个 labelme JSON**,分布在 11 个包目录下。 |
|
|
| --- |
|
|
| ## 2. 清点与几何量测 |
|
|
| ```bash |
| python inventory.py # -> inventory.csv 3728 行 |
| python metrics_fix.py # -> metrics_fixed.csv 1948 行(仅已标注图) |
| ``` |
|
|
| ### `inventory.py` 里两条必须原样保留的正则 |
|
|
| ```python |
| RE_BATCH = re.compile(r'(?<![0-9])(38[0-9]{3})(?![0-9])') |
| RE_PMACH = re.compile(r'(?<![A-Za-z0-9])[Pp]([0-9]{2})([0-9]{6})?(?![0-9])') |
| ``` |
|
|
| **没有批次号 / 机器号的包,字段留空,不做任何默认填充。** |
| 11 个包里有 5 个没有机器号——如果你给它们编一个默认值,`group` 分组就会把不同来源的图混成一组,切分立刻泄漏。 |
|
|
| `FINE_VERT_THR = 30`:标注顶点数中位 ≥30 判为「精细描边」,否则「粗框」。 |
| 这条把 11 个包分成 labelme 3.3.10(顶点中位 80,精描)和 5.11.4(顶点中位 12,粗框)两档。 |
| `样本_38031` **两个版本混装**,要按版本拆开再用。 |
|
|
| ### `metrics_fix.py` 修掉的两个 bug |
| |
| 原版 `metrics.py` 有两处错: |
| 1. **circle 类型的 shape 被漏计**。labelme 的 `circle` 只存圆心+半径两点,原版按多边形算面积得 0。修法是展开成 24 边形再算。 |
| 2. **`tightness` 指标是错的**——它拿多边形面积除以外接框面积,但崩边缺陷的多边形大部分画在片体外,这个比值没有物理意义。换成两个真实可解释的量: |
| - `in_body_ratio`:多边形落在片体内的面积占比 |
| - `vert_density`:顶点数 / 周长,用来区分精描与粗框 |
|
|
| --- |
|
|
| ## 3. 切分 |
|
|
| ```bash |
| python build_splits.py # -> splits/splitA/{meta,dropped}.csv |
| # splits/splitB/{meta,dropped}.csv |
| ``` |
|
|
| ### 两套切分 |
|
|
| | | A 套(全混合) | B 套(跨批次) | |
| |---|---|---| |
| | 目的 | 常规泛化 | **换批次还灵不灵** | |
| | 做法 | 11 个包混合,8:1:1 分层 | `样本_38016_P08262014` **整包**当 test | |
| | train / val / test | 2768 / 434 / 409 | 2787 / 441 / 284 | |
| | test 良品 / 不良 | 185 / 224 | 185 / 99 | |
|
|
| 两套都从 3728 张里**剔除 117 张**: |
|
|
| | 原因 | 张数 | 说明 | |
| |---|---|---| |
| | `rare_class` | 83 | 「碎」64 + 「不可擦脏」19,整类排除 | |
| | `zero_shapes` | 28 | 有 json 但 `shapes` 为空数组,零监督 | |
| | `no_json` | 6 | 缺标注文件 | |
|
|
| 剩 3611 张。剔除清单在 `dropped.csv`,每行带原因,可复查。 |
|
|
| > 「碎」虽然被排除,但它在 [审计](AUDIT.md) 里当**未见类硬正样本**用——模型从没见过这类,能不能抓到是诚实性的关键证据。 |
|
|
| ### 分组规则(最重要的一条) |
|
|
| 分组单位是 **`group = pkg ‖ sess`**(包 ‖ 拍摄会话),**同一组的图绝不跨 split**。 |
|
|
| 288 个组,两套切分都实测 **0 组跨 split**。验证: |
|
|
| ```bash |
| python -c " |
| import pandas as pd |
| for s in ('A','B'): |
| d = pd.read_csv(f'splits/split{s}/meta.csv') |
| bad = d.groupby('group').split.nunique() |
| print(f'split{s}: {len(bad)} 组, 跨split的 {(bad>1).sum()} 组') |
| " |
| # 期望:splitA: 288 组, 跨split的 0 组 / splitB 同 |
| ``` |
|
|
| **不遵守这条,跑出来的数字会虚高。** 同一次拍摄的图之间相关性极强,随机分会让 val/test 里塞满 train 的近邻。 |
|
|
| --- |
|
|
| ## 4. 分割路线(历史主线,结论是「不如分类」) |
|
|
| ```bash |
| python train_v3.py splits/splitA 200 384 32 10 > splits/splitA/train.log 2>&1 |
| python train_v3.py splits/splitB 200 384 32 10 > splits/splitB/train.log 2>&1 |
| ``` |
|
|
| 约 3.5 小时 / 套。参数是 `<split目录> <epochs> <size> <batch> <workers>`。 |
|
|
| `train_v3.py` 相对 seg3 初版的改动,以及**为什么**: |
|
|
| | 改动 | 原因 | |
| |---|---| |
| | 全量 RAM 预载 | 图只有 300 px,3611 张全进内存约 1.2 GB,把 dataloader 从瓶颈里拿掉 | |
| | batch 32 + `channels_last` + bf16 | 吃满 4090;显存占用约 18 GB | |
| | 归一化换成灰世界 + z-score(`gwz`) | 数据存在采集配置色调漂移(B 通道逐日 15→8→5),ImageNet 固定均值方差顶不住 | |
| | 固定随机种子 42 | 可复现 | |
| | **加了 test 泄漏断言** | 见下 | |
|
|
| ```python |
| te_n = int((df.split == 'test').sum()) |
| assert 'test' not in set(tr_df.split) | set(va_df.split), 'test 泄漏进了训练/选型集' |
| ``` |
|
|
| **这条断言不要删。** 本项目上一代就栽在切分泄漏上,加断言比事后审计便宜得多。 |
|
|
| ### 评估 |
|
|
| ```bash |
| python eval_v3.py splits/splitA # -> eval_report.json, eval_{val,test}.csv, curve_{val,test}.csv |
| python eval_v3.py splits/splitB |
| ``` |
|
|
| 预期(`eval_report.json`): |
|
|
| | | A 套 val | A 套 test | B 套 val | B 套 test | |
| |---|---|---|---|---| |
| | 三类合计 Dice | 0.6975 | 0.7432 | 0.7021 | 0.6802 | |
| | 划痕 Dice | 0.6366 | 0.6578 | 0.6502 | 0.5659 | |
| | 崩边 Dice | 0.7713 | 0.8489 | 0.7652 | 0.7811 | |
| | **麻点 Dice** | **0.4580** | **0.3728** | **0.4403** | **0.2609** | |
| | AUROC (`s_max`) | 0.9779 | 0.9922 | 0.9884 | 0.9749 | |
|
|
| 麻点 Dice 只有 0.26–0.46,是全局短板。`train_summary.json` 里 A 套最佳 epoch = 146 / 200, |
| 最后 20 epoch 的 val Dice 标准差 0.0047 —— **已收敛,不需要早停**。 |
|
|
| ### 增广消融 |
|
|
| ```bash |
| bash run_aug_ablation.sh 200 # 4 档 × 2 切分 = 8 个作业 |
| ``` |
|
|
| A 套结果(`splits/splitA/aug_compare.json`),`rec1`/`fp1` 是单阈值口径: |
|
|
| | 增广 | 平均 Dice | 单阈值检出 | 误报 | |
| |---|---|---|---| |
| | none | 0.5947 | 65.6% | 2 | |
| | **weak** | **0.6136** | **70.1%** | 2 | |
| | mid | 0.6061 | 59.4% | 2 | |
| | strong | 0.6086 | 56.7% | 3 | |
|
|
| **weak 最好,strong 反而更差。** 缺陷本身就是细微低对比结构,强增广会把它抹掉。 |
|
|
| ### 判据优化(几何规则) |
|
|
| ```bash |
| python measure_geom.py splits/splitA # -> geom_{val,test}.csv 逐图几何量 |
| python optimize_rules.py # -> rules_maxfp3.json, rules3_maxfp3.json |
| ``` |
|
|
| `rules_maxfp3.json` 里区分 **oracle**(阈值直接在 test 上调,作弊)和 **honest**(阈值在 val 上定): |
|
|
| | | A 套 honest | A 套 oracle | B 套 honest | B 套 oracle | |
| |---|---|---|---|---| |
| | 单一像素阈值 | 71.4% (fp 5) | 49.6% (fp 3) | 69.7% (fp 5) | 65.7% (fp 3) | |
| | 7 维几何判据 | 64.3% (fp 7) | 74.6% (fp 3) | 76.8% (fp 7) | 85.9% (fp 3) | |
|
|
| 7 维是:划痕长度 L / 划痕面积 A / 麻点最大颗 P / 麻点直径 D / 崩边面积 C / 沿边弧长 E / 过角+面积 Cc。 |
|
|
| **注意 honest 与 oracle 的落差**:A 套几何判据 oracle 74.6% → honest 64.3%,掉 10 个点。 |
| 7 个自由度在 434 张 val 上调,过拟合是必然的。**这就是几何判据路线的天花板。** |
|
|
| --- |
|
|
| ## 5. 分类路线(推荐主线) |
|
|
| ### 第一阶段:骨干 × 增广 × 任务 |
|
|
| ```bash |
| EP=60 SZ=320 BS=32 CONC=2 bash run_cls_sweep.sh 1 # 选骨干 |
| EP=60 SZ=320 BS=32 CONC=2 bash run_cls_sweep.sh 2 # 扫增广 × 任务 × 切分 |
| ``` |
|
|
| 产出 18 个模型到 `splits/split{A,B}/cls/`。命名格式 `{task}_{backbone}_{aug}_{size}`。 |
|
|
| - `task`:`bin`(良/不良)或 `cls4`(良/划/麻/崩) |
| - `aug`:`none` / `weak` / `mid` / `strong` |
|
|
| ### 第二阶段:多轴消融 |
|
|
| ```bash |
| bash run_cls2.sh # 19 个作业,并发 3,约 4 小时 |
| ``` |
|
|
| `cls_train2.py` 支持 6 个正交轴: |
|
|
| | 轴 | 取值 | 设计意图 | |
| |---|---|---| |
| | `--sz` | 224 / 256 / 320 / 384 / 448 | 麻点在 224 px 下只有 109 px²,320 下 223 px²,448 下 437 px² | |
| | `--norm` | `gwz` / `imnet` / `zscore` | **`imnet` 是 seg3 初版用的,怀疑它是最大隐患** | |
| | `--aug` | `geo` / `photo` / `mid` / `mixup` / `cutmix` | 把几何增广与光度增广拆开单测 | |
| | `--loss` | `ce` / `ce_nols` / `focal` | 标签平滑的影响、类别不均衡 | |
| | `--bb` | resnet18 / regnety_016 / repvgg_a1 / edgenext_small / convnext_tiny(DINOv3) / efficientvit_b1 | 覆盖 CNN / ViT / 自监督 / 部署友好 | |
| | `--task` | `bin` / `cls4` | | |
| |
| ### 统一评测 |
| |
| ```bash |
| python cls_eval_all.py # -> cls_test_all.csv,42 个模型 |
| python cls_tta_ens.py splits/splitA # -> tta_ens.json,TTA×8 |
| ``` |
| |
| `cls_eval_all.py` 的 `rec@K` / `fp@K` 定义要看清楚: |
| |
| > **在 val 上搜一个阈值,使 val 误报 ≤ K;把这个阈值原样搬到 test。** |
| > `rec@K` 是 test 检出率,`fp@K` 是 **test 上实际发生的误报数**(不受 K 约束,可能超)。 |
| |
| 这是诚实口径。CSV 里还有 `imp_test`(test 口径不良桶良品率)和 `imp_line10`(换算到产线不良率 10%)。 |
| |
| 预期 top 结果(`cls_test_all.csv`): |
| |
| | split | task | backbone | aug/norm | val AUROC | test AUROC | rec@3 | test fp | |
| |---|---|---|---|---|---|---|---| |
| | A | bin | efficientnet_b0 | mid/**imnet** | 0.9951 | **0.9976** | 94.6% | 3 | |
| | A | bin | efficientnet_b0 | cutmix/gwz | 0.9922 | 0.9975 | 95.5% | 4 | |
| | A | bin | mobilenetv3_large_100 | weak/gwz | 0.9917 | 0.9969 | 94.6% | 3 | |
| | A | bin | efficientnet_b0 | mid/gwz | 0.9936 | 0.9918 | **97.3%** | 4 | |
| | B | bin | resnet34 | mid/gwz | 0.9932 | 0.9862 | 94.9% | 7 | |
| |
| **两个反直觉的点,都要照实记:** |
| |
| 1. **`gwz` 没有优势。** 最高 test AUROC 是 `imnet`(0.9976)拿的。我此前推测 `gwz` 能扛色调漂移——**在 test 上没验证出来**,此结论已撤回。真要证明 `gwz` 的价值,得做色调漂移压力测试,本项目没做。 |
| 2. **`convnext_tiny.dinov3_lvd1689m` 彻底失败**(val AUROC 0.5132,test 0.5335 = 随机)。DINOv3 预训练权重在 60 epoch、4e-4 的配置下没收敛。不是说这个骨干不行,是这套超参不适配它。 |
| |
| TTA×8 最好成绩:`mobilenetv3_large_100 weak` **0.9969 → 0.9982**。 |
| 注意 TTA 并非总是有益:`mobilenetv3 none` 0.9959 → 0.9925(**掉了**)。 |
| |
| 四分类逐类召回(`cls4_resnet34_mid_320`,A 套 test): |
| |
| | GT 类别 | 张数 | 判对 | 召回 | |
| |---|---|---|---| |
| | 划痕 | 133 | 117 | 88.0% | |
| | 崩边 | 68 | 64 | 94.1% | |
| | 良品 | 185 | 176 | 95.1% | |
| | 麻点 | 23 | 21 | **91.3%** | |
| |
| > `eval.json` 里 `confusion` 的存储方向是 **外层 key = 预测类,内层 key = 真实类**。 |
| > 读反了会得到完全不同的数字(比如把 88.0% 读成 94.4%)。 |
| |
| --- |
| |
| ## 6. 诚实性审计(**不要跳过**) |
| |
| ```bash |
| python cls_probe.py # -> cls_probe_all.csv |
| python cls_frozen_probe.py splits/splitA resnet34 320 # -> frozen_probe_resnet34_320.json |
| ``` |
| |
| 四道审计的原理、预期数字和判读方式见 [`AUDIT.md`](AUDIT.md)。 |
| |
| 一句话版本:**图像里确实带着可区分批次的信息(包分类准确率 92.7%),但模型没去用它**—— |
| 只见良品的 kNN(0.9634)反而**打败**了用了标签的线性探针(0.9461),说明标签解锁不了额外的捷径。 |
| |
| --- |
| |
| ## 7. 导出与打包部署 |
| |
| ```bash |
| python export_onnx.py # -> release/filter_binary.onnx + .json |
| # release/filter_4class.onnx + .json |
| ``` |
| |
| **`export_onnx.py` 里 `dynamo=False` 不能删。** 用新的 dynamo 导出器会把权重拆成独立的 |
| `.onnx.data` 文件,单文件分发就没了。 |
| |
| 打包成免安装可执行文件: |
| |
| ```bash |
| R=/workspace/CV_Task/seg3/newdata/release |
| pyinstaller --onefile --name filter-predict-cpu \ |
| --add-data "$R/filter_binary.onnx:." --add-data "$R/filter_binary.json:." \ |
| --add-data "$R/filter_4class.onnx:." --add-data "$R/filter_4class.json:." \ |
| --add-binary /workspace/miniconda3/lib/libstdc++.so.6:. \ |
| "$R/predict.py" |
| ``` |
| |
| **三个踩过的坑,照抄即可避免:** |
|
|
| 1. `--add-data` 的相对路径是**相对 specpath 解析**的,不是相对 cwd。用绝对路径 `$R/...`。 |
| 2. 打包后 `__file__` 指向 `/tmp/_MEIxxx`,模型找不到。`predict.py` 里的 `sys.frozen` / `_MEIPASS` 分支处理这个,别简化掉。 |
| 3. **`libstdc++.so.6` 必须显式打进去**。Ubuntu 24.04 的系统库要 GLIBC_2.38,目标机上没有就直接崩。 |
| miniconda 那份只要 glibc 2.26。全部 141 个打包 .so 审计下来最高需求 glibc 2.28。 |
| > 这个错的表象是 numpy 导入失败,**根因不是 numpy**。曾按 numpy 方向排查很久,是弯路。 |
| |
| GPU 版把 `onnxruntime` 换成 `onnxruntime-gpu`,且**必须钉版本**: |
| 1.28 要 CUDA 13,目标机是 CUDA 12.8 —— 用 `1.22.0`(py ≤ 3.12)或 `1.24.4`(py 3.14)。 |
| |
| --- |
| |
| ## 8. 生成报告 |
| |
| ```bash |
| python gen_report.py # 数据核查报告 |
| python gen_showcase.py # 分割效果展示 |
| python gen_nonzero_goods.py # 被模型报警的良品 |
| python gen_p10_report.py # 产线不良率 10% 判废纯度 |
| python precision_table.py # 判废纯度对照表 |
| ``` |
| |
| 5 份 HTML 的内容说明见 [`FILES.md`](FILES.md)。 |
| |
| --- |
| |
| ## 复现检查清单 |
| |
| 跑完对一下这几个数,对不上说明哪一步偏了: |
| |
| - [ ] `inventory.csv` **3728** 行 |
| - [ ] `splits/splitA/meta.csv` **3611** 行,`dropped.csv` **117** 行 |
| - [ ] 跨 split 的组 **0** 个(288 组) |
| - [ ] 分割 A 套 test 三类合计 Dice **≈0.743**,麻点 Dice **≈0.373** |
| - [ ] 分类 A 套 test AUROC 最高 **≈0.9976**(efficientnet_b0 / mid / imnet) |
| - [ ] 未见类「碎」检出 `sui` **≥0.92** |
| - [ ] 冻结特征 kNN test AUROC **≈0.9634** > 线性探针 **≈0.9461** |
| |
| 随机性说明:训练固定了种子 42,但 cuDNN 非确定性算子仍会带来 |
| **±0.005 量级的 AUROC 抖动**。这个噪声地板和榜单前 10 名之间的差距是同量级的 |
| ——**不要根据 0.001 的差异下结论**。要压过噪声需要多种子重复,本项目没做。 |
| |