技术文章

HotPDF OCR 文本层偏移:经 CropBox 做坐标映射

把位图像素映射回页面时,若走的是 MediaBox 而不是渲染器实际栅格化的那个盒子——即裁到 MediaBox 之内的 CropBox(ISO 32000-1 §14.11.2)——OCR 文本层、条码边界和人脸脱敏框就会在裁切过的 PDF 页面上漂移。HotPDF 在 v2.770.153 修好了 ApplyLoadedOCRTextLayer,在 v2.770.154 修好了 DecodeLoadedPageBarcodes 和 DetectLoadedRedactionFindings

通常送上门的 bug 报告长这样:一份扫描合同档案过完 OCR,输出可搜索了,但某个条款编号的搜索命中高亮在印着编号下方偏左半英寸的地方。批次里大多数文件没事,出问题的全都来自同一台扫描仪——它写 /CropBox 来裁掉平板边缘。就是这一个细节,把 OCR 引擎看到的画面与文本层落进去的框分了家;同样的错位也挪动条码边界,更要命的是挪动人脸脱敏框

OCR 文本层为什么会漂离扫描出的文字?

文本层漂移,是因为流水线的两半对「位图盖住哪个矩形」各执一词。v2.766.64 里,HotPDF 让渲染、SVG 导出、查看器和打印都尊重 CropBox:页面按裁到 MediaBox 之内的 CropBox 显示——ISO 32000-1 §14.11.2 正是这么规定的——并新增 GetLoadedPageVisibleBox 返回这个可见盒。识别特性却继续用 GetLoadedPageBox(PageIndex, pbMediaBox, ...) 构建设备到页面的变换。栅格盖的已是可见盒,变换仍假设 MediaBox,于是每个识别出的位置都偏移两个盒子之间的那道缝

所以受影响的窗口很精确。ApplyLoadedOCRTextLayer 从 v2.766.64 到 v2.770.152 放错文本。整页 DecodeLoadedPageBarcodes 与 DetectLoadedRedactionFindings 里的人脸检测多错一个构建,直到 v2.770.153。v2.766.64 之前,渲染器画整个 MediaBox,映射与栅格一致,代价是识别了查看器根本不显示的内容。每个特性的修复同时改三件事:变换、像素预算估算,以及请求记录里交给自定义引擎的页面盒

有几种情况从未受影响:

  • 没有 /CropBox 的页面,或 CropBox 等于 MediaBox 的页面,修复前后映射完全相同
  • 设了 HasRegion 的 DecodeLoadedPageBarcodes 只渲染你传入的区域,也经同一区域映射,所以显式区域解码自始至终正确;区域必须在页面之内的检查仍用 MediaBox
  • 基于模式的脱敏发现(邮箱、卡号之类)来自用户空间的文本提取,不来自栅格,所以只有人脸检测的发现挪了位置

三个坐标系,以及哪些 HotPDF API 用哪个

碰识别的 HotPDF 代码要跟三个坐标系打交道,大多数映射 bug 都出在把其中两个混用

  • 位图像素:左上原点,Y 向下增长,单位是请求 DPI 下的像素。THPDFOCRWord.Left、Top、Right 和 Bottom 在这个系里,可选的基线点、自定义 IHPDFBarcodeDecoder 返回的结果、自定义 IHPDFFaceDetector 给出的框都在这个系里
  • 已加载页面的 PDF 用户空间:左下原点,Y 向上增长,单位磅,且 Bottom < Top。GetLoadedPageBox 与 GetLoadedPageVisibleBox 返回这个系里的 Left、Bottom、Right、Top,THPDFOCRRequest 的 PageLeft、PageBottom、PageRight 与 PageTop 字段、THPDFDecodedBarcode 的边界和 THPDFRedactionFinding 里的矩形也是
  • HotPDF 页面绘制坐标:用来构建新页面(文本输出、形状、条码、链接、表单字段)的 API 用左上原点、Y 向下增长。那个系属于文档生成,与上面的已加载文档 API 毫无关系,所以永远别把已加载页面的用户空间矩形原样喂给它

OCR 单词条目刻意以像素为基:引擎报告它在图像里看到的东西,转换归 ApplyLoadedOCRTextLayer 管。这个分工只有在转换用对盒子时才成立——v2.770.153 恢复的正是这一点

HotPDF 识别坐标系:THPDFOCRWord 框与自定义解码器用的左上原点位图像素,GetLoadedPageBox 与 GetLoadedPageVisibleBox 返回的左下原点 PDF 用户空间,以及绝不能原样接收已加载页面矩形的左上原点页面绘制 API
引擎报告像素,因为那是它们看到的;映射由 HotPDF 做;把两个系混用,就是文本层与脱敏框漂移的原因

OCR、条码与人脸背后的设备到页面变换

