
简介面向机器学习课程期末项目与OCR方向初学者这份轮胎字符识别资源提供了完整的工程实现基于EAST/DB算法完成文本检测结合CRNN模型识别轮胎表面字符并附有源代码、预训练模型与使用说明。作者同时记录了字符提取精度受字体花样、轮胎弯曲影响等实战问题与调优思路适合作为课程设计或入门OCR落地的参考项目。压缩包共157个文件涵盖Python源码、模型权重与配置文件、测试图片和说明文档等整体大小约332.75MB便于直接运行与二次开发。已有165人学习下载无论用于期末汇报还是扩展检测识别方案都具备一定参考价值。1. 轮胎字符识别期末作业里最容易翻车也最容易拿分的方向机器学习期末作业选基于机器学习的轮胎字符识别一多半人是冲着它像真项目去的轮胎侧壁上那串195/65R15 91V是胎宽、扁平比、轮毂直径、载重指数和速度级别的编码磨掉一个字符就影响安全。它比猫狗分类、手写数字多了一条完整链路——图像预处理、字符定位、字符分割、模型识别、评估分析正好把一门机器学习课的知识点全串起来。难点也很直接字符是橡胶凸起字带弧度、会反光、常磨损普通OCR很难直接识别。适合谁做要交付可运行项目、想用一份工程化程度高的作业拿高分的人。下面我按实际做过的方案把数据、代码、参数和坑位一次讲清。2. 先把方案定对为什么轮胎字符识别不能直接套通用OCR2.1 轮胎字符和印刷体差在哪曲面、反光、磨损和艺术字体一开始我想省事直接调现成OCR库。结果在实拍照片上跑下来准确率惨不忍睹。原因不是OCR库不行而是轮胎字符这个模态本身特殊。普通OCR面对的是平面印刷体白纸黑字、对比度高且稳定。轮胎侧壁不一样胎面是弧形字符在曲面上被拉伸变形橡胶表面有纹理反光时字符边界直接淹没在光斑里使用过的轮胎还有磨损笔画缺一块少一块再加上轮胎厂商爱用斜体、带衬线的艺术字不是标准印刷字体。通用OCR的检测模型按长文本行设计对这种紧凑、密集、可变形的字符串检测框本身就框不准。我拿同一批50张轮胎照片做过快速对比Tesseract的整串识别率不到20%而把它拆成定位→分割→单字符识别三段式之后识别率开始能看。这说明一个判断在这个任务上与其跟端到端OCR模型的内部黑匣子较劲不如自己控制每一步。而且从期末作业的角度看自己分阶段实现正好每阶段都能在报告里写清楚原理这在答辩时是加分项。2.2 三段式路线定位、分割、单字符识别每段的选型理由我的方案分三段跟图像处理机器学习模型的课程内容正好对应。第一段是字符定位。常见做法是用形态学操作把字符区域连成连通域再用轮廓提取拿外接矩形。为什么不用更高级的目标检测比如YOLO因为期末场景是轮胎照片背景相对固定字符区域在图像中的尺度变化不大不值得为它准备上百个bounding box标注、训练一个检测网络。形态学定位在两三张样本上把参数调好就能覆盖大多数测试图性价比最高。第二段是字符分割。拿到整个字符区域后按连通域或垂直投影切开成单个字符。这一步决定生死——分割错了后面的识别模型再强也没用。具体代码在第3章给出。第三段是识别模型。这里是基于机器学习的核心。可选路线有两条HOG特征SVM分类器或者CNN卷积神经网络。SVM在少量样本下表现稳、解释性强答辩时能清楚说出HOG提取梯度方向直方图SVM在高维空间找分类超平面CNN是端到端学习特征上限高但要喂更多数据。我的建议是模型主体用CNN同时把HOGSVM跑一遍做baseline对比。两个模型各有价值写进作业里就是一组很有说服力的对比实验这也正是机器学习课程最看重的不同算法在同一任务上的横向比较。2.3 数据集怎么来实拍不够用合成数据补足轮胎数据不是那么好采集的。实拍需要找轮胎、固定角度、控制光照一个学生很难在截止日期前收到几百张高质量实拍图。这里我用的方案是合成少量真实的混合数据集。合成的方法很直接把字符渲染到随机背景上加仿射变换模拟曲面、加高斯噪声和模糊模拟磨损、加随机光照模拟反光。用OpenCV或PIL都能做。字符来源除了字体文件也可以用真实字符截图抠出来贴到背景上这样保留真实纹理。合成数据生成器是期末作业里容易被低估的部分但它决定了模型上线的表现。采样比例建议在1:3到1:5之间即1份真实图配3到5份合成图。因为在纯合成数据上训练的模型遇到真实照片时会有域差异——合成图的字符边缘太干净真实照片有橡胶颗粒和光影渐变。把少量真实图混进训练集模型才能在期末演示的实拍图上不拉胯。类别不均衡的问题也要在生成时规避/和R这类字符在轮胎规格里只出现一两次天然样本少生成时就要按0.5的权重额外多生成。3. 用OpenCV把轮胎字符从照片里抠出来预处理与字符分割实操3.1 灰度、去噪、二值化自适应阈值是光照不均的后悔药拿到一张轮胎侧壁图第一步不是直接二值化而是先把图像降噪。参数我这么设的import cv2 import numpy as np def preprocess(image_path): # 读取原图 img cv2.imread(image_path) # 转灰度字符识别不需要颜色信息且灰度对反光更鲁棒 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊去噪核大小取(3,3)保留笔画边缘不被磨平 blurred cv2.GaussianBlur(gray, (3, 3), 0) # 自适应阈值光照不均时比全局阈值稳得多 binary cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) return img, binary这段代码里最容易调错的是adaptiveThreshold的两个参数。blockSize31表示每个像素的阈值由它周围31x31邻域的像素决定这个值要大于字符笔画宽度大概取字符高度的1.5倍左右C15是一个常数从邻域均值里减掉C太大会把浅色笔画当背景吃掉太小又会让噪点变成前景。轮胎照片带曲面反光用固定阈值cv2.threshold经常让上半部分黑成一团、下半部分字符消失用自适应阈值基本一次就能过。3.2 形态学连笔画、连通域提取字符框二值化之后字符笔画常因为磨损或反光断成一截一截。先做一次闭运算把断笔连接再找连通域。# 闭运算先膨胀后腐蚀连接断裂笔画 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations1) # 找连通域并提取外接矩形 contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) chars [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) # 过滤宽度10像素高度20像素宽高比在0.2到0.8之间 if 10 w 80 and 20 h 120 and 0.2 (w / h) 0.8: # 给每个候选框加2像素边距避免切到笔画边缘 pad 2 x, y, w, h x - pad, y - pad, w 2 * pad, h 2 * pad chars.append((x, y, w, h)) # 从左到右排序保证195/65R15的顺序正确 chars.sort(keylambda box: box[0])闭运算的核大小是这里最需要调的点。核太小断开的笔画接不上核太大相邻字符黏成一个框。我一般从(5,5)起调如果发现R这种左右笔画断成两半就把迭代次数加到2或者核改成(7,7)。过滤条件里的宽高比0.2~0.8是个经验范围数字和字母基本都是瘦高型/这个斜杠会矮一些但0.2的下限能兜住它。排序用字符框左上角的x坐标这个简单方法在字符排列整齐的轮胎照片上足够可靠。3.3 分割结果自检用一张可视化图判断切没切对分割有没有做好不要只盯着字符框的坐标数字直接把框画回原图看。def draw_boxes(image, boxes, save_path): # 在原图上画红色矩形保存到本地直观检查 vis image.copy() for (x, y, w, h) in boxes: cv2.rectangle(vis, (x, y), (x w, y h), (0, 0, 255), 2) cv2.imwrite(save_path, vis) print(f共检测到 {len(boxes)} 个字符区域结果保存在 {save_path})画出来的图一眼就能看出两类典型问题。一类是切碎了——一个字符被分隔成多个连通域比如R被断成三块原因是二值化把中间像素吃掉了或闭运算没接上解决方向是调大闭运算核或降低adaptiveThreshold的C值。另一类是黏连了——6和5粘成一个框通常是闭运算过度或原图字符靠得太近解决方向是减小核的迭代次数或者在二值化后用一次腐蚀把相邻字符分开。我会拿5张不同光照的测试图反复走这个过程直到每个框内基本只有一个完整字符再进入下一步。4. 训练一个单字符识别模型从数据集组织到CNN落地4.1 怎么组织训练数据目录结构就是源代码的第一个交付物期末作业要求交付源代码模型使用说明数据组织就是源代码是否好用的第一步。我习惯把数据目录分成train和val两个文件夹每个类别一个子目录Keras的ImageDataGenerator能直接用这个结构读取。from tensorflow.keras.preprocessing.image import ImageDataGenerator BATCH_SIZE 32 IMG_SIZE 32 # 训练和验证分别指定数据目录 train_datagen ImageDataGenerator( rescale1.0 / 255.0, rotation_range8, width_shift_range0.1, height_shift_range0.1 ) val_datagen ImageDataGenerator(rescale1.0 / 255.0) train_generator train_datagen.flow_from_directory( dataset/train, target_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH_SIZE, color_modegrayscale, class_modecategorical ) val_generator val_datagen.flow_from_directory( dataset/val, target_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH_SIZE, color_modegrayscale, class_modecategorical ) # 打印类别顺序后面预测结果要按这个顺序解析 print(train_generator.class_indices)这里有一个容易被忽略的细节类别的顺序由class_indices决定训练完保存模型时要把这个映射表一起存下来一般是存成一个json文件。很多人在自己的环境里预测出来是乱码不是因为模型错了而是因为预测输出索引和字母映射对不上。字符集建议先精简。轮胎规格串195/65R15 91V这类形态实际出现的字符主要是数字0-9、/、R、少数速度级别字符V、H、W等。没必要硬上36类完整字母表类别越少混淆越小。我一般会在生成训练数据时就规定好字符集例如[/, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, R, V, H, W]。4.2 一个最小可用的CNN模型Keras实现与逐层说明期末作业不需要太花哨的网络一个卷积池化全连接的经典结构就够。代码给Keras版本因为可读性好、答辩讲解容易如果课程用PyTorch按卷积层-池化层-全连接层的结构迁移过去也很快。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout def build_cnn(num_classes): model Sequential([ # 第一层卷积32个3x3卷积核输入是32x32的灰度图 Conv2D(32, (3, 3), activationrelu, input_shape(32, 32, 1)), MaxPooling2D((2, 2)), # 第二层卷积64个3x3卷积核学习笔画级组合特征 Conv2D(64, (3, 3), activationrelu), MaxPooling2D((2, 2)), Flatten(), # 全连接层把特征映射到128维 Dense(128, activationrelu), Dropout(0.5), # 期末样本量小dropout防止过拟合 # 输出层类别数 Dense(num_classes, activationsoftmax) ]) model.compile( optimizeradam, losscategorical_crossentropy, metrics[accuracy] ) return model model build_cnn(num_classeslen(list(train_generator.class_indices.keys()))) model.summary()逐层解释为什么这么设计。第一层Conv2D(32, (3,3))是在学局部笔画特征比如横线、竖线、斜线第二层Conv2D(64)在这个基础上组合出更高级的图案比如横竖拐角。池化层把特征图尺寸减半既减少计算量又让特征对位置不那么敏感。Dropout(0.5)的意思是训练时随机丢弃一半神经元期末数据集通常只有几千张不加这个非常容易过拟合——训练准确率99%、验证准确率60%就是这么来的。4.3 训练与调参epoch、batch size、学习率怎么设from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint # 早停验证集连续4轮不提升就停止训练 early_stop EarlyStopping(monitorval_loss, patience4, restore_best_weightsTrue) # 模型检查点只保存验证集准确率最高的权重 checkpoint ModelCheckpoint( best_model.keras, monitorval_accuracy, save_best_onlyTrue, verbose1 ) history model.fit( train_generator, steps_per_epochlen(train_generator), epochs30, validation_dataval_generator, validation_stepslen(val_generator), callbacks[early_stop, checkpoint] )经验参数epoch设30但实际靠早停让它自己决定——通常第15到25轮就收敛了。batch size用32起步如果显存够大64也行但对这个任务32就够了。学习率用Adam默认的0.001不要手动改改小了收敛慢改大了loss震荡。数据增强里面有一个容易翻车的设置rotation_range不要超过8度。轮胎字符本身有弧度但单个字符在分割出来之后方向基本是正的给太大角度会让模型学到旋转的字符也是对的反而把/和搞混。更不能开horizontal_flip一旦翻转/会变成这俩在轮胎规格里可都是合法字符。4.4 把模型和预处理串成完整识别流程训练完的模型要能端到端使用把这个脚本命名为predict_sidewall.py就是交付代码里的主程序。import json import cv2 import numpy as np from tensorflow.keras.models import load_model def recognize_tire_sidewall(image_path, model_path, char_map_path): # 加载模型和字符映射表 model load_model(model_path) with open(char_map_path, r, encodingutf-8) as f: index_to_char json.load(f) # 形如 {0: 0, 1: 1, ...} 的映射 img, binary preprocess(image_path) # 复用第3章的分割逻辑 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations1) contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) if 10 w 80 and 20 h 120 and 0.2 (w / h) 0.8: boxes.append((x - 2, y - 2, w 4, h 4)) boxes.sort(keylambda b: b[0]) # 所有字符统一resize后组装成一个大batch一次预测 batch np.zeros((len(boxes), 32, 32, 1), dtypenp.float32) for i, (x, y, w, h) in enumerate(boxes): roi binary[y:y h, x:x w] roi cv2.resize(roi, (32, 32)) roi roi.astype(np.float32) / 255.0 batch[i] roi.reshape(32, 32, 1) preds model.predict(batch, verbose0) labels [index_to_char[str(np.argmax(p))] for p in preds] return .join(labels), boxes这里的核心细节是一次性predict一个大batch。初学者最容易犯的错是循环里一个个调用model.predict(roi)每个字符都要走一次完整的模型前向10个字符的耗时是batch预测的约10倍。组装成batch后模型一次前向就能出全部字符的预测结果在答辩现场实时演示时不至于卡顿。最终返回的字符串已经按盒子顺序排好因为轮胎规格串的顺序就是从左到右。5. 轮胎字符识别避坑五个最容易让你周末泡汤的细节5.1 分割出来的字符缺一块怎么办现象可视化分割结果时8 断成了上下两截R 的右腿不见了。原因二值化的C值设得太大浅色笔画被判成了背景或者闭运算的核太小连接不上断裂笔画。解决先把adaptiveThreshold的C值从15降到8到10之间再看一次效果如果还断把闭运算的迭代次数从1加到2。这两个参数是配套调的别只动一个。调完一定要重新跑一遍可视化检查不能只看一张图就下结论。5.2 多个字符黏连成一个框识别直接乱套现象65 被当成一个连通域框出来模型把它识别成单个字符结果完全对不上。原因闭运算核太大或者原图里字符间距本身就小、笔画几乎挨在一起。解决先把闭运算核从(7,7)退回(5,5)迭代次数保持1。如果仍然黏连在二值化之后对closed图做一次cv2.erode(closed, (3,3), iterations1)把相邻字符之间分离出一条缝。注意腐蚀会把笔画变细要在下一轮重新检查有没有新产生的断笔。5.3 模型总把O认成0、1认成I现象预测结果里出现莫名其妙的字母O、I而这串轮胎规格里根本不该有它们。原因原始字符集定义混乱把O、I这类和数字几乎一样的字符也作为独立类别带进了训练。解决第一步删——轮胎规格参数里没有O和I从数据目录里删掉这两个类别。第二步并——如果实拍数据里O和0确实长得一样干脆合并成一个类分类输出就少一个类混淆也消失了。后处理时再根据字符所在位置纠正第3个字符必为/第4个字符必为R其他位置不可能是字母O和I。这类规则后处理是OCR系统里的常规操作。5.4 训练loss下降但验证集准确率一直上不去现象训练集准确率到了99%验证集卡在70%典型的过拟合。原因期末项目数据量太少网络复杂度相对数据集来说太高。解决优先加数据增强的强度——rotation_range从5加到8width_shift_range从0.05加到0.1。同时把Dropout从0.3提到0.5。如果还不行把卷积层从两层减到一层或者第一个卷积层的输出从32降到16。网络参数少了过拟合自然缓解。这里要克制加数据的冲动——期末场景到不了几千张实拍调轻模型比堆数据实际。5.5 识别速度慢答辩演示时卡顿很尴尬现象单张轮胎图要接近1秒才出结果现场演示体验差。原因循环调用predict逐个识别字符模型前向开销被重复计算。解决按第4.4章的方式所有分割字符组装到一个batch里一次预测。如果还有卡顿输入尺寸保持32x32不要随意调大48x48的推理时间要翻约3倍准确率提升却很有限。答辩前先用CPU跑一遍流程如果模型要1秒以上就在演示机上用GPU或者提前把结果跑好截图备用。6. 让期末答辩有得讲模型评估、可视化与三个加分扩展模型训练完别急着交。先把混淆矩阵画出来这是答辩时最能说清问题的图之一。用sklearn一行线就能算from sklearn.metrics import confusion_matrix, classification_report # y_true是真实标签索引列表y_pred是模型预测索引列表 cm confusion_matrix(y_true, y_pred) print(classification_report(y_true, y_pred, target_namesclass_names))看混淆矩阵时对角线以外的热点就是你的模型弱点。假如0这一行其他列有热点说明模型把0经常认成别的字符下一步方向就很明确要么补数据要么合并类要么回头查分割阶段是不是把0切变形了。答辩展示一定要做的一件事是把预测结果画回原图。拿第3章的draw_boxes改造一下把每个字符的预测结果和置信度用cv2.putText写在对应框的上方做成一张标注图。这张图是评委最容易看懂的——所有流程的最终效果都在这张图上配一句红色框是分割结果框上方是模型预测的字符和置信度就讲完了。三个扩展方向性价比排序如下。第一个是迁移学习把CNN换成一个预训练的MobileNet去掉顶层后接新的分类层。虽然字符是灰度小图但MobileNet在ImageNet上学到的底层纹理特征仍然有效在合成数据上训练能明显缓解过拟合。第二个是规则后处理轮胎规格字符串有严格语法——第3位是/第4位是R第5、6位是轮毂直径数字最后1到2位是载重指数和速度级别。把模型输出按这个语法做一次修正比如第3位不是/就强制替换成/靠规则把一小部分模型错误纠正掉也体现你对领域的理解。第三个是HOGSVM baseline对比用sklearn的SVM跑一遍同样的训练数据把两个模型的准确率对比表写进报告这是机器学习课程最愿意看到的实验设计。我现在做一个识别任务的习惯是模型训练完先挑5张最难看的实拍图跑一遍打印出每个字符的置信度。凡是置信度低于0.7的位置一定回头查分割——十次里有八次是分割阶段的问题不是识别网络的问题。分清楚错误发生在哪一阶段比盲目改网络结构有效得多。希望帮到你。本文还有配套的精品资源点击获取