简介:一份基于OpenCV的Code128条码识别与生成解决方案,针对OpenCV自带Barcode模块无法解码Code128类型这一局限,提供可在VS2015 x64环境直接编译运行的完整工程。资源共27个文件,以C++源文件与头文件为主,辅以Visual Studio工程配置、日志记录、界面资源、示例条码图片及ReadMe说明,压缩包整体仅1.67MB,轻量易部署。核心实现涵盖条码生成与识别、图像预处理工具、直线拟合、线程同步等模块,覆盖条码定位、解码与生成的完整链路,既可用于理解Code128编码规则与图像解码流程,也可直接嵌入物流、仓储、工业检测等条码自动化项目或作为二次开发基线。目前已有772人学习下载,对于需要在OpenCV框架内快速扩展Code128能力的开发者,是一份兼具参考价值与实用性的紧凑资源。
1. 基于 OpenCV 的 Code128 条码识别与生成:解码不能指望一个现成函数
Code128 是物流、医疗样本管理、生产追溯里最常见的条码之一,信息密度比 EAN-13 高,还能覆盖 ASCII 全字符。但“基于 OpenCV 做 Code128”和“基于 OpenCV 做二维码”完全是两回事:OpenCV 官方并不提供直接可用的 Code128 解码器,生成端也常被误以为只是把黑白条画出来。实际工程上,这两条链路都得自己打通:生成要按 ISO/IEC 15417 标准算校验位和静区,识别要先靠 OpenCV 完成定位纠偏,再交给自研宽度比例解码或 zbar 收尾。这篇文章从编码规则写到渲染与解码,适合正在做仓储扫码、医疗样本追溯或生产追溯系统的开发者,目标是看完就能搭出最小闭环。
2. Code128 编码规则与 OpenCV 的能力边界:三种字符集、校验位与解码分工
2.1 Code128 的三种字符集与符号结构:选择 A/B/C 的依据
Code128 之所以叫 128,是因为它用 106 个符号(加上起始、停止、切换码)来表达 ASCII 128 个字符。和 EAN-13 那种固定长度、固定位数不同,Code128 是变长条码,一个符号固定由 6 个元素组成——3 条黑条和 3 个白空交替排列,每个元素宽度是 1 到 4 个模块(module),总计 11 个模块。起始码和停止码略有差异,停止码是 7 个元素、13 个模块。
三种字符集是选型时要第一个想清楚的事:
| 字符集 | 覆盖内容 | 单符号表达 | 典型场景 |
|---|---|---|---|
| Code128-A | 大写字母、数字、标点、控制字符 | 1 个字符 | 医疗样本、设备控制命令、大写为主的数据 |
| Code128-B | ASCII 全字符(32 到 127) | 1 个字符 | 文件名、混合大小写文本、人员信息 |
| Code128-C | 纯数字,两位数字压成一个符号 | 2 个数字 | 物流运单、库存编号、金额、日期 |
选型的核心依据是数据长度和字符构成。连续数字超过 4 位,用 Code128-C 可以把条码长度砍掉将近一半,打印面积更小、更耐磨损;内容是英文混合文本,用 B 模式最省事;带大量控制字符的老系统协议,用 A 模式。生产环境更常用的是自动切换:数据里连续数字多了就插入 CODE-C 切换码,遇到字母再切回 B。自动切换算法的判断阈值没有标准答案,我一般把“连续 4 位以上数字”作为切入 C 模式的条件。
校验位是 Code128 能不能被扫描器认可的关键。算法固定:取起始码的 value 作为初值,从第一个数据字符开始,每个符号乘以递增权重 1、2、3……,累加后对 103 取模。这个校验位必须严格计算,生成端漏掉它,条码看似正常,但在不少扫描设备的固件里会被直接判废。
2.2 OpenCV 对 Code128 的真实支持:别把定位和解码混为一谈
很多刚接触这个方向的人以为 OpenCV 里有个现成函数能直接解码 Code128,这是最常见的误判。OpenCV 4.x 确实增加了条码检测模块,但它对 EAN-8、EAN-13、UPC-A 这类商品零售条码的覆盖更完整,对 Code128 的支持在不同版本和不同编译选项下表现不一,而且很多发行版默认没启用完整的条码解码后端。把它当成一个稳定可依赖的 Code128 解码器,在生产环境里会踩坑。
工程上的分工通常是这样:OpenCV 负责图像侧——灰度化、二值化、形态学处理、定位条码区域、旋转纠偏、提取扫描线;真正的“把黑白宽度翻译成字符”这一步,交给两条路之一——调用 zbar 开源库(pyzbar 封装)做解码,或者自己按宽度比例法实现一个轻量解码器。前者省事,后者在条码过短、过密、zbar 拒绝识别的场景里有奇效。
还有一个容易犯的错误:把二维码的 QRCodeDetector 思路直接套到一维条码上。二维码靠寻像图形定位,整体是一个矩阵;Code128 是沿一个方向变化的宽度序列,定位和采样完全是两套逻辑。QRCodeDetector 无法用来解 Code128,反过来也是。
2.3 生成与识别为什么是对称约束:静区、条高与 X 尺寸
生成 Code128 不是把黑白条“画出来”就完事。扫描识别时,设备依赖静区(quiet zone)来确定条码从哪里开始、在哪里结束。标准要求条码左右两侧各留至少 10 个模块宽的空白,少了静区,内容再正确也可能读不出来。生成端必须把静区画进图里,这是硬约束。
条高和 X 尺寸(一个模块的物理宽度)同样决定可读性。条高建议不低于 32 个模块,实际打印时还受条码总长度的 15% 约束,取较小值;X 尺寸在印刷行业要求不小于 0.19mm,屏幕显示场景这个约束可以放宽,但导出图片打印时必须按目标 DPI 反推像素宽度。很多自绘条码在屏幕上扫得动、打印出来就翻车,原因就是没有做 DPI 换算,模块宽度低于物理极限。
还有个常见误用是用系统字体里的 Code128 字体来生成条码。字体方案看着省事,但字体不负责静区控制、条高不可配置,而且不少字体实现与校验位规则不完全一致,做内部演示还行,落到追溯和物流场景是拿产品质量开玩笑。自己控制模块序列再渲染,才谈得上对识别端的可预测性。
3. 用 OpenCV 生成 Code128 条码:编码器、渲染参数与自检脚本
3.1 B 模式编码器:从字符串到符号序列
生成的第一步是把字符串变成符号 value 序列,再映射成模块宽度序列。下面是一个 B 模式的完整编码器骨架,校验位计算和停止码处理都在里面。字符表 PATTERNS 是从 ISO/IEC 15417 附录整理的 107 行映射(value 到 6 个元素的模块宽度),我一般会把这张表独立存成 JSON,生成和识别两端共用,避免两处数据不一致。
import json import numpy as np def load_pattern_table(path="code128_table.json"): # 表结构: {"104": [2,1,1,2,1,4], "0": [2,1,2,2,2,2], ...} # 每个 value 对应 6 个元素的模块宽度:条 空 条 空 条 空 with open(path, "r", encoding="utf-8") as f: raw = json.load(f) return {int(k): tuple(v) for k, v in raw.items()} def encode_code128_b(text, patterns): # START_B 的 value 是 104 values = [104] for ch in text: v = ord(ch) - 32 # B 模式下 value 0~95 对应 ASCII 32~127 if not (0 <= v <= 95): raise ValueError(f"B 模式不支持字符: {ch}") values.append(v) # 校验位: 起始码 + 每个数据符号乘递增权重,模 103 checksum = values[0] for i, v in enumerate(values[1:], start=1): checksum += i * v checksum %= 103 values.append(checksum) # 停止码 value 是 106 values.append(106) # 把符号序列展开成模块序列 modules = [] for v in values: modules.extend(patterns[v]) return values, modules这段代码里,ord(ch) - 32是 B 模式的核心换算:空格 ASCII 32 对应 value 0,波浪号 ASCII 126 对应 value 94,ASCII 127 对应 value 95。校验位从 1 开始加权,而不是从 0,很多人第一次写会在这个细节上算错。values的最后两个元素分别是校验位和停止码,停止码本身不参与校验计算。
参数上需要注意:patterns[v]中的 6 个元素一定是“条、空、条、空、条、空”交替,并且总和为 11(停止码是 7 个元素、13 个模块,我这里把停止码也按 6 元素存,最后的终止黑条在渲染时单独补一个 2 模块黑条,见 3.2 节)。如果从网上下载的表里出现总和不是 11 的数据,基本是格式转换错了,趁早换源。
3.2 渲染成图:模块宽度、条高与静区的选取
拿到模块序列后,用 OpenCV 渲染就很简单了:创建一个全白图像,从左到右扫描模块序列,遇到表示黑条的模块就填黑。这里不建议用cv2.line逐条画矩形,一是慢,二是模块边界容易产生半像素锯齿;直接用 numpy 的切片批量赋值,干净利落。
def render_code128(modules, module_width=2, bar_height=80, quiet_zone=10): # 静区: 标准要求左右各 10 个模块宽 total_modules = sum(modules) + quiet_zone * 2 width = total_modules * module_width img = np.full((bar_height, width, 3), 255, dtype=np.uint8) x = quiet_zone * module_width is_bar = True # Code128 总是从黑条开始 for m in modules: w = m * module_width if is_bar: img[:, x:x + w] = (0, 0, 0) x += w is_bar = not is_bar # 处理停止码末端的终止黑条:停止码 106 的 6 元素结束后,再补 2 模块黑条 img[:, x:x + 2 * module_width] = (0, 0, 0) return img参数选择上,我一般这样定:屏幕显示的临时条码,module_width=2、bar_height=80足够;要打印张贴的标签,module_width必须按目标打印机 DPI 换算,300 DPI 下每个模块至少 2.2 个像素,建议直接取 3 到 4 像素留余量。quiet_zone=10是标准下限,靠边裁剪严重的场景我会加到 14 到 16 个模块。bar_height低于 50 像素时,部分扫描枪会嫌条高不足拒绝识别,宁可高一点。
这里有一个从数据到像素的换算逻辑:条码物理宽度 = (模块总数 + 左右静区) × X 尺寸。如果你要做一个 60mm 宽的标签,X 尺寸定为 0.25mm,那总模块数就不能超过 240。编码器算出模块序列后先算总模块数,超了就得升级到 Code128-C 压缩或缩小 X 尺寸,这些都应该在渲染前做校验,而不是等打印出来才发现扫不动。
3.3 生成端自检:用模拟退化验证可读性
生成不是“能画出来”就算完成,我习惯在生成后立刻做一轮退化自检:把条码图做高斯模糊、等比缩小再放大、轻微旋转,然后调用第 4 章的解码脚本看能不能读出来。这一步能提前暴露模块宽度取值过小、条空对比度不足、静区留太少这三类问题。
def self_check(img, decode_fn): # 1) 轻微模糊,模拟打印墨迹扩散 blurred = cv2.GaussianBlur(img, (3, 3), 0) # 2) 缩小到原来的 60%,模拟远距离拍摄 h, w = blurred.shape[:2] small = cv2.resize(blurred, (int(w * 0.6), int(h * 0.6)), interpolation=cv2.INTER_AREA) # 3) 旋转 2 度,模拟贴标歪斜 M = cv2.getRotationMatrix2D((w / 2, h / 2), 2, 1.0) rotated = cv2.warpAffine(small, M, (small.shape[1], small.shape[0])) for name, img_t in [("blur", blurred), ("small", small), ("rotate", rotated)]: ok, text = decode_fn(img_t) print(f"{name}: {ok}, {text}")这个自检脚本的定位是回归测试,不是精度测试。它验证的是:我改了一个渲染参数之后,最坏情况下的可读性没有明显劣化。如果你发现缩小到 60% 后解码失败,优先检查module_width是否偏小,而不是去调解码端参数——生成端的冗余,永远比解码端的努力更值钱。
4. 用 OpenCV 识别 Code128:定位、纠偏、解码三步走
4.1 定位条码区域:二值化、形态学闭运算与轮廓筛选
识别链路的第一步是把 Code128 从整幅图里抠出来。一维条码的特征是:竖向条纹密集、整块区域的横向跨度远大于纵向高度。OpenCV 定位的思路就是放大这个特征——用形态学闭运算把密集的竖条连成一片,再用宽高比筛掉其他干扰区域。
def locate_barcode(gray): # 自适应阈值: blockSize 要大于单条宽度,偏移量取 15 让黑色条纹更纯粹 binary = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize=35, C=15 ) # 闭运算: 横向 21 像素的核把相邻黑条连成连通域 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (21, 3)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) # Code128 区域宽高比通常大于 3:1 if w > 50 and h > 20 and w / h > 3: candidates.append((x, y, w, h)) # 按宽度排序,取最宽的作为主条码区域 candidates.sort(key=lambda r: r[2], reverse=True) return candidates[0] if candidates else None自适应阈值这里,blockSize=35是基于“单条黑条宽度通常在 3 到 8 像素”的经验值。如果 blockSize 小于条宽,条码会被当成背景;如果太大,光照不均时局部对比度又拉不开。THRESH_BINARY模式下白底黑条得到黑色条纹为 0,这个极性后面解码时要保持一致。闭运算的核宽度我取 21 像素,是为了让紧密排列的竖条合成一个实心矩形;太窄连不起来,太宽会把旁边的文字也并进来。
宽高比筛选里的w / h > 3是个经验值:标准 Code128 条码宽度通常是高度的 1.5 到 3 倍,但拍摄角度倾斜时投影宽高比会变化。如果你的应用场景是固定角度拍摄,可以放宽到 2;手持拍摄角度随机,我会同时保留多个候选区域,逐个尝试解码。
4.2 纠偏与扫描线采样:把二维图像压成一维宽度序列
定位到 ROI 之后,条码可能是歪的。此时用minAreaRect算出最小外接矩形的旋转角,把 ROI 转正,再沿水平方向取一条扫描线,转成 0/1 序列。单行采样噪声大,我更习惯取中间 3 行分别转宽度序列,解码时取多数一致的结果。
def extract_scanline(gray, roi): x, y, w, h = roi roi_gray = gray[y:y + h, x:x + w] # 最小外接矩形拿旋转角 _, thresh = cv2.threshold(roi_gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) rect = cv2.minAreaRect(max(contours, key=cv2.contourArea)) angle = rect[2] # 角度修正: 让条码长边保持水平 if angle > 45: angle = angle - 90 M = cv2.getRotationMatrix2D((roi_gray.shape[1] / 2, roi_gray.shape[0] / 2), angle, 1.0) rotated = cv2.warpAffine(roi_gray, M, (roi_gray.shape[1], roi_gray.shape[0])) # 取中间一行,转成 0/1(0 代表黑条,1 代表白空) scanline = rotated[rotated.shape[0] // 2, :] binary_line = (scanline > 128).astype(np.uint8) # run-length 编码: 连续相同像素值的宽度序列 runs = [] cur = binary_line[0] count = 1 for v in binary_line[1:]: if v == cur: count += 1 else: runs.append(count) cur = v count = 1 runs.append(count) return runs, cur这段代码里,scanline取的是旋转后图像的正中间一行。实际拍摄中条码可能上下有轻微弯曲,中间行最稳定。run-length编码是整个解码的基础:一个 Code128 的模块宽度在图上可能是 2 到 4 像素,我们不需要关心像素绝对值,只需要关心连续黑白段的相对比例。angle修正里if angle > 45的判断是为了避免条码旋转 90 度时方向混淆——一维条码转 90 度后就是竖着的,应该按横摆处理。
这里有个容易踩的细节:二值化后的极性。如果原图是深色背景浅色条码,binary_line中黑条会变成 1,此时解码前要把序列反转,或者在做 generate 扫描线时统一判断第一个元素是黑条还是白空。我习惯的做法是:解码函数里根据第一个条/空判断极性,自动翻转,而不是在预处理端硬编码。
4.3 解码:宽度归一化、查表与校验位复核
解码是识别链路里“OpenCV 管不到”的部分。最快的路径是调 zbar:
from pyzbar.pyzbar import decode as zbar_decode def decode_with_zbar(img_bgr): results = zbar_decode(img_bgr) for r in results: if r.type == "CODE128": return True, r.data.decode("utf-8", errors="replace") return False, Nonezbar 的优点是成熟、快,缺点是它对过短或过密的 Code128 有时会直接忽略。如果 zbar 扫不出来,就需要自研宽度比例解码器:
def decode_by_width(runs, patterns, polarity=0): # 重建模块序列: 用 run 长度的低分位数估计基本模块宽度 arr = np.array(runs, dtype=float) base = np.percentile(arr, 10) if base == 0: return False, None modules = [] for r in runs: m = int(round(r / base)) if m < 1 or m > 4: return False, None modules.append(m) # 极性: 如果第一段是白空,整体翻转(条码应从黑条开始) if polarity == 1: modules = [1 if m == 0 else 0 for m in modules] # 示意,实际需按元素翻转 # 更严谨的做法: 从 modules 中丢弃第一个元素并重新组合 # 反向查表: pattern tuple -> value rev = {tuple(v): k for k, v in patterns.items()} # 找 START_B (104) start_pos = None for i in range(len(modules) - 6): if tuple(modules[i:i + 6]) in rev and rev[tuple(modules[i:i + 6])] == 104: start_pos = i + 6 break if start_pos is None: return False, None values = [] pos = start_pos while pos < len(modules): key = tuple(modules[pos:pos + 6]) if key not in rev: return False, None v = rev[key] if v == 106: # STOP pos += 7 # 跳过终止黑条 break values.append(v) pos += 6 else: return False, None # 校验位复核 total = 104 for i, v in enumerate(values[:-1], start=1): total += i * v if total % 103 != values[-1]: return False, None text = "".join(chr(v + 32) for v in values) return True, text宽度归一化是整个自研解码器最需要调的地方。np.percentile(runs, 10)取的是最小 10% 宽度的均值附近,理论上 Code128 中最窄的条或者空就是 1 个模块,所以这个低分位数可以近似成基本模块宽度。如果条码模糊导致最小元素宽度被放大,这个估值会偏大,后续模块数会整体偏小;此时把百分位降到 5,或者用所有 run 长度的众数,效果可能更好。m限制在 1 到 4,因为 Code128 单元素最大就是 4 个模块,超过这个范围说明归一化基准选错了。
把 zbar 和自研解码器组合使用的策略是:先 zbar,失败再自研。两条路径用同一个decode_fn包装,方便第 3 章的生成自检脚本直接复用。自研解码器不需要处理 Code128-C 的两位数字解压,因为 B 模式的文本还原只做v + 32;如果你生成的条码是自动切换模式,解码端还要识别 CODE-C 切换码并连续取两个数字为一组,这块可以在拿到 B 模式 value 序列后再补一层状态机。
5. Code128 识别与生成的 5 个避坑:从反色条码到静区被裁
5.1 反色条码导致解码器全程失效
现象:条码内容正确、图像清晰,但识别脚本返回空结果,用 zbar 也解不出来。把中间变量画出来才发现,原本白底黑条的图被处理成了黑底白条。
原因:手机拍摄或图像增强算法在暗光环境下会自动做极性反转,部分美化滤镜也会把深色区域提亮。adaptiveThreshold的THRESH_BINARY输出中,白色条纹成了 0,黑色条纹成了 255,宽度序列不变但条空顺序颠倒,解码端从黑条开始的假设直接失效。
解决:在解码入口处统计 ROI 内黑色像素占比。如果黑像素超过 55%,大概率是反色图,反转后再进解码流程。更稳的做法是把 run-length 序列的第一个元素作为极性判断依据——Code128 永远以黑条开始,如果第一个有效元素是白空,就把整个序列的条空互换。
5.2 模糊与摩尔纹导致宽度测量失真
现象:条码打印在透明塑料袋或反光膜上,图片放大后条纹边缘出现粘连或断裂,解码时模块数出现 0 或 5 这类非法值。
原因:拍摄分辨率不足或压缩过度时,1 个模块宽的黑条和相邻白空的边界变得模糊,二值化后细条被吞掉或者细空被填上。摩尔纹则会让采样行恰好落在周期性的 明暗干扰上,宽度序列整体失真。
解决:预处理阶段先对 ROI 做一次GaussianBlur((3, 3), 0)再二值化,有一定效果;更可靠的是做开运算(先腐蚀后膨胀)分离粘连,内核宽度取 1 个模块的估计像素数。遇到摩尔纹,不要只采一行,取 5 条扫描线分别解码,按多数投票(见 6.2)。这类问题的血泪经验是:不要在解码端死磕,回拍一张更清晰的照片永远比调参快。
5.3 光照不均与局部反光造成条码中段断裂
现象:同一个条码,灯光从侧面打能扫过,正上方 LED 照射时中间一段解码失败,报错位置总在条码中部。
原因:局部反光让一部分黑条变成灰条,全局阈值或过大的自适应阈值 blockSize 把该黑的地方判成白。识别脚本如果用的固定阈值而不是自适应阈值,这种现象几乎必现。
解决:切换成adaptiveThreshold,并让blockSize严格大于单条黑条的像素宽度(典型值是条宽的 4 到 6 倍)。如果反光强烈,我会再加一步亮度归一化:对灰度图做cv2.GaussianBlur得到背景亮度估计,用原图减去背景再加中值,压掉不均匀光照。识别端的“玄学”多半来自这里,先归一化再谈解码。
5.4 静区被裁切或条码紧贴图像边界
现象:条码完全拍全了,人眼看得清清楚楚,但解码器返回 None。检查定位结果发现 ROI 已经顶到图像左边缘或右边缘。
原因:扫码设备判断条码起点依赖静区,如果 ROI 里没有足够的空白,起始码前面的宽度序列会被误读成某个字符。图像采集时如果做了边缘裁剪,或者条码本身布局离标签边缘太近,就会出现这种问题。
解决:定位到 ROI 后,在宽度方向两侧各外扩 ROI 宽度的 25% 作为解码区域,再重新采样扫描线。生成端同步做约束:render_code128的quiet_zone参数强制不小于 10,导出图片左右各留 30 像素以上余量。这个坑在自绘条码里最常见,因为开发者看渲染结果时注意力全在黑白条纹上,很少留意边缘留白。
5.5 Code128-C 模式下前导零丢失
现象:生成字符串 “0123” 时,如果编码器自动切换到了 Code128-C,扫描结果变成 “123”,前导零丢了。
原因:Code128-C 按两位数字一组编码,数值 1 会被编码成 “01” 或直接压缩成 1 位处理;如果编码器在分组时没处理好奇数长度字符串,第一位单数字会丢失。严格说这是编码器实现缺陷,不是 OpenCV 的锅,但在“生成-识别”闭环里表现得像识别错误。
解决:编码器自动模式里,对奇数长度的数字串,先输出一个 Code128-B 的单数字符号,再切换 Code128-C 处理剩余偶数位;或者反过来,用 FNC1 占位后补零处理。我的习惯是:凡是纯数字数据,统一走 Code128-C,且在任何分支前先检查长度奇偶。这个校验应该在encode_code128_b之外单独写一层自动模式函数,避免 B 模式编码器被误用。
6. 让识别更稳的三个进阶技巧:回归测试、多行投票与可视化排障
6.1 用“生成-退化-识别”闭环锁定回归基线
改一次渲染参数或预处理逻辑,都可能让原本可读的条码失效。我不允许自己凭感觉判断“应该没问题”,而是把第 3 章的self_check扩成一个参数化回归:对一组固定的测试字符串,分别做模糊、缩小、旋转、加噪声四类退化,要求识别成功率不低于一个阈值。测试集要覆盖纯数字、混合文本、特殊字符(如%、#、@)、奇数长度数字串这几类。
| 退化类型 | 参数范围 | 通过标准 |
|---|---|---|
| 高斯模糊 | 核 3×3 到 7×7 | 解码成功且数据一致 |
| 等比缩放 | 缩小到 50% 到 80% | 解码成功 |
| 旋转 | 0 到 10 度 | 解码成功 |
| 高斯噪声 | sigma 10 到 30 | 解码成功 |
这套闭环的核心价值不是测出 100% 通过,而是让你在改动任何一端后立刻知道哪一步退化了。我见过最典型的翻车是:某人为了适配暗光环境把自适应阈值 blockSize 从 35 改到 15,结果明亮场景下的条码宽度序列被切碎,一次回归测试就暴露了问题。
6.2 多行采样与投票:单行扫描线不够稳
单行采样在条码轻微弯曲、打印墨迹不均、摩尔纹出现时都会失败。我通常取 ROI 内均匀分布的 5 条水平线,分别做 run-length 和宽度归一化,解码结果按文本投票,取多数作为最终结果。
def decode_multi_scanline(rotated, patterns, scan_lines=5): h = rotated.shape[0] results = [] positions = [int(h * p) for p in np.linspace(0.2, 0.8, scan_lines)] for y in positions: scanline = rotated[y, :] binary_line = (scanline > 128).astype(np.uint8) runs = [] cur = binary_line[0] count = 1 for v in binary_line[1:]: if v == cur: count += 1 else: runs.append(count) cur = v count = 1 runs.append(count) ok, text = decode_by_width(runs, patterns) if ok: results.append(text) if not results: return False, None # 投票: 取出现次数最多的文本 from collections import Counter text, cnt = Counter(results).most_common(1)[0] return True, text投票行数scan_lines=5是个折中:过少没有统计意义,过多会让 ROI 高度较低时采样线相互重叠。np.linspace(0.2, 0.8, scan_lines)让采样线避开上下边缘——边缘通常会有污渍、裁剪噪声或无关印刷内容。投票时我按完整文本一致来计数,因为 Code128 的校验位已经保证了解码结果的内部一致性,某一行如果解出不同文本,意味着那条扫描线踩到了明显干扰,不该参与投票。
6.3 可视化排障:把每一步的中间结果存图
自研识别链路最痛苦的时刻是“明明没报错,就是解不出来”。我会在定位、二值化、旋转、扫描线四个节点各存一张图,排障时一眼看出是哪一步坏了。具体做法很简单:cv2.imwrite("debug_contour.png", debug_img)之类,把定位框画在原图上,把扫描线画在旋转图上,把 run-length 序列打印成文本。
这个习惯帮我快速分清了两个容易混淆的失败模式:如果轮廓图里定位框把条码框对了但扫描线图上条纹断开,问题在预处理;如果扫描线图上条纹完整但解码输出 None,问题在宽度归一化参数。后者我会去调np.percentile的百分位,而不是盲目改阈值。把中间结果变成可见的图,是让这条链路从“黑匣子”变成“可调参数”的最短路径。
我现在做 Code128 相关改动时,固定流程永远是:改参数 → 跑回归 → 看中间图 → 确认没有劣化。这套习惯帮我挡掉了太多只在特定图片上翻车的问题,也希望帮到你。
本文还有配套的精品资源,点击获取