HotPDF 用一个由五个输入构建的仿射矩阵把位图像素映射到页面:旋转、缩放 DPI / 72、位图高度,以及渲染盒的 Left、Bottom、Right、Top。OCR、条码解码和人脸检测共用同一个例程,所以一个错的盒子输入以同样的方式砸中三者。对未旋转的页面,页到设备的矩阵 [A B C D E F] 是:

  • A = Scale 且 D = -Scale,其中 Scale = DPI / 72;负的 D 把用户空间(Y 向上)翻成位图空间(Y 向下)
  • B = C = 0,因为未旋转的页面没有剪切、也没有轴交换
  • E = -Left * Scale,把盒子的左边缘挪到像素第 0 列
  • F = BitmapHeight + Bottom * Scale,把盒子的下边缘映射到 y = BitmapHeight,也就是位图下边缘,于是上边缘落在第 0 行

像素经那个矩阵的逆回到页面。Request.PageRotation 携带页面的 /Rotate,归一化成 0、90、180 或 270(凡不是 90 的倍数的值一律当 0),渲染器按 ISO 32000-1 §7.7.3.3 的要求顺时针转页。旋转之下轴会交换,钉在位图原点上的盒子边缘换成另一对。写成逆公式,记 S = DPI / 72、x 与 y 为像素、H 为位图高度:

/Rotate页面 X页面 Y映射依赖的盒子边缘
0Left + x / SBottom + (H - y) / SLeft、Bottom
90Left + y / SBottom + x / SLeft、Bottom
180Right - x / SBottom + y / SRight、Bottom
270Right - y / STop - x / SRight、Top

最后一列解释了这个 bug 在生产里为什么看着随机。只裁掉页面顶部的 CropBox 不动 Left 和 Bottom,所以正立页面完美无缺,只有带 /Rotate 270 的页面漂移。旋转还交换位图尺寸:90 和 270 度时,位图宽 (Top - Bottom) * S 像素、高 (Right - Left) * S 像素

HotPDF 页面旋转的逆映射表:/Rotate 0 与 90 时变换钉住渲染盒的 Left 和 Bottom 边缘,180 时钉住 Right 和 Bottom,270 时钉住 Right 和 Top——混合文档里裁切页面因此朝每个方向各漂各的
页面带着不同的 /Rotate 值时,同一道半英寸裁切看起来像三个不同的 bug,因为每个朝向钉住的是不同的一对盒子边缘

MediaBox [0 0 612 792] 配 CropBox [36 36 576 756] 会出什么事?

每边裁半英寸时,若用 MediaBox,未旋转页面的文本层恰好落在扫描文字左方 36 磅、下方 36 磅处。取一页 US Letter,CropBox 从每边裁掉 36 磅(0.5 英寸)。可见盒 540 × 720 磅,于是在默认 OCR 分辨率 300 DPI 下缩放为 300 / 72 ≈ 4.1667,位图 2250 × 3000 像素

假设引擎报告一个词,像素框 Left 450、Top 600、Right 900、Bottom 660,无基线。HotPDF 随后把基线放在底边之上词高的 20% 处,即像素行 648,并映射起点 (450, 648):

  • 走可见盒:x = 36 + 450 / 4.1667 = 144.0,y = 36 + (3000 - 648) / 4.1667 = 600.48,正是这个词印着的地方
  • 走 MediaBox:x = 0 + 108.0 = 108.0,y = 0 + 564.48 = 564.48,整体偏移 (-36, -36) 磅
HotPDF 的 CropBox 漂移剖析,US Letter 页面、MediaBox 0 0 612 792、CropBox 36 36 576 756:渲染器以 300 DPI 栅格化可见盒,词像素 450 经 GetLoadedPageVisibleBox 映射得 144.0 与 600.48,而 MediaBox 变换落在 108.0 与 564.48
栅格盖的是 CropBox,所以任何从 MediaBox 构建的变换都会把每个识别出的词恰好挪动裁切边距那么远

把同一页转起来,误差方向就变了,因为牵涉的边缘不同。/Rotate 180 时 X 项用的是 Right,612 顶替 576 把文本层往右推 36 磅,而 Bottom 仍往下拉 36 磅。/Rotate 270 时 Right 和 Top 都偏大,文本层右移 36 磅、上移 36 磅。朝向混杂的文档能朝三个方向漂——这是这个 bug 的可靠指纹。自己手写、从盒子推导缩放的代码,比如 Bitmap.Width / (Right - Left),还会在偏移之外把每个坐标拉伸 612 / 540,约 13%

你的哪些 PDF 文档受影响?

