简介:针对摄像头实时识别二维码场景,这份Python示例代码面向初中级Python开发者和OpenCV学习者,提供从简单检测到深度模型推理的完整示例集合。资源共7个文件,压缩包约901KB,包含3个带详细注释的Python脚本、2个Caffe模型文件以及2个prototxt配置文件;脚本覆盖OpenCV内置检测器的单码与多码识别,模型文件则支持基于深度学习的检测方案。示例分别演示单个二维码定位、多二维码同时识别,以及Caffe模型的加载与推理流程,代码中详细标注了摄像头读取、灰度转换、检测器参数和模型输入输出等关键环节。读者既可快速借鉴OpenCV API实现轻量级识别,也可参考prototxt与caffemodel的搭配用法,迁移至更复杂的视觉项目。目前已有623人参与学习,适合希望用摄像头完成扫码功能、深入理解二维码检测原理或学习Caffe模型调用的开发者。
1. 摄像头识别二维码,瓶颈常不在解码而在定位
摄像头识别二维码,python 项目里十有八九会先想到 opencv。近处放一张规则二维码,QRCodeDetector.detectAndDecode一行就能拿到内容;可真放到智能车摄像头、jetson 或树莓派摄像头场景里,一旦出现仰角、反光、小码或模糊,解码函数会直接返回空字符串,你根本分不清是没拍到还是拍到了但解不出来。这套示例把问题拆成了三档:demo_simple.py处理单码,demo_multi.py处理多码,demo_deeplearning.py用 Caffe 模型先定位再交给 OpenCV 解码,配套给了detect.caffemodel、detect.prototxt、sr.caffemodel、sr.prototxt。对正在从纯 OpenCV 方案向深度学习落地的工程师来说,三个 demo 刚好是一条完整递进路线。
2. demo_simple 单码流程:从 VideoCapture 到 detectAndDecode 的边界
demo_simple.py走的是 OpenCV 最直接的调用链:打开摄像头,循环read(),再调detectAndDecode。这段代码能跑通,但对摄像头的依赖非常强,下面从调用链开始把每个环节的边界说清楚。
2.1 demo_simple 的主循环与返回结构
先看最基本的单码循环:
import cv2 # 0 是默认摄像头;USB 多路摄像头被占用时换成 1、2 cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) if not cap.isOpened(): raise RuntimeError("camera open failed") detector = cv2.QRCodeDetector() while True: ok, frame = cap.read() if not ok: continue # demo_simple 里最省事的路径:检测+解码一次完成 data, bbox, straight = detector.detectAndDecode(frame) if bbox is not None: bbox = bbox.astype(int) cv2.polylines(frame, [bbox], True, (0, 255, 0), 2) if data: print("QR:", data) cv2.putText(frame, data, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) cv2.imshow("demo_simple", frame) key = cv2.waitKey(1) & 0xFF if key == ord("q"): break cap.release() cv2.destroyAllWindows()这里的detectAndDecode返回三个值:data是解码出的字符串,解不出来就是空字符串;bbox是四边形四点坐标,shape 为(1, 4, 2),顺序是左上、右上、右下、左下;straight是透视矫正后的二维码图像。注意bbox的坐标是浮点类型,直接画线前要转成int,否则cv2.polylines在某些 OpenCV 版本里会报TypeError。
windows 下加上cv2.CAP_DSHOW是为了走 DirectShow 后端,能明显降低打开摄像头和逐帧读取的延迟。linux 下不要带这个参数,直接用cv2.VideoCapture(0)走 V4L2。cap.read()本身会做帧同步,内部 skips 还是阻塞由驱动和缓冲策略决定,不是代码能完全控制的。
2.2 解码失败时拆开 detect 与 decode,先看定位结果
detectAndDecode最大的问题是它把定位和解码绑在一起,一旦返回空字符串,你无法判断是「没找到二维码」还是「找到了但解码失败」。排查时把两步拆开:
ok_boxes, points = detector.detect(frame) if not ok_boxes: print("not found") continue # points 是四边形坐标,shape 为 (1, 4, 2) pts = points[0].astype(int) # 定位成功但解码失败时,把 ROI 存下来 x1, y1 = pts[:, 0].min(), pts[:, 1].min() x2, y2 = pts[:, 0].max(), pts[:, 1].max() roi = frame[y1:y2, x1:x2] cv2.imwrite("roi_debug.jpg", roi) data, straight = detector.decode(frame, points[0].reshape(1, 4, 2))detect只负责找三个定位角点和第四个角点,返回的是伪四边形。如果detect能返回True但decode拿不到内容,常见原因有三个:二维码太靠近图像边缘导致静区不足、透视畸变过大、或者运动模糊。此时不要急着调模型,先打开保存的roi_debug.jpg看图案是否完整。另一个常见问题是光照不均匀,二维码一半曝掉一半正常,解码器计算明暗模块阈值时会把整张图压坏。对这种场景,先把每个候选四边形用透视矫正拉正,再做自适应阈值:
import numpy as np dst_size = 320 src = pts.astype(np.float32) dst = np.array([[0, 0], [dst_size - 1, 0], [dst_size - 1, dst_size - 1], [0, dst_size - 1]], dtype=np.float32) M = cv2.getPerspectiveTransform(src, dst) qr_clean = cv2.warpPerspective(frame, M, (dst_size, dst_size)) # 局部对比度增强,模块边界更清晰 gray = cv2.cvtColor(qr_clean, cv2.COLOR_BGR2GRAY) gray = cv2.equalizeHist(gray) data, _ = detector.decode(gray)这里把四边形映射到320x320的规范尺寸,等于给解码器一个稳定的输入。equalizeHist只适合对比度偏平的场景,如果你拿正常光照的图也加,反而会因为噪声被放大导致解码率下降。
2.3 摄像头参数设置的边界
cap.set设置的参数不一定全被驱动接受,这跟摄像头型号、接口和系统都有关系。常用参数如下:
| 参数 | 示例值 | 说明 |
|---|---|---|
CAP_PROP_FRAME_WIDTH | 1280 | 分辨率越高小码越清晰,但处理耗时上升 |
CAP_PROP_FRAME_HEIGHT | 720 | 配合宽度一起设,单独设一个无效 |
CAP_PROP_FPS | 30 | 部分 USB 摄像头会忽略,需要读真实值 |
CAP_PROP_BUFFERSIZE | 1 | 减少积压帧,降低画面延迟 |
CAP_PROP_EXPOSURE | -6 | 曝光值范围取决于驱动,优先设为固定值 |
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) cap.set(cv2.CAP_PROP_EXPOSURE, -6)BUFFERSIZE调成 1 是摄像头扫码项目里经常被忽略的一步。默认驱动队列里可能攒了 5 帧,read()读到的永远是几百毫秒前的画面,二维码已经移过视野了。曝光固定后画面亮度不会突变,避免解码器在暗区和亮区间反复抖动。CAP_PROP_EXPOSURE的具体数值没有通用标准,在 V4L2 设备上通常是整数,在 DirectShow 下则可能是任意档次值,建议先手动枚举一遍。
3. demo_multi 多码管线:检测、排序、再解码
把摄像头画面对准多个二维码,detectAndDecode就失效了。它内部只处理一个结果,多码场景必须换成 multi 系列接口。demo_multi 的核心思想是把「检测定位」和「内容解码」拆成两条数据管线,中间留出处理位置。
3.1 为什么多码要用 detectMulti 与 decodeMulti
OpenCV 4.x 的QRCodeDetector提供了detectMulti和decodeMulti,前者负责找到所有疑似二维码四边形,后者逐个解码。直接调detectAndDecodeMulti虽然一步到位,但可控制的环节太少。实际项目我一般分开用:
retval, points = detector.detectMulti(gray) if not retval: continue # points 数量就是当前帧里“疑似二维码”的个数 print("candidate:", len(points)) ok, decoded_info, _ = detector.decodeMulti(frame, points) for text, pts in zip(decoded_info, points): if text: cv2.polylines(frame, [pts.astype(int)], True, (0, 255, 0), 2) cv2.putText(frame, text, tuple(pts[0].astype(int)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)detectMulti返回的points是二维数组,每个元素是一组四点坐标。注意它识别的是「疑似」四边形,并不是每个都是真的二维码,解码失败也正常。decodeMulti的decoded_info是元组,顺序和points一致,解不出来的元素是空字符串。
一个容易被坑的点:detectMulti只接受灰度图或 BGR 图,不接受 RGB。如果你用cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)转换后再喂给detectMulti,部分 OpenCV 版本会直接报断言错误,因为内部默认按 BGR 连续内存处理。
3.2 灰度图、直方图均衡化与候选排序
多码场景光照更难控制,因为一个画面里可能同时存在亮区和暗区。常见做法是分层处理:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 画面整体偏暗时才做均衡化,否则会引入边缘噪声 if gray.mean() < 80: gray = cv2.equalizeHist(gray) retval, points = detector.detectMulti(gray)gray.mean() < 80是一个经验阈值,你可以改成灰度直方图的 90 分位和 10 分位之差来判定对比度。不要每个帧都做equalizeHist,这对二维码解码并不是无害的,模块边界可能会在过度增强后产生伪边缘,让解码器多出大量错误候选。
拿到多个候选框之后,推荐按面积从大到小排序,优先解码大码。大码在画面信息量上更完整,先解出结果可以提前返回,减小计算压力:
candidates = [] for p in points: area = cv2.contourArea(p.astype(np.float32)) candidates.append((area, p)) candidates.sort(key=lambda x: -x[0]) for area, p in candidates: ok, text, _ = detector.decode(frame, p.reshape(1, 4, 2)) if ok: print(text)这里用cv2.contourArea需要把坐标转成float32,否则某些 OpenCV 构建会返回负数。排序的意义在于:一个很小的二维码在摄像头运动模糊时几乎不可能解码成功,先把大码解掉,输出有效性会高很多。
3.3 候选框重叠与 NMS
detectMulti在同一个二维码上可能返回 2 个近似的四边形,特别是当二维码边缘和背景纹理接近时。直接把这些重叠框全丢给decodeMulti,会产生重复解码和坐标抖动。一个简单 NMS 能显著稳定输出:
def iou(a, b): a = a.astype(np.float32) b = b.astype(np.float32) x1 = max(a[:, 0].min(), b[:, 0].min()) y1 = max(a[:, 1].min(), b[:, 1].min()) x2 = min(a[:, 0].max(), b[:, 0].max()) y2 = min(a[:, 1].max(), b[:, 1].max()) if x2 <= x1 or y2 <= y1: return 0.0 inter = (x2 - x1) * (y2 - y1) area_a = cv2.contourArea(a) area_b = cv2.contourArea(b) union = area_a + area_b - inter + 1e-6 return inter / union keep = [] for p in points: if any(iou(p, q) > 0.5 for q in keep): continue keep.append(p)iou用外接矩形的交集除以候选四边形区域并集,阈值 0.5 表示两个候选框高度重合。实际项目里可以把阈值压到 0.3,因为同一个码的多次定位一般 IoU 在 0.6 以上。这个 NMS 虽然简单,但足够解决多码场景下重复框的问题,比引入复杂 tracking 的思路更实用。
4. demo_deeplearning:Caffe 模型先定位,再交给 OpenCV 解码
demo_deeplearning 的路线和纯 OpenCV 完全不同。它先用detect.caffemodel和detect.prototxt做目标检测,把二维码的矩形区域找出来,再在 ROI 上用cv2.QRCodeDetector解码。对于斜视、暗光、密集排列的二维码,检测模型的召回率比 OpenCV 自带的定位器高不少。
4.1 用 OpenCV DNN 加载 detect.prototxt 与 detect.caffemodel
OpenCV DNN 模块本身支持读取 Caffe 模型,不需要额外安装 caffe:
import cv2 import numpy as np net = cv2.dnn.readNetFromCaffe("detect.prototxt", "detect.caffemodel") net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)readNetFromCaffe的第一个参数是网络结构 prototxt,第二个参数是权重 caffemodel。prototxt 里定义的是网络层结构,包括输入尺寸、卷积层参数、检测输出层配置;caffemodel 里面全是具体权重。放在生产环境时,建议先打印一层网络结构确认输入:
for name, param in net.getLayerNames(), None: print(name)如果你在 jetson 或装有 CUDA 的设备上跑,把 backend 换成cv2.dnn.DNN_BACKEND_CUDA,target 换成cv2.dnn.DNN_TARGET_CUDA或DNN_TARGET_CUDA_FP16。CPU 推理和 CUDA 推理的差异在解码阶段可能被融合减弱,但模型里的卷积层收益很大。
4.2 从 SSD 输出到归一化四边形
假设detect.prototxt是 SSD 结构,net.forward()的输出通常是(1, 1, N, 7)的 blob,每个检测框包含 7 个数值:图片编号、类别编号、置信度、以及归一化的 x1, y1, x2, y2。关键代码段如下:
cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) qr_decoder = cv2.QRCodeDetector() input_size = (416, 416) while True: ok, frame = cap.read() if not ok: continue h, w = frame.shape[:2] blob = cv2.dnn.blobFromImage( frame, 1/255.0, input_size, swapRB=True, crop=False ) net.setInput(blob) detections = net.forward() for i in range(detections.shape[2]): conf = detections[0, 0, i, 2] if conf < 0.5: continue x1 = detections[0, 0, i, 3] * w y1 = detections[0, 0, i, 4] * h x2 = detections[0, 0, i, 5] * w y2 = detections[0, 0, i, 6] * h # 外扩 10%,给解码器保留静区 dx = (x2 - x1) * 0.1 dy = (y2 - y1) * 0.1 x1 = max(0, int(x1 - dx)) y1 = max(0, int(y1 - dy)) x2 = min(w, int(x2 + dx)) y2 = min(h, int(y2 + dy)) roi = frame[y1:y2, x1:x2] data, bbox, _ = qr_decoder.detectAndDecode(roi) if data: print(f"conf={conf:.2f} text={data}") text = data[:20] cv2.putText(frame, text, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imshow("demo_deeplearning", frame) if cv2.waitKey(1) & 0xFF == ord("q"): breakblobFromImage这里的swapRB=True很关键,因为 Caffe 模型一般用 BGR 训练,内存布局和 OpenCV 默认一致,部分模型则要求 RGB。crop=False保持原始长宽比后 letterbox,避免二维码被拉伸变形。
SSD 输出的框是矩形,和QRCodeDetector.detect返回的四边形不完全一致。但矩形框足够给解码器提供静区信息,所以直接裁剪 ROI 是合理的。唯一要注意的是detectAndDecode对 roi 尺寸敏感,ROI 太小建议先放大两倍再解码。
4.3 sr.caffemodel 在高精度小码场景下的用法
sr.prototxt和sr.caffemodel是超分辨率模型。当检测框像素尺寸小于 120 像素时,直接喂给QRCodeDetector的解码成功率会断崖式下降,因为二维码模块的最小宽度不够。超分模型在这种场景下能把 ROI 放大 2 到 3 倍,让解码器拿到更平滑的边缘:
sr_net = cv2.dnn.readNetFromCaffe("sr.prototxt", "sr.caffemodel") def upscale_roi(roi, max_side=360): h, w = roi.shape[:2] if min(h, w) >= 180: return roi blob = cv2.dnn.blobFromImage(roi, 1/255.0, (w, h)) sr_net.setInput(blob) up = sr_net.forward() # Caffe 超分输出通常是 CxHxW,需要转回 HxWxC up = up[0].transpose(1, 2, 0) up = np.clip(up * 255.0, 0, 255).astype(np.uint8) return up超分并不是每次都能带来正向收益。如果二维码本身清晰,只是尺寸小,直接cv2.resize配合INTER_CUBIC就能达到接近的效果;如果二维码本身是运动模糊的,超分只能让模糊看起来更平滑,不能恢复模块间的高频信息。因此 sr 模型应当用在「静止小码」场景,而不是用来对抗镜头失焦。
4.4 DNN 检测的帧管理:输入尺寸与耗时取舍
DNN 输入尺寸是部署时最需要权衡的参数。以 CPU 推理为例,大致遵循这样一个趋势:
| 输入尺寸 | 相对耗时 | 小码检出 | 适合场景 |
|---|---|---|---|
| 320x320 | 较低 | 一般 | 智能车摄像头高速移动 |
| 416x416 | 中等 | 较好 | 通用手持扫码 |
| 640x640 | 高 | 最好 | 远距离小码,jetson 等强设备 |
这里的时间与设备强相关,但趋势是确定的:分辨率每增加一倍,卷积计算量翻四倍。对摄像头连续帧来说,不要盲目追求单帧检出率,因为下一帧还会来。我更推荐416x416作为入门尺寸,然后观察roi的最小宽度,如果经常出现roi_width < 80,再增大输入或加超分。
帧管理方面,DNN 推理最好和摄像头采集解耦。一个简单的做法是每处理一帧后cap.grab()一次,等于主动丢弃中间帧:
ok = cap.grab() if not ok: continue ok, frame = cap.retrieve()这样每读一帧就丢一帧,画面延迟比cap.read()更稳定,因为read()本身会等待下一帧可用。摄像头队列压力大时,cap.read()可能阻塞几十毫秒,而grab + retrieve能控制节奏。
5. 用录流回放代替现场调参,快速卡住摄像头识别率瓶颈
在 jetson 或树莓派上反复把摄像头拔插调参效率太低,尤其是户外光线不稳定时,同一个参数在上午和下午会得到完全相反的结论。我一般先录一段带二维码的 mp4,再离线跑整套检测流程,把失败帧和耗时数据一起 dump 出来,最后回到现场验证。
import argparse import cv2 import time ap = argparse.ArgumentParser() ap.add_argument("--source", default="test.mp4") ap.add_argument("--conf", type=float, default=0.4) args = ap.parse_args() cap = cv2.VideoCapture(args.source) net = cv2.dnn.readNetFromCaffe("detect.prototxt", "detect.caffemodel") decoder = cv2.QRCodeDetector() fail_count = 0 frame_id = 0 while True: ok, frame = cap.read() if not ok: break t0 = time.perf_counter() h, w = frame.shape[:2] blob = cv2.dnn.blobFromImage(frame, 1/255.0, (416, 416), swapRB=True, crop=False) net.setInput(blob) detections = net.forward() for i in range(detections.shape[2]): conf = detections[0, 0, i, 2] if conf < args.conf: continue x1 = int(detections[0, 0, i, 3] * w) y1 = int(detections[0, 0, i, 4] * h) x2 = int(detections[0, 0, i, 5] * w) y2 = int(detections[0, 0, i, 6] * h) roi = frame[y1:y2, x1:x2] data, _, _ = decoder.detectAndDecode(roi) if not data: fail_count += 1 cv2.imwrite(f"fail_{frame_id}_{i}.jpg", roi) frame_id += 1 dt = time.perf_counter() - t0 if dt > 0.15: print(f"frame {frame_id} slow: {dt*1000:.1f} ms") print(f"fail frames: {fail_count}")--source传 mp4 文件就是离线回放,传摄像头 index 就是实时模式。同一个脚本复用一条推理链路,能直接对比不同视频参数下的fail_count。dt > 0.15用来标记耗时异常的帧,通常是码大、候选多或者摄像头自动曝光切换导致画面亮度突变引起的。
如果fail_count集中在某几张连续帧,先看它是不是运动模糊;如果失败帧全分布在 ROI 很小的情况,就去调--conf或放大输入尺寸。回放模式下还可以给--source传截取片段,比如只截二维码从远到近的 10 秒,专门验证检测模型在小码上的下限。这样把参数调整变成数据对照,而不是靠肉眼盯屏幕,摄像头识别率的上限自然就提上去了。
本文还有配套的精品资源,点击获取