简介:面向Java与Android开发者的ZXing多二维码识别示例工程源码,聚焦一张图片内同时存在多个二维码时的检测与解析需求,适合图像识别入门者及需要批量扫码能力的项目参考。压缩包共14个文件,约12KB,以4个java源码与4个class文件为主体,另含Maven构建脚本、Eclipse工程配置及偏好设置,目录精简,便于导入IDE查看类结构与运行逻辑。资源围绕MultiFormatReader多格式解码、图像灰度化与二值化预处理、循环扫描结果收集及异常处理等关键环节给出可复用实现,也可作为二次开发与算法学习的对照材料。目前已有3792人学习下载,适合希望快速搭建多二维码识别原型或研究ZXing解码流程的开发者。
1. 一张图里七八个二维码,单码 API 为什么只返回一个
用 ZXing 做多二维码识别,很多人第一次都会撞上同一个场景:一张盘点用的货架照片,纸上密密麻麻贴了八个二维码,QRCodeReader跑完却只吐出主条结果。它其实并没有完全失效,只是在其语义中,decode一旦拿到第一个可解结果就会返回,剩下的码根本没有进入后续处理。这时应换用com.google.zxing.multi.qrcode.QRCodeMultiReader的decodeMultiple,它返回Result[],一次找齐。
这套源码工具不复杂:core提供检测与解码算法,javase补上BufferedImage适配,总量适合用一两天摸清。适合做票据、试卷、资产标签批量识别的后台工具开发者,也适合想把 ZXing 嵌进自己流水线的人。下面从依赖和目录拆起,一路写到二次开发。
2. 工程骨架与依赖:core 与 javase 各管什么
先把依赖关系摆清,否则后面调参时会不知道某个类该从哪个 jar 里找。ZXing 把“跟平台无关的算法”全放在core,把“依赖 AWT/ImageIO 的胶水代码”放在javase,这层切分直接决定了你的代码能不能跑到 Android 或服务端无头环境里。
2.1 两个 jar 的职责边界
core里能拿到的东西包括:LuminanceSource、BinaryBitmap、Binarizer一组输入抽象,Reader/MultiFormatReader解码入口,com.google.zxing.multi多码包,以及RGBLuminanceSource这种纯数组像素源。javase里最关键的是一个类:com.google.zxing.client.j2se.BufferedImageLuminanceSource,它把java.awt.image.BufferedImage转成LuminanceSource。另外还有MatrixToImageWriter之类的生成侧工具。
这个边界带来的实际约束是:服务端要做多二维码识别,只要不碰桌面 GUI,javase完全够用;但如果你在 Android 上跑,就不能引javase(AWT 不存在),得自己写一个LuminanceSource子类,或者直接用core里的RGBLuminanceSource,把int[]像素数组喂进去。我一般会在项目里建一个LuminanceSourceFactory,桌面端和服务端各走一个实现,上层解码代码不用改。
多码识别所需的QRCodeMultiReader在core的com.google.zxing.multi.qrcode包下,这意味着提依赖时javase只是“读图方便”,不引它也能跑,只是你得自己造像素源。
2.2 目录结构与 Maven 坐标
一个够用的最小工程长这样,不追求分层,先跑通再谈封装:
zxing-multi-qr/ ├── pom.xml └── src/main/java/demo/qr/ ├── MultiQrScanner.java // 核心:图进,结果列表出 ├── ImagePreprocess.java // 缩放、裁剪、灰度 └── Cli.java // 命令行入口,方便压测pom.xml里两个坐标都写 3.5.x,具体小版本建议跟你们私服锁死,别用浮动版本,否则某天 CI 上突然换了实现,排查起来很难定位:
<dependencies> <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> </dependencies>core是算法本体,没有它连Result类都不存在;javase只在你需要用ImageIO.read读文件时才必须。如果你后面要把识别逻辑抽成公共模块给别的服务用,记得把javase标成provided或者干脆隔离在一个 adapter 模块里,别让它污染核心包。
编译和跑起来的命令很朴素,先用exec-maven-plugin或者直接java -cp都行:
# 编译并打出一个带依赖的 jar,方便丢到服务器上跑 mvn -q clean package dependency:copy-dependencies -DoutputDirectory=lib # 单张图跑一遍,第二个参数是最长边像素上限 java -cp "target/classes:lib/*" demo.qr.Cli ./samples/shelf.jpg 2400这里有一步容易被忽略:dependency:copy-dependencies会把core和javase一起拉到lib/目录下。如果你的图很大、要批量跑几万张,别用java -jar那种 fat jar 方式反复启进程,直接常驻一个 JVM,图路径从标准输入读,能省掉大量类加载开销。
提示:
javase依赖 AWT,在无头服务器上启动时加-Djava.awt.headless=true,否则某些环境下ImageIO初始化会抛异常。
3. 核心链路拆解:从 BufferedImage 到 decodeMultiple
ZXing 的输入链路是固定三段式:像素源 → 二值化 → 位图。多码识别只是在最后一步把Reader换成了QRCodeMultiReader,前面三段完全一致。把这条链路吃透,后面所有调参都有落点。
3.1 三段式输入:LuminanceSource、Binarizer、BinaryBitmap
LuminanceSource的概念是“灰度亮度矩阵”,它只关心每个像素有多亮,不关心颜色。BufferedImageLuminanceSource的构造函数有两个常用重载:一个吃整张图,一个吃(image, x, y, width, height)的裁剪区域。后者在多码场景里非常有用——你已经知道码大致在哪几个区域,就没必要让检测器扫全图。
Binarizer负责把灰度矩阵压成黑白点阵。ZXing 提供两个实现:GlobalHistogramBinarizer用全局直方图取一个阈值,快,但对光照不均的图很脆弱;HybridBinarizer做局部自适应阈值,慢一些,但对手机拍的、带阴影的、反光的图明显更稳。多码识别我基本只用HybridBinarizer,因为一张图里往往同时存在明亮区和阴影区,全局阈值必然牺牲一半。
BinaryBitmap只是把上面两者包一起,对外暴露getBlackMatrix()和getBlackRow()。注意它是有状态的缓存对象,同一个BinaryBitmap实例不要在多线程里共享,每个线程自己 new 一个。
BufferedImage image = ImageIO.read(new File(path)); // 整图进;若已知区域,用 (image, x, y, w, h) 裁剪,能显著减少误检 LuminanceSource source = new BufferedImageLuminanceSource(image); // 光照不均的实拍图用 Hybrid,干净截图可用 GlobalHistogram 换速度 BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));ImageIO.read返回null时不会抛异常,而是静默失败,这一点必须显式判空,否则后面 NPE 的位置会离真正的问题很远。new File(path)换成InputStream更好,方便从对象存储直读,但记得自己关流。
3.2 QRCodeMultiReader 内部做了什么
QRCodeMultiReader继承QRCodeReader,同时实现MultipleBarcodeReader接口,核心方法是decodeMultiple(BinaryBitmap, Map<DecodeHintType,?>)。它的执行顺序大致是:
第一步,调MultiDetector.detectMulti(image, hints)。MultiDetector内部复用FinderPatternFinder,先把图里所有疑似“回”字形定位图案找出来,得到一堆候选点;然后对这些候选点做三三组合,用几何关系筛:三个点要构成近似等腰直角三角形,三个点的estimatedModuleSize要接近,斜边上的模块数要符合版本范围。筛出来的每一组,就是一个候选二维码区域。
第二步,对每个DetectorResult调decode(bits, hints),这一步继承自QRCodeReader,会做网格采样、格式信息读取、纠错、数据段解析。解不出来就跳过,不中断。
第三步,如果detectMulti一个都没找到,而 hints 里开了TRY_HARDER且图像支持旋转,会转 90 度再试一次。
这套机制解释了两个高频现象:一是二维码挨得太近、静区(quiet zone)被吃掉时,finder pattern 会被误合并,导致漏检;二是同一张图里如果混了 DataMatrix 或 Aztec,QRCodeMultiReader根本不会看它们,因为它的检测器只认 QR 的定位图案。
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class); hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); hints.put(DecodeHintType.CHARACTER_SET, "UTF-8"); hints.put(DecodeHintType.POSSIBLE_FORMATS, EnumSet.of(BarcodeFormat.QR_CODE)); // 返回数组可能为空,也可能为 null,两者都要判 Result[] results = new QRCodeMultiReader().decodeMultiple(bitmap, hints); if (results == null || results.length == 0) { return Collections.emptyList(); }TRY_HARDER打开后,检测阶段会放宽候选点筛选、尝试更多组合,代价是耗时可能翻两三倍;POSSIBLE_FORMATS限定为QR_CODE能省掉格式试探,在多码场景下是纯收益;CHARACTER_SET只影响字节段到字符串的转换,不影响检测,但填错就是满屏乱码。
3.3 遍历 Result[]:位置、格式、原始字节
拿到Result[]只是开始,真正落地要取三样东西:文本、位置、原始字节。
getText()是解码后的字符串,长度可能为空串(某些纯二进制码)。getBarcodeFormat()用来确认确实是QR_CODE。getResultPoints()返回ResultPoint[],一般四个点,对应三个定位图案加一个校正图案,坐标是相对于传入的BinaryBitmap的像素坐标——如果你前面做过缩放,记得按比例还原回原图坐标,否则在图上画框会整体偏移,这个偏移量很隐蔽。
getRawBytes()拿原始字节,配合getResultMetadata().get(ResultMetadataType.BYTE_SEGMENTS)可以拿到分段字节列表,做二进制载荷(比如 protobuf、加密串)时必须走这条路,别经过getText(),否则字符集转换会破坏数据。
List<Hit> hits = new ArrayList<>(); for (Result r : results) { if (r == null) continue; // 数组里可能有 null 占位 ResultPoint[] pts = r.getResultPoints(); // 取中心点做排序键;pts 可能为空,必须防御 float cx = 0, cy = 0; if (pts != null && pts.length > 0) { for (ResultPoint p : pts) { cx += p.getX(); cy += p.getY(); } cx /= pts.length; cy /= pts.length; } hits.add(new Hit(r.getText(), cx, cy, r.getRawBytes())); } // 按阅读顺序排:先上后下,先左后右 hits.sort(Comparator.comparingDouble((Hit h) -> h.cy).thenComparingDouble(h -> h.cx));Result[]的顺序是检测器内部产生的顺序,跟人眼阅读顺序无关,直接输出会给下游造成困扰。按中心点 Y 再 X 排序是个便宜且有效的近似,如果码排成多行多列,可以先把 Y 按行高聚类再排 X,效果更接近人工标注。getResultPoints()返回空数组的情况真实存在(部分低版本或特殊采样路径),不判空会在生产上偶发 NPE。
4. 参数与预处理调优:hints 里的开关和分辨率
同一张图,参数组合不同,识别率能差出一倍。这一节把 hints 逐个过一遍,再讲分辨率这个最容易被低估的变量。
4.1 每个 DecodeHintType 到底改了什么
| Hint | 作用阶段 | 建议值 | 代价 |
|---|---|---|---|
TRY_HARDER | 检测 + 解码 | 多码场景开 | 耗时 2~3 倍 |
POSSIBLE_FORMATS | 解码入口分派 | EnumSet.of(QR_CODE) | 无,纯收益 |
CHARACTER_SET | 字节段转字符串 | UTF-8,失败再试GBK | 无 |
PURE_BARCODE | 检测 | 多码场景必须关 | 开了会退化成单码 |
ALSO_INVERTED | 检测 | 有反色码时开 | 少量耗时 |
NEED_RESULT_POINT_CALLBACK | 检测 | 调试时用 | 回调频繁,别在生产开 |
PURE_BARCODE是最典型的问题:它假设输入图是一张“干净、居中、只有码”的位图,检测阶段会跳过定位图案搜索,直接用固定算法采样。单码裁剪图用它速度极快,但只要图里有两个码,它就只能给你一个,甚至一个都不给。我见过有项目为了提速全局打开它,结果多码需求上线后识别率断崖式下跌。
ALSO_INVERTED从 3.3 之后才有,用于白码黑底的反色二维码。如果你的物料打印有深色底,这个 hint 值得开,代价可控。
NEED_RESULT_POINT_CALLBACK只在你想知道“检测器到底看到了哪些候选定位点”时才用。排查漏检时把它打开,回调里打印每个点的坐标和模块尺寸,能直接看出是定位图案没被找到,还是找到了但几何校验没过。
4.2 大图缩放与裁剪:先降维再识别
手机直出照片动辄 4000×3000,HybridBinarizer要对整图做局部阈值,内存和 CPU 都不便宜。但缩放不是越小越好:一个 QR 码的单个模块在图上至少要占 2 像素,检测器才能稳定识别定位图案。经验值是——先估算目标码在图上的占比,保证缩完后每个码的边长不低于 80 像素,整体最长边压到 2000 到 3000 之间比较稳。
static BufferedImage scale(BufferedImage src, int maxSide) { int w = src.getWidth(), h = src.getHeight(); double ratio = (double) maxSide / Math.max(w, h); int nw = (int) Math.round(w * ratio), nh = (int) Math.round(h * ratio); BufferedImage dst = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB); Graphics2D g = dst.createGraphics(); // 双线性足够,缩放质量对检测影响小于阈值算法 g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g.drawImage(src, 0, 0, nw, nh, null); g.dispose(); return dst; }TYPE_INT_RGB比TYPE_INT_ARGB更省内存,也不会在透明通道上引入额外的亮度计算。ratio大于 1 时说明原图比上限还小,这时候直接返回原图,别放大——放大不会带来新信息,只会让二值化更糊。
如果业务上能拿到码的大致区域(比如固定版式的表单),用BufferedImageLuminanceSource的裁剪构造器把每个区域单独切出来,逐块调用decodeMultiple,识别率往往比整图检测高得多,因为每个区域里的前景占比更合理,局部阈值也更准。
4.3 编码字符集:UTF-8 与 GBK 的兜底顺序
CHARACTER_SET只影响字节段解释。QR 标准里没有强制的字符集声明(除非用了 ECI 模式),所以同一批码,有的生成器写 UTF-8,有的写 GBK,混在一起很常见。硬编码一个字符集,必然有一批乱码。
稳妥做法是两轮:第一轮用UTF-8解,如果getText()里出现大量替换字符(\uFFFD)或者明显不符合业务字符集,用GBK再解一次。更彻底的方式是不依赖getText(),直接用getResultMetadata().get(BYTE_SEGMENTS)拿原始字节,自己按业务约定的编码解码。
static String decodeWithFallback(Result r) { String t = r.getText(); // 替换字符比例高,说明 UTF-8 解释失败 long bad = t.chars().filter(c -> c == 0xFFFD).count(); if (bad > 0) { byte[] raw = r.getRawBytes(); if (raw != null) { try { return new String(raw, "GBK"); } catch (Exception ignored) { } } } return t; }注意getRawBytes()返回的是整个数据段拼接后的字节,如果码里用了多个字节段(比如 ECI 切换),这样拼会出错,那种情况必须走BYTE_SEGMENTS逐段处理。日常业务码基本用不到 ECI,但做通用工具时要把这个分支留出来。
5. 避坑与排查:多码只出一个、乱码、OOM 的实录
这一章是按线上真实问题倒推的,每条都按现象、原因、解决写,顺序基本等于排查顺序。
5.1 五个高频问题
问题一:图里明明有八个码,只返回一个。
现象是decodeMultiple返回长度 1 的数组,且每次跑结果稳定。原因大概率是开了PURE_BARCODE,或者POSSIBLE_FORMATS设成了MultiFormatReader的默认全集导致先命中某个单码路径。解决:确认 hints 里没有PURE_BARCODE,把POSSIBLE_FORMATS明确设成QR_CODE,再用NEED_RESULT_POINT_CALLBACK打一遍候选定位点,确认检测器确实“看见”了其余几个码。
问题二:码挨得太近时中间几个消失。
现象是边缘的码能识别,中间成排的识别不到。原因是二维码之间的静区不足,FinderPatternFinder把相邻码的定位图案错误合并成一组。解决:调大缩放比(让每个码物理尺寸更大),或者按版式预先切块,对每块单独识别;如果物料还能改,把码间距做到至少 4 个模块宽。
问题三:识别成功但文字是乱码。
现象是getText()返回一堆问号或方块。原因是生成端和识别端字符集不一致。解决:按 4.3 的两轮兜底走,优先用rawBytes自行解码。还有一个隐蔽原因——某些生成器把中文按 UTF-8 写却声明了 ISO-8859-1,这种情况下CHARACTER_SET设什么都没用,只能拿原始字节猜。
问题四:批量跑几千张后 OOM。
现象是 GC 频繁、老年代持续上涨。原因通常不是 ZXing 本身,而是ImageIO的ImageReader缓存没释放,加上每张图都 new 一个大BufferedImage没复用。解决:用ImageIO.getImageReaders手动管理 reader 并dispose(),或者引入ImageIO.setUseCache(false)关掉磁盘缓存;批量任务按 500 张分片,每片结束显式System.gc()只是兜底,真正要解决的是别同时持有原图和缩放图。
问题五:升级 ZXing 版本后decodeMultiple编译不过或者行为变了。
现象是原来返回Result[]的方法签名对不上,或者同一张图结果数量变少。原因是 3.x 中多码包有过接口调整,MultipleBarcodeReader的返回类型和GenericMultipleBarcodeReader的构造参数在部分小版本间有差异。解决:把 ZXing 版本在dependencyManagement里锁死,升级前跑一遍固定样本集的回归,记录每张图的识别数量和文本哈希,差异超过阈值就拦住。
5.2 一段可直接用的排查代码
排查阶段我一般会加一个 debug 开关,把检测到的候选点、结果数量、每张图耗时都打出来,比盲猜快得多:
long t0 = System.currentTimeMillis(); Result[] rs = new QRCodeMultiReader().decodeMultiple(bitmap, hints); long cost = System.currentTimeMillis() - t0; int n = rs == null ? 0 : rs.length; // 一行日志同时带上数量、耗时、图尺寸,便于横向对比 System.out.printf("file=%s size=%dx%d hits=%d cost=%dms%n", file.getName(), image.getWidth(), image.getHeight(), n, cost); for (Result r : rs == null ? new Result[0] : rs) { ResultPoint[] p = r.getResultPoints(); System.out.printf(" text=%s points=%d%n", r.getText(), p == null ? 0 : p.length); }hits和cost这两个指标要一起看:hits少而cost低,说明根本没检测到,问题在预处理;hits少而cost高,说明检测器在大量候选点里空转,问题在图像质量或静区。points长度为 0 的结果要特别留意,说明这个Result的定位信息不完整,后续拿它做坐标标注会出问题。
注意:别在生产日志里打完整
text,二维码里可能是手机号、订单号这类敏感信息,打个长度和哈希前缀就够了。
6. 二次开发:把识别逻辑封装成可复用的批量服务
单次识别的代码不到五十行,真正花时间的是把它变成一个能长期跑的服务。我的改造顺序一般是三步:先抽接口,再做并发,最后补结果治理。
第一步,定义QrScanService接口,输入是InputStream加一组ScanOption(最大边长、字符集、是否允许反色),输出List<QrHit>。把BufferedImageLuminanceSource这一层完全隔离在实现类里,将来换 Android 或换别的图像库,上层不用动。ScanOption用 builder 而不是构造函数,后面加参数不破坏调用方。
第二步,并发。识别是 CPU 密集型,线程数设成核数或核数加一就够,别开太大。用固定线程池配合有界队列,队列满了走拒绝策略而不是无限堆积——图片解码很吃内存,积压几千个任务就是 OOM 前兆。每张图的处理内部不要再开并行,decodeMultiple本身已经是单线程全图扫描。
第三步,结果治理,这里最容易被跳过:
| 治理项 | 做法 | 为什么必须做 |
|---|---|---|
| 去重 | 按text + 中心点取整做 key | 同一码可能被不同定位点组合重复命中 |
| 排序 | 先按 Y 聚类成行,行内按 X | 下游要按阅读顺序展示 |
| 坐标归一 | 缩放系数还原到原图尺寸 | 否则前端画框全偏 |
| 置信度 | 用定位点数量、模块尺寸做粗排 | 低质量结果可让用户人工复核 |
// 去重 + 归一,放在结果返回前统一做一次 Map<String, QrHit> uniq = new LinkedHashMap<>(); for (QrHit h : raw) { // 中心点取整到 10px,避免同码不同采样点被判成两个 String key = h.text + "@" + Math.round(h.cx / 10) + "," + Math.round(h.cy / 10); uniq.merge(key, h, (a, b) -> a.points >= b.points ? a : b); // 保留定位点多的 }验证方法别靠肉眼。我会准备一个固定样本集:三十到五十张图,覆盖单码、多码、密集、模糊、反色、中文这几种情况,每张图人工标注期望的码数量,然后写一个 JUnit 参数化测试断言数量一致、关键文本一致。每次调参或升版本,先跑这个集合,比看日志靠谱得多。样本集要进仓库,别放个人目录里。
血泪经验是:多码识别的参数调优很容易变成玄学,同一组参数在 A 图上升了识别率,在 B 图上又掉下去。真正能守住质量的只有回归样本集和版本锁定这两件事。从我第一次因为升级依赖导致线上识别率掉了三成之后,我每次动 ZXing 版本或者 hints 组合,都强制走一遍那五十张图的回归再合并。希望帮到你。
本文还有配套的精品资源,点击获取