至少一页的可见盒与 MediaBox 不同,这份 PDF 文档就有暴露面,而 HotPDF 用几行代码就能告诉你。对每一页,把 pbMediaBox 的 GetLoadedPageBox 与 GetLoadedPageVisibleBox 做比较,同时打印 GetLoadedPageRotation,就能按上表预测漂移方向。THPDFPageBoundary 还有 pbCropBox、pbBleedBox、pbTrimBox 和 pbArtBox,但 GetLoadedPageBox(pbCropBox) 在没有裁切盒时回退到 MediaBox,也不做裁剪,所以该比较的对象是可见盒

uses
  System.SysUtils, HPDFDoc;

procedure ReportCroppedPages(const FileName: string);
var
  Pdf: THotPDF;
  I: Integer;
  ML, MB, MR, MT, VL, VB, VR, VT, Tmp: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) < 1 then
      raise Exception.Create('Cannot load ' + FileName);
    for I := 0 to Pdf.LoadedPageCount - 1 do
    begin
      if not Pdf.GetLoadedPageBox(I, pbMediaBox, ML, MB, MR, MT) then
        Continue;
      // 存储的数组可能以任意顺序列角点
      if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
      if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
      // 已归一化并裁剪到 MediaBox 之内
      if not Pdf.GetLoadedPageVisibleBox(I, VL, VB, VR, VT) then
        Continue;
      if (Abs(VL - ML) > 0.01) or (Abs(VB - MB) > 0.01) or
         (Abs(VR - MR) > 0.01) or (Abs(VT - MT) > 0.01) then
        Writeln(Format('Page %d  MediaBox [%g %g %g %g]  visible [%g %g %g %g]  /Rotate %d',
          [I + 1, ML, MB, MR, MT, VL, VB, VR, VT,
           Pdf.GetLoadedPageRotation(I)]));
    end;
  finally
    Pdf.Free;
  end;
end;

对这样的脚本,GetLoadedPageVisibleBox 有两个细节要紧。函数失败时不碰它的 out 参数,所以调用前预设一个默认页面尺寸是安全做法。畸形 CropBox 与 MediaBox 完全不相交时,函数返回 MediaBox 而不是空矩形。报告列出了页面、而你的部署构建对 OCR 早于 v2.770.153、对条码与人脸早于 v2.770.154 的话,升级后对这些页面重跑识别。受影响构建提交的 OCR 层已经留在保存的文件里,默认的 SkipPagesWithText 选项会让第二轮跳过那些页面,除非你关掉它或先移除旧层

自定义 IHPDFOCREngine 该怎么把像素映射回 PDF 空间?

自定义 IHPDFOCREngine 应当以位图像素返回词框、把映射交给 HotPDF;只为自己的判断逻辑转用户空间,而且要用请求里的盒子,绝不用 MediaBox。从 v2.770.153 起,请求的 PageLeft、PageBottom、PageRight 和 PageTop 描述的就是渲染出的可见盒,与 Request.Bitmap 精确对应。下面的辅助函数是库变换的逆,包括它对正立页面使用实际位图高度这一点,所以与 HotPDF 逐像素一致

uses
  System.SysUtils, System.Math, Vcl.Graphics, HPDFDoc;

// 位图像素(左上原点,Y 向下)转 PDF 用户空间
// (左下原点,Y 向上),经位图渲染时所用的盒子
procedure HotPixelToPage(Rotation, DPI, BitmapHeight: Integer;
  Left, Bottom, Right, Top: Single; X, Y: Double;
  out PageX, PageY: Double);
var
  S: Double;
begin
  S := DPI / 72.0;
  case Rotation of
    90:  begin PageX := Left + Y / S;  PageY := Bottom + X / S; end;
    180: begin PageX := Right - X / S; PageY := Bottom + Y / S; end;
    270: begin PageX := Right - Y / S; PageY := Top - X / S; end;
  else
    PageX := Left + X / S;
    PageY := Bottom + (BitmapHeight - Y) / S;
  end;
end;

引擎内部需要用户空间的现实理由是区域规则:不想让信头可搜索的发票,或让识别器犯迷糊的印章区。下面的引擎用 TInterfacedObject 写,引用计数管理生命周期,按词中心落在页面哪里过滤词,再把幸存者以像素坐标原样返回。RunRecognizer 代表你自己的识别器调用

