资讯详情

zxing多二维码识别:从decode到区域切分与参数调优

📅 2026/10/11 15:01:09 | 华诺云谱 👁 阅读
zxing多二维码识别:从decode到区域切分与参数调优
简介这份源码工具面向需要在Java或Android项目中实现多二维码批量识别的开发者围绕ZXing条码解码库展开解决单张图片中同时存在多个二维码时的检测与解析问题。资源包共14个文件以4个java源文件与4个class编译文件为核心辅以prefs配置、classpath、project及xml等工程描述文件压缩包约12KB属于轻量级Eclipse/Maven工程结构导入后即可查看完整实现。已有3789人学习下载说明该方案在二维码处理场景中具有较高参考价值。读者可从中获取多二维码扫描的代码组织方式、MultiFormatReader解码调用逻辑、图像预处理与循环解码思路以及异常处理与结果提取的实践写法适合用于批量处理含码图片或构建支持多码识别的应用也可作为深入理解ZXing解码流程的入门素材。1. 一张图里塞了十几个二维码zxing 默认只给你一个结果电商大促的素材审核后台经常遇到这种图运营把十几张商品二维码拼成一张长图让你批量提取链接。你拿 zxing 的MultiFormatReader一跑decode返回的永远只有第一个码剩下的像被吞了一样。这不是 zxing 坏了而是默认的decode走的是「找到一个就收工」的逻辑它压根没打算处理多码场景。这篇笔记就围绕「使用 zxing 识别一幅包含多个二维码的图片」这件事把原理、选型、可复现的代码和踩过的坑讲清楚。适合两类人一是做素材审核、票据归档、批量入库的工程师需要从一张图里抠出所有二维码二是刚接触 zxing、被NotFoundException和「只返回一个」折磨过的开发者。核心结论先放这单码用decode多码必须用MultipleBarcodeReader配合GenericMultipleBarcodeReader再叠加自己的区域切分策略否则漏检率会高得让你怀疑人生。2. 多码识别为什么不能靠 decode 一把梭zxing 的查找逻辑与选型2.1 decode 与 MultipleBarcodeReader 的分工差异zxing 的Reader.decode(BinaryBitmap)内部走的是decodeWithState它调用FinderPatternFinder在整张图里找定位图案找到一组就交给Decoder解码成功即返回。整个过程没有「继续找下一组」的循环所以一张图里多个码它只认最先被定位到的那一个。MultipleBarcodeReader是另一个接口实现类GenericMultipleBarcodeReader的思路是先按当前Reader解出一个码拿到它的ResultPoint边界然后把这张图按边界切成若干子区域递归地对每个子区域再解。这样理论上能把多个码逐个挖出来。但它的递归策略比较粗暴对码与码之间间距小、排列不规则的图容易切歪或者重复解同一个码。选型上如果你的图是「规整网格排列、码之间有明显白边」GenericMultipleBarcodeReader直接能用如果是「随意拼贴、码大小不一、间距很小」就得在它外面再包一层自己的区域切分比如先做连通域分析或者按投影切分再对每个子图调单码decode。2.2 依赖引入与最小可运行环境用 Maven 的话核心依赖就一个dependency groupIdcom.google.zxing/groupId artifactIdcore/artifactId version3.5.3/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdjavase/artifactId version3.5.3/version /dependencycore提供解码核心javase提供BufferedImageLuminanceSource这类把BufferedImage转成 zxing 能吃的LuminanceSource的工具。版本上 3.5.x 对多码的支持比 3.3 之前稳定GenericMultipleBarcodeReader的递归深度和切分逻辑有调整。如果你还在用 3.2 的老版本建议先升上来否则下面的一些参数行为会对不上。2.3 一个能跑通的多码识别骨架先给一个最简可运行版本用GenericMultipleBarcodeReader直接解import com.google.zxing.*; import com.google.zxing.client.j2se.BufferedImageLuminanceSource; import com.google.zxing.common.HybridBinarizer; import com.google.zxing.multi.GenericMultipleBarcodeReader; import com.google.zxing.multi.MultipleBarcodeReader; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; import java.util.EnumMap; import java.util.Map; public class MultiQrDemo { public static void main(String[] args) throws Exception { BufferedImage image ImageIO.read(new File(codes.png)); LuminanceSource source new BufferedImageLuminanceSource(image); BinaryBitmap bitmap new BinaryBitmap(new HybridBinarizer(source)); // 关键用 MultiFormatReader 作为底层单码 reader MapDecodeHintType, Object hints new EnumMap(DecodeHintType.class); hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); hints.put(DecodeHintType.POSSIBLE_FORMATS, java.util.Arrays.asList( BarcodeFormat.QR_CODE)); hints.put(DecodeHintType.CHARACTER_SET, UTF-8); MultiFormatReader singleReader new MultiFormatReader(); singleReader.setHints(hints); MultipleBarcodeReader multiReader new GenericMultipleBarcodeReader(singleReader); Result[] results multiReader.decodeMultiple(bitmap, hints); for (Result r : results) { System.out.println(r.getText() r.getResultPoints()[0]); } } }逻辑说明GenericMultipleBarcodeReader构造时接收一个单码Reader这里用MultiFormatReader并限定只解QR_CODE避免它去尝试其他格式浪费时间。decodeMultiple返回Result[]每个Result带文本和定位点。TRY_HARDER打开后底层会做更细的扫描对低对比度或小码有帮助但耗时上升明显。参数说明POSSIBLE_FORMATS一定要限定不限定的话MultiFormatReader会依次尝试所有格式多码场景下耗时翻几倍。CHARACTER_SET设 UTF-8 是为了中文内容不乱码zxing 默认有时会猜成 ISO-8859-1。TRY_HARDER在码数量多、图大的时候建议开但如果你对延迟敏感可以先不开看漏检情况再决定。3. 从「能解一个」到「稳定解全部」区域切分与参数调优3.1 为什么 GenericMultipleBarcodeReader 会漏码GenericMultipleBarcodeReader的递归切分基于已解出码的ResultPoint。它拿到四个角点后会按这些点算出边界框然后把图切成「左、右、上、下」几个区域继续找。问题出在如果两个码挨得很近第一个码的边界框可能把第二个码的一部分包进去导致切出来的子图里第二个码不完整解不出来或者切分后子图里第一个码还在递归又解一遍陷入重复。另一个常见漏检原因是HybridBinarizer对光照不均的图二值化效果差。一张拼图里如果有的码背景偏暗、有的偏亮全局二值化会把暗的那部分直接变成全黑定位图案消失。这时候换GlobalHistogramBinarizer也不一定好更稳的做法是先做局部自适应二值化或者把图分块后分别二值化。3.2 自己切区域按投影和连通域把码分开对拼贴类图片我一般会先做一步预处理把图转灰度做自适应阈值二值化然后按行、列投影找「空白间隔」把图切成若干候选块再对每块单独调decode。这样比GenericMultipleBarcodeReader的递归切分可控得多。import org.opencv.core.*; import org.opencv.imgcodecs.Imgcodecs; import org.opencv.imgproc.Imgproc; import java.util.ArrayList; import java.util.List; public class RegionSplitter { static { System.loadLibrary(Core.NATIVE_LIBRARY_NAME); } public static ListMat splitByProjection(String path) { Mat gray Imgcodecs.imread(path, Imgcodecs.IMREAD_GRAYSCALE); Mat bin new Mat(); // 自适应阈值块大小 25常数 10 Imgproc.adaptiveThreshold(gray, bin, 255, Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY_INV, 25, 10); // 行投影统计每行白色像素数 Mat rowProj new Mat(); Core.reduce(bin, rowProj, 1, Core.REDUCE_SUM, CvType.CV_32S); // 找投影接近 0 的行作为水平切分线 ListInteger rowCuts findZeroRuns(rowProj, bin.cols(), 0.02); ListMat blocks new ArrayList(); int prev 0; for (int cut : rowCuts) { if (cut - prev 20) { // 忽略太窄的条 Mat sub gray.submat(prev, cut, 0, gray.cols()); blocks.add(sub); } prev cut; } if (gray.rows() - prev 20) { blocks.add(gray.submat(prev, gray.rows(), 0, gray.cols())); } return blocks; } private static ListInteger findZeroRuns(Mat proj, int totalCols, double ratio) { ListInteger cuts new ArrayList(); int threshold (int) (totalCols * 255 * ratio); for (int i 0; i proj.rows(); i) { int val (int) proj.get(i, 0)[0]; if (val threshold) cuts.add(i); } return cuts; } }逻辑说明adaptiveThreshold用高斯加权做局部二值化对光照不均的拼图比全局阈值稳。Core.reduce按行求和得到行投影投影值接近 0 的行说明这一行几乎没有白色像素大概率是码之间的空白间隔。findZeroRuns把这些行找出来作为切分线。切出来的每个Mat再转成BufferedImage喂给 zxing 单码decode。参数说明adaptiveThreshold的块大小 25 是经验值图越大可以适当调大但必须是奇数。常数 10 控制阈值偏移值越大二值化后白色区域越少适合背景偏亮的图背景偏暗就调小。ratio0.02 表示投影值小于总列数乘 255 乘 0.02 才算空白行这个值太大会把码内部的行也当切分线太小则切不开一般 0.01 到 0.05 之间调。3.3 切完之后怎么调 zxing 参数对每个子块调decode时hints 要重新设一遍因为MultiFormatReader的 hints 是实例级的复用同一个 reader 时注意别被上一次的 hints 污染。我一般每个子块新建一个MultiFormatReader或者用reset()清状态。private static String decodeBlock(BufferedImage img) { LuminanceSource source new BufferedImageLuminanceSource(img); BinaryBitmap bitmap new BinaryBitmap(new HybridBinarizer(source)); MapDecodeHintType, Object hints new EnumMap(DecodeHintType.class); hints.put(DecodeHintType.POSSIBLE_FORMATS, java.util.Arrays.asList(BarcodeFormat.QR_CODE)); hints.put(DecodeHintType.CHARACTER_SET, UTF-8); hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); try { return new MultiFormatReader().decode(bitmap, hints).getText(); } catch (NotFoundException e) { return null; // 这个块里没有码跳过 } }这里TRY_HARDER对子块可以放心开因为子块面积小耗时可控。CHARACTER_SET固定 UTF-8避免中文链接乱码。如果子块里码是倒置的zxing 默认会尝试旋转但TRY_HARDER打开后旋转尝试更彻底。3.4 多码结果的去重与排序切分后可能出现同一个码被两个相邻子块都解出来的情况需要按文本去重。另外结果顺序最好按码在图中的位置排方便和业务数据对齐。ListResult all new ArrayList(); SetString seen new HashSet(); for (Mat block : blocks) { BufferedImage bi matToBufferedImage(block); String text decodeBlock(bi); if (text ! null seen.add(text)) { all.add(new Result(text, null, null, BarcodeFormat.QR_CODE)); } } // 按 y 坐标排序需要保留位置信息时 all.sort(Comparator.comparingInt(r - r.getResultPoints()[0].getY()));去重键用文本内容因为同一张图里不同码内容相同的概率极低。如果业务上允许重复码可以把去重键换成「文本 位置」。排序依赖ResultPoint如果切分时没保留位置可以自己在切分阶段记录每个块的偏移量排序时用偏移量代替。4. 避坑与排查多码识别里最容易翻车的 5 个点4.1 现象只返回一个码且总是同一个原因直接用了MultiFormatReader.decode而不是MultipleBarcodeReader.decodeMultiple。decode找到第一个就返回不会继续找。解决换成GenericMultipleBarcodeReader包一层或者自己切区域后循环调decode。如果已经用了decodeMultiple还是只返回一个检查是不是POSSIBLE_FORMATS没限定导致底层在尝试其他格式时提前返回了非二维码结果。4.2 现象返回的码数量对但文本乱码原因CHARACTER_SET没设或者设错。zxing 在没有 hint 时会根据字节内容猜编码中文内容经常猜成 ISO-8859-1。解决hints 里显式加DecodeHintType.CHARACTER_SET值设UTF-8。如果码本身是用 GBK 编码生成的那就设GBK这个得看生成端用什么编码。4.3 现象小码解不出来大码正常原因HybridBinarizer对小尺寸码的二值化不够细或者TRY_HARDER没开。解决开TRY_HARDER如果还不行把原图放大 2 到 3 倍再解用双线性插值。放大后码的定位图案更清晰zxing 的FinderPatternFinder更容易命中。注意放大别用最近邻会产生锯齿反而干扰。4.4 现象码挨得近时漏检中间那个原因GenericMultipleBarcodeReader的递归切分把中间码切成了两半或者切分后子图里码不完整。解决改用投影切分或连通域切分确保每个码完整落在至少一个子块里。切分时子块之间留 5 到 10 像素重叠避免码被切在边界上。重叠区域解出的重复码用文本去重。4.5 现象识别耗时随码数量线性增长大图卡死原因TRY_HARDER加上全图递归每个子区域都做全量扫描。解决先切区域再解切完后每个子块面积小TRY_HARDER的代价可控。另外POSSIBLE_FORMATS只留QR_CODE别让它尝试 DataMatrix、Aztec 等格式。如果图特别大先降采样到宽度 2000 像素左右再处理码的定位图案在这个分辨率下通常还能保留。5. 进阶用定位图案先验把多码识别做成流水线上面讲的切分加单码解码已经能覆盖大部分拼图场景。但如果你的图是「码大小不一、旋转角度各异、还有背景干扰」的复杂拼贴可以再往前一步先用 zxing 的FinderPatternFinder把图里所有二维码的定位图案找出来拿到每个码的三个角点再按角点算出每个码的边界框最后对每个边界框单独解码。这样切分依据来自码本身的定位图案比投影切分更准。import com.google.zxing.common.detector.MathUtils; import com.google.zxing.qrcode.detector.FinderPattern; import com.google.zxing.qrcode.detector.FinderPatternFinder; // 假设已有 BinaryBitmap bitmap FinderPatternFinder finder new FinderPatternFinder(); // 这里需要传入 BitMatrix实际使用时从 BinaryBitmap 取 // FinderPatternInfo[] 不是公开 API需要反射或改用 ResultPoint 收集FinderPatternFinder在 zxing 里不是完全公开的 API直接调会有访问限制。更稳的做法是用MultiFormatReader解出第一个码后拿它的ResultPoint作为已知码位置然后在原图上把这些位置涂白再解第二次循环直到解不出为止。这个「涂白再解」的策略实现简单对码之间不重叠的图效果很好。BufferedImage working ImageIO.read(new File(codes.png)); ListString texts new ArrayList(); while (true) { LuminanceSource source new BufferedImageLuminanceSource(working); BinaryBitmap bitmap new BinaryBitmap(new HybridBinarizer(source)); try { Result r new MultiFormatReader().decode(bitmap, hints); texts.add(r.getText()); // 把已解出的码区域涂白 Graphics2D g working.createGraphics(); g.setColor(Color.WHITE); ResultPoint[] points r.getResultPoints(); int minX (int) MathUtils.min(points[0].getX(), points[1].getX(), points[2].getX()); int minY (int) MathUtils.min(points[0].getY(), points[1].getY(), points[2].getY()); int maxX (int) MathUtils.max(points[0].getX(), points[1].getX(), points[2].getX()); int maxY (int) MathUtils.max(points[0].getY(), points[1].getY(), points[2].getY()); g.fillRect(minX - 5, minY - 5, maxX - minX 10, maxY - minY 10); g.dispose(); } catch (NotFoundException e) { break; // 没有更多码了 } }这个循环的终止条件是NotFoundException每解出一个码就把它的边界框涂白下一轮自然去找剩下的。涂白区域往外扩 5 像素避免定位图案边缘残留导致重复解同一个码。MathUtils.min和max是 zxing 自带的工具方法处理多个ResultPoint时比手写循环简洁。验证方法上我一般会准备三类测试图规整网格拼图、随机拼贴、带旋转和缩放的拼贴。每类跑 20 张统计漏检率和误检率。漏检率高于 5% 就回去调切分参数或二值化方法误检率高于 1% 就检查去重逻辑和 hints 里的格式限定。这个验证习惯帮我省了很多次上线后才发现漏码的后悔药。最后说个血泪经验多码识别里最耗时间的不是写代码是调二值化和切分参数。不同来源的图差异极大别指望一套参数打天下。我现在的做法是把二值化方法和切分阈值做成配置项按图源分组每组单独调。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