轮胎字符识别实战:从图像分割到CNN分类的完整流程
2026/9/24 23:13:35 网站建设 项目流程

简介:这份资源面向计算机、电子信息工程、数学等专业的大学生,服务于课程设计、期末大作业与毕业设计场景,核心任务是轮胎字符识别。包内提供完整源代码、文档说明与配套数据,代码采用参数化编程,参数可灵活调整,注释清晰,并附有运行结果,便于读者理解机器学习项目的整体实现流程。资源共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="", encoding="utf-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_min=300, r_max=420): """按半径范围截取环形字符带,返回 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, dp=1.2, minDist=500, param1=100, param2=30, minRadius=200, maxRadius=600 ) 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_minr_max要根据你的实际图像调,一般先跑一次看圆心和半径对不对,再定这两个值。param2是霍夫圆检测的累加器阈值,调小会检出更多圆,调大更严格。如果检不到圆,先把param2降到 20 试试。

3.2 自适应二值化与字符切分

展开后的 ROI 里,字符是亮背景上的暗字或暗背景上的亮字,取决于打光方式。用自适应阈值比全局阈值稳,因为光照不均。

def binarize_and_split(roi_path, min_area=80): """二值化并按连通域切出单字""" 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(key=lambda b: b[0]) # 按 x 坐标从左到右排序 return roi, boxes roi_img, char_boxes = binarize_and_split("roi_01.png") print(f"切出 {len(char_boxes)} 个候选字符")

blockSize=31决定局部阈值的感受野,字符笔画粗就调大,细就调小,必须是奇数。C=10是偏移量,值越大二值化后前景越少。min_area过滤掉噪点,h/w比例过滤掉横躺的干扰。排序这步很重要,字符识别最终要按顺序拼成字符串,顺序错了结果就错了。

3.3 单字归一化与数据集生成

切出来的单字大小不一,要统一到固定尺寸再送进网络。一般归一化到 32x32 或 28x28,灰度化后做直方图均衡。

def build_char_dataset(roi_img, boxes, out_dir="chars", size=(32, 32)): """把每个字符框裁出来,归一化后保存""" os.makedirs(out_dir, exist_ok=True) 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, interpolation=cv2.INTER_AREA) char_img = cv2.equalizeHist(char_img) # 直方图均衡,抗光照差异 cv2.imwrite(os.path.join(out_dir, f"char_{i:03d}.png"), char_img) count += 1 return count n = build_char_dataset(roi_img, char_boxes) print(f"保存 {n} 个单字样本到 chars/")

pad=4是给字符留边,避免笔画贴边被裁掉。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_classes=36): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), 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_classes=36) 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()), dtype=torch.float32).view(1, 32, 32) / 255.0 y = torch.tensor(self.label2idx[row["label"]], dtype=torch.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_size=64, shuffle=True) optimizer = torch.optim.Adam(model.parameters(), lr=1e-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(f"epoch {epoch+1}, loss {total_loss/len(loader):.4f}")

lr=1e-3是 Adam 的常用起点,如果 loss 震荡就降到 5e-4。batch_size=64在几百张图时大概每轮 10 个 batch 左右。训练轮数看 loss 曲线,一般 20 到 40 轮收敛。注意这里没有写验证集划分,实际做的时候要从 CSV 里切 20% 出来做验证,否则你不知道有没有过拟合。

4.3 推理与字符串拼接

单字分类完,要按位置顺序拼成字符串。因为前面已经按 x 坐标排过序,直接按顺序取 argmax 结果拼接即可。

def recognize_string(model, roi_img, boxes, label2idx, device="cpu"): """对 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, dtype=torch.float32).view(1, 1, 32, 32) / 255.0 with torch.no_grad(): logits = model(tensor.to(device)) pred = logits.argmax(dim=1).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_csv="hard_examples.csv"): model.eval() hard = [] with torch.no_grad(): for x, y in val_loader: logits = model(x) preds = logits.argmax(dim=1) 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], dim=0).max().item() }) pd.DataFrame(hard).to_csv(out_csv, index=False) print(f"发现 {len(hard)} 个难例,已导出到 {out_csv}")

confidence这一列很有用:如果错误样本的置信度普遍很高,说明模型是「自信地错」,大概率是标签有问题;如果置信度低,说明模型没学好,需要加数据或调结构。导出后人工过一遍,把标错的改过来,再合并回训练集。

最后说个我自己的习惯:每做完一个版本,把模型文件、训练 CSV、推理脚本、当时的参数配置一起打包存一个文件夹,命名带日期。轮胎字符识别这种项目,调参过程经常反复,没有版本管理的话,三天后就忘了哪个模型是哪个参数训出来的。这个习惯看着笨,但能省掉很多「后悔药」时刻。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询