简介:这是一份面向制造业物料标签、快递包裹条码及通用图文场景的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", errors="replace")) # 链路二:OCR 引擎是否可加载 from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) print("ocr ready:", ocr is not None)use_angle_cls=True打开方向分类,手机拍的图经常整体旋转 90 度,不开这个参数整页都会被判成横向文本;lang="ch"同时覆盖中文和英文,不需要再单独指定 en。show_log=False只是把引擎的启动日志压掉,生产环境建议保留,出问题时那几行日志是唯一的黑匣子。
注意: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_factory=list) codes: List[CodeResult] = field(default_factory=list) 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 像素时strength=7,否则用 12。block和c也是同理,c越大越"敢判黑",在浅色条码上要往上调 3~5。
还有一步经常被忽略:限制长边。手机原图动辄 4000×3000,OCR 和条码都在上面跑,耗时是 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)), interpolation=cv2.INTER_AREA)3.2 透视矫正与 ROI 裁剪
斜着拍的文档和面单,条码的模块宽度在图像上是不均匀的,左窄右宽,ZBar 的扫描线一旦跨不过去就报解不出。这时候不是换库能解决的,得先把四边形拉回矩形。
做法是找最大轮廓近似成四边形,把四个角点按左上、右上、右下、左下排好,再做透视变换。
def order_points(pts: np.ndarray) -> np.ndarray: rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # 左上:x+y 最小 rect[2] = pts[np.argmax(s)] # 右下:x+y 最大 d = np.diff(pts, axis=1) 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]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(img, M, (w, h))order_points里的argmin(s)/argmax(d)是标准写法,但对严重旋转的图会失效——整体转了 45 度以上时,x+y 最小的点可能落在真正的左上角之外。生产环境里我一般会先跑一次小角度旋转搜索(-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, symbols=SYMBOLS): x, y, w, h = r.rect out.append({ "type": r.type, "data": r.data.decode("utf-8", errors="replace"), "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, fx=2.0, fy=2.0, interpolation=cv2.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, cls=True)为例,返回的是按页组织的列表,每页里每条是[box, (text, score)]。直接把这些碎片拼起来,中英混排的地址会变成"北京市 Beijing 市 朝阳区"这种错位字符串,因为检测阶段是按文本行切的,英文单词和中文块会被切成不同行的不同片段。
所以这一层的核心工作不是"调引擎",是还原阅读顺序。流程是:过滤低置信度 → 按 y 坐标分行 → 行内按 x 排序 → 按语言决定拼接符 → 输出行文本。
# app/core/ocr_engine.py from paddleocr import PaddleOCR from .result import TextBlock _engine = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) def ocr_blocks(img, min_conf: float = 0.6) -> list: raw = _engine.ocr(img, cls=True) blocks = [] for page in raw or []: for line in page or []: box, (text, conf) = line if conf < min_conf: continue # 低置信度行拼进去只会污染结果 blocks.append(TextBlock( text=text, conf=float(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_sim+eng", psm: int = 6) -> list: data = pytesseract.image_to_data( img, lang=lang, config=f"--psm {psm} --oem 3", output_type=Output.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], key=lambda 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_sim+eng这个写法少一个都不行:只写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_whitelist=0123456789" digits = pytesseract.image_to_string(roi, lang="eng", config=cfg).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, key=lambda 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, key=lambda 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 libzbar0,Alpine 用apk add zbar。另外把ldconfig跑一遍,或者确认LD_LIBRARY_PATH包含库目录。构建完用python -c "from pyzbar.pyzbar import decode"做一次冒烟检查,别等跑起来才发现。
5.2 现象:二维码解出来了,但内容是b'https://...'这种带前缀的脏值
现象是前端拿到的字符串首尾多了b'和',直接拿去请求 URL 一定是 404。
原因是pyzbar返回的data字段类型是bytes,很多人图省事写了str(r.data),这个调用不是解码,是把 bytes 的 repr 转成字符串,于是前缀全带上了。
解决就是老老实实.decode("utf-8", errors="replace")。加errors="replace"是为了兜住非 UTF-8 编码的老条码(有些厂商会用 GBK 编码中文),避免一个坏码把整个批次请求打挂。更稳的做法是先试 utf-8,失败再试 gbk,最后兜底 latin-1:
def 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", errors="replace")5.3 现象:中文全部识别成乱码或者干脆识别不出来
现象是纯英文图识别正常,一换中文图,要么返回一堆拉丁字母乱码,要么整段空。
原因是 Tesseract 默认只装英文语言包,lang="chi_sim"找不到模型文件时会静默降级或直接报错;另一种情况是lang写成了chi,这个代码在旧版本里存在、新版本已废弃。
解决分两步:系统层装tesseract-ocr-chi-sim(Debian)或tesseract-lang(macOS),然后用tesseract --list-langs确认列表里有chi_sim;代码里统一写chi_sim+eng。如果切到了 PaddleOCR,lang="ch"是它自己的语言代码,跟 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_conf=0.75), PreprocessConfig(adaptive_c=8)), "label": (OcrConfig(min_conf=0.55), PreprocessConfig(adaptive_c=12)), }注意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(maxsize=2048) def cached_scan(image_hash: str, mode: str): ... # 实际识别逻辑,key 里必须带 mode,否则 text 请求会拿到 code 的缓存lru_cache的 key 里带mode是必须的,这一点我吃过亏:只按 image_hash 缓存,第一次用mode=code请求,第二次同样图用mode=both直接命中缓存,返回的只有条码没有文字,排查了半天以为是 OCR 挂了。
第三处是建回归集并量化。二次开发最怕的是"改完感觉变好了",实际是某类图变好、另一类图变差。我的做法是在tests/fixtures下按场景分目录,每类 10~20 张,写一个脚本跑全量并输出对照表:
| 场景 | 样本数 | 改造前条码命中 | 改造后条码命中 | 改造前 OCR 行准确率 | 改造后 OCR 行准确率 |
|---|---|---|---|---|---|
| 正拍面单 | 20 | 95% | 100% | 92% | 94% |
| 斜拍面单 | 20 | 62% | 88% | 71% | 85% |
| 反光标签 | 15 | 40% | 73% | 58% | 61% |
| 小尺寸二维码 | 15 | 53% | 93% | - | - |
| 中英混排票据 | 20 | 80% | 82% | 76% | 89% |
这张表的价值不在数字本身,在于它能告诉你每次改动的代价:把denoise_strength从 10 调到 15,正拍面单可能没变化,小尺寸二维码却从 93% 掉到 70%。没有这张表,你只会在下一次线上反馈时才发现。
最后一条经验是关于取舍的。这套项目里我最开始想的是"一次把图处理到最干净,OCR 和条码都吃同一份输出",实际跑下来发现两条链路对图像的要求是冲突的:OCR 喜欢适度去噪后的灰度,条码喜欢锐利的二值。硬合并的结果是两边都不到最优。后来改成各走各的前处理分支,共享的只有限长和方向矫正,整体识别率反而上去了。
从那以后我每次接识别类需求,都强制先把回归集和对照表搭出来,再动一行参数——没有度量的调参就是赌运气,赌赢了不知道赢在哪,赌输了连后悔药都没地方买。希望这套结构和踩过的坑,能帮你少走两段弯路。
本文还有配套的精品资源,点击获取