广州十一月的夜晚,终于有了点凉意。室友们有的在打游戏,键盘敲得劈啪作响,有的已经拉上床帘睡了。我坐在书桌前,盯着屏幕上 IDE 暗色的背景,旁边是一杯早就凉透的速溶咖啡。笔记本的散热风扇正发出低沉的轰鸣声,热风一阵阵吹在握着鼠标的手背上。
这是大四上学期,计算机视觉课程的期末大作业。群里的同学们大多选了猫狗分类,或者用现成的 YOLO 模型去跑个口罩检测。这些题目网上满地都是现成的代码,跑通、截图、写报告,一个晚上就能搞定。但我不想这么做。四年下来,绩点维持在 4.02,拿了两次国奖,但我心里比谁都清楚,在这所位于广州郊区的民办三本院校里,纸面上的成绩和荣誉,在真正踏入社会的那一刻,能提供的缓冲力微乎其微。我需要一些更贴近泥土、更具有工业质感的东西。
所以我选了工业缺陷检测。
视线的尽头是流水线
把视线从实验室和校园移开,往南走几十公里,就是东莞和深圳密密麻麻的工厂。在那里,AOI(Automated Optical Inspection,自动光学检测)是产线上极其重要的一环。
我在脑海里构想着那个场景:明晃晃的无影灯下,传送带以恒定的速度把一个个金属零件送过来。如果是人工检测,质检员需要死死盯着这些反光的金属表面,寻找哪怕是头发丝粗细的划痕、微小的凹坑,或者是边缘的裂纹。人眼是有极限的,盯着看上两个小时,视觉就会开始疲劳,注意力一旦涣散,一个带有暗伤的零件就会混入合格品中,流向下一个环节。这不仅是效率低下的问题,更是良品率的致命伤。
而这,正是深度学习最应该出现的地方。机器不会疲倦,不会因为昨晚没睡好而漏检,只要给它定义好规则,喂饱了数据,它就能在几毫秒内给出稳定而冷酷的判断。把视觉任务交还给摄像头和显卡,把人从那种机械的、消耗性的凝视中解放出来,这是我理解的技术的意义。
凝视一千两百个切片
找数据花了我不少时间。最后敲定的是一个包含 1200 张工业零件图片的数据集,正常样本和缺陷样本刚好各占一半,600 对 600。
下载解压后,我没有急着写 dataloader,而是点开文件夹,一张一张地往下按方向键,看了足足半个小时。图片里的零件呈现出一种冰冷的灰白色调,正常的零件表面平滑,带着均匀的反光;而缺陷零件则千奇百怪。有的划痕像是一道闪电劈在金属上,有的凹坑边缘粗糙,像是被什么钝器砸过,还有的裂纹极其细微,隐藏在金属本身的纹理中,如果不是我凑近屏幕,几乎很难分辨。
这 1200 张图片,就是 1200 个真实的物理切片。我看着它们,心里有一种奇妙的错觉:我正在试图教一堆由硅晶体和铜线组成的硬件,去理解另一堆金属在物理世界中受到的创伤。
数据集的平衡是件幸运的事,但在真实的工厂里,这几乎是不可能的。正常的流水线上,可能一万个零件里才会出现几十个不良品。那种极度不平衡的长尾分布,才是工业界常态。不过作为课程项目,我决定先在这个理想化的 1200 张切片里,把基础的闭环跑通。
克制的选择
这几年,计算机视觉领域的模型更新得太快了。从各种变体 CNN,到后来一统天下的 Vision Transformer (ViT),论文里的 SOTA 数据一个月刷新一次。如果为了报告好看,我完全可以套一个最新的大模型,写一堆华丽的术语。
但我选了 ResNet18 作为 backbone。
它太老了,老到在现在的顶会论文里只能作为 baseline 的 baseline 出现。但我喜欢它。在工业部署的语境下,ResNet18 展现出了一种极具实用主义的美感。它极其轻量,参数量小,不需要动辄几十 G 显存的算力怪兽来伺候。
import torchvision.models as models
import torch.nn as nn
# 没有任何花哨的结构,直接加载预训练权重
model = models.resnet18(pretrained=True)
# 将最后的全连接层替换为二分类输出
model.fc = nn.Linear(512, 2)
敲下这几行代码的时候,我心里很踏实。对于一个只有 1200 张图片的二分类任务来说,ResNet18 不是妥协,而是最优解。如果用太复杂的模型,在这么小的数据集上必然会遭遇严重的过拟合——模型会死记硬背下这 600 张缺陷图片的每一个像素,而不是真正学会“什么是缺陷”。更重要的是,在实际的工厂里,跑推理的往往不是机房里的 A100,而是产线旁边一台布满灰尘的工控机,甚至是像 Jetson Nano 这样的边缘计算设备。在那些算力和内存极其受限的盒子里,ResNet18 能以极高的帧率跑完推理,而那些庞然大物连加载进内存都费劲。
炼丹的十五分钟
训练的过程,像是一场与数据的对话。
第一次跑的时候,我只做了简单的 Resize 和 ToTensor。笔记本里的 RTX 3060 开始发力,屏幕上的 Loss 值像瀑布一样刷下来。但很快我就发现,验证集的准确率卡在 85% 左右上不去了。
我停下程序,靠在椅背上想了想。金属零件在传送带上的位置是随机的,光照的角度也可能因为车间窗户外的天气变化而有微小的差异。我给的数据太“干净”、太“死板”了。
于是我加上了数据增强。我不希望模型只是记住划痕在图片左上角,所以加了 RandomHorizontalFlip 和 RandomVerticalFlip;我不希望模型对光线过于敏感,所以加了 ColorJitter 来微调亮度和对比度;我还加上了随机的旋转。每一张图片在进入模型之前,都会被扭曲、翻转、改变明暗,就像它们在真实的、充满变量的物理世界中可能遭遇的那样。
学习率的设置也是个精细活。我没有用固定的学习率,而是用了 StepLR。一开始,学习率稍微大一点,让模型在广阔的参数空间里大步流星地寻找最优解的方向;等跑了十几 个 epoch 之后,学习率衰减,让模型在找到的局部最优解附近慢慢踱步,细致地调整权重。这就像是雕刻,一开始用大斧头劈出轮廓,最后用小刀一点点修饰细节。
最后一次训练,我设定了 50 个 epoch。
寝室里很安静,只有风扇的呼啸声。我盯着终端输出的进度条,看着 Train Loss 一点点往下降,Val Accuracy 像爬楼梯一样一点点往上挪。大约过了 15 分钟,风扇的声音渐渐弱了下来,训练结束了。
终端上最后定格的数字是:验证集准确率 98.33%。
看到这个数字的时候,我并没有激动得跳起来,只是长长地舒了一口气。我知道,这个 98.33% 是数据增强、预训练权重(迁移学习)和合理的学习率衰减共同作用的结果。它证明了这套逻辑在这个封闭的数据集上是完全成立的。
走出实验室的想象
项目完成了,我把代码整理干净,写好 README,推到了 GitHub 上,仓库名叫 resnet-defect-detection。看着绿色的 "Commit" 按钮按下,这个作业算是画上了句号。
但我脑子里的思考并没有停止。
98.33% 真的很高,在教务系统的评分标准里,这绝对是一个能拿满分的数字。但我深知,如果明天就把这套代码搬到东莞某家五金厂的流水线上,这个数字可能会瞬间暴跌到 70% 甚至更低。
真实的工业现场太复杂了。如果摄像头镜头上沾了一滴机油怎么办?如果今天换了一批不同材质的金属,反光率变了怎么办?如果在连续运行 24 小时后,工控机过热导致推理延迟增加了 50 毫秒,致使机械臂抓取错位了怎么办?
在学校里,我们处理的是 .jpg 和 .png,但在工厂里,他们面对的是光、影、灰尘、温度和时间。深度学习模型在实验室里是一个数学问题,但在工业落地时,它就变成了一个复杂的工程问题。如何用 C++ 和 TensorRT 重写推理代码以榨干硬件的最后一丝性能?如何设计更好的光源让缺陷在物理层面就凸显出来?如何在产线上收集那极少数的真实缺陷样本进行模型的持续迭代?
这些问题,在这份期末作业里找不到答案。
我把笔记本合上,屏幕的荧光消失在黑暗里。大四的时光已经所剩无几,周围的人都在忙着考研、考公或者海投简历。我不知道自己未来会不会真的站在那条轰鸣的流水线旁,去解决那些沾满灰尘的工程问题。但至少在这个微凉的广州秋夜,在这个由 1200 张图片和几百行 Python 代码构建的微小世界里,我摸到了技术真实跳动的脉搏。
它不在那些虚无缥缈的宏大概念里,而在那 15 分