首页 / 资讯中心 / 文章详情

OCR与条码二维码识别:OpenCV+pyzbar+PaddleOCR实践

OCR与条码二维码识别:OpenCV+pyzbar+PaddleOCR实践 ★ FEATURED ARTICLE
简介这是一份面向制造业物料标签、快递包裹条码及通用图文场景的OCR与条码识别实践包重点解决不同供应商标签样式不一、人工录入耗时且易错的问题同时兼顾中英文文字、条形码和二维码的快速检测与识别。压缩包共16个文件约15MB包含Python脚本、Paddle推理模型与参数文件、配置文件及效果图等脚本负责调用与视频、图片预测模型文件支撑文字检测、方向分类和识别流程配置与说明文件便于快速跑通。已有174人学习适合具备一定Python基础、希望将识别能力落地到仓储物流或产线物料追溯的开发者参考。读者可据此理解从图像输入、模型加载到结果输出的完整组织方式并借鉴包裹条码识别与标签文字提取的实现思路用于供应商物料判别和后台处理。1. 从一张快递面单说起文字识别、条形码和二维码为什么必须一起做一张快递面单贴在歪斜的桌面上手机斜着拍了一张左上角是二维码中间一串 CODE128 条形码下面是中文收件地址混着英文门牌号。这种图丢给单一模型多半只能干半件事——OCR 能把地址读出来条码却因为反光和透视变形解不出换个主打扫码的库条码秒解地址又识别不全。做物流、仓储、零售盘点、票据归档这类场景文字识别和条码二维码识别从来不是两个需求而是同一张图上的两条输出链路。这里拆的项目就干这件事输入一张原始照片输出一份结构化的结果——哪些文字、置信度多少、框在哪哪些码、类型是什么、内容有没有通过校验位。适合想把识别能力接进自己业务后台的后端开发也适合刚上手 OpenCV 想找一个完整落地样本的新手。下面从工程骨架一路拆到能上手改的地方。2. 工程骨架OCR 与条码解码两条链路怎么并行2.1 选型为什么文字识别和条码识别不能共用一个模型很多人的第一反应是找个多模态大模型全干了。真跑起来会发现两件事条码识别本质是符号解码不是语义理解它对像素的要求是边缘锐利、模块宽度可分辨一个 QR 码在 200 像素宽时解不出放大到 400 像素就解出了这个过程对模型来说是玄学对算法来说是确定的采样问题文字识别本质是序列识别它更怕模糊、怕小字号、怕中英文贴在一起没分隔。所以这个项目里两条链路是完全独立的两套引擎共享的只有前处理层和结果结构。合格的组合是文字识别走 PaddleOCR 这类带检测框的深度学习引擎中文场景识别率明显高于传统方案或用 Tesseract 兜底条码走 pyzbar底层是 ZBar二维码和常见一维码一次覆盖。两者都不需要训练装完就能跑这是能落地的关键——业务方要的是这周能上不是三个月后有个自研模型。链路编排上还有一个很值钱的设计短路。先解条码解到了直接返回不跑 OCR。面单、价签、资产标签这类场景 70% 以上的请求会命中这条短路单张耗时从几百毫秒掉到几十毫秒。2.2 依赖安装与最小可跑脚本依赖分三层图像处理、条码、OCR。条码库在 Linux 上需要系统级共享库这是第一个容易翻车的地方先装系统包再装 Python 包。# 系统层pyzbar 依赖 zbar 共享库 # Debian/Ubuntu sudo apt-get install -y libzbar0 tesseract-ocr tesseract-ocr-chi-sim # macOS brew install zbar tesseract tesseract-lang # Python 层 pip install opencv-python numpy pyzbar pillow pip install fastapi uvicorn python-multipart # 二选一深度学习 OCR 或传统 OCR pip install paddleocr paddlepaddle pip install pytesseract装完先别急着写接口用一段脚本验证三条链路都活着。这段脚本的作用相当于体检任何一环返回空都要当场停下来查不然后面所有现象都会被误判成算法不行。import cv2 from pyzbar.pyzbar import decode img cv2.imread(fixtures/sample.jpg) print(shape:, None if img is None else img.shape) # 读图失败这里就是 None # 链路一条码/二维码 codes decode(img) for c in codes: # data 是 bytes直接 str() 会得到 b... 这种带前缀的脏值 print(c.type, c.data.decode(utf-8, errorsreplace)) # 链路二OCR 引擎是否可加载 from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) print(ocr ready:, ocr is not None)use_angle_clsTrue打开方向分类手机拍的图经常整体旋转 90 度不开这个参数整页都会被判成横向文本langch同时覆盖中文和英文不需要再单独指定 en。show_logFalse只是把引擎的启动日志压掉生产环境建议保留出问题时那几行日志是唯一的黑匣子。注意cv2.imread遇到中文路径会返回 None这是 OpenCV 的老毛病。文件名来自用户上传时一定会踩统一改成np.frombuffer(open(path,rb).read(), np.uint8)再cv2.imdecode读。2.3 目录结构与统一结果结构两条链路输出格式完全不一样OCR 给的是四点框 文本 置信度条码给的是矩形 类型 字节串。接口层如果直接透传这两个结构前端要写两套解析逻辑后面每加一种码型都要改前端。所以工程上第一件事是定义一个统一的结果结构把差异吃掉。# app/core/result.py from dataclasses import dataclass, field, asdict from typing import List, Optional dataclass class TextBlock: text: str conf: float # 0~1 box: List[List[int]] # 四点坐标顺序为左上、右上、右下、左下 line_id: Optional[int] None dataclass class CodeResult: type: str # QRCODE / EAN13 / CODE128 ... data: str rect: List[int] # x, y, w, h valid: bool True # 一维码是否通过校验位 dataclass class ScanResult: texts: List[TextBlock] field(default_factorylist) codes: List[CodeResult] field(default_factorylist) elapsed_ms: int 0 source: str image # image / video_frame / pdf_page def to_dict(self): return asdict(self)valid这个字段看着多余实际很关键EAN-13、UPC-A 这类一维码自带校验位解出来的字符串有可能是误读业务侧如果直接拿它去查库存会查不到。把校验结果提前算好放进结构里调用方按valid过滤比事后在业务代码里各处if干净得多。整体目录按一条链路一个模块切不按技术分层切scan-kit/ ├── app/ │ ├── main.py # FastAPI 入口挂载路由与异常处理 │ ├── api/routes.py # /scan /scan/batch 两个端点 │ ├── core/ │ │ ├── preprocess.py # 灰度、去噪、二值化、透视矫正 │ │ ├── barcode_engine.py # pyzbar 封装 多尺度重试 │ │ ├── ocr_engine.py # PaddleOCR / Tesseract 双实现 │ │ └── result.py # 上面的统一结构 │ └── utils/validators.py # EAN/UPC 校验位 ├── tests/fixtures/ # 回归样本图改造前后都靠它对比 ├── config.yaml # 阈值全部外置见第 6 章 └── requirements.txtfixtures目录别嫌麻烦第一次搭的时候放十张典型图正拍、斜拍、反光、小码、中英混排、纯数字后面每改一次预处理参数就跑一遍识别率掉了立刻能看见。没有这个回归集调参就是纯靠运气。3. 预处理与条码解码一张糊图从解不出到解出的全过程3.1 灰度、去噪与自适应二值化条码解码失败的图八成不是码本身坏了是背景太脏。pyzbar 内部会自己做二值化但它用的是固定的局部窗口遇到大面积阴影、渐变反光就失效。所以预处理的目标不是让图变好看是把背景压平让黑模块和白模块的对比度拉满。# app/core/preprocess.py import cv2 import numpy as np def to_gray(img: np.ndarray) - np.ndarray: if img.ndim 2: return img if img.shape[2] 4: img cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) def denoise(gray: np.ndarray, strength: int 10) - np.ndarray: # h 参数取 7~15太小噪点去不掉太大会把细笔画和细条码抹平 return cv2.fastNlMeansDenoising(gray, None, strength, 7, 21) def adaptive_binarize(gray: np.ndarray, block: int 31, c: int 10) - np.ndarray: # block 必须是奇数经验值取图像短边的 1/20 左右最小 11 if block % 2 0: block 1 return cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block, c )strength这个参数是我踩过最多次的地方对着高清扫描件调到 15 效果很好同一套参数拿到手机拍的图细小的 CODE128 条纹直接被抹没解码率断崖式下跌。后来统一改成按图像短边自适应短边小于 800 像素时strength7否则用 12。block和c也是同理c越大越敢判黑在浅色条码上要往上调 3~5。还有一步经常被忽略限制长边。手机原图动辄 4000×3000OCR 和条码都在上面跑耗时是 2000 像素版本的 4 倍以上识别率却不会更高。统一在入口缩到长边 2000后面所有模块都省事。def limit_long_side(img: np.ndarray, max_side: int 2000) - np.ndarray: h, w img.shape[:2] scale max_side / max(h, w) if scale 1.0: return img return cv2.resize(img, (int(w * scale), int(h * scale)), interpolationcv2.INTER_AREA)3.2 透视矫正与 ROI 裁剪斜着拍的文档和面单条码的模块宽度在图像上是不均匀的左窄右宽ZBar 的扫描线一旦跨不过去就报解不出。这时候不是换库能解决的得先把四边形拉回矩形。做法是找最大轮廓近似成四边形把四个角点按左上、右上、右下、左下排好再做透视变换。def order_points(pts: np.ndarray) - np.ndarray: rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上xy 最小 rect[2] pts[np.argmax(s)] # 右下xy 最大 d np.diff(pts, axis1) rect[1] pts[np.argmin(d)] # 右上y-x 最小 rect[3] pts[np.argmax(d)] # 左下y-x 最大 return rect def warp_document(img: np.ndarray, pts: np.ndarray) - np.ndarray: rect order_points(pts) tl, tr, br, bl rect w int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl))) h int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl))) dst np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(img, M, (w, h))order_points里的argmin(s)/argmax(d)是标准写法但对严重旋转的图会失效——整体转了 45 度以上时xy 最小的点可能落在真正的左上角之外。生产环境里我一般会先跑一次小角度旋转搜索-15 到 15 度步长 5 度挑解码成功率最高的角度作为矫正结果这样不用改算法也能覆盖手抖拍歪的情况。ROI 裁剪是另一条提速路径。业务上如果知道条码一定在面单右上角切出那块区域单独解码成功率和速度都会上去。配置化写法是把 ROI 定义成相对比例而不是绝对像素# config.yaml 片段 roi: enabled: true regions: - name: label_top x: 0.55 y: 0.05 w: 0.40 h: 0.30用相对比例的好处是同一份配置在 1080P 和 4K 输入上都成立换了相机不用重调。3.3 多尺度重试与校验位pyzbar 对小尺寸条码不友好这在血泪经验里排前二一张图里二维码只有 80×80 像素直接decode返回空放大 2 倍再解就出来了。所以封装引擎时不要只调一次做成阶梯重试。# app/core/barcode_engine.py import cv2 import numpy as np from pyzbar.pyzbar import decode, ZBarSymbol from .preprocess import to_gray, adaptive_binarize SYMBOLS [ZBarSymbol.QRCODE, ZBarSymbol.EAN13, ZBarSymbol.EAN8, ZBarSymbol.CODE128, ZBarSymbol.CODE39, ZBarSymbol.UPCA] def _decode_once(img: np.ndarray) - list: out [] for r in decode(img, symbolsSYMBOLS): x, y, w, h r.rect out.append({ type: r.type, data: r.data.decode(utf-8, errorsreplace), rect: [int(x), int(y), int(w), int(h)], points: [(p.x, p.y) for p in r.polygon], }) return out def decode_with_retry(img: np.ndarray): gray to_gray(img) attempts [ (raw, gray), (x2, cv2.resize(gray, None, fx2.0, fy2.0, interpolationcv2.INTER_CUBIC)), (otsu, cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU)[1]), (adaptive, adaptive_binarize(gray)), ] for name, probe in attempts: found _decode_once(probe) if found: return found, name # 第二个返回值标记命中的策略便于线上排查 return [], none四个尝试策略的顺序是经过权衡的raw覆盖大多数清晰图x2救小码otsu救整体偏暗或偏亮的图adaptive救背景不均匀的图。每次尝试的耗时是线性叠加的最坏情况四轮全跑一张 2000 像素的图大概 80~120 毫秒可以接受。返回第二个值strategy不是为了给业务用是为了线上出问题时能按策略分组看命中分布——如果某个策略命中率骤降说明那批图的质量变了。一维码解出来之后一定要过校验位。EAN-13 的规则是前 12 位按 1、3 交替加权求和用 10 减个位的余数作为第 13 位误读的码基本都过不了这一关# app/utils/validators.py def ean13_checksum_ok(code: str) - bool: if not code.isdigit() or len(code) ! 13: return False digits [int(c) for c in code] total sum(d * (3 if i % 2 else 1) for i, d in enumerate(digits[:12])) return (10 - total % 10) % 10 digits[12]注意这里的权重顺序EAN-13 是从左往右数第 1 位权重 1第 2 位权重 3和 UPC-A 的规则不同别抄错。校验不过不代表一定错脏污导致的个别位误读也会过校验概率约 1/10所以我在结构里保留valid字段让调用方决定是丢弃还是走人工复核。4. 文字识别链路中英文混排的行合并与置信度过滤4.1 检测加识别的两阶段结构OCR 引擎输出的原始结果不是一句话而是一堆带四点框的碎片。以 PaddleOCR 的ocr(img, clsTrue)为例返回的是按页组织的列表每页里每条是[box, (text, score)]。直接把这些碎片拼起来中英混排的地址会变成北京市 Beijing 市 朝阳区这种错位字符串因为检测阶段是按文本行切的英文单词和中文块会被切成不同行的不同片段。所以这一层的核心工作不是调引擎是还原阅读顺序。流程是过滤低置信度 → 按 y 坐标分行 → 行内按 x 排序 → 按语言决定拼接符 → 输出行文本。# app/core/ocr_engine.py from paddleocr import PaddleOCR from .result import TextBlock _engine PaddleOCR(use_angle_clsTrue, langch, show_logFalse) def ocr_blocks(img, min_conf: float 0.6) - list: raw _engine.ocr(img, clsTrue) blocks [] for page in raw or []: for line in page or []: box, (text, conf) line if conf min_conf: continue # 低置信度行拼进去只会污染结果 blocks.append(TextBlock( texttext, conffloat(conf), box[[int(x), int(y)] for x, y in box], )) return blocksmin_conf定在 0.6 是折中调到 0.8很多浅色小字被丢掉地址会缺门牌号降到 0.4噪声行开始混进来|、~、印章残影都会被当成文字。真实项目里这个值应该按业务类型分档——票据类容错低用 0.75面单类容错高用 0.55。引擎实例必须是模块级单例不能每次请求PaddleOCR(...)。初始化一次要加载模型几百毫秒到一两秒放请求里等于自杀。但单例又带来并发问题引擎内部有共享状态多线程并发调用偶发结果错乱甚至崩溃。常见做法是进程内加锁串行化或者直接uvicorn --workers 4让每个 worker 进程持有自己的实例——后者吞吐更好代价是内存翻倍。4.2 Tesseract 兜底与 PSM 参数深度学习引擎在中文上强但对纯数字串这种场景反而不如传统引擎稳而且部署体积大。所以这个项目留了 Tesseract 作为第二实现用配置切换。它的关键全在--psm和语言包两个参数上。import pytesseract from pytesseract import Output def ocr_text(img, lang: str chi_simeng, psm: int 6) - list: data pytesseract.image_to_data( img, langlang, configf--psm {psm} --oem 3, output_typeOutput.DICT ) lines {} for i, word in enumerate(data[text]): word word.strip() conf float(data[conf][i]) if not word or conf 0: # conf 为 -1 表示非文字块 continue key (data[block_num][i], data[par_num][i], data[line_num][i]) lines.setdefault(key, []).append((data[left][i], word, conf)) out [] for key in sorted(lines): words sorted(lines[key], keylambda t: t[0]) # 行内按 x 排 text .join(w[1] for w in words) # 中文之间不能加空格 out.append({text: text, conf: sum(w[2] for w in words) / len(words)}) return outchi_simeng这个写法少一个都不行只写chi会找不到语言包直接抛错只写chi_sim英文会被当成生僻汉字乱认。--oem 3表示用默认的 LSTM 引擎别用oem 0那个是老的识别器中文基本废掉。拼接时用.join而不是 .join是中文场景的铁律。Tesseract 把中文按字切、北京市会拆成三个 word中间加空格就变成北 京 市。但纯英文场景又需要空格所以实际代码里得按字符判断def join_words(words): buf for _, w, _ in words: if buf and buf[-1].isascii() and w[0].isascii(): buf buf w return bufPSM 参数值得单独列一下选错了识别率能差一倍PSM 值含义适用场景3全自动分页扫描整页文档默认值6假定为单一文本块面单、票据主体区域7假定为单行文本条码下方的数字串、编号8假定为单个词印章、标签上的短词11稀疏文本海报、货架文字分散排布条码下方那串供人眼读的数字用psm 7加白名单能到接近 100%cfg --psm 7 -c tessedit_char_whitelist0123456789 digits pytesseract.image_to_string(roi, langeng, configcfg).strip()白名单是双刃剑加了它引擎只认这十个字符任何干扰都会被吸附成最近的数字所以必须搭配校验位函数一起用否则会得到一串看起来很合理、实际全是错的数字。4.3 行合并与后处理拿到块列表之后行合并的判据是 y 方向重叠度不是简单的 y 中心差。两块文字如果 y 区间重叠超过较矮那块高度的一半就判为同一行这比固定阈值的 y_tol 稳得多——字号不一致的图标题大、正文小用固定阈值一定会拆错行。def merge_lines(blocks: list, overlap_ratio: float 0.5) - list: if not blocks: return [] blocks sorted(blocks, keylambda b: (b.box[0][1], b.box[0][0])) rows, cur [], [blocks[0]] def y_range(b): ys [p[1] for p in b.box] return min(ys), max(ys) for b in blocks[1:]: top, bottom y_range(b) prev_top, prev_bottom y_range(cur[-1]) overlap min(bottom, prev_bottom) - max(top, prev_top) h min(bottom - top, prev_bottom - prev_top) if h 0 and overlap / h overlap_ratio: cur.append(b) else: rows.append(cur) cur [b] rows.append(cur) return [ .join(b.text for b in sorted(r, keylambda x: x.box[0][0])) for r in rows ]overlap_ratio取 0.5 是个安全值表格类文档要降到 0.3因为同一行里的单元格文字高度差很大竖排文本则完全不能用这套逻辑得改成按 x 重叠判断这是另一个话题。后处理还有两件事值得做。一是把 OCR 结果和条码结果做交叉校验面单上的运单号通常同时出现在条形码里和下方的大字里两边都拿到之后做模糊匹配去掉空格和连字符后比对一致就提升置信度不一致就标记为可疑单据转人工。二是把文本结果按置信度排序输出而不是保持原图的阅读顺序——业务侧很多时候只关心有没有识别到某个关键词排序后的结果前几条命中率最高可以提前短路。5. 避坑与排查识别率忽高忽低的五个常见根因5.1 现象容器里一启动就 ImportError本地却好好的现象很典型本地 macOS 跑得好好的打成镜像丢到 Linux 容器里from pyzbar.pyzbar import decode直接抛ImportError: Unable to find zbar shared library。原因是 pyzbar 只是一个 ctypes 包装它自己不带 zbar 的二进制靠运行时去找系统的libzbar.so。macOS 上brew install zbar装过就一直在容器里是干净环境自然找不到。解决方式是在镜像构建阶段装系统包而不是在 Python 层折腾Debian 系用apt-get install -y libzbar0Alpine 用apk add zbar。另外把ldconfig跑一遍或者确认LD_LIBRARY_PATH包含库目录。构建完用python -c from pyzbar.pyzbar import decode做一次冒烟检查别等跑起来才发现。5.2 现象二维码解出来了但内容是bhttps://...这种带前缀的脏值现象是前端拿到的字符串首尾多了b和直接拿去请求 URL 一定是 404。原因是pyzbar返回的data字段类型是bytes很多人图省事写了str(r.data)这个调用不是解码是把 bytes 的 repr 转成字符串于是前缀全带上了。解决就是老老实实.decode(utf-8, errorsreplace)。加errorsreplace是为了兜住非 UTF-8 编码的老条码有些厂商会用 GBK 编码中文避免一个坏码把整个批次请求打挂。更稳的做法是先试 utf-8失败再试 gbk最后兜底 latin-1def safe_decode(raw: bytes) - str: for enc in (utf-8, gbk, latin-1): try: return raw.decode(enc) except UnicodeDecodeError: continue return raw.decode(utf-8, errorsreplace)5.3 现象中文全部识别成乱码或者干脆识别不出来现象是纯英文图识别正常一换中文图要么返回一堆拉丁字母乱码要么整段空。原因是 Tesseract 默认只装英文语言包langchi_sim找不到模型文件时会静默降级或直接报错另一种情况是lang写成了chi这个代码在旧版本里存在、新版本已废弃。解决分两步系统层装tesseract-ocr-chi-simDebian或tesseract-langmacOS然后用tesseract --list-langs确认列表里有chi_sim代码里统一写chi_simeng。如果切到了 PaddleOCRlangch是它自己的语言代码跟 Tesseract 不通用混用会返回空结果——这是两套引擎最容易搞混的地方。5.4 现象手机直出的照片方向是歪的同一张图 PC 上正常、服务端全歪现象是同一张 JPEG用图片查看器打开是正的传到服务端识别出来整页横着OCR 全部失败或者只识别出零星几个词。原因是手机拍照时把方向信息写进了 EXIF 的 Orientation 标签图像像素本身是横的。用 PIL 读图再np.array()转 numpy 的路径会丢掉这个标签而 OpenCV 的imdecode默认会应用 EXIF 方向——两条路径行为不一致代码里混着用就出这种玄学问题。解决是统一入口走 PIL 的路径补一句ImageOps.exif_transpose(img)走 OpenCV 的路径确认没有传IMREAD_IGNORE_ORIENTATION。另外 OCR 引擎的use_angle_cls只处理 0/90/180/270 这几个整数角度对 5 度以内的倾斜无效小角度倾斜还是得靠前处理里的旋转搜索。5.5 现象单张测试很快一上并发就超时甚至进程被杀现象是压测时 QPS 上不去日志里出现请求堆积严重时容器 OOM 重启。原因通常有两个。一是 OCR 引擎被写成了每次请求新建实例模型反复加载内存曲线锯齿状上升二是单例被多线程并发调用引擎内部的推理上下文不是线程安全的除了结果错乱还可能触发底层崩溃。解决办法是明确并发模型要么每个 worker 进程持有一个实例、进程内用锁串行化推理用uvicorn --workers 4拉吞吐要么把 OCR 拆成独立服务前面挂队列做削峰。同时给输入加一道硬限制——长边超过 2000 像素先缩、文件超过 8MB 直接 413这两条能挡掉绝大部分把服务打挂的请求。6. 二次开发把识别结果接进业务的三处改造点拿到能跑的版本之后真正决定这套东西好不好用的是三件事按投入产出比排序。第一处是把阈值全部外置。第 3、4 章里那些min_conf、strength、block、overlap_ratio硬编码在代码里的版本只能算 Demo。抽成配置之后同一份代码能覆盖票据高精度、低召回和面单高召回、容忍噪声两类业务不用维护两个分支。# app/core/settings.py from dataclasses import dataclass dataclass class OcrConfig: min_conf: float 0.6 lang: str ch use_angle_cls: bool True dataclass class PreprocessConfig: max_side: int 2000 denoise_strength: int 10 adaptive_block: int 31 adaptive_c: int 10 rotate_search: bool True CONFIGS { receipt: (OcrConfig(min_conf0.75), PreprocessConfig(adaptive_c8)), label: (OcrConfig(min_conf0.55), PreprocessConfig(adaptive_c12)), }注意receipt和label两套配置改的是不同维度票据类提置信度门槛宁可漏也别错面单类放宽门槛同时把adaptive_c调高让浅色条码更容易被判成黑模块。这两类调参方向是相反的所以不能只用一个全局配置。第二处是加一层结果缓存。同一张面单在分拣线上会被多个环节重复识别按图像的感知哈希pHash做 10 分钟缓存命中率在流水线场景能到 40% 以上。实现不用引入外部组件functools.lru_cache配一个自算的哈希就够import cv2, hashlib, functools def phash(img, size: int 16) - str: small cv2.resize(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), (size, size)) _, bits cv2.threshold(small, small.mean(), 255, cv2.THRESH_BINARY) return hashlib.md5(bits.tobytes()).hexdigest() functools.lru_cache(maxsize2048) def cached_scan(image_hash: str, mode: str): ... # 实际识别逻辑key 里必须带 mode否则 text 请求会拿到 code 的缓存lru_cache的 key 里带mode是必须的这一点我吃过亏只按 image_hash 缓存第一次用modecode请求第二次同样图用modeboth直接命中缓存返回的只有条码没有文字排查了半天以为是 OCR 挂了。第三处是建回归集并量化。二次开发最怕的是改完感觉变好了实际是某类图变好、另一类图变差。我的做法是在tests/fixtures下按场景分目录每类 10~20 张写一个脚本跑全量并输出对照表场景样本数改造前条码命中改造后条码命中改造前 OCR 行准确率改造后 OCR 行准确率正拍面单2095%100%92%94%斜拍面单2062%88%71%85%反光标签1540%73%58%61%小尺寸二维码1553%93%--中英混排票据2080%82%76%89%这张表的价值不在数字本身在于它能告诉你每次改动的代价把denoise_strength从 10 调到 15正拍面单可能没变化小尺寸二维码却从 93% 掉到 70%。没有这张表你只会在下一次线上反馈时才发现。最后一条经验是关于取舍的。这套项目里我最开始想的是一次把图处理到最干净OCR 和条码都吃同一份输出实际跑下来发现两条链路对图像的要求是冲突的OCR 喜欢适度去噪后的灰度条码喜欢锐利的二值。硬合并的结果是两边都不到最优。后来改成各走各的前处理分支共享的只有限长和方向矫正整体识别率反而上去了。从那以后我每次接识别类需求都强制先把回归集和对照表搭出来再动一行参数——没有度量的调参就是赌运气赌赢了不知道赢在哪赌输了连后悔药都没地方买。希望这套结构和踩过的坑能帮你少走两段弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站