简介:一套直接可用的ZXing多二维码识别工程源码,面向Java或Android开发者,解决一张图片中同时识别多个二维码的常见需求。资源涵盖ZXing集成、图片读取、灰度化与二值化预处理、MultiFormatReader循环解码及异常处理等关键逻辑,通过循环调用解码器直至图像中无剩余二维码,适合作为学习示例或项目原型。包内含14个文件,包括4个Java源文件、4个已编译class文件以及Eclipse工程配置文件(prefs、classpath、project等),另含pom.xml便于构建,整体仅12KB,轻量易部署。目前已有3791人学习浏览,借助这份源码可快速掌握ZXing解码流程,理解decode方法在单图多码场景下的迭代调用方式,为后续构建批量二维码识别工具打下基础。
1. 使用zxing识别一幅包含多个二维码的图片:为什么单码识别翻车,多码要换思路
业务方丢过来一张图,上面贴了六个二维码,需求一句话:使用zxing识别一幅包含多个二维码的图片,识别结果要按“从上到下、从左到右”的顺序吐出来。我一开始以为这就是把单码识别套个 for 循环的事,结果第一次跑就翻车了——zxing 默认的MultiFormatReader在整图上只认出一个码,另外五个跟不存在一样。后来把一个多码识别源码工程拆完才发现,问题不在 zxing 的算法能力,而在调用姿势:单码和多码走的是两套入口,二值化策略、图片尺寸和提示参数都会直接影响召回率。
这份资源是一个可跑的 Java 工程,核心是把官方GenericMultipleBarcodeReader包装成命令行工具,能读出一张图里的多个二维码及其坐标,还附带批量回归脚本和样例图。它解决的不是“能不能识别”,而是“怎么稳定识别、怎么不丢码、结果怎么落库”。适合两类人:一类是刚接触扫码开发,想把多码识别一次跑通的新手;另一类是做单码识别已久、产品突然要求多码识别的熟手,需要一份能调参、能验证、能临时拿去生产的参考实现。
2. 多码识别原理与构建:从单码 Reader 到 GenericMultipleBarcodeReader
2.1 识别主流程:BarcodeFormat、Hint 与 MultiFormatReader
在拆多码之前,先把单码 pipeline 看清楚。zxing 的解码链路是固定的:图像像素转成LuminanceSource,再经过Binarizer得到二值化的BinaryBitmap,最后交给Reader去检测和解码。下面这段是标准单码调用:
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class); hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE)); hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); MultiFormatReader reader = new MultiFormatReader(); reader.setHints(hints); BinaryBitmap bitmap = new BinaryBitmap( new HybridBinarizer(new BufferedImageLuminanceSource(image))); try { Result result = reader.decodeWithState(bitmap); System.out.println(result.getText()); } catch (NotFoundException e) { System.out.println("没有识别到二维码"); }这里有几个关键点。POSSIBLE_FORMATS明确指定只找 QR_CODE,能从根上减少误报;TRY_HARDER相当于让扫描器用更慢但更细的滑窗去全图搜索,对小码、旋转码帮助明显,代价是时间涨一倍以上。decodeWithState会用setHints设置好的参数,直接传bitmap就能解;如果改用了decode(bitmap, hints),每次都要把 hints 传进去,逻辑上更啰嗦。
这套链路本身没有任何问题,但MultiFormatReader.decode的语义就是“返回最可信的一个结果”。同一张图上多码平铺时,它拿到第一个置信度结果就收工了,后面几个码不会继续找。想让一张图输出多个二维码,就要换掉这个入口,或者说换掉阅读器的组织方式。
2.2 多码识别的两条路径:官方包装类和手动裁剪循环
第一条路径是官方提供的GenericMultipleBarcodeReader,使用方式非常直接:
GenericMultipleBarcodeReader multipleReader = new GenericMultipleBarcodeReader(new MultiFormatReader()); Result[] results = multipleReader.decodeMultiple(bitmap, hints); System.out.println("识别到 " + results.length + " 个码"); for (Result r : results) { System.out.printf("%s | %s | 定位点数量=%d%n", r.getText(), r.getBarcodeFormat(), r.getResultPoints().length); }decodeMultiple的内部逻辑不是玄学:它先解出一个码,拿到这个码的ResultPoint定位点后,估算出码所在区域,再从整图里把这一块屏蔽掉,然后继续解剩下的图,如此往复,直到找不到新码或达到递归深度限制。各版本实现细节有差别,但大体都是“解一个、排除一个”的思路。
正因如此,它有一个先天弱点:如果两个二维码挨得太近,第一步的排除区间可能会把第二个码的静区(quiet zone)切掉,导致第二个码解不出来。我遇到这种情况时会叠加一条手动路径——自己维护一个“涂白循环”:
private List<Result> manualMultiScan(BufferedImage image, MultiFormatReader reader) { List<Result> found = new ArrayList<>(); BufferedImage work = copyImage(image); // 最多尝试 20 轮,避免极端情况下死循环 for (int i = 0; i < 20; i++) { Result r; try { r = reader.decodeWithState(toBinaryBitmap(work)); } catch (NotFoundException e) { break; } found.add(r); // 按定位点外接矩形涂白,4 是留出的安全边距 fillBarcodeRectWhite(work, r.getResultPoints(), 4); } return found; }手动涂白比裁剪更省事:坐标体系还是原图的,不用做坐标换算;涂白半径可调,遇到漏码时把 margin 从 4 加到 6 或 8 就能避开“切除静区”的坑。代价是如果 margin 调太大,两个相邻的码可能被一块白斑盖住。实际项目中我通常先跑官方decodeMultiple,漏码了再用这条手动路径补刀,两份结果合并去重。
2.3 工程结构:把多码识别工具跑起来
资源包解压后是一个标准 Maven 工程,核心文件不多:
multi-qr-demo/ ├── pom.xml ├── src/main/java/demo/ │ ├── MultiQRMain.java # 入口,读取图片并打印结果 │ ├── ImageUtils.java # 灰度化、铺白底、涂白工具 │ └── MultiQRTest.java # JUnit 回归测试 └── sample/ ├── six-codes.png # 六码平铺图 ├── rotated-and-blur.png # 带旋转和轻微模糊 └── row-codes.jpg # 横排三码图依赖只有 zxing 的两个包:
<dependency> <groupId>com.google.zxing</groupId> <artifactId>core</artifactId> <version>${zxing.version}</version> </dependency> <dependency> <groupId>com.google.zxing</groupId> <artifactId>javase</artifactId> <version>${zxing.version}</version> </dependency>core是所有解码逻辑所在,javase提供BufferedImageLuminanceSource、MatrixToImageWriter这类 J2SE 专用类。如果后续要移植到 Android,javase要删掉,灰度化改成自己的RGBLuminanceSource实现。
运行入口在MultiQRMain,命令行指定图片路径即可:
mvn -q compile exec:java -Dexec.mainClass=demo.MultiQRMain \ -Dexec.args=sample/six-codes.png正常输出是每行一个码:文本内容、码制、定位点坐标。这套结构中ImageUtils承担了所有“脏活”,下一章就讲它处理的那些图像问题。
3. 图像预处理与参数调优:分辨率、二值化与旋转的影响
3.1 从 BufferedImage 到 RGBLuminanceSource:位深、Alpha 与颜色空间
zxing 不直接认识BufferedImage,它只认LuminanceSource——一个只含亮度信息的像素源。最常见的做法是取像素数组后直接构造:
private static BinaryBitmap toBinaryBitmap(BufferedImage image) { int w = image.getWidth(); int h = image.getHeight(); int[] pixels = image.getRGB(0, 0, w, h, null, 0, w); return new BinaryBitmap( new HybridBinarizer(new RGBLuminanceSource(w, h, pixels))); }getRGB返回的 int 数组是 ARGB 格式,RGBLuminanceSource内部会从 R、G、B 通道计算灰度。这里藏着一个很坑的细节:一张带 Alpha 通道的 PNG,如果某个区域是半透明或者全透明,getRGB依然会把 RGB 三通道的值填进去,但很多前端导出工具在透明区域会把 RGB 置为 0(黑色)。整张图丢进 zxing 后,透明区域变成大片黑块,二值化直接把附近的二维码边缘连成一片。
我处理这种图的做法是先铺白底,再取灰度:
// 半透明 PNG 先铺白底,避免透明像素被当成黑色 BufferedImage flatten = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); Graphics2D g = flatten.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0, 0, w, h); g.drawImage(image, 0, 0, null); g.dispose();注意TYPE_INT_RGB本身会丢弃 Alpha,这一步顺带把颜色模型统一了,后续识别结果在不同 JDK 上更一致。ImageIO.read读同一张 JPG,在不同系统上可能返回TYPE_INT_ARGB、TYPE_3BYTE_BGR等不同模型,统一成TYPE_INT_RGB是成本最低的“去平台化”手段。
另外,二值化器有两个常见选择:HybridBinarizer和GlobalHistogramBinarizer。前者按局部区域算阈值,对光照渐变、手机照片表现好;后者全图统一阈值,速度快但遇到明暗不均就断码。数据包里的样例图都是干净合成图,两种都能过;真实业务图建议默认HybridBinarizer。
3.2 Hint 参数表:哪些参数真正影响多码召回
多码场景下,hint 参数不是随手填的。我把实际项目里用过的一组参数整理一下:
| Hint | 推荐值 | 作用 | 注意 |
|---|---|---|---|
POSSIBLE_FORMATS | [QR_CODE] | 限定只在二维码中找,防止误识别条形码 | 如果图里混有 Data Matrix,就把它也加进去 |
TRY_HARDER | true | 用小窗口全图重扫,提升小码/旋转码召回率 | 大图下耗时接近翻倍 |
CHARACTER_SET | "UTF-8" | 决定文本解码字符集 | 不设置时中文内容大概率乱码 |
NEED_RESULT_POINT_CALLBACK | true | 解码过程中实时回调定位点 | 调试用,生产环境没必要常开 |
TRY_HARDER是双刃剑。一张 2000px 宽的六码图,关掉它可能 400ms 解完,但横排最右边的码偶尔丢失;打开它大概率全收,但耗时跳到 800ms 以上。如果图片本身由程序生成、码足够大,关掉即可;如果图来自手机拍摄或截图压缩,必须打开。
还有一个容易被忽略的点:单个码的模块(module)宽度。zxing 对“码内最小单元在图像上的像素宽度”很敏感,模块宽度低于 2px 时,TRY_HARDER也救不回来;反过来,整张图 4000px、一个码占满半张图时,直接识别会非常慢。常见做法是先做一次等比缩放,让图的短边落在 1000 到 1500px 区间,再开TRY_HARDER。缩放算法我一般用最近邻(TYPE_NEAREST_NEIGHBOR),虽然边缘锯齿难看,但二维码是最吃“硬边缘”的图像,双线性插值反而会把模块边界糊掉。
3.3 参数组合实测:一张六码图的成功率对比
用sample/six-codes.png做了一组典型观测,以下是我的实测趋势,具体数值因机器而异但也大致符合这个量级:
| 场景 | 图像宽度 | TRY_HARDER | 识别个数 | 耗时 |
|---|---|---|---|---|
| 原始六码图 | 1536 | 关 | 5 | ~320ms |
| 原始六码图 | 1536 | 开 | 6 | ~650ms |
| 等比缩到一半 | 768 | 开 | 6 | ~150ms |
| 原始图加 10px 白边 | 1556 | 开 | 6 | ~680ms |
| 缩到三分之一 | 512 | 开 | 4 | ~70ms |
跑一把自动计时也很方便:
long start = System.nanoTime(); Result[] results = multiReader.decodeMultiple(bitmap, hints); long costMs = (System.nanoTime() - start) / 1_000_000; System.out.println("识别数=" + results.length + ", 耗时=" + costMs + "ms");这组测试说明两件事:第一,TRY_HARDER对漏码有直接帮助,但救不了模块宽度低于阈值的缩略图;第二,给整张图加一圈纯白边框(我习惯加 10 到 20px)能把贴边码的静区补回来,漏码率明显下降。这个白边操作在 ImageMagick 里一行命令就能做,也可以直接在 Java 里用Graphics2D画。
4. 多二维码识别避坑:现象、原因、解决办法
4.1 现象:一张图只返回一个码
这是最常见的坑,多半发生在“我把单码工程的decode换成了decodeMultiple,结果还是只出一个”的场景。
- 原因一:用的是旧版
MultiFormatReader.decodeWithState,没换成包装类,返回逻辑还是“只取一个”。 - 原因二:
decodeMultiple确实跑了,但整张图尺寸太大、TRY_HARDER没开,小码在第一次滑窗里根本没被扫到,排除区域又占了大半张图,后续递归找不到新码。 - 解决:先确认入口换成
GenericMultipleBarcodeReader;再把TRY_HARDER打开;最后给原图加白边。这三板斧能解决八成“只出一个”的情况。剩余的可能是某个码被大块纯色盖住,需要去查原图生成逻辑。
4.2 现象:横排几个码总是漏掉最右边那个
横排三码图,decodeMultiple稳定识别前两个,第三个时灵时不灵。
- 原因:横向排列时,第一个码被排除后,递归裁剪的区间把第二个码的右侧静区一起切掉了,第三个码紧跟着也受影响;如果三个码间距均匀,排除区间还会和第三个码的探测图形重叠。
- 解决:换手动涂白循环,把 margin 从 4 调到 8,多留静区;再不行就顺手把图旋转 90 度再跑一遍,叠加两份结果。旋转后同一张图会从垂直方向重新切分,能绕开横向裁剪的盲区。
4.3 现象:同一张 JPG 在 Windows 和 Linux 上结果不一致
一张带 ICC 色彩配置的 JPG,在 Windows 上识别出 5 个码,部署到 Linux 变成 3 个,怎么看都像玄学。
- 原因:JDK 的
ImageIO在不同操作系统上解析颜色空间、EXIF 方向的实现有差异,灰度化后部分模块边缘被拉断;加上默认字符集不同,中文内容也可能干扰结果。 - 解决:读图后立刻转成
TYPE_INT_RGB,丢弃 ICC 与透明通道;hint 里显式设置CHARACTER_SET=UTF-8;再用image.getWidth()重新取一遍实际宽高,避免 EXIF 方向导致的宽高互换。做完这三步,跨系统结果基本就一致了。
4.4 现象:中文内容乱码,或者坐标和图上对不上
识别出了码,文本是“锟斤拷”风格,或者按坐标画框画歪了。
- 原因:文本乱码是没设置
DecodeHintType.CHARACTER_SET,zxing 按默认编码解了非 ASCII 内容;坐标对不上是因为用了手动裁剪或多级缩放,ResultPoint存的是裁剪后坐标/缩放后坐标。 - 解决:hint 里补上
CHARACTER_SET=UTF-8;手动裁剪时给结果点加回偏移量:
// 从 cropX, cropY 开始的子图识别后,坐标要平移回原图 ResultPoint translated = new ResultPoint( p.getX() + cropX, p.getY() + cropY);如果做了缩放,所有坐标再乘回缩放比例。这条经验后来成了血泪教训:坐标必须和最终的展示图层处于同一坐标系,否则你在图上画的框和识别结果永远差一段距离。
5. 结果去重与批量回归:把识别能力固化成可交付的形态
5.1 去重与排序:让输出符合业务顺序
decodeMultiple返回顺序由内部递归路径决定,业务上几乎不能用。我会做两层整理:按文本去重,再按坐标排序。
private static int minY(Result r) { return Arrays.stream(r.getResultPoints()) .mapToInt(p -> (int) p.getY()) .min().orElse(0); } Map<String, Result> unique = new LinkedHashMap<>(); for (Result r : results) { // 同一文本视为重复码,保留第一次出现的结果 unique.putIfAbsent(r.getText(), r); } List<Result> ordered = new ArrayList<>(unique.values()); ordered.sort(Comparator .comparingInt((Result r) -> minY(r)) .thenComparingInt(r -> minX(r)));排序用了“先 y 后 x”的策略,符合自上而下、从左到右的常见需求。如果码是卡片式两列排布,按 y 排序后还需要按 y 阈值分组的逻辑,不然同一行左右两列会被 y 坐标的微小差异拆散。
5.2 批量回归:把参数调优变成可验证的流程
多码识别里最怕的就是“这次好了,下次又坏了”。我拿到这份资源后的习惯,是把sample目录当成回归基线,每次改参数都跑一遍全量统计:
int miss = 0; for (String name : expectedMap.keySet()) { BufferedImage img = ImageIO.read(new File(testDir, name)); Result[] rs = recognize(img); if (rs.length != expectedMap.get(name)) { System.out.println("MISS: " + name); miss++; } } System.out.println("漏码样本数: " + miss + "/" + expectedMap.size());expectedMap里保存的是每个人为生成的样例图“应当识别出多少个码”。任何TRY_HARDER、缩放比、双线性插值之类的改动,都必须先过这关再谈优化。从那以后,我每次接多码识别需求都会先建一张“基线测试表”,把固定图、参数组合、期望结果固化下来,改一行参数就全量回归一把。这套流程帮我避开了大半重复踩坑的时间,希望帮到你。
本文还有配套的精品资源,点击获取