资讯动态

OpenCV+OCR火车票识别全流程:图像预处理、轮廓定位与信息提取实战

发布时间:2026/9/28 17:40:03 来源:尧图企业网站定制
简介一份基于Python与OpenCV的火车票识别系统完整项目面向计算机视觉初学者和希望掌握OCR落地应用的开发者。项目实现了从图像预处理、边缘检测、轮廓定位到文字识别的完整流程可帮助读者理解OpenCV核心函数与Tesseract等OCR工具的配合方式。压缩包共13个文件包含2个Python源码、8张测试样张、文本说明及JSON配置整体仅340KB结构紧凑。源码中涉及灰度化、二值化、高斯滤波、Canny边缘检测及轮廓分析等关键技术配合样张可直接运行验证。已有159人学习下载适合作为课程设计或入门实战参考便于快速复现火车票信息提取思路并拓展到其他票据识别场景。1. 一张旧火车票图片丢进OpenCV这套识别流程到底能拆出什么做票据信息提取的人多半遇到过这种需求手里几十张火车票照片要手动把车次、出发时间、座位号、姓名、价格一条条敲进表格既慢又容易错。这个基于Python的OpenCV火车票识别系统就是针对这个场景写的。它不是那种需要GPU训练几天的深度学习黑匣子而是先把图像做灰度化、均衡化、二值化、边缘检测、轮廓定位把票面按信息区域切成小图再用OCR识别文字。整套流程每条命令都是可见的中间结果全部落盘到pictures目录排错时可以一张一张看是哪一步出了问题。对于入门OpenCV的开发者来说这套代码比直接用现成OCR服务更值得拆因为它把图像处理的每一步都摊开了对于想快速落地票据识别的从业者它也是一个可以换皮复用的基础框架。下面我从项目原始文件出发把代码结构和识别链路逐段讲清楚。2. 项目结构与图像预处理先读懂文件布局再做灰度化和对比度增强2.1 从launch.json到OCR.py这套代码的模块划分拿到这个“TrainTicketIdentification-master”压缩包千万别急着直接跑主脚本。先打开文件夹看一遍结构你会看到这样几个关键部分.vscode/launch.jsonVS Code的调试配置里面通常定义了python解释器路径和启动参数环境不对的时候可以在这里改。pictures/存放所有中间处理结果和最终输出图。trainID.png是原票样start_time、end、locate、price、name、start、ticket这些是各环节切出来的小图。ticket_identify.py主流程脚本负责读图、预处理、定位、分割。OCR.pyOCR识别封装既调用了本地OCR逻辑也封装了百度OCR的接口。__pycache__/baiduOCR.cpython-36.pyc这是Python 3.6编译过的缓存文件说明项目里确实集成过百度OCR的调用代码。也就是说这套系统在文字识别上走了双路线本地离线用Tesseract联网时可以用百度OCR作为兜底。实际运行前建议先用命令python --version确认Python版本。.pyc文件名里的cpython-36暗示原作者是在Python 3.6环境下开发的但OpenCV的API在3.x和4.x之间变化不大高版本Python直接跑大部分代码也没问题。常见做法是先创建虚拟环境再安装依赖。python -m venv venv # Windows下激活 venv\Scripts\activate # Linux/macOS下激活 source venv/bin/activate pip install opencv-python pytesseract pillow numpy这里我建议把opencv-contrib-python也一起装上因为后续可能用到SIFT这类特征点算法预构建的opencv-python库里不包含。参数方面opencv-python和opencv-contrib-python不能同时装二选一即可。装完后验证一下python -c import cv2; print(cv2.__version__)如果出现ModuleNotFoundError: No module named cv2说明装错环境或者还没激活虚拟环境。这是新手最常见的第一道坎。2.2 直方图均衡化的两个实用参数为什么不能直接二值化火车票拍照经常遇到光线不均匀票面有的地方亮有的地方暗。直接把彩色图转灰度再二值化很可能把淡色文字和背景一起变成白色或者阴影区域变成黑块。所以预处理链路里灰度化之后一定要先做直方图均衡化。看一下主脚本里典型的处理代码import cv2 def preprocess(image_path): # 读入彩色图 src cv2.imread(image_path) # 转灰度RGB三通道变单通道保留亮度信息 gray cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) # 直方图均衡化拉大像素灰度分布区间 equalized cv2.equalizeHist(gray) # 高斯模糊去除噪点避免后面边缘检测产生过多假轮廓 blurred cv2.GaussianBlur(equalized, (3, 3), 0) # 自适应阈值比固定threshold更扛光照不均 binary cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2) return binary逻辑说明cvtColor把三通道彩色图压成单通道灰度图这是所有后续处理的基础换用不同色彩空间操作反而增加复杂度。equalizeHist会把原本集中在某个亮度区间的像素值拉伸到0-255全范围这样浅灰色背景被推向白色深色文字被推向黑色对比度明显提升。GaussianBlur里的(3, 3)是卷积核尺寸核越大越模糊但文字笔画也会变粗建议不超过5x5。最后用adaptiveThreshold而不是固定阈值的threshold因为自适应阈值会根据每个像素邻域计算局部阈值对票面受光不匀的情况友好很多。11是邻域块大小2是常量偏移写死这两个值在大多数票据扫描图上都能出不错的效果。这里有个容易被忽略的细节直方图均衡化后保存中间图方便对比。原项目pictures里的trainID.png就是用来做这一步对比的。我一般会在调试阶段把每一步的结果都用cv2.imwrite输出后缀带步骤名这样后面定位边界问题会省很多时间。3. 票面定位与区域分割Canny边缘、轮廓筛选和透视矫正的落地细节3.1 边缘检测参数与轮廓筛选火车票放在桌上拍照往往不是正对着镜头票面会有一定旋转和透视畸变。要准确切出每个信息区第一步是把票面这张四边形的外边界找出来。OpenCV里最常用的组合是Canny边缘检测加findContours。import cv2 import numpy as np def locate_ticket_polygon(binary_img): # 检测边缘 edged cv2.Canny(binary_img, 50, 150) # 寻找所有轮廓 contours, hierarchy cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 按轮廓面积排序取最大的前几个 contours sorted(contours, keycv2.contourArea, reverseTrue) for cnt in contours[:5]: perimeter cv2.arcLength(cnt, True) # 多边形拟合得到四个角点 approx cv2.approxPolyDP(cnt, 0.02 * perimeter, True) if len(approx) 4: return approx return None逻辑说明cv2.Canny(binary_img, 50, 150)里两个参数是双阈值。小于50的像素点不会被当成边缘大于150的确定是边缘介于两者之间的若与确定边缘相连则也被保留。阈值越高检测到的边缘越少越不容易被纹理干扰。票面本身边缘清晰50-150是个比较稳妥的起步值。findContours传RETR_EXTERNAL只找最外层轮廓避免把票面内部的文字框也算进来。CHAIN_APPROX_SIMPLE压缩轮廓点减少内存占用。后面的approxPolyDP是核心它用一条折线逼近轮廓0.02 * perimeter是逼近精度精度值越小拟合结果越接近轮廓原始形状。当拟合结果为四个角点时就认为找到了票面的外框。这一步常见的坑是拍照背景复杂时最大轮廓可能是桌面边缘或者阴影边缘。解决方法是增加面积过滤先算一下图像总面积只保留面积占比超过一定阈值的轮廓比如超过图像面积的三分之一。另外如果票面底色是红色灰度化后红色会偏灰边缘可能不明显可以先做红通道提取src[:, :, 2]和另外两个通道做差分再转灰度。3.2 用轮廓外接矩形切分六个信息区拿到票面四角后下一步是把票面从原图中抠出来做透视变换让它变成正视图然后再按区域切分。原项目pictures目录里列出了start_time.png、end.png、price.png、name.png等文件说明作者是按固定坐标切了六块出发时间、到达时间、出发站、到达站、票价、姓名。透视变换的代码如下def four_point_transform(image, pts): # 原始四个角点顺序左上、右上、右下、左下 rect order_corners(pts) (tl, tr, br, bl) rect # 计算目标矩形的宽高 widthA np.linalg.norm(br - bl) widthB np.linalg.norm(tr - tl) maxWidth max(int(widthA), int(widthB)) heightA np.linalg.norm(tr - br) heightB np.linalg.norm(tl - bl) maxHeight max(int(heightA), int(heightB)) # 目标四点坐标 dst np.array([[0, 0], [maxWidth - 1, 0], [maxWidth - 1, maxHeight - 1], [0, maxHeight - 1]], dtypefloat32) # 计算透视变换矩阵 M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (maxWidth, maxHeight)) return warped逻辑说明order_corners是自定义函数用来把四个角点按左上、右上、右下、左下排序因为approx返回的角点顺序不固定。排序方法通常是先对四个点按坐标和排序左上点是xy最小右下是xy最大再区分右上和左下。widthA和widthB分别计算底边和顶边长度取最大作为目标宽度这样可以避免因为视角倾斜导致宽度不一致。getPerspectiveTransform根据源四点和目标四点算出3x3矩阵warpPerspective执行实际变换。变换后的票面图像就是正对视角的矩形这时候再按比例切分信息区就准确了。对于火车票不同印刷批次会有版式差异但整体布局固定。常见做法是先把整票裁出来然后按相对坐标切分。比如出发日期在左上角区域x范围大约在票面宽度的0.05到0.5之间y范围在0.1到0.4之间。这些比例需要根据实际票样微调。我一般在切分前先用cv2.imshow可视化叠加矩形框确认坐标落在正确位置再批量切。切分后的小图直接命名成start_time.png等方便对应到字段。4. OCR识别Tesseract与百度OCR两条路线的切换逻辑4.1 预处理过的票面区域如何交给OCR定位并切出小图后就到了文字识别环节。OpenCV本身不识别文字它只负责把图片处理到适合OCR的形态。项目里OCR.py同时封装了本地Tesseract和百度OCR这个设计很实用本地环境没有网络或者不想传文件出去时用Tesseract跑追求准确率尤其是中文识别时切到百度OCR接口。先看本地Tesseract的调用代码import pytesseract from PIL import Image import cv2 def ocr_local(img_path, psm7, langchi_sim): # 先读图再转成PIL格式pytesseract只接受PIL或ndarray img cv2.imread(img_path) # 放大一倍小字识别率会明显提升 img cv2.resize(img, None, fx2, fy2, interpolationcv2.INTER_CUBIC) # 转灰度并去噪 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray cv2.bilateralFilter(gray, 9, 75, 75) # 再变成二值图 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) # 交给pytesseract识别 text pytesseract.image_to_string(binary, langlang, configf--psm {psm}) return text.strip()逻辑说明resize放大两倍是最关键的一步火车票打印字体偏小尤其座位号和发车时间直接识别经常丢字符放大后笔画更清晰。bilateralFilter是双边滤波能降噪的同时保留边缘比单纯高斯模糊更适合文字图。二值化用OTSU自动阈值这个函数会自己算最佳分割阈值不需要人工指定。image_to_string里的--psm 7是把图像当成一行文本处理适合单个字段区域如果切出来的区域包含多行--psm 6更合适它会按统一块处理。langchi_sim是简体中文语言包需要提前用命令tesseract --list-langs确认是否安装了。没装的话识别中文会直接乱码。4.2 识别结果的清洗与字段映射OCR输出往往带空格、换行甚至误识别字符不能直接存库。比如“2019年6月5日”可能识别成“2019年6月5日 ”或者漏掉“年”。需要一个后处理函数import re def clean_field(raw_text, field_type): raw_text raw_text.replace( , ).replace(\n, ).replace(|, 1) if field_type time: # 匹配形如2019-06-05 08:30的日期时间 match re.search(r(\d{4}.\d{1,2}.\d{1,2}\s*\d{1,2}:\d{2}), raw_text) return match.group(0) if match else raw_text if field_type price: match re.search(r\d\.\d{2}, raw_text) return match.group(0) if match else raw_text if field_type name: # 去掉常见误识别字符 cleaned re.sub(r[^\u4e00-\u9fa5], , raw_text) return cleaned return raw_text逻辑说明OCR把|识别成I的情况很多这里直接替换成数字1但实际还得看票面字体如果|频繁出现是内容的一部分就不要替换。时间字段用正则从长串文本里抓取日期时间模式避免OCR把冒号识别成句号导致整个字段失效。价格字段抓两位小数因为票价固定是数字加点两位小数。姓名是纯中文直接过滤掉所有非汉字字符。这些清洗规则需要拿真实车票样本反复调不同打印批次错误模式不同。原项目里的info.txt应该就是作者记录的清洗字段映射可以参考它的格式。百度OCR调用相对简单原项目里的baiduOCR.pyc是封装好的类核心逻辑是先把小图base64编码然后POST到百度AI开放平台的/rest/2.0/ocr/v1/general_basic接口返回的words_result数组里每项带一个words字段。需要在代码里配置API_KEY和SECRET_KEY。它的优势是中文识别准确率比Tesseract高一个档次尤其手写体或者模糊票面缺点是必须联网且免费额度有限。项目里两条路线并存就是考虑到部署现场网络不稳定。5. 避坑指南火车票识别最常见的五个翻车点5.1 现象threshold之后文字断成碎片跑完预处理查看中间图发现“北京南”的“京”字笔画断成几截或者整个字中间是空的。原因多半是二值化阈值选得太高或者光照不均导致局部灰度值超过了阈值。解决方法是别用固定阈值改为adaptiveThreshold并把blockSize从11调到15让邻域统计范围更大。如果仍然断裂检查GaussianBlur的核大小核太大把细笔画磨掉了核太小去不掉噪点。经验值是车票扫描图用3x3高斯核加自适应阈值手机拍的倾斜图用5x5核。5.2 现象findContours报错“contourarea ()未定义标识符”很多人从OpenCV旧版代码迁移过来写的是cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)然后用contourArea(cnt)结果报错说未定义标识符。这个问题的根源一般是变量名拼写错误比如把cv2.contourArea写成了contourArea漏掉了cv2.前缀。另一个隐蔽原因是Python终端正好有个叫contourArea的函数被之前代码覆盖了导致解释器找不到正确属性。解决方法是统一写成cv2.contourArea(cnt)并在代码开头检查版本OpenCV 4.x中findContours返回两个值旧版返回三个值如果从网上复制混了也会出问题。建议用以下方式兼容cnts cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours cnts[0] if len(cnts) 2 else cnts[1]5.3 现象同一张票每次识别结果不一样同一个火车票图片跑了两次输出字段有时一致有时日期多一位小数或者站点名称不同。原因有二一是图像本身有噪声二值化时同一个像素可能被分到不同类别二是Tesseract的PSM模式不稳定对模糊字符存在多解。解决方法是把预处理和识别都固定随机种子并把图像resize改成固定尺寸而不是固定倍数避免插值算法产生差异。更可靠的做法是对同一区域识别三次取多数投票结果。代码里可以做一个简单循环把三个结果里出现两次以上的字段作为最终值能明显降低随机波动。5.4 现象Tesseract识别中文全是乱码安装pytesseract以后识别英文正常一遇到中文车票输出一堆方框和问号。直接原因就是没装中文语言包。Windows下语言包放在C:\Program Files\Tesseract-OCR\tessdata目录需要单独下载chi_sim.traineddata放进去。Linux下同样下载到/usr/share/tesseract-ocr/5/tessdata/。确认语言包存在后在image_to_string里显式写langchi_sim。另外一个细节是火车票字体是点阵打印的笔画有锯齿Tesseract对这类字体识别率偏低可以先把图像做一次cv2.dilate膨胀把锯齿填平再识别。5.5 现象pictures目录下中间结果图不更新运行完脚本pictures目录里还是旧的start_time.png明明改过切分坐标重新跑生成的图还是老样子。这个坑一般出现在用了绝对路径或者忘记覆盖保存。OpenCV的imwrite默认会覆盖同名文件但如果脚本里拼路径时用了相对路径而运行目录不是项目根目录图片就写到别处去了。解决方法是先os.chdir到脚本所在目录或者用os.path.join(os.path.dirname(__file__), pictures)构造完整路径。还有一种情况是代码里cv2.imshow阻塞在等待按键窗口没关闭时脚本一直没执行到imwrite那行看起来像图片没更新。检查办法是在每个imwrite后加print(saved:, path)看日志输出到哪一步。6. 进阶用法批处理、识别率评估和把系统改造成通用票证识别6.1 批量跑票的脚本模板单张图片调试通过后真正的效率提升来自批处理。火车票OCR的落地场景很少是只识别一张的往往是几十张连续扫描件。写一个遍历文件夹的脚本把预处理、定位、切分、识别整条链路包成一个函数import os import glob def process_ticket(image_path, output_diroutput): os.makedirs(output_dir, exist_okTrue) binary preprocess(image_path) polygon locate_ticket_polygon(binary) if polygon is None: return {image: image_path, status: no_ticket} warped four_point_transform(cv2.imread(image_path), polygon.reshape(4, 2)) regions { datetime: warped[10:80, 20:300], start_station: warped[90:150, 20:300], price: warped[150:200, 20:300], } result {} for name, region in regions.items(): tmp_path os.path.join(output_dir, f{name}.png) cv2.imwrite(tmp_path, region) text ocr_local(tmp_path) result[name] clean_field(text, name) return result if __name__ __main__: for img_path in glob.glob(tickets/*.jpg): res process_ticket(img_path) print(img_path, res)逻辑说明这个模板把每个阶段封装成独立函数方便替换。regions里的坐标是示意值实际要根据你手里的票样调整。批处理时建议把每张图的识别结果写入CSV而不是打印到终端因为数据量大的时候终端滚动太快存文件还能保留中间状态。对于找不到票面轮廓的图不要直接报错跳过要把图片路径记到failed.txt里结束后人工检查这批图。6.2 识别率的正确评估方法判断系统能不能用别看单张效果要跑样本集做统计。我会准备20张不同类型火车票其中包含红票蓝票、清晰票褶皱票把真实值录入Excel再和OCR输出逐字段比对。评估指标不是整票全对率而是字段级准确率比如20张票共100个字段正确识别90个准确率就是90%。这个指标比“识别成功几张”能更精确暴露问题字段。如果发现时间字段经常错而站点名字段全对就去单独优化时间字段的预处理参数。我一般会让脚本自动生成一份对照表手工核对后把结果存回Excel整个过程半小时搞定。6.3 换用EasyOCR或云OCR的扩展思路如果Tesseract准确率满足不了要求可以低成本地把OCR.py里的ocr_local替换成EasyOCR。EasyOCR基于深度学习对中文和数字的鲁棒性比Tesseract好很多缺点是模型文件有几百兆CPU上识别速度比Tesseract慢。改法很简单import easyocr reader easyocr.Reader([ch_sim, en]) def ocr_easy(img_path, psmNone, langch_sim): img cv2.imread(img_path) results reader.readtext(img, detail0, paragraphTrue) return .join(results)逻辑说明easyocr.Reader初始化会加载模型这个操作最好放在脚本启动时执行一次别在循环里反复初始化否则光加载模型就能拖死批处理。detail0表示只返回文本不返回坐标框paragraphTrue会把相邻短文本合并成一行很适合火车票上日期和时间的连续排列。如果项目允许联网传输图片也可以直接换成云OCR接口识别率更高但要注意票据属于敏感信息是否允许出本地要评估清楚。最后说一个我自己的习惯以前我总是一上来就调OCR参数后来发现OCR模型的识别上限其实由预处理决定。从那以后我每拿到一张新票样会先用cv2.namedWindow一张张回放灰度图、二值图、切分图确认预处理阶段把每个字段的区域都留够边距了再去碰OCR。这套火车票识别系统里最有价值的不是识别那一下而是前面一串定位和切分的功夫。你复现时如果遇到识别不准优先怀疑切分区域是不是包含到相邻文字别急着换模型。希望这套流程能帮你少走一点弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