type
  TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
  private
    FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single;  // 用户空间
    function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
      out Words: THPDFOCRWords): boolean;  // 你的识别器,像素框
  public
    constructor Create(SkipLeft, SkipBottom, SkipRight, SkipTop: Single);
    function GetName: AnsiString;
    function Recognize(const Request: THPDFOCRRequest;
      out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
  end;

function TZoneFilterOCREngine.Recognize(const Request: THPDFOCRRequest;
  out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
var
  Raw: THPDFOCRWords;
  I, Count: Integer;
  CX, CY: Double;
begin
  Diagnostic := '';
  SetLength(Words, 0);
  if not RunRecognizer(Request.Bitmap, Request.MaxWords, Raw) then
  begin
    Diagnostic := 'recognizer failed';
    Exit(False);
  end;
  SetLength(Words, Length(Raw));
  Count := 0;
  for I := 0 to High(Raw) do
  begin
    HotPixelToPage(Request.PageRotation, Request.DPI,
      Request.Bitmap.Height, Request.PageLeft, Request.PageBottom,
      Request.PageRight, Request.PageTop,
      (Raw[I].Left + Raw[I].Right) / 2, (Raw[I].Top + Raw[I].Bottom) / 2,
      CX, CY);
    if (CX >= FSkipLeft) and (CX <= FSkipRight) and
       (CY >= FSkipBottom) and (CY <= FSkipTop) then
      Continue;
    Words[Count] := Raw[I];  // 仍是像素:HotPDF 自己做映射
    Inc(Count);
  end;
  SetLength(Words, Count);
  Result := True;
end;

像对待其他引擎一样把它交给 ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info)。库在信任返回结果之前会先校验:框跑出位图、Right <= Left 或 Bottom <= Top、Confidence 落在 0..1 之外或低于 MinimumConfidence 的词会被丢弃并计入 Info.DroppedWordCount。返回的词超过 MaxWordsPerPage,或把累计总数推过 MaxTotalWords,整个调用以预算错误失败,所以引擎里要尊重 Request.MaxWords。返回之前别把词框转成用户空间;HotPDF 会把那些磅值当像素,文本层就会向位图原点坍缩

映射你自己检测器的输出

同一个辅助函数也服务于架在 RenderLoadedPageToBitmap 上的自家流水线——它渲染可见盒、应用 /Rotate,与识别特性如出一辙。用 GetLoadedPageVisibleBox 读盒子,按 HotPDF 的方式归一化旋转,对每个像素框映射两个对角。Y 轴翻转,90 与 270 度时轴还会交换,所以映射出的角点顺序不定;取映射点的最小最大值——HotPDF 构建条码边界用的也是这个办法

const
  DPI = 200;
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  VL, VB, VR, VT: Single;
  Rotation: Integer;
  PxL, PxT, PxR, PxB, X1, Y1, X2, Y2: Double;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('scanned-ids.pdf');
    if not Pdf.GetLoadedPageVisibleBox(0, VL, VB, VR, VT) then Exit;
    Rotation := Pdf.GetLoadedPageRotation(0) mod 360;
    if Rotation < 0 then Inc(Rotation, 360);
    if (Rotation <> 90) and (Rotation <> 180) and (Rotation <> 270) then
      Rotation := 0;
    Bmp := Pdf.RenderLoadedPageToBitmap(0, DPI);
    if Bmp = nil then Exit;
    try
      MyDetector(Bmp, PxL, PxT, PxR, PxB);  // 你的代码,像素框
      HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
        PxL, PxT, X1, Y1);
      HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
        PxR, PxB, X2, Y2);
      Writeln(Format('User-space box [%.2f %.2f %.2f %.2f]',
        [Min(X1, X2), Min(Y1, Y2), Max(X1, X2), Max(Y1, Y2)]));
    finally
      Bmp.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

旋转行为的更深入讨论见 摊平页面旋转而不弄坏页面盒子,消费同一变换的条码解码流水线见 从 PDF 页面解码旋转的 QR 码。如果你的引擎包装外部识别器,面向可搜索 PDF 的 Tesseract OCR 适配器展示了同一接口的进程隔离与取消那一面

速查:CropBox 安全的坐标映射

  • 渲染器栅格化的是可见盒、即裁到 MediaBox 之内的 CropBox(ISO 32000-1 §14.11.2);每个像素到页面的映射都必须用这个盒子,用 GetLoadedPageVisibleBox 读
  • HotPDF v2.770.153 修复 ApplyLoadedOCRTextLayer;v2.770.154 修复整页 DecodeLoadedPageBarcodes 与 DetectLoadedRedactionFindings 的人脸发现;v2.766.64 到这些版本之间的构建受影响
  • THPDFOCRWord 框是左上原点的位图像素;GetLoadedPageBox 与 GetLoadedPageVisibleBox 返回左下原点、Bottom < Top 的 PDF 用户空间
  • 缩放是 DPI / 72;从 DPI 推导,绝不要拿页面盒子除位图宽度
  • /Rotate 决定哪些边缘要紧:0 和 90 用 Left、Bottom,180 用 Right、Bottom,270 用 Right、Top
  • OCR 词以像素返回、交给 HotPDF 映射;只为自己的过滤逻辑做转换
  • 受影响构建处理过的裁切页面要重跑 OCR,并记住 SkipPagesWithText 会跳过已带旧层的页面

这里用到的识别特性、页面盒查询和已加载文档渲染,都随面向 Delphi 与 C++Builder 的 HotPDF 组件交付;授权、试用下载与完整功能清单见 HotPDF Delphi PDF 组件页