简介:这套基于OpenCV-Python的英文数字检测识别源码,面向从事OCR、图像文本识别方向的中级开发者与学习者,主要解决图片中英文和数字的自动定位与识别问题。代码在opencv-python 4.11.0.86环境下测试通过,集成文本检测与识别两套深度学习模型,可直接对测试图片执行端到端识别,也便于根据业务需求替换模型或调整预处理流程。压缩包共21个文件,整体大小约118.34MB,文件类型涵盖Python调用脚本、ONNX与PB格式的模型文件、JPG/PNG测试样张、依赖清单及字符集说明,从源码到模型再到测试数据均包含在内,结构清晰。目前已有70人浏览学习,属于面向实践的小型源码包。使用者可获得完整可运行的检测识别脚本、基于EAST架构的文本检测模型、基于CRNN的识别模型,以及多张不同场景的测试图片和字符集文件,省去自行训练与模型转换的繁琐环节,能够快速上手OpenCV与深度学习模型结合实现OCR,也可作为后续项目二次开发的基础。
1. 基于 opencv-python 的英文数字检测识别:一个能直接跑的 ONNX 推理源码包
生产线上拍下来的批号、快递单上手写体编号、设备铭牌上那一长串字母数字组合,这些场景要的不是云端 OCR 那种大而全,而是一个离线、可控、能塞进现有 Python 流程里的识别方案。这套基于 opencv-python 的英文数字检测识别源码,核心就两样东西:一个训练好的 ONNX 模型,一份从图像预处理到结果输出的完整推理脚本。你不需要懂训练,也不需要搭 GPU 环境,装好依赖就能把图片里的英文和数字框出来并转成字符串。适合手里有 Python 基础、想在本地快速跑通检测识别、后续还要做二次开发的从业者。
2. 模型加载与预处理:ONNX Runtime 会话与 OpenCV 图像管线的衔接
2.1 ONNXRuntime 和 ONNX 模型不是一回事:先搞清楚推理会话怎么建
第一次接触这个资源的人,十有八九会卡在"onnx"和"onnxruntime"这两个词上。简单说,ONNX 是一种模型文件格式,就像打包好的权重文件,本身不能跑;ONNXRuntime(通常缩写成 ort)才是真正加载并执行这个文件的推理引擎。这个区别不是概念抠字眼,而是直接关系到你怎么写代码——你用的是 from onnx import 还是 from onnxruntime import,决定了后面一系列 API 的写法。
推理侧通常不关心模型是怎么训练出来的。哪怕你看到资源里写了"基于 PyTorch 训练",只要最终导出成了 .onnx 文件,在你的项目里就只需要 onnxruntime 这一个依赖。PyTorch 转 ONNX 是训练侧做的事,那是另一个话题;你拿到的是成品模型,唯一任务就是把它加载起来、喂数据、取结果。
import onnxruntime as ort providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] sess = ort.InferenceSession("ocr_det.onnx", providers=providers) print("可用推理后端:", ort.get_available_providers())providers 列表的作用是让 onnxruntime 优先尝试 GPU,如果当前机器没有可用的 CUDA 环境,它会自动回退到 CPUExecutionProvider,不会直接崩。这样写的好处是同一份代码在开发机和部署机上都能跑,区别只是速度。如果你明确知道部署机没有 GPU,直接把 providers 写成["CPUExecutionProvider"]更稳妥,省去启动时的探测开销。
拿到会话对象之后,第一件事应该是看一眼模型的输入输出信息,而不是急着喂图:
inp = sess.get_inputs()[0] print("输入名:", inp.name) print("输入形状:", inp.shape) print("输入类型:", inp.type) for out in sess.get_outputs(): print("输出名:", out.name, "形状:", out.shape)输入形状里如果出现 -1 或者字符串形式的动态维度,比如['batch', 3, 'height', 'width'],说明模型支持动态输入尺寸。这个时候你就需要自己给一个固定的 H 和 W,通常取模型的训练尺寸,常见的是 640×640 或 320×320。这个尺寸不是随便定的,它直接影响检测小目标的成功率,后面第 4 章会专门讲怎么调。
2.2 给模型喂对数据:BGR 转 RGB、Letterbox、归一化一次做齐
opencv-python 读出来的图像通道顺序是 BGR,但绝大多数检测模型在训练时用的是 RGB。如果你直接把 cv2.imread 的结果喂给模型,相当于把红色通道和蓝色通道对调了,模型看到的颜色和训练时完全相反。对灰度图没影响,但对彩色图就是灾难。踩过这个坑的人会告诉你:彩色图识别率暴跌,第一怀疑对象永远是通道顺序。
读图之后别急着缩放。很多人图省事,直接用 cv2.resize 把图片拉成 640×640,这在图片比例接近正方形时勉强能用,一旦遇到长条形图片,内容会被水平拉伸,字符变形,检测框也跟着偏。正确的做法是 Letterbox:等比缩放后,用灰色填充到目标尺寸,保证图片内容不变形。
import cv2 import numpy as np def letterbox(img, dst_size=(640, 640)): h, w = img.shape[:2] target_w, target_h = dst_size scale = min(target_w / w, target_h / h) new_w = round(w * scale) new_h = round(h * scale) resized = cv2.resize(img, (new_w, new_h)) canvas = np.full((target_h, target_w, 3), 114, dtype=np.uint8) dx = (target_w - new_w) // 2 dy = (target_h - new_h) // 2 canvas[dy:dy + new_h, dx:dx + new_w] = resized return canvas, scale, dx, dy def preprocess(img_bgr, dst_size=(640, 640)): img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) lettered, scale, dx, dy = letterbox(img_rgb, dst_size) blob = lettered.astype(np.float32) / 255.0 blob = np.transpose(blob, (2, 0, 1)) blob = np.expand_dims(blob, axis=0) blob = np.ascontiguousarray(blob) return blob, scale, dx, dy这里返回的 scale、dx、dy 不是临时变量,后面把检测框坐标还原到原图时全靠它们,千万别丢了。填充值 114 是 YOLO 系列训练时常用的灰色值,灰色不会给模型引入额外的纹理干扰;如果你的模型源码里用的是 0 填充,就把 114 改成 0。怎么确认?看一下资源里的训练脚本或者推理 demo 里有没有类似cv2.copyMakeBorder的逻辑,这是唯一靠谱的依据。
归一化用的是除以 255,把像素值压到 0 到 1 之间。有一部分模型训练时用的是 -1 到 1 的归一化,这时你需要在除以 255 之后再执行(blob - 0.5) / 0.5。判断方法很简单:跑一张图看输出置信度。如果所有框的置信度都异常低(比如全部低于 0.1)或者输出直接是 nan,优先怀疑归一化方式不对,把两种都试一遍马上见分晓。
3. 输出张量解析与后处理:从网络输出到可用的坐标和字符
3.1 输出张量是黑匣子的钥匙:形状与坐标格式判断
onnxruntime 的sess.run()返回的是一个列表,里面每个元素对应一个输出张量。检测类模型通常只有一个输出,形状一般是(1, N, 6)或者(1, 6, N),其中 N 是模型预测的候选框数量,6 代表每条预测的六个值:中心点 x、中心点 y、框宽 w、框高 h、置信度 conf、类别编号 cls_id。
先别急着解析,看一眼形状再动手:
outputs = sess.run(None, {inp.name: blob}) raw = outputs[0] print("原始输出形状:", raw.shape)常见做法是输出形状排在第二位的是 6,比如(1, 6300, 6),那直接按行解析就行。但如果打印出来是(1, 6, 6300),说明通道维在中间,需要先转置。这个判断不费时间,但写错了解析代码,后面全是乱的。
def parse_outputs(raw, conf_thresh=0.5): pred = raw[0] if raw.ndim == 3 else raw if pred.shape[1] == 6: pred = pred.T boxes = [] scores = [] class_ids = [] for row in pred: cx, cy, w, h = row[:4] conf = float(row[4]) cls_id = int(row[5]) if conf < conf_thresh: continue x1 = cx - w / 2.0 y1 = cy - h / 2.0 x2 = cx + w / 2.0 y2 = cy + h / 2.0 boxes.append([x1, y1, x2, y2]) scores.append(conf) class_ids.append(cls_id) return boxes, scores, class_ids这里有个容易被忽略的小坑:模型的输出坐标可能是 cxcywh 格式,也可能是 xyxy 格式。绝大多数 YOLO 系模型输出的是 cxcywh,所以上面代码先按中心点格式解析。如果你发现框的位置整体对不上——比如框的中心明显不在字符上——再尝试直接把前四列当作 x1、y1、x2、y2 用。两种格式在推理代码里就一行判断的区别,但写错了结果完全不可用。
3.2 类别映射与 NMS 去重:把序号变成字符
模型输出的 cls_id 是整数,它对应的是训练时的类别表顺序。英文数字检测识别这个场景,类别表通常是数字 0-9 加字母 A-Z,共 36 类;有的模型会把大小写字母分开,那就是 62 类;还有的模型为了降低识别难度,会把容易混淆的 0 和 O、1 和 I 合并成同一类。所以类别映射一定要以资源里自带的 class_names.txt 或源码里的 CLASSES 列表为准,不要凭感觉猜。
CLASSES = [str(i) for i in range(10)] + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") # 如果模型包含小写字母,继续追加 list("abcdefghijklmnopqrstuvwxyz") # 部分模型会将 0/O 合并、1/I 合并,此时类别表短一截,按实际文件来有了坐标和分数,还有一步不能省:NMS(非极大值抑制)。模型在同一个字符周围往往会输出多个重叠的候选框,NMS 的作用就是保留置信度最高的那个,把重叠度过高的其他框去掉。
def xyxy_to_xywh(boxes): converted = [] for x1, y1, x2, y2 in boxes: converted.append([x1, y1, x2 - x1, y2 - y1]) return converted def nms(boxes, scores, iou_thresh=0.45): if len(boxes) == 0: return [] xywh = xyxy_to_xywh(boxes) keep = cv2.dnn.NMSBoxes(xywh, scores, 0.4, iou_thresh) if isinstance(keep, np.ndarray): keep = keep.flatten() elif len(keep) == 0: keep = [] return keep注意cv2.dnn.NMSBoxes在 OpenCV 4.5.4 之后返回值类型有变化,有时返回np.ndarray,有时返回空元组。上面代码里做了两种兼容,直接复制就能用,不需要逐版本适配。
NMS 的 iou_thresh 参数控制的是"两个框重叠到什么程度算重复"。默认 0.45 对字符检测来说偏高一点,如果场景里字符排得很密(比如序列号连在一起),建议降到 0.3 左右,否则相邻的字符框可能被误合并。
到这一步,你拿到的还是 Letterbox 坐标系里的坐标,需要还原到原始图像尺寸:
def restore_box(box, scale, dx, dy): x1, y1, x2, y2 = box return [ (x1 - dx) / scale, (y1 - dy) / scale, (x2 - dx) / scale, (y2 - dy) / scale ]这段逻辑很短,但它是后处理里最容易出错的地方。第 5 章会专门列一个避坑条目,这里先记住公式:原图坐标 =(Letterbox 坐标 − 填充偏移)÷ 缩放系数。
4. 完整调用:把检测识别过程串成一个可复现的 Python 脚本
4.1 主流程代码:图片、文件夹、摄像头三种入口一套逻辑
前面的预处理、推理、后处理都是零件,这一节把它们装配成一个能直接跑的脚本。我一般习惯把源码包里的推理 demo 拆成三个入口:单张图片、批量文件夹、摄像头实时流。三种入口共用同一套 detect 逻辑,只是数据来源不同。这样做的好处是开发时用单张图调试,上线前用文件夹批量验证,现场演示时切摄像头,不用改核心代码。
import os import cv2 import numpy as np import onnxruntime as ort import argparse def load_model(model_path): providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] return ort.InferenceSession(model_path, providers=providers) def detect(sess, img_bgr, conf_thresh=0.5, iou_thresh=0.45): blob, scale, dx, dy = preprocess(img_bgr) outputs = sess.run(None, {sess.get_inputs()[0].name: blob}) raw = outputs[0] boxes, scores, class_ids = parse_outputs(raw, conf_thresh) keep = nms(boxes, scores, iou_thresh) results = [] for i in keep: x1, y1, x2, y2 = restore_box(boxes[i], scale, dx, dy) label = CLASSES[class_ids[i]] results.append((x1, y1, x2, y2, scores[i], label)) return results def draw_result(img, results): vis = img.copy() for x1, y1, x2, y2, conf, label in results: x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 255, 0), 2) text = f"{label} {conf:.2f}" cv2.putText(vis, text, (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) return vis入口部分用 argparse 接收参数,这样不用改代码就能切换运行模式:
def main(): parser = argparse.ArgumentParser() parser.add_argument("--model", default="ocr_det.onnx") parser.add_argument("--source", required=True, help="图片路径 / 文件夹路径 / 摄像头索引") parser.add_argument("--conf", type=float, default=0.5) parser.add_argument("--iou", type=float, default=0.45) args = parser.parse_args() sess = load_model(args.model) if os.path.isdir(args.source): for name in os.listdir(args.source): path = os.path.join(args.source, name) if not name.lower().endswith((".jpg", ".png", ".jpeg", ".bmp")): continue img = cv2.imread(path) if img is None: print(f"读取失败: {path}") continue results = detect(sess, img, args.conf, args.iou) vis = draw_result(img, results) out_path = os.path.join(args.source, "out_" + name) cv2.imwrite(out_path, vis) chars = "".join([r[5] for r in results]) print(f"{name} -> {chars} conf均值: " f"{np.mean([r[4] for r in results]) if results else 0:.3f}") else: cap = cv2.VideoCapture(int(args.source) if args.source.isdigit() else args.source) while True: ok, frame = cap.read() if not ok: break results = detect(sess, frame, args.conf, args.iou) cv2.imshow("detect", draw_result(frame, results)) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() if __name__ == "__main__": main()文件夹模式下,输出图片会以out_前缀写入原目录,方便你直接对比原图和识别结果。字符顺序这里的做法是按检测顺序拼接,但真实场景里常见做法是:按 x 坐标排序后拼接,否则左右顺序可能乱。如果你识别的是序列号这种对顺序敏感的内容,在拼接前把 results 按 x1 排序。
4.2 两个阈值和输入尺寸:最该调的三个参数
这个资源能跑通只是第一步,效果好不好全看参数。最主要的三个参数:conf_thresh、iou_thresh、输入尺寸。
conf_thresh 是置信度阈值,低于这个值的预测框直接丢弃。默认 0.5 在光照均衡的图片上表现稳定,但遇到昏暗环境或模糊字符,真实目标的置信度可能只有 0.3 左右,这时候还坚持 0.5 就会漏检。我一般建议先用 0.25 跑一遍看结果,如果误检框太多再往上调。误检比漏检更容易发现,因为你会看到明显不该出现的框。
输入尺寸对检测效果的影响比很多人以为的大。模型训练的原始尺寸通常写在资源描述或训练配置里,用 640×640 训练的模型,你推理时也尽量用 640×640。强行改成 1280×1280 并不会让精度翻倍,反而可能因为尺度分布变化导致识别率下降。如果你想提升小目标的识别率,适当加大输入尺寸到 736 或 768 是可行的,但要重新跑一遍批量验证,不能只看一两张图的效果。
iou_thresh 前面提过,字符密集场景下调到 0.3。这个参数的调节原则是:你发现相邻字符被合并成一个框,调低;你发现同一个字符出现两个重叠框,调高。
5. 避坑指南:ONNX 推理最常见的五个翻车现场
5.1 彩色图片识别率暴跌:BGR 和 RGB 的顺序陷阱
现象:灰度图识别一切正常,换成彩色图后漏检率飙升,甚至一张都识别不出来。
原因:opencv-python 的cv2.imread读出来的通道顺序是 BGR,而模型训练时用的是 RGB。彩色图的红蓝通道被整体对调,模型看到的颜色完全是"假的",特征提取自然全乱。灰度图没有颜色信息,所以不受影响。
解决:在预处理第一步显式做通道转换。img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)。这条代码应该放在读图之后、任何缩放操作之前。如果资源里的源码已经写了,别以为就安全了,检查一下它是否在 resize 之前执行,顺序错了也可能出问题。
5.2 目标变小或坐标偏移:Letterbox 缺失与还原公式错误
现象:小字符检测不到,或者检测框位置和字符实际位置错位,框偏左上或右下。
原因:直接cv2.resize到固定尺寸导致图片内容被拉伸变形,字符比例失真,模型检测不到;或者虽然做了 Letterbox,但还原坐标时忘了把偏移量减掉。
解决:使用第 2 章的 letterbox 函数做等比缩放,并且保证每一处坐标还原都用到对应的 scale、dx、dy。一个实用技巧是:在调试阶段,把检测框画在 Letterbox 之后的图上而不是原图上,先确认框位置对不对,再谈还原。这样能快速定位问题出在检测阶段还是还原阶段。
5.3 输出全 0 或 nan:归一化方式和训练时不匹配
现象:模型跑完,输出张量里所有值都是 0,或者出现 nan;检测结果为空,没有任何报错。
原因:模型训练时用的是 -1 到 1 的归一化,推理代码只除以 255(0 到 1),或者反过来。模型输入的数据分布和训练时完全不同,网络输出自然就是垃圾。
解决:做一个小实验,把同一张图分别用 0-1 和 -1-1 两种归一化方式各跑一次,对比输出值的绝对值最大值。数值明显合理的那一个(不是 0 也不是 nan)就是正确的归一化方式。还有一种可能:模型要求先减均值再除标准差,这种情况资源源码里应该有对应的 mean 和 std 值,翻一下训练脚本。
5.4 换机器就报错:ONNXRuntime 版本与 Provider 不匹配
现象:在开发机上跑得好好的代码,拷贝到服务器或新电脑上,import onnxruntime 直接崩,或者报DLL load failed、CUDA_VISIBLE_DEVICES相关的错误。
原因:onnxruntime 和 onnxruntime-gpu 是两个包,不能同时装。GPU 版本的 onnxruntime 依赖的 CUDA 和 cuDNN 版本和服务器上装的对不上,初始化推理会话时就会失败。
解决:部署机上先跑ort.get_available_providers()打印一下,看哪些后端真正可用。如果服务器没有配置 CUDA 环境,就把 providers 改成["CPUExecutionProvider"],不要保留 CUDA 的选项。onnxruntime 的 CPU 推理速度对单张图片检测来说通常也能接受,别为了 GPU 硬着头皮去配 CUDA,那个坑更深。
5.5 框能出但整体错位:坐标还原时忘了除以 scale
现象:框能框住内容,但明显偏小或者偏大,位置整体朝一个方向偏移。
原因:坐标还原时只减了 dx、dy,忘记了除以 scale;或者把 cxcywh 转 xyxy 的公式搞反了,w 和 h 被当成绝对值直接使用。
解决:检查还原公式是否完整:(letterbox坐标 - 填充偏移) / scale。另外注意,偏移量 dx、dy 是 Letterbox 时计算出的填充尺寸,不是随便填的居中偏移。如果你自己改了 Letterbox 的逻辑,这两个值必须同步重新计算。
6. 进阶技巧:int8 量化与批量验证,部署前的最后一道体检
模型跑通只是第一步,真正交付之前有两件事值得做:一是用 onnxruntime 的量化工具把模型从 FP32 压到 INT8,二是做一次完整的批量验证。先把量化做了,因为它直接影响部署时的硬件门槛。
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "ocr_det.onnx", "ocr_det_int8.onnx", weight_type=QuantType.QInt8 )动态量化不需要校准数据集,API 调用两行代码就能拿到一个新模型,模型体积通常能缩小到原来的四分之一左右,CPU 推理速度也有明显提升。代价是精度可能有轻微下降,某些边界情况的置信度会变低。所以量化后的模型必须重新跑一遍批量验证,不能直接替换上线。
批量验证的方法很简单:准备一批覆盖不同光照、不同角度、不同清晰度的真实图片,循环跑检测,把每张图的平均置信度和最低置信度记下来。我习惯把结果汇总成一张表格,逐行看哪些图的最低置信度接近阈值。这些图就是上线后最容易翻车的高危样本。如果发现某一类场景(比如逆光拍摄)整体置信度偏低,就应该针对这类场景做一些预处理补偿,比如先做一次直方图均衡化再进模型。
如果你的最终目标不是 PC 端运行,而是部署到瑞芯微 RK 系列这类边缘设备,这步做完之后还有一个转换路径:用 rknn-toolkit2 把 ONNX 模型转成 RKNN 格式。这个转换过程和量化类似,也需要准备一批校准图片,而且对算子的支持程度有要求,转之前先确认模型里没有不支持的算子,否则会卡在转换阶段。我接过一个螺丝批号识别的项目,单张测试图跑得完美,上线后被车间灯光角度变化集体搞翻车,根源就是验证样本太单一。从那以后我每次换视觉场景,都强制跑一遍批量验证脚本,把最低置信度的十张图拉出来逐张看,再决定要不要调参数或换预处理方式。这个习惯帮我避掉了很多上线后的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取