轮胎字符识别实战:从图像分割到CNN分类的完整流程
简介这份资源面向计算机、电子信息工程、数学等专业的大学生服务于课程设计、期末大作业与毕业设计场景核心任务是轮胎字符识别。包内提供完整源代码、文档说明与配套数据代码采用参数化编程参数可灵活调整注释清晰并附有运行结果便于读者理解机器学习项目的整体实现流程。资源共157个文件以png、jpg图像数据为主辅以19个Python脚本、模型权重与配置文件如pdmodel、pdiparams、yml等以及txt说明和字体文件压缩包约333MB目录结构便于按模块查阅。数据处理链路覆盖原始数据提取高度数据、高度图转化、裁切、修复、展平、规格化、去噪、直方图均衡化与裁切等环节可帮助读者掌握从图像预处理到字符识别的完整思路。目前已有268人学习适合需要参考完整赛题方案、排错思路与可运行代码的读者。1. 轮胎字符识别到底在识别什么从一条产线质检需求说起轮胎侧壁那圈凸起的字符包含规格、生产批次、DOT 码、模具号是质检和追溯的唯一凭据。人工抄录一条胎要十几秒还容易看错所以「轮胎字符识别」这件事在工业视觉里一直有真实需求。2023 机器学习作业里出现这个题目本质是让你用一套完整流程走通图像采集、字符区域定位、字符分割、单字分类、结果拼接。它解决的是「把胎侧那圈弧形排列、光照不均、字模凸起的字符自动读出来」的问题适合正在做课程设计、想找一个端到端 CV 小项目练手、或者要给产线做原型验证的人。热词里的「字符识别」「数据集」「机器学习模型」在这里都能对上号但别被「作业」两个字骗了它其实是一个缩小版的工业 OCR 系统。2. 轮胎字符识别的技术路线选型为什么不用通用 OCR 直接怼2.1 通用 OCR 在胎侧图像上翻车的三个原因很多人第一反应是拿现成的 OCR 库直接跑结果准确率惨不忍睹。原因有三第一胎侧字符是凸起字模压出来的边缘有高光阴影和印刷体分布完全不同第二字符沿圆弧排列通用 OCR 的文本检测假设是水平或小角度倾斜遇到大曲率直接漏检第三工业现场光照是环形光或条形光反光区域会把字符吃掉一半。所以这个作业的正确姿势是拆成「定位 分割 分类」三段而不是端到端硬怼。2.2 三种可选方案与适用边界方案做法优点局限传统 CV 模板匹配阈值分割 轮廓筛选 模板比对无需训练、可解释换光照就废字模磨损后匹配率骤降CNN 单字分类手工切出单字训练分类网络准确率高、数据需求小依赖分割质量分割错就全错检测 识别端到端类似 CRNN 或 YOLO CTC流程短需要大量标注作业规模下不划算我一般会选第二种先用传统方法把字符区域框出来再切单字最后用一个小 CNN 做分类。这样每一步都能单独调、单独看中间结果出问题知道是哪一环。作业场景下数据量通常只有几百张端到端模型根本喂不饱。2.3 数据集怎么准备才够用轮胎字符识别的数据集有两个来源一是自己拿手机或工业相机拍二是用公开的轮胎字符数据集。自己拍的话注意每个字符类别至少 30 张覆盖不同光照和角度。标注格式建议直接用「类别名 图片路径」的 CSV别一上来就搞 COCO 或 VOC作业规模用不上。数据增强重点做亮度扰动和轻微旋转因为现场光照和轮胎摆放角度就是会变。# 数据集组织脚本把原始图片按类别分文件夹生成训练用 CSV import os import csv import random DATA_ROOT tire_chars # 原始数据根目录每个子文件夹是一个字符类别 OUTPUT_CSV train_labels.csv # 输出标注文件 rows [] for label in os.listdir(DATA_ROOT): label_dir os.path.join(DATA_ROOT, label) if not os.path.isdir(label_dir): continue for fname in os.listdir(label_dir): if fname.lower().endswith((.jpg, .png, .bmp)): rows.append([os.path.join(label_dir, fname), label]) random.shuffle(rows) # 打乱顺序避免同类扎堆 with open(OUTPUT_CSV, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([path, label]) writer.writerows(rows) print(f共生成 {len(rows)} 条样本)这段脚本做的是最基础的数据整理遍历每个类别文件夹把图片路径和类别名写成两列 CSV。DATA_ROOT改成你自己的数据目录OUTPUT_CSV是输出文件名。打乱顺序这一步别省否则训练时同类样本连续进入BatchNorm 的统计量会偏。如果类别不平衡比如数字「0」有 200 张而字母「Q」只有 20 张后面训练时要加权重或过采样。3. 从胎侧原图到单字样本定位与分割的实操步骤3.1 字符区域定位先做 ROI 再做二值化整张胎侧图直接二值化会引入大量噪声正确顺序是先框出字符所在的环形区域再在这个 ROI 里做分割。常见做法是用霍夫圆检测找到轮胎圆心然后按半径范围截取环形带。如果作业数据已经裁好可以跳过这步但要知道工业现场这步不能省。import cv2 import numpy as np def extract_roi(img_path, r_min300, r_max420): 按半径范围截取环形字符带返回 ROI 图像 img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray cv2.medianBlur(gray, 5) # 中值滤波去椒盐噪声 circles cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp1.2, minDist500, param1100, param230, minRadius200, maxRadius600 ) if circles is None: return None cx, cy, r np.round(circles[0][0]).astype(int) # 极坐标展开把环形带拉成矩形方便后续分割 polar cv2.warpPolar( gray, (int(2 * np.pi * r_max), r_max - r_min), (cx, cy), r_max, cv2.WARP_POLAR_LINEAR ) return polar[:, :] roi extract_roi(tire_01.jpg) if roi is not None: cv2.imwrite(roi_01.png, roi)warpPolar是关键它把环形字符带展开成水平排列的矩形这样字符就从弧形变成近似水平后面的分割和分类都好做。r_min和r_max要根据你的实际图像调一般先跑一次看圆心和半径对不对再定这两个值。param2是霍夫圆检测的累加器阈值调小会检出更多圆调大更严格。如果检不到圆先把param2降到 20 试试。3.2 自适应二值化与字符切分展开后的 ROI 里字符是亮背景上的暗字或暗背景上的亮字取决于打光方式。用自适应阈值比全局阈值稳因为光照不均。def binarize_and_split(roi_path, min_area80): 二值化并按连通域切出单字 roi cv2.imread(roi_path, 0) # 自适应阈值blockSize 取奇数C 是常数偏移 binary cv2.adaptiveThreshold( roi, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10 ) # 形态学闭运算把断裂的笔画连起来 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) boxes [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) if w * h min_area: continue # 按宽高比过滤字符一般高大于宽 if h / max(w, 1) 1.2: continue boxes.append((x, y, w, h)) boxes.sort(keylambda b: b[0]) # 按 x 坐标从左到右排序 return roi, boxes roi_img, char_boxes binarize_and_split(roi_01.png) print(f切出 {len(char_boxes)} 个候选字符)blockSize31决定局部阈值的感受野字符笔画粗就调大细就调小必须是奇数。C10是偏移量值越大二值化后前景越少。min_area过滤掉噪点h/w比例过滤掉横躺的干扰。排序这步很重要字符识别最终要按顺序拼成字符串顺序错了结果就错了。3.3 单字归一化与数据集生成切出来的单字大小不一要统一到固定尺寸再送进网络。一般归一化到 32x32 或 28x28灰度化后做直方图均衡。def build_char_dataset(roi_img, boxes, out_dirchars, size(32, 32)): 把每个字符框裁出来归一化后保存 os.makedirs(out_dir, exist_okTrue) count 0 for i, (x, y, w, h) in enumerate(boxes): pad 4 x0, y0 max(0, x - pad), max(0, y - pad) x1, y1 min(roi_img.shape[1], x w pad), min(roi_img.shape[0], y h pad) char_img roi_img[y0:y1, x0:x1] char_img cv2.resize(char_img, size, interpolationcv2.INTER_AREA) char_img cv2.equalizeHist(char_img) # 直方图均衡抗光照差异 cv2.imwrite(os.path.join(out_dir, fchar_{i:03d}.png), char_img) count 1 return count n build_char_dataset(roi_img, char_boxes) print(f保存 {n} 个单字样本到 chars/)pad4是给字符留边避免笔画贴边被裁掉。INTER_AREA在缩小图像时比INTER_LINEAR更少产生摩尔纹。equalizeHist能缓解同一批图里明暗差异大的问题。这一步产出的单字样本需要人工过一遍把切错的、粘连的挑出来否则后面分类网络学到的就是错样本。4. 分类网络训练与推理把单字拼回完整字符串4.1 网络结构选择小 CNN 足够别上 ResNet作业规模的数据集几百到几千张单字图类别数一般 30 多类数字 大写字母 少量符号。这种规模用 3 层卷积 2 层全连接就够了ResNet 反而容易过拟合。输入 32x32 灰度图第一层 32 个 3x3 卷积第二层 64 个第三层 128 个每层后接 ReLU 和 2x2 最大池化最后展平接 128 维全连接和类别数输出。import torch import torch.nn as nn class TireCharNet(nn.Module): def __init__(self, num_classes36): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 128), nn.ReLU(), nn.Dropout(0.5), # 防过拟合 nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x)) model TireCharNet(num_classes36) print(sum(p.numel() for p in model.parameters()), 个参数)Dropout(0.5)在小数据集上是必要的否则训练准确率很快到 99% 而验证集卡在 70%。num_classes按你的实际类别数改如果只有数字就是 10。参数量大概几十万CPU 也能训。4.2 训练循环与关键超参from torch.utils.data import DataLoader, Dataset from PIL import Image import pandas as pd class CharDataset(Dataset): def __init__(self, csv_path, label2idx): self.df pd.read_csv(csv_path) self.label2idx label2idx def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img Image.open(row[path]).convert(L).resize((32, 32)) x torch.tensor(list(img.getdata()), dtypetorch.float32).view(1, 32, 32) / 255.0 y torch.tensor(self.label2idx[row[label]], dtypetorch.long) return x, y labels sorted(pd.read_csv(train_labels.csv)[label].unique()) label2idx {l: i for i, l in enumerate(labels)} ds CharDataset(train_labels.csv, label2idx) loader DataLoader(ds, batch_size64, shuffleTrue) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() for epoch in range(30): model.train() total_loss 0 for x, y in loader: optimizer.zero_grad() out model(x) loss criterion(out, y) loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch1}, loss {total_loss/len(loader):.4f})lr1e-3是 Adam 的常用起点如果 loss 震荡就降到 5e-4。batch_size64在几百张图时大概每轮 10 个 batch 左右。训练轮数看 loss 曲线一般 20 到 40 轮收敛。注意这里没有写验证集划分实际做的时候要从 CSV 里切 20% 出来做验证否则你不知道有没有过拟合。4.3 推理与字符串拼接单字分类完要按位置顺序拼成字符串。因为前面已经按 x 坐标排过序直接按顺序取 argmax 结果拼接即可。def recognize_string(model, roi_img, boxes, label2idx, devicecpu): 对 ROI 中所有字符框推理返回拼接后的字符串 idx2label {v: k for k, v in label2idx.items()} model.eval() chars [] for (x, y, w, h) in boxes: pad 4 x0, y0 max(0, x - pad), max(0, y - pad) x1, y1 min(roi_img.shape[1], x w pad), min(roi_img.shape[0], y h pad) char_img cv2.resize(roi_img[y0:y1, x0:x1], (32, 32)) char_img cv2.equalizeHist(char_img) tensor torch.tensor(char_img, dtypetorch.float32).view(1, 1, 32, 32) / 255.0 with torch.no_grad(): logits model(tensor.to(device)) pred logits.argmax(dim1).item() chars.append(idx2label[pred]) return .join(chars) result recognize_string(model, roi_img, char_boxes, label2idx) print(识别结果:, result)推理时一定要model.eval()否则 Dropout 还在随机丢神经元同一张图两次结果不一样。torch.no_grad()省显存也加速。拼接顺序依赖前面的排序如果字符有上下两行需要先按 y 再按 x 排这个逻辑要按你的实际排版改。5. 轮胎字符识别避坑记录五个让我返工的血泪经验5.1 现象训练准确率 99% 但现场图全错原因训练集和测试集来自同一批拍摄条件模型学到了背景特征而不是字符特征。解决数据增强必须加随机亮度、对比度、高斯噪声并且验证集要用不同时间段拍的图。我一般会留一整天的数据不参与训练专门做验证。5.2 现象字符「8」和「B」总是混原因32x32 分辨率下这两个字符的区分特征被池化层抹掉了。解决把输入尺寸提到 48x48或者减少一次池化。另一个办法是在分类头加一个辅助分支专门做宽高比回归因为「8」和「B」的宽高比有差异。5.3 现象分割出来的字符粘连在一起原因胎侧字符间距小二值化后闭运算的 kernel 太大把相邻字符连成一块。解决把闭运算 kernel 从 3x3 降到 2x2或者改用开运算先断开再闭运算。如果还粘连用垂直投影法找波谷切分比连通域更稳。5.4 现象霍夫圆检测在部分图上找不到圆心原因轮胎边缘被遮挡或图像对比度低。解决不要死磕霍夫圆改用轮廓拟合椭圆或者直接用固定 ROI 模板匹配。作业数据如果轮胎位置固定甚至可以直接硬编码 ROI 坐标省掉检测这步。5.5 现象模型在 CPU 上推理一张图要 2 秒原因每次推理都重新加载模型和做直方图均衡。解决模型只加载一次直方图均衡用查表法预计算。另外把torch.set_num_threads(4)加上CPU 推理能快一倍。如果还慢把模型转成 ONNX 再用 onnxruntime一般能到 200ms 以内。6. 把作业变成能用的原型验证方法与一个提效技巧训练完模型别只看准确率数字要做端到端验证。具体做法是拿 20 张没参与训练的整胎图跑完整流程人工核对识别结果。统计两个指标整串完全正确的比例串准确率和单字准确率。串准确率才是业务关心的因为错一个字符整条胎的追溯信息就废了。我一般要求串准确率到 90% 以上才认为原型可用低于这个值就回去看是分割错还是分类错。判断错误来源有个简单方法把分割后的单字图按顺序存下来和识别结果并排看。如果单字图本身就是错的切多了、切少了、粘连那是分割问题如果单字图对但分类错那是模型问题。这个「中间结果可视化」的习惯能省掉大量瞎调参的时间。一个提效技巧是「难例挖掘」第一轮训练完把所有验证集里分类错误的样本挑出来人工确认标签后加入训练集再训一轮。通常两轮下来那些容易混的字符对8/B、0/O、5/S准确率能提 5 到 10 个百分点。这比盲目加数据增强有效得多。# 难例挖掘找出验证集中预测错误的样本导出待人工复核 def mine_hard_examples(model, val_loader, idx2label, out_csvhard_examples.csv): model.eval() hard [] with torch.no_grad(): for x, y in val_loader: logits model(x) preds logits.argmax(dim1) for i in range(len(y)): if preds[i] ! y[i]: hard.append({ true: idx2label[y[i].item()], pred: idx2label[preds[i].item()], confidence: torch.softmax(logits[i], dim0).max().item() }) pd.DataFrame(hard).to_csv(out_csv, indexFalse) print(f发现 {len(hard)} 个难例已导出到 {out_csv})confidence这一列很有用如果错误样本的置信度普遍很高说明模型是「自信地错」大概率是标签有问题如果置信度低说明模型没学好需要加数据或调结构。导出后人工过一遍把标错的改过来再合并回训练集。最后说个我自己的习惯每做完一个版本把模型文件、训练 CSV、推理脚本、当时的参数配置一起打包存一个文件夹命名带日期。轮胎字符识别这种项目调参过程经常反复没有版本管理的话三天后就忘了哪个模型是哪个参数训出来的。这个习惯看着笨但能省掉很多「后悔药」时刻。希望帮到你。本文还有配套的精品资源点击获取