技术文章

用 HotPDF 解码 PDF 页面中旋转过的 QR 码

HotPDF 对已加载 PDF 页面里的旋转 QR 符号,做法是在解码器内部把采样到的模块矩阵在全部八个 D4 方向上归一化。对线性条码有效的那套外层旋转重试,对 QR 是行不通的;弄明白为什么,能省下你去追查一个「看起来坏了其实没坏」的解码器的一整天

场景再普通不过。扫描的送货单以 PDF 形式进来,每页贴着一张 QR 标签,而扫描员是按进纸托盘怎么方便怎么塞的一摞纸。有的标签正着,有的偏了九十度,还有几张是倒着的。你调用条码解码器,一半页面解析出来了,另一半什么都不返回,而且没有任何报错

为什么旋转扫描掩模永远修不好旋转的 QR?

因为 QR 的定位图形(finder pattern)布局是刻意不对称的,而整图旋转只是把这种不对称原样搬走,并没有消除它。QR Code 在左上、右上和左下三个角放定位方块,右下角留空(ISO/IEC 18004:2015 第 6.3.3 节)。那个空角就是方向线索。把页面位图转九十度,缺口只是挪到另一个角而已。平面上不存在任何非平凡旋转能把三角色标布局映射回它自己,所以只认规范排布的解码器会把每一次尝试依次拒绝

这一点之所以重要,是因为显而易见的修法恰恰是错的。本能反应是把重试挂在外层:渲染页面,把掩模交给解码器,失败就把掩模转一下再来,90、180、270 度轮着试。对 Code 39 这套策略完全正确,因为线性条码有起止模式,只要条的方向转平了扫描器就能找到。对 QR,这是四次注定失败,外加一份「未发现」的报告

D4 群,作用在模块矩阵上

归一化的正确位置在采样之后,作用在布尔模块网格上,而不是像素掩模上。解码器把符号解析成 nn 的明暗模块矩阵之后,就可以枚举正方形的二面体群:四次旋转乘两种镜像,共八个候选方向。对每个候选检查定位三角形,第一个把三个 finder 落在左上、右上、左下的候选就是真实方向。从这里开始,既有流水线原样运行,因为格式信息位、锯齿形数据排布和 Reed-Solomon 纠错全都假设拿到规范矩阵,现在它们确实拿到了

同一 HotPDF QR 模块矩阵在 D4 群 0、90、180、270 度旋转下的四次渲染,展示三个定位图形随空角一起在角落间迁移,因此只有规范方向会把 finder 呈现在解码器要求的左上、右上和左下
旋转像素掩模无法消除 QR finder 的不对称,所以 HotPDF 在采样后的模块矩阵上枚举 D4 方向,保留第一个 finder 落在左上、右上、左下的候选

有两个性质让这一切很便宜。矩阵比起渲染位图小得多,八次转置远比八次页面渲染便宜。而且矩阵是采样器构造的干净布尔数组,任何变换都不会引入从未采样过的值

版本检测是整除搜索,不是一次除法

模块数不能拿采样宽度除以一个假设的模块尺寸得出来,弄错这一点是高分辨率渲染下解码失败的隐蔽来源。版本 v 的 QR 符号横向是 4v + 17 个模块,所以版本 1 是 21 个模块,版本 40 是 177。一个量出来 126 像素宽的掩模,既符合「版本 1、每模块 6 像素」,也同样符合「若干更高版本、模块更小」。线性除法会挑其中一个,而且通常是错的

行得通的是对候选版本做整除搜索。从版本 40 向下走到版本 1,保留那些模块数能整除采样宽度、且每模块至少还有三个像素的候选,取其中最小的版本。三像素下限防止搜索把一个粗糙符号读成荒谬的高密度版本,最小版本规则则解决了剩余的歧义,把答案收敛到扫描器实际会产出的那个读数

HotPDF 对 126 像素采样掩模上 QR 符号的版本检测走查:从版本 40 向下到版本 1 逐一测试候选模块数 4v 加 17 的整除性与三像素模块下限,最终由幸存的最小版本胜出
QR 模块数来自对候选版本的整除搜索,不是拿掩模宽度除以假设的模块尺寸;幸存的最小版本负责消除歧义
var
  Pdf: THotPDF;
  Options: THPDFBarcodeDecodeOptions;
  Codes: THPDFDecodedBarcodes;
  Info: THPDFBarcodeDecodeInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('delivery-notes.pdf');
    Options := THPDFBarcodeDecodeOptions.Default;
    Options.DPI := 300;
    Options.RotationPolicy := bdrpFallback;
    Options.MinimumConfidence := 0.5;
    Options.MaxResults := 16;
    if Pdf.DecodeLoadedPageBarcodes(0, Options, Codes, Info) then
      for I := 0 to High(Codes) do
        if Codes[I].Symbology = bsyQRCode then
          Writeln(Codes[I].Text, '  at ',
            Format('%.0f', [Codes[I].OrientationDegrees]), ' degrees');
  finally
    Pdf.Free;
  end;
end;

THPDFBarcodeDecodeOptions.Default 返回的是填好默认值的记录,不是全零记录——这一点很重要,因为 DPI 为零或结果上限为零是一种「看起来合法、实际上什么都拿不回来」的写法。RotationPolicy 只管外层重试:bdrpNone 只渲染一次,bdrpFallback 在首轮失败后换方向重试,bdrpAll 无条件渲染所有方向。因为 QR 归一化发生在解码器内部,三种策略下 QR 页面都在第一次尝试就解析成功。这个策略是给真正需要它的线性条码准备的

怎么证明一次位图变换没有凭空造像素?

