ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

全连接神经网络在喷码字符识别中的工程实践与选型

全连接神经网络在喷码字符识别中的工程实践与选型 简介面向深度学习与人工智能入门者这份压缩包提供了一套基于全连接神经网络的喷码字符分类识别方案适用于类似牛奶盒生产日期等印刷字符的自动识别场景。资源围绕喷码字符图片展开通过全连接神经网络完成端到端训练与分类适合学习图像分类、字符识别及模型构建流程的开发者参考。压缩包约18MB内部包含8489张PNG字符图片和1个Python训练脚本其中dataset目录存放已标注训练集cut目录为全部数据handle目录则在运行脚本后自动保存分类结果目录结构清晰便于对照学习。目前已有321人学习下载。运行脚本可直观复现数据加载、模型训练、预测分类及结果保存的完整流程分类成功率较高是一份适合机器学习和神经网络入门的实操型资料。1. 喷码字符识别为什么离不开全连接这个“老”分类器喷码字符识别是个比表面看起来窄得多的任务产线上喷墨或激光打出来的日期、批号、追溯码每个字符笔画断断续续、墨迹深浅不一背景还有包装反光。很多人一听是识别任务第一反应就上 YOLO字符识别或者大 CNN结果真实样本只有几千张、类别还不均衡训练时整天过拟合部署到工控机上又显慢。我却经常把全连接神经网络放回这个位置——单字符分类识别本质是“固定尺寸灰度图到几十个类别的映射”它结构透明、训练快、误分样本好分析。下面按一条能直接落地的链路讲预处理、字符分割、模型训练、选型对比、避坑和进阶后处理。2. 从喷码图到全连接网络的输入预处理、分割与归一化全连接网络没有卷积那种平移不变性输入稍有偏移分类结果就可能翻车。这句话已经说明喷码字符识别的瓶颈往往不在模型而在进入模型之前的预处理与分割。预处理的目标不是让人眼看着舒服而是让同一个字符在不同帧、不同包装上都对齐到同一套像素分布。2.1 裁 ROI 与二值化为什么 OTSU 不是万能钥匙先裁 ROI。喷码在瓶盖、铝箔或纸箱上的位置一般固定ROI 可以直接用机械定位的坐标如果相机跟随产线有抖动再用模板匹配或检测模型来给坐标。ROI 越小越好因为后面的分割和分类只对 ROI 负责。然后灰度化真正需要试的是阈值OTSU 对“深色喷码、浅色背景”的包装很稳但同一个纸箱在阴天和晴天拍出的灰度分布不一样还是要靠固定光源压住环境变化。如果背景有渐变反光OTSU 会把反光边缘也当成字符这时候要改用局部自适应阈值。import cv2 import numpy as np def preprocess_roi(img_bgr, roi): # roi: (x, y, w, h)来自机械定位或模板匹配 x, y, w, h roi gray cv2.cvtColor(img_bgr[y:yh, x:xw], cv2.COLOR_BGR2GRAY) # 3x3 高斯核去掉墨点噪声又不糊掉细笔画 blurred cv2.GaussianBlur(gray, (3, 3), 0) # OTSU 自动阈值适合深色喷码、浅色包装 _, binary cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 闭运算把墨点之间的断笔补上核太大会粘住相邻字符 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return binary为什么先裁 ROI因为全连接网络输入必须是定长向量把整个包装图喂进去不仅维度爆炸还会让网络花大量容量去学习“背景不该出现什么”。OTSU 自适应阈值适用于喷码明显比背景深的情况如果喷码是白色或反光很强要在采集端调曝光而不是靠阈值硬扛。闭运算的 3x3 核能补 1~2 像素的断点如果发现字符断裂仍然严重可以按笔画宽度把核扩到 5x5但随后八成会遇到字符粘连所以要先分割再决定要不要加大核。2.2 投影法分割喷码字符粘连、倾斜和断笔一起处理投影法的原理并不复杂对二值图分别统计每行和每列的前景像素数连续的非零区间就是字符行和字符块的边界。全连接神经网络输入的每张图应当恰好包含一个字符所以这一步的正确率直接决定分类上限分割错一个字符识别再怎么调也没用。def split_chars(binary, min_h16, min_w6, max_w_factor1.6): # 水平投影把相邻的字符行拼成一个区域 h_proj binary.sum(axis1) // 255 rows, in_block, start [], False, 0 for y, val in enumerate(h_proj): if val 0 and not in_block: start, in_block y, True elif val 0 and in_block: if y - start min_h: rows.append((start, y)) in_block False if in_block: rows.append((start, len(h_proj))) char_boxes [] for y1, y2 in rows: strip binary[y1:y2] v_proj strip.sum(axis0) // 255 boxes, in_block, start [], False, 0 for x, val in enumerate(v_proj): if val 0 and not in_block: start, in_block x, True elif val 0 and in_block: if x - start min_w: boxes.append([start, y1, x, y2]) in_block False if in_block and len(v_proj) - start min_w: boxes.append([start, y1, len(v_proj), y2]) # 疑似粘连块宽度显著大于平均字符宽度时在垂直投影最弱处切开 widths np.array([b[2] - b[0] for b in boxes]) avg_w widths.mean() if len(widths) else 0 for bx in boxes: w bx[2] - bx[0] if w avg_w * max_w_factor: sub binary[bx[1]:bx[3], bx[0]:bx[2]] vv sub.sum(axis0) // 255 cut None for xx in range(len(vv) // 3, len(vv) * 2 // 3): if vv[xx] 0: cut bx[0] xx break if cut is not None: char_boxes.append((bx[0], bx[1], cut, bx[3])) char_boxes.append((cut, bx[1], bx[2], bx[3])) else: char_boxes.append(tuple(bx)) else: char_boxes.append(tuple(bx)) return char_boxesmin_h16表示相机分辨率下字符高度至少 16 像素如果喷码更小可以降到 10但后面归一化会损失细节。优先保证采集端字符高度在 20 像素以上这是全连接网络能学到笔画细节的下限。min_w6是给数字“1”这种窄字符留的设太大会漏切。粘连切割的max_w_factor1.6表示只有宽度超过平均宽度 1.6 倍才尝试切分避免把正常字符全部误切切割点优先在字符中间三分之一找垂直投影为 0 的列因为常见粘连都在笔画交汇处。倾斜校正应该在 ROI 阶段整体做不要在字符分割后逐字拉正。常见做法是先对二值图的所有前景点求最小外接矩形再用仿射变换把矩形角度拉平字符行与图像坐标轴夹角超过 5 度时投影法基本失效必须校正。2.3 把字符归一化到 28×28补边、展平与最小数据增强分割完的字符框大小不一全连接网络要求定长输入所以要把每个字符图统一到同一尺寸。常见做法是放到 28×28 画布对应输入维度 784。这里有个容易踩的细节直接拉伸会破坏字符宽高比数字“1”会被拉成矮胖形状数字“0”会被压扁识别难度反而增加。我一般用短边缩放再加黑边 padding 的方式。def char_to_input(char_img, size(28, 28)): h, w char_img.shape scale min(size[0] / h, size[1] / w) nh, nw int(round(h * scale)), int(round(w * scale)) resized cv2.resize(char_img, (nw, nh), interpolationcv2.INTER_AREA) canvas np.zeros(size, dtypenp.float32) x0, y0 (size[1] - nw) // 2, (size[0] - nh) // 2 canvas[y0:y0nh, x0:x0nw] resized return canvas / 255.0 # 保存成训练样本时直接展平 x_train.append(char_to_input(char_img).flatten()) y_train.append(label_id)canvas / 255.0让输入落在 0~1全连接网络的权重初始化大多假设输入量纲接近如果先做逐像素标准化在喷码小样本下反而容易放大背景噪声。INTER_AREA在缩小字符时保留笔画连续性比INTER_LINEAR更稳。喷码真实样本往往只有几千张全连接网络参数量动辄十几万必须做数据增强。最有效的两个增强是随机平移和随机膨胀腐蚀def augment_char(char_img): # 随机平移 1~2 像素模拟切框抖动 dx, dy np.random.randint(-2, 3), np.random.randint(-2, 3) M np.float32([[1, 0, dx], [0, 1, dy]]) out cv2.warpAffine(char_img, M, (char_img.shape[1], char_img.shape[0])) # 30% 概率做一次 2x2 膨胀模拟墨迹拖尾 if np.random.rand() 0.3: out cv2.dilate(out, np.ones((2, 2), np.uint8)) # 10% 概率腐蚀一像素模拟断笔 if np.random.rand() 0.1: out cv2.erode(out, np.ones((2, 2), np.uint8)) return out平移不超过 2 像素否则字符会顶到画布边缘旋转增强要谨慎喷码生产时基本水平过度旋转等于制造分布外样本。增强最好在每个 epoch 在线做而不是离线存 100 份副本否则模型会记住重复数据。把这些样本整理成x_train / y_train / x_val / y_val之后下一步就是设计全连接网络。3. 用 PyTorch 搭全连接网络做喷码字符分类结构、训练与评估全连接网络在这个任务里扮演的是“线性决策面组合器”每个隐藏神经元相当于在像素空间里划一条超平面层数太多容易把墨迹噪声当成边界特征。所以网络结构不需要深但要和数据量匹配。3.1 结构尺寸784 进来36 类出去输入维度由特征维度决定。28×28 展平是 784如果后续改用 HOG 特征维度大约在几百输出维度由字符集决定。喷码常见字符集是数字 10 个加大写字母 24 个去掉 I、O共 34 类再加连字符或点号就是 36 类。宁可少分类也不要轻易设一个“其他”类去装所有未知形状因为“其他”类样本很难凑齐。隐藏层宽度由样本量决定。5000 到 20000 张字符图用两层256 - 128足够如果只做纯数字 10 类128 - 64也够用。不要一上来搞四层参数量上去了断笔噪声也被学进去了。字符识别领域的经验是喷码字符是人工设计的规整字形和手写字符识别不同手写字符形变大所以需要 CNN 自动提特征喷码字形稳定全连接直接吃像素就能分出主要结构。3.2 训练循环一张能跑出高准确率的全连接网络下面这段代码是完整可跑的训练骨架包含模型定义、早停、学习率衰减和最优权重保存。BatchNorm1d对输入分布剧烈变化的喷码很有帮助——不同生产线的灰度分布不同BatchNorm 让中间层不至于被某条产线的数据带偏。Dropout(0.3)是必须的尤其样本只有几千张时。import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader class SprayCodeDataset(Dataset): def __init__(self, x, y): self.x torch.tensor(x, dtypetorch.float32) self.y torch.tensor(y, dtypetorch.long) def __len__(self): return len(self.y) def __getitem__(self, i): return self.x[i], self.y[i] class FCNClassifier(nn.Module): def __init__(self, input_dim784, num_classes36): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, 128), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.net(x) def train_model(model, train_loader, val_loader, epochs60): criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5 ) best_acc 0.0 patience_counter 0 for epoch in range(epochs): model.train() for xb, yb in train_loader: optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() optimizer.step() model.eval() correct, total, val_loss 0, 0, 0.0 with torch.no_grad(): for xb, yb in val_loader: out model(xb) val_loss criterion(out, yb).item() * yb.size(0) preds out.argmax(1) correct (preds yb).sum().item() total yb.size(0) val_loss / total acc correct / total print(fepoch {epoch1}: val_loss{val_loss:.4f} val_acc{acc:.4f}) scheduler.step(val_loss) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_fcn.pt) patience_counter 0 else: patience_counter 1 if patience_counter 15: breakBatchNorm1d要求每个 batch 样本数大于 1所以 DataLoader 里要设置drop_lastTrue否则最后一批如果只剩 1 张图会直接报错。lr1e-3配合 Adam 是稳妥起点如果验证 loss 反复横跳把学习率降到3e-4不要急着换优化器。weight_decay1e-4是 L2 正则和 Dropout 一起抑制过拟合。早停的patience15意味着验证准确率连续 15 个 epoch 不提升就停止保存的是best_fcn.pt不是最后一个 epoch 的权重否则很可能存到过拟合版本。几千样本的全连接网络通常 30 个 epoch 左右就能收敛60 轮上限已经够宽。3.3 评估与拒识准确率之外还要看错在哪个字符训练完不能只看总准确率。喷码字符里 0/O、1/I、2/Z、7/T 互相混淆是常态必须看混淆矩阵和每个类别的精确率召回率。分类识别模型上线后还要加一道拒识逻辑softmax 输出总和恒为 1即使输入根本不是字符它也会硬给一个高置信度结果。所以生产环境里我一般用predict_with_reject做输出from sklearn.metrics import confusion_matrix, classification_report def predict_with_reject(model, x, threshold0.85): model.eval() with torch.no_grad(): p torch.softmax(model(x), dim1) conf, pred p.max(dim1) return pred if conf threshold else -1 def evaluate_model(model, loader, num_classes, threshold0.85): model.eval() y_true, y_pred [], [] with torch.no_grad(): for xb, yb in loader: out model(xb) conf, pred torch.softmax(out, dim1).max(dim1) pred torch.where(conf threshold, pred, torch.tensor(-1)) y_true.extend(yb.tolist()) y_pred.extend(pred.tolist()) print(classification_report(y_true, y_pred, labelsrange(num_classes))) print(confusion_matrix(y_true, y_pred))拒识阈值 0.85 不是拍脑袋。建议在验证集上画“阈值-误识率”曲线阈值越高误识率越低但拒识率升高。产线一般允许 1%~3% 的拒识选一个能把误识率压到 0.5% 以下的阈值比较合理。如果 0 和 O 在字符集里同时存在且总是混最干净的做法是在标注阶段就把它们合并成同一类而不是指望模型自己学会区分喷码字符识别是工程任务不是学术竞赛减少类别往往比增加样本更有效。4. 全连接 vs CNN vs YOLO字符识别喷码场景怎么选型选型是个反复被问的问题。我的判断标准很简单数据量和喷码位置是否固定决定模型复杂度。下面从数据、速度、部署三个角度拆开说。4.1 先看数据量和 ROI什么时候全连接足够手写字符识别样本量大、字体形变大主流方案是 CNN 自动提取特征喷码字符恰恰相反同一台喷码机的字形相对固定字符间差别集中在断点和油墨扩散而不是结构形变。ROI 固定时全连接网络直接对 784 维像素建立分类面效果不差。如果喷码内容位置不固定比如软包装喷码随褶皱起伏才需要先加检测器这时 YOLO字符识别会被引进链路。常见误区是被深度模型的“热度”带着走。我见过不少团队一开始就用 YOLO 检测整行喷码后来发现产线机械定位已经让喷码落在固定区域真正难的还是分割和字符分类于是缩回全连接方案误识率反而更低。选型的第一条原则是先问物理环境是否把问题简化了再决定模型。4.2 三个方案对比全连接、CNN 与 YOLO字符识别方案训练数据量需不需要字符框标注CPU 单字符推理部署体积适用喷码场景全连接网络几千张字符图不需要0.1~1 ms几 MBROI 固定、字形稳定小 CNN2 万张以上不需要1~5 ms十几 MB字形变化大、背景干扰多YOLO 字符识别端到端需要大量带框样本需要通常要 GPU百 MB 以上喷码位置不固定、多行多区域全连接网络的优势不是精度碾压而是工程代价最低单字符推理在 CPU 上轻松跑进 1 毫秒模型文件只有几 MB工控机不需要额外 GPU。CNN 在字形变化大时更稳但样本量少就发挥不出来。YOLO字符识别适合把检测和识别一并解决但喷码大多是规则文本端到端模型会浪费大量参数去学“字符在哪里”而产线往往已经告诉你字符在哪里。如果一定要用 YOLO 做检测后面的分类头也建议换成轻量分类器不要让检测头兼职识别。4.3 混合路线CNN 特征 全连接头折中方案是用一个小 CNN 做特征提取器全连接层做分类头。这个形态在字符识别项目里很常见也是标题里“全连接神经网络对喷码字符分类识别”最常见的工程落地形式不管前面用不用卷积最后做分类决策的始终是全连接层。class HybridClassifier(nn.Module): def __init__(self, num_classes36): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 16, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2) ) self.classifier nn.Sequential( nn.Linear(32 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_classes) ) def forward(self, x): z self.features(x).flatten(1) return self.classifier(z)输入 28×28两次池化后是 7×7×32所以分类头的输入维度是 1568。混合路线的优势是对字符形变的鲁棒性更好代价是训练时要同时调 CNN 的卷积核大小和增强策略调试时间明显变长。几千样本的项目不建议第一版就走到这里先把全连接版跑通把分割和拒识逻辑验证好再决定要不要引入卷积特征。5. 喷码字符识别落地的 5 个典型坑避坑清单这一章把我在喷码字符识别项目里反复看到的踩坑记录整理成清单。每一条都是真实产线上会出现的问题按“现象、原因、解决”写。5.1 成像与分割阶段的三笔血泪账坑 1倾斜字符行分割后大量重叠。现象字符行做水平投影时上下边界分不开粘连字符错切后续分类准确率怎么调都上不去。原因喷码头安装角度偏转或包装走带倾斜字符行与图像坐标轴夹角超过 5 度投影法直接失效。解决对整个 ROI 的字符块求最小外接矩形用仿射变换把矩形拉回水平再重新做投影分割。角度小于 2 度时影响不大大于 5 度必须校正。def deskew(binary): coords np.column_stack(np.where(binary 0)) rect cv2.minAreaRect(coords) angle rect[-1] if abs(angle) 5: h, w binary.shape M cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) binary cv2.warpAffine(binary, M, (w, h)) return binarycv2.minAreaRect返回的角度范围是 -90 到 0正则之前要先判断字符是顺时针倾斜还是逆时针倾斜否则可能越转越歪。校正后要重新分割旧坐标已经失效。坑 2环境光变化导致 OTSU 阈值失效。现象白天准确率 95%傍晚开灯后图像整体发白二值化把喷码字符的浅色边缘也滤掉了。原因OTSU 假设灰度直方图是双峰环境光变化时背景峰和字符峰重叠自动阈值不再可靠。解决产线上固定光源和相机曝光参数是首选如果确实无法固定改用cv2.adaptiveThreshold做局部阈值但要注意它对边缘反光更敏感。不要指望一个阈值吃一年四季。坑 3闭运算核太大把“0”填成实心。现象字符“0”在分割后内部被填满模型把所有“0”都识别成“8”或“O”。原因用 5×5 闭运算修补断笔核的尺寸超过字符内孔半径空心部分被填没了。解决核大小不能超过字符内孔半径。建议先统计喷码笔画宽度再取max(3, stroke_width)作为闭运算核边长。验证方式很简单打印闭运算后的字符图肉眼看空心结构是否保留。5.2 训练与评估阶段最容易翻车的两个地方坑 4同一串喷码的字符泄漏到训练集和验证集。现象验证集单字符准确率 98.5%现场整串识别率却只有 88%。原因随机划分字符样本时同一喷码字符串的 12 个字符被拆到训练集和验证集模型其实记住了“这条字符串的视觉特征”而不是字符类别。解决按字符串 ID 做分组划分确保同一个字符串的所有字符只出现在一个集合里。用sklearn.model_selection.GroupShuffleSplit可以一步搞定。后续评估也要统计“整串正确率”一条喷码 12 个字符错 1 个整串就错这才是产线指标。坑 5只看准确率不设拒识阈值。现象模型把背景上的水痕识别成字符置信度高达 0.92导致整串喷码错误被判成合格品流入仓库。原因softmax 输出总和恒为 1不管输入是不是字符它都会给某个类高置信度这不是模型自信而是数学性质。解决给输出加拒识分支最大 softmax 概率小于阈值时返回“无法识别”而不是硬分。把这块逻辑放到第 3.3 节的predict_with_reject里产线指标会立刻改善。另外要收集那些最容易误识的“坏样本”单独建一个测试集每次改模型都跑一遍防止回归。6. 进阶多帧投票与词典后处理把喷码分类识别再抬一档真正上线时单字符识别准确率 99% 并不等于整串可追溯。一条 12 位的批号只要错 1 位这批货就可能被系统判成未知批次。我一般会在全连接网络之后加两层保护多帧投票和词典后处理。多帧投票的做法是相机连续拍 3 帧分别走同一套预处理和分类流程把 3 帧的结果按字符位置对齐后取多数票。如果某一帧某个字符被拒识剩下两帧一致就按一致结果走如果三帧都不一样宁可整串拒识。产线节拍快、只能拍一帧时这一步可以省但至少要有词典后处理。import re from collections import Counter def vote_by_position(pred_frames, threshold0.7): # pred_frames: list[list]每个元素是单帧的字符预测序列 final [] for pos in zip(*pred_frames): pos [p for p in pos if p ! -1] valid [p for p in pos if p[1] threshold] if not valid: final.append(-1) continue cls, _ Counter([v[0] for v in valid]).most_common(1)[0] final.append(cls) return final def refine_with_rule(ocr_chars, patternr^\d{8}[A-Z]{2}\d{4}$): # 只对定长、定格式的批号和日期生效 s .join(str(c) for c in ocr_chars) if re.match(pattern, s): return s return None # 拒识而不是硬给一个结果refine_with_rule的正则必须按实际喷码规则写。日期类喷码可以写成^\d{8}$批号含字母和数字时要回到包装规范里查不能套通用规则。如果喷码是自由文本词典后处理的反作用很大——你以为是修错实际上会把真实内容杀掉。另一个效果好且省参数的做法是把 0/O、1/I 这类易混字符直接合并成同一类只要规则上它们不会同时出现少一个类别全连接网络就少一个难以区分的决策面。我后来养成的习惯是所有改进都先在保留的真实样本集上跑整串正确率和误识率而不是只看单字符准确率。全连接网络再能学也救不回分割切歪的图多帧投票与词典后处理才是最后一道后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表