File size: 15,409 Bytes
0e9808f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# 完整复现指南

从零复现本项目的全部实验。按顺序执行即可。

**总耗时**:数据准备约 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 的差异下结论**。要压过噪声需要多种子重复,本项目没做。