数一数两边的墨点,要求总数一致。旋转就是像素的一个置换,仅此而已,所以输出中非零单元格的数量必须等于输入中的数量。当外层重试路径里的一次掩模旋转报告进去 4800 个置位单元格、出来 7439 个时,仅这一项对比就足以给这个变换定罪,一行几何代码都不用读

原因很平庸,但值得提炼成一条规则。用 SetLength 定尺寸的动态数组,作为函数结果、沿着一条运行时不清零的路径返回时,不保证是零;旋转从未写过的单元格就带着原来残留在那里的字节。其中一些陈旧字节非零,而非零就意味着墨。修复是一行代码:在置换循环开始前执行 FillChar(Result[0], N, 0)。它背后的纪律更广:任何返回掩模或位图缓冲的函数都应该显式清空输出,而不是指望分配语义

比缺陷本身更有意思的是它怎么活过了三个版本。QR 把方向处理搬进解码器之后,QR 就完全不再经过外层掩模旋转,那条代码路径仅剩的消费者是 Code 39。共享基础设施就是这样藏 bug 的:一个功能的覆盖率让某条路径看起来被测过了,而真正依赖它的那个功能自己一个测试都没有。每个被新功能弃用的路径,都需要一个仍在使用它的测试

在页面坐标里读回结果

解码器产出的每个几何值都表示在尝试位图的坐标系里,而调用方需要的是 PDF 用户空间。转换分两步:先撤销重试施加的四分之一圈旋转,再撤销把用户空间映射到位图的渲染变换。写进 THPDFDecodedBarcode 的是用户空间里的轴对齐包围盒,LeftBottomRightTop 遵循 Y 轴向上的 PDF 约定,外加一个逆时针的 OrientationDegrees

HotPDF 条码流水线:从渲染页面位图出发,经采样得到布尔模块矩阵,做 D4 归一化、整除版本检测和 Reed-Solomon 解码,再经两阶段坐标转换撤销重试旋转与渲染变换,最终 THPDFDecodedBarcode 在用户空间发布 Left、Bottom、Right、Top 和 OrientationDegrees
解码器内部的 QR 归一化让页面在第一次尝试就解析成功;两阶段坐标转换把尝试位图里的结果变成用户空间的轴对齐包围盒

第二步转换的方向搞反了,症状会很恶心:文本完美解码,而你给审阅叠加层画的框落在正确位置的镜像上。任何要在解码器之上搭审阅界面的人都应该对着一个已知夹具做断言——故意把符号放在某个页面角落附近,Y 轴一旦翻转就一目了然。同样的推理适用于一切跨渲染边界的坐标,这也是为什么在解码器之上动手之前,值得先读一读 在 Delphi 中把 PDF 页面渲染成位图

内置解码器做什么,不做什么

内置解码器是一个有边界的、无依赖的实现,它对自身极限很诚实,不会悄悄降级。它识别 Code 39 和 QR,在发布任何数据之前先验证受 BCH 保护的格式位和掩模图案,并且不对受损符号做纠错恢复。如果你的输入是光照不均的曲面标签照片,那是另一个问题类别,需要专用引擎

// 换上你自己的引擎:实现 IHPDFBarcodeDecoder,把它传给带
// 解码器参数的重载。页面渲染、预算、坐标映射和去重
// 仍归 HotPDF 管
if not Pdf.DecodeLoadedPageBarcodes(PageIndex, MyDecoder, Options,
     Codes, Info) then
  case Info.Status of
    bdsBudgetExceeded:
      Log('raise MaxPixels or lower DPI: ' + string(Info.Diagnostic));
    bdsRenderError:
      Log('page did not render: ' + string(Info.Diagnostic));
    bdsDecoderError:
      Log(string(Info.DecoderName) + ' failed: ' + string(Info.Diagnostic));
  end;

THPDFBarcodeDecodeInfo 是生产流水线真正挣回成本的地方。RotationAttemptCountDecoderCallCount 告诉你外层重试到底跑没跑;ReceivedResultCount 对照 AcceptedResultCount 能区分「解码器一无所获」和「置信度阈值把它找到的全拒了」;RenderedPixelsPeakWorkingBytes 是批处理任务开始抖动时你该看图的指标。空结果集加 bdsSucceeded 意味着页面真的没有可读符号——这与 bdsBudgetExceeded 是两个完全不同的运维事实

预算字段值得一个深思熟虑的决定,而不是随手用默认。MaxPixelsMaxWorkingBytes 存在的理由是 DPI 按平方放大:A4 页面从 300 DPI 提到 600 DPI,渲染成本和峰值分配都翻四倍,而一个声明了巨大页面尺寸的不受信任输入能把一次扫描作业变成一次内存耗尽事故。把上限设成你最坏合法文档所需要的值,然后让 bdsBudgetExceeded 把离群值路由到更慢、更隔离的路径

如果你的文档把机器可读标签和打算索引的印刷文本混在一起,条码解码器与 HotPDF 内置模板匹配 OCR里的识别引擎天然成对;同一故事的生成侧在 用 HotPDF 把条码画进 PDF。两者跑在同一套渲染与预算基础设施上,所以已经为其中一个设好了合理上限的流水线,几乎免费得到另一个

旋转容错属于那种工作时没人注意、失灵时让人暴怒的功能,而它的工程教训可以推广到 QR 之外:归一化要尽量贴近语义表示去做,而不是在像素层做——在那里数据还带着捕获过程留下的全部偶然。HotPDF 把它作为 HotPDF Delphi PDF component 的一部分发布,同一批接入流水线通常需要的渲染、OCR 和页面分析组件都在旁边