ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenCV与OCR结合的火车票识别:从图像预处理到结构化输出

OpenCV与OCR结合的火车票识别:从图像预处理到结构化输出 简介基于Python与OpenCV的火车票识别系统项目压缩包面向计算机视觉初学者及有图像处理需求的开发者旨在实现车票信息的自动定位与文字提取。资源包含完整Python源码、测试图片及配置文件覆盖图像灰度化、直方图均衡化、二值化、高斯滤波去噪、Canny边缘检测、findContours轮廓定位、OCR文字识别等关键步骤并辅以形态学操作优化文字区域适合作为课程设计或入门实战项目参考。压缩包内共有13个文件其中2个Python脚本承载识别主流程8张PNG图片用于效果验证另有JSON配置、说明文档与pyc缓存文件整体体积约340KB结构轻量且目录清晰便于按需阅读和快速上手。目前已吸引159人学习下载包含可直接运行的识别代码与测试样本可帮助读者理解OpenCV在票证识别场景中的完整调用方式从图像预处理到最终识别输出形成完整链路并能延伸至车票、票据等其他文本识别任务。1. 火车票识别不是模板匹配先把问题拆成定位与识字两步拿到一张真实的蓝色火车票上面有底纹、有红色印章、有反光还有各种斜着盖上去的戳记。模板匹配最多只能告诉你票长什么样不能告诉你这张票上的车次是 G101 还是 G10I也不能告诉你票价到底是 42.50 还是 42.80。这类识别任务真正麻烦的地方在于票面的视觉噪声和文字语义混在一起直接整票丢给 OCR 会得到一堆碎片。这个项目把问题拆成了两层第一层用 OpenCV 做预处理、票面定位和字段分割把整张票变成“车次”“日期”“票价”等独立小图第二层再用 OCR 引擎读这些小图最后通过正则把结果整理成结构化字典。这种“OpenCV 视觉预处理 OCR 识别”的路线在发票识别、保单识别、物流面单识别里都是主流做法适合正在做文档识别的后端工程师也适合想系统了解轮廓检测和图像预处理实际用法的 Python 开发者。2. 预处理怎么做才不会被红章和底纹带偏灰度化、均衡化与形态学2.1 彩色转灰度不是丢信息而是减少干扰火车票原图是 RGB 三通道但识别模块更关心文字的明暗对比并不关心颜色。直接用彩色图做二值化时红色印章和高饱和度的底纹都会被当成显著前景产生大量噪声。所以第一步永远是把图像从 BGR 转成灰度图。OpenCV 默认使用cvtColor这一步实际上是在对 R、G、B 三个通道做加权合并权重更偏向绿色通道因为人眼对绿色更敏感。import cv2 image cv2.imread(ticket.png) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray cv2.equalizeHist(gray)COLOR_BGR2GRAY表示按 OpenCV 的 BGR 通道顺序读取权重公式为 0.114 * B 0.587 * G 0.299 * R。equalizeHist是直方图均衡化它把票面上偏暗或偏亮区域的灰度值拉得更开。这一步不是必须的但如果你发现票面上同时存在黑字和白字不加均衡化时白色文字很容易在浅色底纹里直接消失。均衡化以后常见做法是加一次轻度高斯滤波blurred cv2.GaussianBlur(gray, (3, 3), 0)。核太小压不掉扫描纹理核太大会把数字“1”和“7”的边角磨平。我一般习惯先不看滤波直接观察二值化结果如果笔画边缘出现毛刺再回头加滤波避免一开始就引入不确定因素。2.2 固定阈值扛不住光照变化局部阈值更适合票据场景如果整张票面照度均匀用全局阈值确实最省事Otsu 自动算阈值效果不错。但真实场景里手机拍到的车票经常一边亮一边暗红色印章区域的灰度值和底色混在一起。此时 Otsu 会把印章区域整体判成前景OCR 进入之后发现一整行都是噪点。更合适的做法是使用局部阈值它对每个像素周围的邻域单独计算阈值抗光照梯度的能力更好。binary cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10 )blockSize31表示每个像素参考其周围 31 x 31 邻域的平均亮度C10表示在这个平均亮度基础上减 10 作为最终阈值。参数需要跟着图像分辨率走600 像素宽的票面用 31 合适2000 像素宽的大图如果还用 31会把文字笔画也当成背景。判断方法很简单如果文字出现大量断裂就把blockSize调小如果背景被整块识别成前景就把blockSize调大。这里用THRESH_BINARY_INV是为了把文字变成白色方便后续findContours按白色连通域处理。如果你用 Tesseract黑白是否反转影响不大但 OpenCV 的轮廓查找通常约定白字黑底统一反转以后后续的投影统计和面积筛选才更容易对齐。2.3 形态学操作先消噪再连通笔画不能断二值化之后票面上的网格底纹和印章印记还会残留一部分。如果直接跑findContours会出现成百上千个小闭合区域严重影响后续筛选。形态学操作在这里是最直接的工具。开运算是先腐蚀再膨胀能去掉小面积的孤立噪点闭运算是先膨胀再腐蚀能填补文字笔画中间的断裂缺口。kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations1) closed cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations1)getStructuringElement返回一个 3x3 的矩形结构元你也可以用MORPH_ELLIPSE或MORPH_CROSS。矩形核对横平竖直的文字更友好椭圆核更适合处理圆形印章残留。如果发现日期里的“月”字中间断成了三截就把闭运算的核改成 (5, 5) 或把iterations调到 2。但要注意迭代次数越大笔画越粗相邻字段可能粘连在一起。一个比较实用的自检方法把这一步输出的图片保存成preprocess.png在编辑器中看 5 秒。如果人眼都看不出哪些区域是文字OCR 就更不可能识别成功。3. 票面定位与 ROI 分割从 Canny 边缘到字段投影切割3.1 Canny 检测和轮廓筛选先把整张票面从背景里摘出来火车票照片里的背景可能是桌面、手掌或者其他纸片。如果把整张图直接交给 OCR票面外面的大片文字会抢占识别通道所以必须先找到票面轮廓。第一步是提取边缘然后从边缘结果里找到最外层的轮廓。这个环节用 Canny 比 Sobel 更合适因为 Canny 做了非极大值抑制和双阈值连接得到的边缘是单像素宽、连续的后续找轮廓更稳定。edges cv2.Canny(gray, 50, 150) contours, hierarchy cv2.findContours( edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) contour max(contours, keycv2.contourArea) epsilon 0.02 * cv2.arcLength(contour, True) approx cv2.approxPolyDP(contour, epsilon, True)Canny的 50 和 150 分别表示低阈值和高阈值。边缘强度小于 50 的直接丢弃大于 150 的确定为边缘介于两者之间的只有与确定边缘相连才会保留。票面底色和背景对比越弱这两个阈值越要调低。findContours使用RETR_EXTERNAL只取最外层边界避免把票面内的文字区域也当成独立轮廓CHAIN_APPROX_SIMPLE会压缩直线段上的冗余点让后续多边形拟合更高效。approxPolyDP用epsilon 0.02 * 弧长控制拟合精度这个值越大拟合出来的多边形顶点越少。火车票大多是矩形得到 4 个顶点就可以做透视变换如果得到 5 个以上顶点大概率是票面边缘被手指或其他物体遮挡这种情况需要在轮廓面积筛选前先做一次凸包处理。3.2 透视矫正倾斜的票面必须先摆正再切字段车票放在桌面上拍摄镜头很难完全垂直于票面。只要倾斜角度超过 5 度后面按 y 坐标切字段时同一行的文字就可能被切到两张图里。所以在切 ROI 之前一般会先做一次透视矫正。做法是先拿到轮廓的 4 个顶点按“左上、右上、右下、左下”排序再通过透视变换矩阵把票面拉正。import numpy as np def order_points(pts): pts pts.reshape(4, 2) rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect w 1000 h int(w * 0.6) dst np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtypefloat32) matrix cv2.getPerspectiveTransform(order_points(approx), dst) warped cv2.warpPerspective(gray, matrix, (w, h))order_points用坐标和与差值排序左上角点的 xy 最小右下角点的 xy 最大右上角点的 y-x 最小左下角点的 y-x 最大。这个规则只在票面近似标准矩形时成立。如果票面被严重裁切4 个顶点中有 1 个落在图像外排序就会失效。warpPerspective输出的宽度固定为 1000高度按宽度乘 0.6 推算这个比例并不适合所有车票。更稳妥的做法是用approx中 4 个点两两之间的距离计算原始宽高比再结合目标宽度换算高度。写死 0.6 只是为了让演示代码能直接跑起来实际项目中不要把比例写死。3.3 投影法切字段先做水平投影再按高度过滤候选区域拿到摆正后的灰度图并用同样的阈值参数生成二值图后就可以按票面版式去切字段。蓝色火车票的字段排布相对固定车次、日期、座位号、票价各占一行。常见做法是统计每一行上有多少个前景点这个统计结果就是水平投影。投影值接近 0 的连续区间是行与行之间的空隙投影值大于 0 的连续区间就是一行文字。row_sums np.sum(binary_warped, axis1) // 255 rows [] start None for i, v in enumerate(row_sums): if v 0 and start is None: start i elif v 0 and start is not None: rows.append((start, i)) start None if start is not None: rows.append((start, len(row_sums))) for top, bottom in rows: if bottom - top 8: continue roi warped[top:bottom, :]row_sums把每个像素的灰度值累加后除以 255得到该行白色像素的个数。这样做的好处是统计数据可以直接和文字高度比较不用再猜一个像素阈值。rows保存的是每个文字行的起始 y 和结束 ybottom - top 8用来过滤噪点产生的极窄横带。切出每行以后对每一行再做垂直投影可以把“G101”“2024年01月15日”这样的列切开。实际项目中车次、票价这类字段还可以结合轮廓外接矩形筛选但投影法更适合做行切分因为车票上的行间距相对均匀。下表是字段切割时经常要检查的几项检查项作用常见坑轮廓面积去掉噪点和印章碎片面积阈值设太大会把细长数字“1”丢掉外接矩形宽高比区分整行和单字符车次里的数字和字母混排宽高比波动较大字符间距切分粘连字符间距阈值应基于字符宽度动态调整ROI 纵坐标区分日期和车次所在行透视矫正后 y 坐标才可信矫正前不要用4. 识别模块怎么接OCR.py 里的本地与云端接口、清洗与结构化4.1 OCR 通道选型把本地 Tesseract 和云端 API 封装成统一接口OpenCV 本身不做文字识别它只负责把图切好。真正读字符的是 OCR 引擎。从项目文件看OCR.py是识别模块的入口而baiduOCR.cpython-36.pyc说明原项目在 Python 3.6 环境下使用过百度 OCR 的服务。实际做工程时我通常会把 OCR 部分设计成可切换的接口这样本地 Tesseract 和云端 API 之间可以按成本、按网络环境自由切换不会影响前面已经调好的切分逻辑。# OCR.py import base64 import cv2 import requests import pytesseract class LocalOCR: def recognize(self, image, langchi_sim): return pytesseract.image_to_string( image, langlang, config--psm 7 ) class BaiduOCR: def __init__(self, ak, sk): self.token self._get_token(ak, sk) def recognize(self, image, langCHN_ENG): _, buf cv2.imencode(.png, image) result requests.post( https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic, params{access_token: self.token}, data{image: base64.b64encode(buf)}, ) return result.json()[words_result][0][words]--psm 7告诉 Tesseract 按“单行文本”识别这对已经切好的字段图最合适。如果整票识别psm 应该改成--psm 6表示按文本块识别。云端接口示例里imencode先按 PNG 编码base64.b64encode再转成传输字符串。这里有个容易忽略的坑baiduOCR.cpython-36.pyc是 Python 3.6 的字节码如果你现在的环境是 Python 3.10 以上直接加载会报bad marshal data错误。所以把 OCR 封装成接口的意义不只是方便换供应商也方便你在遇到 pyc 兼容性问题时只替换一个文件而不是改动整条链路。4.2 每个 ROI 进 OCR 之前先统一尺寸和底色切出来的字段图高度可能只有 20 到 40 像素直接识别会漏字。本地 Tesseract 尤其依赖分辨率它训练数据里的字符是按 300 DPI 打印效果生成的低分辨率输入会导致大量误识。云端接口对分辨率的要求低一些但统一放大后识别率会更稳定。所以进入 OCR 前一般还要做一次尺寸归一化。def prepare_roi(roi, target_h64): h, w roi.shape scale target_h / h resized cv2.resize( roi, (int(w * scale), target_h), interpolationcv2.INTER_CUBIC ) resized cv2.copyMakeBorder( resized, 10, 10, 10, 10, cv2.BORDER_CONSTANT, value255 ) resized cv2.threshold( resized, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU )[1] return resizedtarget_h64是经验值Tesseract 对高度在 64 到 96 像素之间的文字识别率较高。copyMakeBorder在四周加 10 像素白边相当于给 OCR 一点留白空间防止边缘字符被裁掉。INTER_CUBIC适合放大如果做缩小则应该用INTER_AREA。经过前面步骤得到的 ROI 明暗分布已经相对均匀所以这里用 Otsu 全局阈值就够了不需要再走adaptiveThreshold。注意如果识别的是“2024-01-15”这种带连接符的日期放大后连接符会被拉粗甚至和数字粘连。这时不要在图像层面强行断开而是在识别后的清洗阶段用正则兼容多种日期格式。图像改得越多丢失真实结构的风险越高。4.3 清洗与结构化OCR 原文进字段字典出OCR 返回的文本通常夹杂空格、换行和误识字符比如车次里的“8”被读成“B”日期里的“0”被读成“O”。结构化这一步决定了下游系统能否直接使用这些数据。下面是按常见火车票字段写的一版解析示例逻辑上和原项目读取info.txt后整理结构化数据的目标一致。import re def parse_ticket(text): result {} patterns { train_no: r[GDCZKT]\d{1,4}, date: r\d{4}-\d{2}-\d{2}|\d{4}年\d{2}月\d{2}日, price: r\d(?:\.\d{2})?, } for key, pattern in patterns.items(): m re.search(pattern, text) result[key] m.group(0) if m else None return resulttrain_no正则里的[GDCZKT]覆盖了高铁、动车、城际、直达、快速和特快列车的常见开头字母。\d{1,4}匹配车次编号不是\d{3}因为部分短途车次只有两位数。价格正则只匹配数字和小数点如果你希望保留“元”字可以改成\d(?:\.\d{2})?元。清洗时要特别注意不要对整段文本做全局替换因为车次里真实的字母“O”是合法字符被全局改成数字 0 反而会破坏数据。最安全的做法是先判断 ROI 属于哪个字段再按字段做替换。比如日期字段里如果“O”出现在连接符旁且前后都是数字就可以替换为 0。下表列出了一些常见误识和对应处理方式字段误识示例处理思路车次G101 - G1O1只在数字上下文把 O 替换为 0日期2024-01-15 - 2024-O1-15连接符前后必须是两位数字才替换票价42.50 - 42.S0只保留数字和点号点号后的 O 替换为 0姓名张三 - 张兰无法只靠正则解决需要结合姓名库或上下文5. 让识别率不再看运气模糊、倾斜与印章遮挡下的验证技巧5.1 先建立 20 张图的回归集不要只在pictures目录里那几张样例图上调参数。这些图是经过筛选的“干净样本”真实拍摄的车票会有手指遮挡、褶皱、反光等各种问题。我一般会找 20 张光照不同、角度不同、墨量不同的车票图按字段正确率打分。每张图单独计算“车次是否正确”“日期是否正确”“票价是否正确”最后汇总成字段级准确率。这比只看整票识别成功与否更能定位问题。比如票价字段经常把小数点后面的“50”丢掉检查 ROI 时发现是垂直投影切分时把小数点和后一位数字切到了另一块区域。这时改正则没用因为数据在进入正则之前就已经缺失。正确做法是调整垂直投影的切分割阈值或者把 ROI 的宽度向外扩展几个像素。5.2 用退化模拟主动暴露参数边界真实场景的模糊、倾斜和局部遮挡可以用 OpenCV 模拟这个技巧适合在交付前快速找到参数边界。高斯模糊模拟镜头失焦旋转模拟摆放角度偏差局部色块模拟印章遮挡。把每一组退化后的图片喂给同一套识别流程统计识别率变化能直接看出最薄弱的环节。def augment_roi(roi): blurred cv2.GaussianBlur(roi, (5, 5), 0) M cv2.getRotationMatrix2D( (roi.shape[1] // 2, roi.shape[0] // 2), 3, 1.0 ) rotated cv2.warpAffine(blurred, M, (roi.shape[1], roi.shape[0])) shadow cv2.addWeighted(rotated, 0.8, np.zeros_like(rotated), 0.2, 0) return shadowgetRotationMatrix2D以图像中心为原点旋转 3 度这个角度很小但足以暴露透视矫正是否把文字行切歪。addWeighted的两个权重 0.8 和 0.2 表示把原图与黑色图叠加压暗整体亮度模拟欠曝场景。如果压暗 20% 后识别率开始下降说明adaptiveThreshold里的C值太贴近临界点应该再调大一点给阈值多留出余量。退化样本不需要太多每个类型 5 张就够。跑完以后把识别失败的图按“预处理输出、OCR 原文、结构化结果”三层分别保存很快就能判断问题出在切分阶段还是识别阶段。5.3 把中间结果输出到独立目录比什么都重要原项目里pictures下存了不少定位和切图后的图片这是很有价值的习惯。你可以在ticket_identify.py中加一个调试模式把locate.png、trainID.png、start_time.png等中间结果输出到output/目录每条样本单独建立一个子目录文件名带上识别是否成功。下次调参时直接打开目录看不用重新跑完整条链路。我在实际项目中还会在每张小图上叠加识别结果比如在price.png上方用cv2.putText写上42.50 0.93置信度来自 OCR 返回的分数或 Tesseract 的image_to_data输出。这样能一眼看出到底是切图坐标错了还是 OCR 改错了字符。把这些小图按日期归档时间久了就是一套很好的回归数据集比临时从网上下载测试图靠谱得多。本文还有配套的精品资源点击获取
返回列表