简介:这是一份基于机器学习的轮胎字符识别完整项目,源自作者期末大作业,答辩评审分达到九十八分,所有代码均经过调试,解压后按说明即可直接运行。项目面向计算机、通信、人工智能、自动化等相关专业学生与从业者,可作为期末课程设计、课程大作业或毕业设计参考,也适合机器学习初学者用来训练图像识别技能。包内共一百五十六个文件,涵盖源码、训练好的模型权重、轮胎字符图像样本、使用说明文档以及识别结果示例图等,压缩包整体约三百三十三MB;源码、模型和文档按模块归类,便于快速定位与二次开发。目前已有一百三十人学习,项目自带完整调试环境与推理脚本,跑通后能直观看到轮胎字符的识别效果,也可自行修改数据集或模型结构,实现更丰富的字符识别功能。整体方案规范完整、可操作性较强,能帮助学习者省去搭建环境与调参排查的时间,是课程作业或入门实践的高性价比选择。
1. 从一条DOT码说起:为什么轮胎字符识别是机器学习入门课的黄金选题
期末作业拿到「基于机器学习的轮胎字符识别」这个题目时,很多人的第一反应是“这不就是OCR吗,调个现成库不就完了”。真做下去才发现,轮胎胎壁上的字符远没有文档扫描件那么友好——字符凸起或内凹在弧面上,拍摄角度带透视,阳光一照还有反光,胎泥和磨损让笔画时断时续。把这一行字符从照片里稳定读出来,恰好覆盖了机器学习项目最完整的一条链路:图像预处理、数据构造、模型选型、训练调参、错误分析与交付汇报。
这个题好在三点。第一,数据不用求人,自己拿手机对着轮胎拍就能攒几十张真实样本,再配合合成数据就能撑起训练集;第二,任务目标清晰,输出就是一行字符,打分标准明确,不像分类题那样解释空间很大;第三,模型规模不需要很大,一台普通笔记本电脑用 CPU 也能跑完训练,期末答辩时演示和讲解都容易展开。适合的人群很明确:正在做机器学习课程设计、需要在一个月内从零跑通并写出报告的学生,以及想用“场景字符识别”练手视觉项目、但不想一上来就碰大规模数据集的入门工程师。
下面我会按你自己做项目时的真实顺序来讲:先把数据和预处理搞定,再决定模型路线和训练参数,然后专门讲那些让项目翻车的经典坑,最后说怎么把模型和脚本打包成一份能交、能讲、能演示的作业。
2. 数据是第一关:轮胎DOT码的合成数据与预处理管线搭建
2.1 轮胎字符的真实形态:凸起、曲率与脏污噪声
轮胎胎壁上的字符通常有两类。一类是模具直接刻出来的凹陷字符,另一类是硫化时形成的凸起字符,比如常见的 DOT 认证码、生产周数、工厂代号和品牌标识。无论哪一类,它们都有一个共同特点:字符和背景的对比度不稳定。
凹陷字符在侧光下会形成阴影,凸起字符在直射光下会高光过曝,这两种情况都会让一个字符在视觉上“断掉”。更麻烦的是轮胎侧面是弧面,当镜头正对胎面中心时,字符行会呈现明显的弯曲排列,首尾字符的透视形变比中间大。胎泥、磨损和老化裂纹进一步破坏笔画完整性。换句话说,轮胎字符识别和标准的手写字符识别任务很不一样:手写字符是“背景干净、笔画清晰”,轮胎字符是“背景脏、笔画破损、光照随机”。
所以拿到题目后,我建议你先做一件看起来不急于建模的事:把真实轮胎照片按“端面光照”“侧光”“脏污程度”“字符排列方向”分类看一遍。这一步会让你后面做预处理时少走很多弯路。常见做法是先用手机拍 30 到 50 张不同轮胎的胎壁照片,覆盖白天、阴天、地库等场景,再把它们作为真实测试集和微调集保留下来。
2.2 合成数据生成脚本:几十行代码把样本量做到上万
训练字符分类模型需要每个类别的样本数量相对均衡。真实轮胎照片每张能切出的字符有限,手工标注几百个字符都算多,直接训练 CNN 很容易过拟合。所以最可靠的做法是合成数据为主、真实数据微调。合成数据的思路很简单:生成随机的背景、字体、旋转、模糊和亮度扰动,把单个字符画上去,标上对应的类别标签,批量导出。
下面这个脚本可以在一分钟内生成几千张单字符样本,用于后面的字符分类模型训练:
import numpy as np import cv2 from PIL import Image, ImageDraw, ImageFont CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" def synthetic_char(char, img_size=32, font_path=None): # 亮背景 + 高斯噪声,模拟轮胎橡胶表面的颗粒感 img = np.random.randint(180, 255, (img_size, img_size), dtype=np.uint8) noise = np.random.normal(0, 8, (img_size, img_size)) img = np.clip(img.astype(np.float32) + noise, 0, 255).astype(np.uint8) # 用 PIL 绘制字符,字体选择接近轮胎模具字体的等宽字体 pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)) draw = ImageDraw.Draw(pil_img) font = ImageFont.truetype(font_path or "DejaVuSansMono.ttf", 24) fill = np.random.randint(15, 55) # 字符颜色略做变化 draw.text((4, 2), char, font=font, fill=(fill, fill, fill)) img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_BGR2GRAY) # 随机旋转、模糊和亮度扰动,模拟拍摄中的透视与光照变化 angle = np.random.uniform(-10, 10) M = cv2.getRotationMatrix2D((img_size / 2, img_size / 2), angle, 1.0) img = cv2.warpAffine(img, M, (img_size, img_size), borderValue=200) img = cv2.GaussianBlur(img, (3, 3), 0.3) img = np.clip(img * np.random.uniform(0.6, 1.4), 0, 255).astype(np.uint8) return img # 生成 36 个类别各 500 张 for c in CHARS: for i in range(500): img = synthetic_char(c) cv2.imwrite(f"syn_data/{c}_{i:04d}.png", img)脚本的核心是让字符生成绕不开三个随机源:旋转角度、模糊程度、亮度增益。angle控制在正负 10 度以内,对应真实拍摄时轮胎弧面造成的轻微倾斜;GaussianBlur的核大小固定为 3 乘 3,因为轮胎字符是硬质橡胶,不会像纸张文字那样出现大范围墨迹晕染;亮度增益乘以 0.6 到 1.4 的随机系数,是为了覆盖反光区域和阴影区域同时出现在一张图里的情况。字体方面,DejaVuSansMono 等等宽字体比黑体更接近轮胎模具字的等宽排布感,如果你手头有真实轮胎字符的字体或类似的工业钢印字体,优先用真实的。
这里有一个容易被忽略的点:不要把字符画在纯白背景上。轮胎橡胶即使在最干净的情况下也不是纯白,而是深浅不一的灰黑色,所以背景的基础亮度设在 180 到 255 之间,而不是固定为 255。加高斯噪声的幅度8也不要改太大,太大会让字符笔画完全淹没在噪声里,模型学到的就变成了“猜噪声”,而不是“读字符”。
2.3 预处理管线的三个环节:定位、二值化、切分
训练之前,还要把真实照片变成模型能吃的输入。轮胎字符识别的预处理通常分成三段:先定位出字符所在的带状区域,再对区域做增强和二值化,最后把一行字符切成单个字符。
定位这一步最常见的做法不是训练目标检测模型,而是先用轮廓分析把“笔画密集的团块区域”找出来。轮胎字符是凸起或凹陷的,在图像里会形成一组高对比度的边缘,把这些边缘的轮廓找出来,再用面积、宽高比、位置这三条规则过滤,就能把胎壁上的字符带大致框出来。如果你拍的照片里有轮胎品牌logo、花纹等干扰元素,可以按“字符带通常出现在胎壁中段、宽高比大于 3 比 1”这一先验来排除。
定位完成后,二值化是关键。很多初学者直接对整张灰度图做全局 Otsu 阈值,结果在有反光区域的照片上,字符要么全白、要么断成碎片。更可靠的做法是先做 CLAHE 对比度增强,再用局部自适应阈值。下面是二值化与切分的通用代码:
import cv2 import numpy as np def preprocess_band(band_gray): # CLAHE 增强对比度,抑制反光带来的局部亮区 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(band_gray) # 局部自适应阈值,blockSize 取奇数,C 为修正常数 binary = cv2.adaptiveThreshold( enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) # 闭运算补断笔:先膨胀再腐蚀,把靠近的笔画连起来 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 3)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return closed def split_chars(image, min_width=8, min_height=20): # 按列投影找到字符之间的空白间隔 col_sum = image.sum(axis=0) cols = col_sum > 0 regions = [] start = None for i, v in enumerate(cols): if v and start is None: start = i elif not v and start is not None: regions.append((start, i)) start = None # 过滤过窄和过矮的噪声区域 h, w = image.shape result = [] for x1, x2 in regions: roi = image[:, x1:x2] ys = np.where(roi.sum(axis=1) > 0)[0] if len(ys) == 0: continue y1, y2 = ys[0], ys[-1] if (x2 - x1) >= min_width and (y2 - y1) >= min_height: result.append((x1, y1, x2, y2)) return result参数说明:adaptiveThreshold的blockSize=31表示局部邻域大小,邻域越大,对局部光照变化的适应越粗;C=15表示从邻域均值中减去的常数,数值越大,二值化后保留的前景像素越少。如果反光严重导致断裂,可以把C调到 10 或 8;如果背景纹理被当成字符保留下来,就把C调大。MORPH_CLOSE的核选横向 5、纵向 3,是为了优先连接同一字符内部横向断裂的笔画,比如“8”断成上下两截的情况。切分时的min_width=8和min_height=20要按你拍照时字符的实际像素大小调整,拍得近字符大,这两个值就得相应放大。
要注意的是,投影切分法只对字符间距明显、排列接近水平的图像效果好。一旦遇到粘连字符或者弧面倾斜严重的照片,切分就会出错,这时就需要考虑下一章的模型选型问题:到底是继续走“切分 + 单字符分类”,还是换成端到端的序列识别模型。
3. 模型选型与训练:在 yolo 字符识别与轻量 CNN 之间做取舍
3.1 三条技术路线的对比:从“检测+分类”到“端到端 CRNN”
把字符区域找出来后,接下来的模型路线有三条常见选择。每条路线对应不同的工作量、训练成本和期末答辩时的解释难度。
路线一是“轮廓定位 + 单字符分类”。先用轮廓分析把整行字符切成单个字符,再训练一个轻量 CNN 对每个字符分类,最后把预测结果拼成字符串。这是最符合机器学习课程原理的路线,因为你可以把预处理、特征提取、分类器、评估拆成独立的模块来讲解。缺点是切分错误会直接传导到识别结果,一个字符切坏,后面整体跟着错。
路线二是用 YOLO 这类目标检测模型直接检测每个字符的位置和类别,也就是常说的 yolo 字符识别方案。它把“定位”和“分类”合成了单阶段检测问题,对真实场景中脏污、遮挡、倾斜的鲁棒性明显更强,因为你不再依赖任何预设的切分规则。但代价也很直接:你需要准备带边界框标注的训练数据,轮胎字符数量多且密集,标注成本比路线一高得多。期末作业周期内,除非你已经有标注好的数据,否则我不建议从零标注再来训练 YOLO。
路线三是用 CRNN 做端到端序列识别,输入一行字符图像、直接输出字符串序列。它的好处是彻底绕开了字符切分问题,对粘连字符和倾斜排列的宽容度更高。但 CRNN 的训练对超参数更敏感,CTC 损失收敛不稳定是常态,调起来很考验经验,也就是大家常说的“有点玄学”。对期末作业来说,模型解释起来不如路线一直观,答辩时容易被追问细节。
下面给出三条路线在几个关键维度上的对比:
| 路线 | 定位成本 | 切分依赖 | 训练难度 | 期末友好度 |
|---|---|---|---|---|
| 轮廓定位 + CNN 分类 | 低 | 高 | 低 | 高,原理清晰、拆解方便 |
| YOLO 字符检测 | 高,需框标注 | 无 | 中高 | 中,效果好看但工作量大 |
| CRNN 端到端序列识别 | 低 | 无 | 高,CTC 调参复杂 | 中,解释成本高 |
我一般会建议期末项目选路线一作为主体,如果真实照片效果不理想,再考虑用 CRNN 作为改进方向写进“后续工作”。这样既能保证项目在有限时间内完成,又给报告留出了“改进空间”这个加分项。
3.2 训练一个轻量字符分类模型:可复现的 PyTorch 最小脚本
路线一定下来,核心工作就变成了训练一个单字符分类模型。分类类别根据字符集来定:DOT 码常见的是 0-9、A-Z 共 36 类,有些厂商还会用到括号、星号等符号,但在课程项目中通常只做 36 类就足够。
下面是可直接运行的最小训练脚本。输入是 32 乘 32 的灰度图,输出是 36 类的概率分布:
import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image import glob class CharNet(nn.Module): def __init__(self, n_classes=36): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 输出 16x16 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 输出 8x8 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 输出 4x4 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 256), nn.ReLU(), nn.Dropout(0.2), nn.Linear(256, n_classes) ) def forward(self, x): return self.classifier(self.features(x)) class CharDataset(Dataset): def __init__(self, img_paths, labels): self.paths = img_paths self.labels = labels self.tf = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) def __len__(self): return len(self.paths) def __getitem__(self, idx): img = Image.open(self.paths[idx]).convert("L") img = self.tf(img) label = self.labels[idx] return img, label训练循环部分用标准的交叉熵损失和 Adam 优化器:
model = CharNet(n_classes=36) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=8, gamma=0.1) criterion = nn.CrossEntropyLoss() train_loader = DataLoader(train_ds, batch_size=64, shuffle=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False) for epoch in range(20): model.train() for imgs, labels in train_loader: optimizer.zero_grad() out = model(imgs) loss = criterion(out, labels) loss.backward() optimizer.step() model.eval() correct = 0 total = 0 with torch.no_grad(): for imgs, labels in val_loader: out = model(imgs) pred = out.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) print(f"epoch {epoch+1}, val acc {correct / total:.4f}") scheduler.step() torch.save(model.state_dict(), "char_model.pth")这个网络结构是典型的轻量 CNN:三层卷积加三层全连接,参数总量不到 50 万,CPU 训练一个 epoch 大约几十秒。BatchNorm 放在卷积和 ReLU 之间,可以让小批量训练更稳定。Dropout 只加在分类层之前,概率设 0.2,对防止小数据集过拟合已经够用。输入统一缩放到 32 乘 32,是考虑到轮胎字符在切分后通常接近正方形,而且 32 的尺寸能保留足够的笔画细节。
3.3 训练参数怎么设:lr、batch_size、early stopping 的合理区间
训练参数是这题最容易踩坑的地方。先说lr,Adam 下 1e-3 是稳妥起点,但如果你的合成数据量很大、真实数据微调量很小,微调阶段建议把 lr 降到 1e-4,否则真实数据少的时候模型权重会被合成数据的主导梯度冲乱。StepLR每 8 个 epoch 把学习率乘以 0.1,可以在训练后期细调权重,典型的三段式衰减。
batch_size的合理区间取决于切分后的字符大小和训练集规模。合成数据上万张时,64 或 128 都可以;如果用真实照片微调且只有几百张,batch_size 就设 16 或 32,因为批量太大时每个 batch 的分布方差小,模型容易在少量真实样本上快速过拟合。
最实用的一招是 early stopping。训练脚本里每轮打印验证集准确率,当验证准确率连续 5 个 epoch 不增长时,直接保存当前最优模型并停止。很多人为了跑满固定 epoch 数,反而在过拟合区间把模型练坏了。我自己做这个项目时习惯把训练分成两段:先在纯合成数据上训练到验证集收敛,再加载最优权重,用全部真实字符数据微调 10 到 15 个 epoch。这个流程既保证了基本特征学习,又能把轮胎纹理的真实分布带进模型。
4. 轮胎字符识别避坑实录:五个让模型翻车的经典场景
4.1 loss 一直降,测试集全错:先查标签对齐
现象:训练过程中训练损失正常下降,验证集准确率却一直趴在 3%、4% 左右,和随机猜测差不多。
原因:最常见的是数据集在读取时图片路径和标签没有一一对应。比如你图方便,用glob按文件名排序读图片,又用另一个列表按目录排序读标签,两个顺序一旦不一致,模型等于在“看图猜根本没有规律的编号”,loss 当然能降,因为它在拟合随机关系。
解决:把所有样本统一放进唯一的 Dataset 类,在__getitem__里同时返回图像和标签,避免任何中间环节手工维护“路径列表”和“标签列表”。这个错误信息最隐蔽的点在于,loss 曲线完全正常,如果不打印几个 batch 的图片和标签对照,你根本发现不了。
4.2 合成数据效果惊艳,真实照片全军覆没:域差异
现象:合成数据测试准确率 95% 以上,一到自己手机拍的轮胎照片,准确率直接掉到 30% 左右。
原因:合成数据里的字体、背景、噪声分布和真实轮胎差异太大。模型学到的是“合成字体在合成背景上的样子”,而不是“轮胎橡胶上的字符长什么样”。这是所有靠合成数据训练视觉模型都会撞上的域差异问题,轮胎字符识别尤其明显,因为轮胎表面的纹理结构远比合成背景复杂。
解决:第一,合成数据里尽量加入真实轮胎照片的光照特征,比如把背景换成随机裁切的真实轮胎纹理图,而不是纯噪声;第二,拍摄并手工切分至少 200 到 300 个真实字符,用真实数据对模型做微调;第三,在报告中把“纯合成”和“合成 + 真实微调”两组准确率做成对比表格,这本身就是很好的实验分析素材。后续如果还想再进一步,可以用域适应方法,在特征提取层加入域对抗训练,把合成域和真实域的特征分布拉近。
4.3 反光造成的断笔与粘连:把二值化做成可调参数而不是硬阈值
现象:字符区域存在强反光时,图片上“8”被断成“3”和“0”的左右两半,“M”被折成两截,切分也把半个字符当成一个字符框出来。
原因:全局阈值二值化对光照不均非常敏感。反光区域像素值整体偏高,一个固定阈值根本照顾不到所有区域,要么反光处全白、要么暗处全黑。
解决:把二值化改成局部自适应阈值,并在切分前用闭运算把断笔补上。adaptiveThreshold的blockSize和C要按实际字符大小来调:字符在图上占 30 到 40 像素宽时,blockSize取 31 合适;如果手机离轮胎很近、字符占 80 像素以上,就把blockSize相应放大到 61。闭运算的核优先用横向椭圆核,比如(9, 3),能更好地连接横向断裂的笔画。这里要提醒一句,闭运算是“后悔药”,它只能补小裂缝,如果字符已经被拍成两段完全分离的连通域,闭运算也无能为力,只能靠拍摄时补光来改善。
4.4 竖排 DOT 码与弧面字符:检测框与白边增强
现象:轮胎胎壁上的字符有时沿着弧面排列成弧形,甚至部分轮胎的 DOT 码是竖排的。投影切分对这类图像直接失效,切出来的全是斜条或压扁的字符。
原因:投影法假定字符行是水平的、字符之间是上下贯通的空白列。弧面字符有旋转角度,竖排字符的投影方向完全相反,这两个前提都不成立。
解决:对弧形排列的字符,先用轮廓的最小外接矩形拿到旋转角度,再用仿射变换把字符带转正,然后才进入切分流程。对竖排字符,把预处理中的投影方向从“按列求和”改成“按行求和”,即先判断字符带是横向还是竖向,再决定切分方向。合成数据阶段就要加入旋转增强,而且旋转时要先给图片扩边再旋转,避免字符在旋转过程中被图像边界切掉。我一般会把旋转范围从正负 10 度扩大到正负 25 度,并把背景扩充成 48 乘 48 再旋转和裁剪,这样模型对倾斜的容忍度会好很多。
4.5 类别不均衡导致模型变成“判数狂魔”
现象:DOT 码的构成以数字为主,字母里 E、N、T 出现频率也远高于 Q、Z 这类冷门字母。训练后模型对高频数字的召回率高,对冷门字母几乎不认。
原因:交叉熵损失在类别样本数差距大时,会把模型往高频类别方向推。模型学到“所有字符都猜数字”就能拿到一个不低的训练准确率。
解决:两种常见做法。第一种是类别加权,按每个类别样本数的倒数设置交叉熵权重;第二种是改用 Focal Loss,让模型把注意力集中在难分的类别上。对期末项目来说,类别加权更简单也更直观。在 PyTorch 里可以用torch.tensor构造权重列表传给CrossEntropyLoss(weight=...),权重为总样本数除以每类样本数。还要注意验证集要保持和真实 DOT 码分布一致的字符构成,不要用均匀分布去评估,否则准确率数字会骗人。
5. 把模型打包成能交的作业:推理脚本、可视化与报告叙事
5.1 完整推理脚本:从单张轮胎图到一行字符
模型训练完,最重要的一步是把预处理、切分、分类串成一条完整的推理链路。下面这个脚本可以直接对单张轮胎照片输出识别结果,并把每个字符的置信度打出来:
import cv2 import torch import numpy as np from torchvision import transforms CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CharNet(n_classes=36).to(device) model.load_state_dict(torch.load("char_model.pth", map_location=device)) model.eval() tf = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) def extract_band(gray): # 简化版:按边缘密度找字符带,实际可按轮廓过滤 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) binary = cv2.adaptiveThreshold( enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 3)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return closed def predict_image(image_path): img_bgr = cv2.imread(image_path) gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) binary = extract_band(gray) boxes = split_chars(binary) result_text = [] confs = [] for x1, y1, x2, y2 in boxes: char_patch = gray[y1:y2, x1:x2] char_patch = cv2.resize(char_patch, (32, 32)) tensor = tf(char_patch).unsqueeze(0).to(device) with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1) conf, cls_idx = prob.max(dim=1) result_text.append(CHARS[cls_idx.item()]) confs.append(conf.item()) return "".join(result_text), confs if __name__ == "__main__": text, confs = predict_image("test_tire.jpg") print(f"识别结果: {text}") print(f"逐字符置信度: {[round(c, 3) for c in confs]}")这个脚本的价值在于它把前面所有内容串起来了:extract_band是第 2 章里的预处理封装,split_chars就是之前写的投影切分函数,CharNet是第 3 章训练好的模型。运行后输出一行字符和每个字符的置信度,置信度低的位置就是你该去补拍、补标注的地方。
实际交作业时,把这个脚本包装成接受文件夹批量处理的版本会更方便:遍历目录下所有测试图片,把识别结果写进 CSV,同时把原图和预测结果并排画出来。评审老师看到的不只是“模型能跑”,而是“工具能批量干活”,这个印象差别很大。
5.2 错误样本可视化与模型能力边界的验证
模型交付不只是给一个.pth文件,还要证明它什么时候可靠、什么时候不可靠。可按“预测错误字符”和“真实字符”交叉统计,生成一张 36 乘 36 的混淆矩阵,再从错误样本中挑出典型图片单独保存。
轮胎字符识别里最常见的混淆集中在几组形状相似的字符:O 和 0、I 和 1、B 和 8、Z 和 2。如果你查混淆矩阵时发现主要错误聚集在这几对上,说明模型已经学到了合理的视觉特征,只是在相似形状的区分上还不够。这时可以加一条规则:DOT 码规范中很多厂家用 O 而不用 0,用 I 而不用 1,可以按这个先验直接修改逻辑输出。
验证模型边界时不要只看整体准确率。按“光照条件”“倾斜角度”“字符是否粘连”分别统计准确率,你会发现模型的能力边界非常具体:正常光照下 94%,强反光下 61%;水平排列 96%,竖排 70%。这些数字写进报告比单给一个总准确率有说服力得多,答辩时也能先发制人,主动讲清模型的适用条件。
5.3 答辩时讲什么:用一组对比实验支撑你的工作
期末答辩的评分重心通常不在“准确率多高”,而在“你是否真的理解了机器学习流程”。我建议报告按这个结构组织:问题定义与数据特点、合成数据构造方法与动机、预处理管线的每一步为什么这样设计、模型结构选择理由、训练策略与调参过程、定量评估与错误分析、局限与改进方向。
其中最有分量的是对比实验。建议至少有两组:第一组是模型对比,同一个数据集上跑 SVM/HOG、轻量 CNN、带预处理的 CNN,用表格展示三者准确率;第二组是消融实验,分别是“不去反光处理”“不用闭运算补断笔”“纯合成数据不微调”“合成数据加真实微调”四组配置的识别结果。不用太复杂,但每组实验都要能在报告中解释一句“为什么差、为什么好”。这比堆一个 99% 的准确率数字更能体现工程思维。
最后说一个我自己做这类项目时留下的教训:最初我把过半时间花在调 OCR 库和切分参数上,觉得“算法厉害就万事大吉”,后来发现轮胎字符识别的瓶颈从来不在分类模型,而在数据分布和预处理。你花一个星期把合成数据做得接近真实,比花一个星期把 CNN 换大一号有效得多。先把数据域问题解决掉,再回头看模型,你会发现准确率自己就上来了。希望这篇笔记能帮你把这条路线走顺,少踩几个我已经替你踩过的坑。
本文还有配套的精品资源,点击获取