简介:面向工业视觉缺陷检测的三星油污数据集,聚焦头发丝与小黑点两类细微油污缺陷,适合目标检测、小目标识别及缺陷分类任务的数据补充与模型验证,也可作为工业质检课程或算法复现的实践素材。压缩包共1324个文件,其中660张jpg原图与660个同名xml标注文件一一对应,另附4个txt说明文件,整体约314.54MB,采用rar打包,目录命名便于按类别与样本状态检索。资源以TFS、XZW等类别标识组织缺陷样本,并包含正常样本对照,可直接用于主流检测框架的数据读取、标注核对与训练评估,减少自建数据集时的采集和标注成本。已有1412人学习下载,适合需要快速搭建油污缺陷检测基线、开展小目标检测实验或扩充工业缺陷样本库的开发者与研究人员。
1. 660 张图、两个类别:油污缺陷分类真正的难点不在模型
“油污缺陷类别只有两个:头发丝和小黑点,数据集大小 660 张。”这是我在产线项目里拿到过的最轻的一份数据,也是最容易做砸的一份。轻在类别少、任务简单,砸在样本少、目标小、两类还长得像。头发丝是一条弯曲的低对比细线,小黑点是一块接近圆形的深色斑,放大到 512 像素看区别明显,缩到 224 像素经常连人眼都要犹豫两秒。660 张按 7:1.5:1.5 切开,验证集不到 100 张,换一个随机种子准确率就能跳十个点。这篇讲的是三件具体的事:怎么把这 660 张图清干净并定好标签口径,怎么在这么小的数据上训出一个不虚高的分类器,以及怎么判断它在产线上到底能不能用。
2. 动手前先给 660 张图做体检:清点、去重、定标签
2.1 头发丝和小黑点为什么会混成一类
先把两类缺陷的物理来源摆清楚,后面的标注规则和模型设计才有依据。头发丝多数是擦拭布纤维、手套纤维、环境飘落的织物纤维,形态是细长条,原始图上宽度常常只有 1~3 个像素,长度几十到几百像素,颜色从浅灰到深黑都有,遇到侧向补光还会出现中间亮、两边暗的反光带。小黑点多半是油滴、油污凝结或者颗粒附着,接近圆形,直径 3~15 像素,中心深、边缘有扩散过渡,边界不像纤维那么锐利。
混淆就发生在这两个形态的中间地带。一段长度只有十几像素的短纤维,长宽比接近 1,看起来和黑点几乎一样;反过来,油污被擦拭拖过之后会带上一条尾巴,又像一根粗短的丝。放大看能分,缩小看不能分——这句话决定了后面所有参数:输入分辨率不能随便压,能先裁出候选区域再分类,就别把整张图塞进网络。
标注口径必须提前写死,不能靠标注的人当天手感。我一般的做法是沿缺陷主轴量长度 L、量最大宽度 W,L/W ≥ 3 判头发丝,否则判小黑点;对 L/W 落在 2~4 之间的边界样本单独打一个「难例」标记,训练时保留,评估时单独出一行数字。这样做的好处是模型不会被边界样本带偏,你也能知道它到底在哪个区间失灵。另外提醒一句,标签映射不要依赖os.listdir的返回顺序,目录名排序在不同文件系统上不一定一致,dot排在hair前面还是后面,直接决定 0 和 1 谁是谁,这种错我见过不止一次,训练 loss 一路正常,最后混淆矩阵整个反了。
2.2 用一段脚本把数据集体检做完
660 张图人工一张张看,两个小时能看完,但看完记不住分布。先跑一遍统计,把尺寸、亮度、清晰度和重复图四件事一次性摸清楚,后面调参才有依据。
import cv2 import numpy as np from pathlib import Path from collections import defaultdict ROOT = Path("./oil_defect_660") # 数据根目录:ROOT/hair、ROOT/dot CLASSES = ["hair", "dot"] # 显式写死类别顺序,别依赖目录遍历顺序 def dhash(img, size=8): """差值哈希:64 位指纹,用于快速找同源图""" gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) small = cv2.resize(gray, (size + 1, size), interpolation=cv2.INTER_AREA) diff = small[:, 1:] > small[:, :-1] return "".join("1" if b else "0" for b in diff.flatten()) stats = defaultdict(list) hash_map = defaultdict(list) for cls in CLASSES: for p in sorted((ROOT / cls).glob("*")): img = cv2.imread(str(p), cv2.IMREAD_COLOR) if img is None: print("坏图或无法解码:", p) continue h, w = img.shape[:2] gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) stats[cls].append({ "file": p.name, "w": w, "h": h, "mean": float(gray.mean()), # 整体亮度 "sharp": float(cv2.Laplacian(gray, cv2.CV_64F).var()), # 清晰度 }) hash_map[dhash(img)].append(f"{cls}/{p.name}") for cls in CLASSES: arr = stats[cls] ws = np.array([a["w"] for a in arr]) hs = np.array([a["h"] for a in arr]) ms = np.array([a["mean"] for a in arr]) ss = np.array([a["sharp"] for a in arr]) print(f"{cls}: n={len(arr)} 宽 {ws.min()}-{ws.max()} 高 {hs.min()}-{hs.max()} " f"亮度均值 {ms.mean():.1f} 清晰度P10 {np.percentile(ss, 10):.1f}") dups = {k: v for k, v in hash_map.items() if len(v) > 1} print("完全重复组数:", len(dups)) for k, v in list(dups.items())[:10]: print(v)这段脚本干四件事。dhash把每张图压成 64 位指纹,指纹完全相同的就是字节级重复图,先清掉最明显的一批;工程上更常用的是汉明距离 ≤ 2 判为同源,用现成的哈希库算一下距离即可,不必自己实现。mean是灰度均值,用来判断两类图的拍摄亮度是否系统性偏移——如果头发丝全在暗光下拍、小黑点全在亮光下拍,模型很可能学的是光照而不是缺陷。sharp用拉普拉斯方差近似清晰度,取 P10 那一档单独拎出来看,糊到看不清的图留着只会污染标签。宽高统计则是为了决定输入分辨率:原图如果是 4096 级别的,直接 resize 到 224,一根 2 像素宽的丝会被压成不到 0.2 像素,物理上就消失了,这不是模型能力问题,是信息在预处理阶段已经丢了。
跑完你会得到一张分布表,我习惯把它记在实验日志第一页,后面每次改预处理都回来对一眼。血泪经验是:先看数据再调参,比先调参再怀疑数据,至少省三天。
2.3 划分训练/验证/测试:分层加分组,别用 random_split
小样本任务里,划分方式对最终数字的影响比模型结构还大。产线图的命名往往带批次信息,同一片产品可能在不同工位、不同补光下拍了好几张,这些图高度相似。如果按图片随机划分,同一片产品的图会同时出现在训练集和验证集,验证准确率能虚高十几个点,上线立刻翻车。
正确做法是「分层 + 分组」:先按类别分层保证两类比例一致,再按批次号分组,整组进训练或整组进验证。
import random from collections import defaultdict from pathlib import Path def group_key(p: Path): """从文件名提取分组键,例如 LOT0123_0007.jpg -> LOT0123 文件名没有批次信息时,退化为按单图划分,但要意识到风险""" return p.stem.split("_")[0] def split_by_group(root, classes, ratios=(0.7, 0.15, 0.15), seed=42): rng = random.Random(seed) out = {} for cls in classes: groups = defaultdict(list) for p in sorted((root / cls).glob("*")): groups[group_key(p)].append(p) keys = sorted(groups) rng.shuffle(keys) # 打乱的是组,不是图 n = len(keys) n_tr, n_va = int(n * ratios[0]), int(n * ratios[1]) tr, va, te = keys[:n_tr], keys[n_tr:n_tr + n_va], keys[n_tr + n_va:] out[cls] = { "train": [p for k in tr for p in groups[k]], "val": [p for k in va for p in groups[k]], "test": [p for k in te for p in groups[k]], } print(cls, {s: len(out[cls][s]) for s in ("train", "val", "test")}) return out关键在于rng.shuffle(keys)打乱的是组列表而不是图片列表,这样同一批次的图不会被拆散。参数上,ratios用 7:1.5:1.5 是 660 张这个量级的常规选择,但分组之后实际比例会有偏移,一定要把打印出来的数字核对一遍。如果某一类的验证集少于 30 张,单次划分的结论基本不可信,改成 5 折交叉验证,让每个样本都当过一次验证数据,报告均值±标准差,而不是报一个孤零零的峰值。我吃过这个亏:某次报告 96.2% 准确率,换种子重跑变 88.5%,客户当场就问“哪个是真的”,其实两个都真,问题是样本量撑不起小数点后一位。
3. 660 张不该从零训:骨干选择、输入分辨率与增强边界
3.1 为什么小样本必须借预训练权重
660 张图、两个类别,从随机初始化开始训一个卷积网络,结局基本是训练集 100%、验证集 60% 上下震荡。原因不复杂:卷积核需要看到足够多的边缘、纹理、颜色组合才能学到可迁移的低层特征,660 张图提供的组合数远远不够。用在大规模自然图像上预训练过的权重做初始化,低层卷积已经会提取边缘和斑点,你只需要让它把注意力挪到油污这个域上,收敛需要的样本量能降一个数量级。
微调策略上,我一般分两段走。第一段冻结骨干,只训最后的分类头,学习率 1e-3,跑 10 个 epoch 左右,让随机初始化的头部先收敛到一个合理位置;第二段解冻全部参数,骨干学习率降到 1e-4、头部保持 5e-4,用余弦退火跑完剩下的 epoch。这么做是为了避免一上来就用大学习率把预训练权重冲垮,那等于白借。
骨干选型不用纠结太久,660 张的规模撑不起大模型,参数越多过拟合越快。常见做法是在 mobilenetv3_small、efficientnet_b0、resnet18 这三档里选最小的那个,先跑通基线,指标不够再往上换一档,每次只动一个变量。别一上来就上大骨干,最后你分不清是模型强还是增强调对了。
真正影响这类任务上限的不是骨干,是输入分辨率。头发丝在原始图上只有 1~3 像素宽,分类网络最后要做全局池化,细丝信号在 7×7 的特征图上可能只占一个格子,被平均掉就没了。两条路:一是把输入从 224 提到 512,让细丝在特征图上多占几个格子;二是先做候选区域提取,用阈值分割或者简单的形态学操作把可疑小块裁出来,再送进分类器判是丝还是点。第二条路在产线上更常见也更稳,因为裁剪后目标占比大,224 的输入就够用,推理也快。
3.2 用现成的模型库搭一个 512 输入的分类器
import timm import torch def build_model(num_classes=2, backbone="mobilenetv3_small_100", pretrained=True, drop=0.2): model = timm.create_model( backbone, pretrained=pretrained, # 借预训练权重,小样本的命根子 num_classes=num_classes, drop_rate=drop, # 分类头前的 dropout,抑制过拟合 ) return model model = build_model() # 第一段:冻结骨干,只训分类头 for name, p in model.named_parameters(): if not name.startswith("classifier"): p.requires_grad = False trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"可训练参数: {trainable:,}")这段代码的重点在两处。pretrained=True是必须的,别为了“干净”从零训。drop_rate=0.2在 660 张规模下是个稳妥值,太小压不住过拟合,太大会让头部收敛变慢。冻结那一段建议用named_parameters()打印一下名字再写判断条件,不同骨干的头部命名不一样,有的是classifier,有的是head,有的是fc,条件写错等于没冻结,训练日志上看不出来,只能靠打印确认。输入 512 时,batch 16 在单张 8 GB 显存的卡上大约占 3~4 GB,跑得动。
3.3 哪些增强会把头发丝变成小黑点
数据增强是这轮任务里最容易做过头的地方。增强的目的是模拟产线上真实的成像变化,不是把图片变得“更丰富”。下面几类要特别当心。
RandomResizedCrop配scale=(0.5, 1.0)是分类任务的常规配置,但在这里是灾难:它可能把缺陷整个裁掉,标签却还是原来的类,等于往训练集里灌噪声标签。小目标分类要么把下界提到 0.85,要么干脆不用随机裁剪,只做等比缩放加 padding。
大角度旋转也要克制。产线是固定视角,纵向丝和横向丝的成像特征本来就有差异,用 ±90° 旋转会让模型以为方向不重要,实际部署时遇到某个方向的丝反而判不准。±15° 足够覆盖装夹误差。
颜色抖动的幅度直接决定类别语义会不会被改掉。小黑点的判别依据就是深色高对比,brightness开到 0.5,黑点被提亮后和浅色背景混在一起,人眼都判不了,模型只能瞎猜。brightness和contrast控制在 0.2 以内,饱和度和色调这两项在灰度为主的缺陷图上没必要动,直接设 0。
MixUp 和 CutMix 在这个任务里我建议关掉。它们把两张图按比例混合生成新样本,对自然图像分类有效,但头发丝和小黑点混在一起生成的图既不是丝也不是点,属于凭空造出第三类,660 张的规模经不起这种语义污染。
from torchvision import transforms IMAGENET_MEAN = [0.485, 0.456, 0.406] IMAGENET_STD = [0.229, 0.224, 0.225] train_tf = transforms.Compose([ transforms.Resize((512, 512)), # 保住细丝,别再往下压 transforms.RandomAffine(degrees=15, # 只覆盖装夹误差 translate=(0.05, 0.05), scale=(0.95, 1.05)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.0, hue=0.0), # 幅度收住,别改语义 transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ]) val_tf = transforms.Compose([ transforms.Resize((512, 512)), transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ])验证集只做缩放和归一化,不能加任何随机增强,否则同一张图每次评估结果都不一样,你没法判断模型是在进步还是在掷骰子。RandomAffine里的translate=0.05是平移比例,512 输入下约等于 25 像素,够覆盖定位误差又不至于把目标推出画面。这套配置跑下来,训练集和验证集的差距通常能压到 5 个点以内;如果还是 20 个点以上,问题多半在数据划分泄漏或者标签错误,回到第 2 章重新体检,别急着换模型。
4. 训练参数怎么定:一份能复现的基线配置与评估口径
4.1 超参表:先把数字写死,再谈调优
小样本实验最怕每次跑完只记一个准确率,回头想复现都复现不了。我的习惯是先固定一张超参表,所有实验围绕它做单变量改动。
| 项目 | 取值 | 说明 |
|---|---|---|
| 骨干 | mobilenetv3_small_100 | 660 张规模优先选小模型 |
| 输入尺寸 | 512×512 | 保细丝,224 下 2 px 宽度会消失 |
| batch size | 16 | 单卡 8 GB 显存可跑 |
| 优化器 | AdamW | 权重衰减与学习率解耦,小数据更稳 |
| 头部学习率 | 1e-3(冻结阶段)/ 5e-4(解冻后) | 头部随机初始化,需要更大步长 |
| 骨干学习率 | 1e-4 | 解冻后压住,别冲垮预训练权重 |
| weight decay | 0.05 | 配合 AdamW,抑制过拟合 |
| 调度器 | CosineAnnealingLR | T_max 与总 epoch 一致 |
| epoch | 冻结 10 + 解冻 30 | 共 40 轮,够收敛也不至于过拟合 |
| label smoothing | 0.1 | 660 张规模下能压一点过拟合 |
| 随机种子 | 固定 42 | 复现的前提 |
表里的数字不是唯一正确答案,但它是一个能跑出稳定基线的起点。改动顺序建议:先动输入尺寸,再动学习率,最后才动骨干。输入尺寸的影响通常最大,因为这类任务的瓶颈在信息量,不在模型容量。
4.2 类别不平衡用采样器处理,别只会加权损失
660 张里两类未必各占一半,头发丝和小黑点的自然分布很可能差两到三倍。处理不平衡有两条路:给损失函数加类别权重,或者用加权采样器改变每个 batch 的构成。前者改动小,后者效果更直接,我一般先用采样器。
import torch from torch.utils.data import DataLoader, WeightedRandomSampler # train_ds 是 ImageFolder 结构,targets 是每张图的类别索引 targets = torch.tensor(train_ds.targets) counts = torch.bincount(targets, minlength=2).float() class_weight = 1.0 / counts # 类别越少,权重越大 sample_weight = class_weight[targets] sampler = WeightedRandomSampler( weights=sample_weight, num_samples=len(sample_weight), # 每个 epoch 仍采同样多的样本 replacement=True, # 少数类会被重复抽到 ) train_loader = DataLoader(train_ds, batch_size=16, sampler=sampler, num_workers=4, pin_memory=True)replacement=True意味着少数类的同几张图一个 epoch 里会被看很多遍,过拟合风险随之上升,所以要配合 3.3 那套克制的增强一起用,别一边重复采样一边又不敢增强。num_samples保持等于数据集大小,是为了让每个 epoch 的迭代步数稳定,方便对比不同实验的收敛曲线。如果用了采样器之后验证集上少数类召回上去了但精确率掉得厉害,说明重复采样过头,把num_samples降到 0.8 倍试试,或者改成损失加权。
4.3 训练循环与「别只看准确率」的评估口径
训练循环本身没什么花样,要害在于保存模型用哪个指标。产线上漏检和误报的代价不对称:一根头发丝流到下一道工序可能造成整片报废,一次误报只是多看一眼。所以保存最优模型时不能看整体准确率,要看头发丝这一类的召回。
import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR device = "cuda" if torch.cuda.is_available() else "cpu" model = build_model().to(device) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = AdamW([p for p in model.parameters() if p.requires_grad], lr=1e-3, weight_decay=0.05) scheduler = CosineAnnealingLR(optimizer, T_max=40) best_recall, best_state = -1.0, None for epoch in range(40): if epoch == 10: # 第二阶段:解冻骨干,降学习率 for p in model.parameters(): p.requires_grad = True optimizer = AdamW([ {"params": model.parameters(), "lr": 1e-4}, ], weight_decay=0.05) model.train() for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() loss = criterion(model(x), y) loss.backward() optimizer.step() scheduler.step() # 每个 epoch 结束在验证集上算一次 ys, ps = evaluate(model, val_loader) recall_hair = recall_score(ys, ps, pos_label=CLASSES.index("hair")) if recall_hair > best_recall: best_recall = recall_hair best_state = {k: v.clone() for k, v in model.state_dict().items()} torch.save(best_state, "best_hair_recall.pth") # 存 state_dict,别存整个模型evaluate用第 4.4 节的实现即可。几个参数值得说明:label_smoothing=0.1把硬标签软化,模型不会对单张图过度自信,在 660 张规模下通常能换来一两个点的验证提升;解冻后重建优化器是为了让新的学习率立刻生效,如果沿用旧优化器只改param_groups,动量状态会带着旧步长的惯性,收敛曲线会出现一个突兀的台阶。保存state_dict而不是整个模型对象,是为了后面导出 ONNX 和跨环境加载时不依赖原始类定义。
评估指标至少报三样:每类召回、混淆矩阵、以及验证集样本数。
import numpy as np import torch from sklearn.metrics import classification_report, confusion_matrix @torch.no_grad() def evaluate(model, loader): model.eval() ys, ps, probs = [], [], [] for x, y in loader: x = x.to(device) logits = model(x) prob = torch.softmax(logits, dim=1)[:, CLASSES.index("hair")] ps += logits.argmax(1).cpu().tolist() probs += prob.cpu().tolist() ys += y.tolist() print(f"样本数 n={len(ys)}") print(classification_report(ys, ps, target_names=CLASSES, digits=3)) print(confusion_matrix(ys, ps)) return ys, ps验证集只有 90 张左右时,一个样本就是 1.1 个百分点,报告里写 95.6% 是不诚实的,正确写法是「86/90,召回 0.956,95% 置信区间约 0.89~0.99」。这套口径看起来啰嗦,但它能挡住后面无数次“为什么线上和离线差这么多”的追问。我自己踩过的坑是:验证集 78 张,某次跑到 98.7%,欢天喜地做了汇报,第二周换了个批次数据,召回掉到 71%,回头一查,验证集里头发丝只有 23 张,那个 98.7% 的置信区间宽得能跑马。
5. 避坑与排查:小样本缺陷分类最容易翻车的 5 个现场
5.1 验证集 98%,上线第一天大面积漏检
现象:离线评估每类召回都在 0.95 以上,部署到产线当天,头发丝漏检率超过三成。原因:划分时按图片随机切分,同一片产品的多张图分散在训练集和验证集,模型在验证集上认的是那片产品的背景纹理,不是缺陷本身。解决:回到 2.3 节按批次分组重划,然后做一次背景泄漏检查——把验证集的缺陷区域涂黑再评估一次,如果准确率从 0.95 掉到 0.6 附近,说明模型严重依赖缺陷区域,还算健康;如果涂黑后仍有 0.9,说明它学的是背景,必须重做划分或补充跨批次数据。
5.2 loss 正常下降,但头发丝全被判成小黑点
现象:训练 loss 平稳降到 0.1 以下,混淆矩阵显示头发丝这一类召回接近 0,全部预测成小黑点。原因:输入分辨率太低。原图 resize 到 224 之后,2 像素宽的丝在视觉上退化成一个小斑点,和黑点在像素层面已经无法区分,模型做了正确的判断,错的是预处理。解决:把输入提到 512,或者改成先裁候选区域再分类。验证方法很简单,把同一张图分别缩到 224 和 512 并排看一眼,人眼都分不出来的分辨率,不要指望模型能分。
5.3 换台机器加载模型就报错
现象:训练机上torch.save(model, ...)存完,推理机上torch.load抛ModuleNotFoundError或者Can't get attribute。原因:整个模型对象被序列化,加载时需要原始定义类所在的模块路径,推理机上的目录结构不一样就找不到。解决:一律只存state_dict,加载时先build_model()建结构再load_state_dict。如果一定要存整个模型,至少保证两边的代码目录完全一致,但这不是长久之计。
5.4 热力图高亮全在背景上
现象:拿几张错分样本做梯度加权类激活可视化,高亮区域集中在图像边缘和背景的纹理上,缺陷位置一片冷色。原因:数据里存在批次与类别的相关性,比如头发丝的样本大多来自某个工位、背景偏暗,模型就学了“暗背景=头发丝”。解决:先统计每类的亮度均值和批次分布,差异大的做分层抽样或背景替换增强;再考虑把输入改成局部裁剪,强制模型只看缺陷邻域。这一步不做,模型在实验室里再准,换个工位就废。
5.5 五个随机种子跑出五个结果
现象:同样的代码、同样的数据,只改seed,验证准确率在 85% 到 94% 之间来回跳。原因:验证集太小,单次划分的方差盖过了模型差异,这时候比较两个模型谁更强是没有意义的。解决:改成 5 折交叉验证,报告均值和标准差;如果两折之间差异超过 8 个点,先怀疑数据划分,而不是模型结构。我现在的习惯是任何小于 1000 张的数据集,实验记录里必须写清折数和方差,只写一个峰值的实验结论一律不认。
6. 上线前的一轮压力自测:TTA、阈值与误检复盘
模型训完、离线指标也达标,离能上线还差一步:模拟真实推理链路跑一遍。这一步不做,前面所有数字都只是实验室数字。
第一步做测试时增强。分类任务里最划算的是水平翻转加多尺度,把原图、翻转图、0.9 倍和 1.1 倍缩放图各推理一次,概率取平均。代码只有几行,收益通常在 1~2 个点,代价是推理耗时翻四倍。产线节拍紧的话,只留水平翻转这一路,性价比最高。
@torch.no_grad() def predict_tta(model, img_tensor): """img_tensor: 1x3xHxW,返回头发丝类别的平均概率""" model.eval() views = [ img_tensor, torch.flip(img_tensor, dims=[3]), # 水平翻转 torch.nn.functional.interpolate(img_tensor, scale_factor=0.9, mode="bilinear", align_corners=False), torch.nn.functional.interpolate(img_tensor, scale_factor=1.1, mode="bilinear", align_corners=False), ] probs = [] for v in views: v = torch.nn.functional.interpolate(v, size=(512, 512), mode="bilinear", align_corners=False) probs.append(torch.softmax(model(v.to(device)), dim=1)[0, 1].item()) return sum(probs) / len(probs)第二步定阈值,别直接用argmax。argmax隐含的假设是两类代价相同,产线上显然不是。做法是把验证集和测试集的概率输出全部导出来,按 0.05 的步长扫一遍阈值,画一条「头发丝召回 vs 误报数」的曲线,然后根据后道工序能容忍的误报量反推阈值。我一般会把阈值定在召回 0.97 对应的那个点上,宁可多报一点,让人工复核兜底。这套流程听起来土,但比拍脑袋定 0.5 靠谱得多。
第三步做误检复盘表,这个表比任何指标都好用。
| 图号 | 真实类别 | 预测类别 | 置信度 | 复核结论 | 归因 |
|---|---|---|---|---|---|
| 0137 | hair | dot | 0.62 | 确为短丝 | 长度不足,长宽比接近阈值 |
| 0241 | dot | hair | 0.58 | 确为拖尾油点 | 拖尾被当成丝的延伸 |
| 0388 | hair | dot | 0.91 | 标注错误 | 原图标错,需回修 |
| 0412 | dot | hair | 0.55 | 疑似反光干扰 | 补光角度造成条状高光 |
每周抽 30 张错分图填一遍,归因列出现频率最高的那一类,就是下一步该动的地方:是补数据、改标注规则,还是调预处理。我见过太多团队把精力花在换模型上,而复盘表里明明白白写着 40% 的错分来自标注不一致。
最后是导出和耗时。用torch.onnx.export导一份固定 512 输入的模型,在目标机器上跑 200 次取 P95 耗时,确认满足节拍再谈部署。别在训练机上测,显卡型号差一档,数字能差三倍。
这套流程走完,我对这 660 张图能给出什么水平的模型心里就有数了:它不是那种能吹到 99% 的项目,但每类召回、误报量、失效边界都写得清清楚楚,产线敢用,我也敢签字。做小样本缺陷分类这些年,最大的教训是别追指标峰值,追可解释的失败模式——峰值会骗人,失败模式不会。希望帮到你。
本文还有配套的精品资源,点击获取