简介:这份源码工具面向需要在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> <groupId>com.google.zxing</groupId> <artifactId>core</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.google.zxing</groupId> <artifactId>javase</artifactId> <version>3.5.3</version> </dependency>core提供解码核心,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 Map<DecodeHintType, 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 List<Mat> 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 的行作为水平切分线 List<Integer> rowCuts = findZeroRuns(rowProj, bin.cols(), 0.02); List<Mat> 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 List<Integer> findZeroRuns(Mat proj, int totalCols, double ratio) { List<Integer> 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)); Map<DecodeHintType, 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 多码结果的去重与排序
切分后可能出现同一个码被两个相邻子块都解出来的情况,需要按文本去重。另外结果顺序最好按码在图中的位置排,方便和业务数据对齐。
List<Result> all = new ArrayList<>(); Set<String> 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")); List<String> 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 里的格式限定。这个验证习惯帮我省了很多次上线后才发现漏码的后悔药。
最后说个血泪经验:多码识别里最耗时间的不是写代码,是调二值化和切分参数。不同来源的图差异极大,别指望一套参数打天下。我现在的做法是把二值化方法和切分阈值做成配置项,按图源分组,每组单独调。希望帮到你。
本文还有配套的精品资源,点击获取