把位图像素映射回页面时,若走的是 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 恢复的正是这一点
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 | 映射依赖的盒子边缘 |
|---|---|---|---|
| 0 | Left + x / S | Bottom + (H - y) / S | Left、Bottom |
| 90 | Left + y / S | Bottom + x / S | Left、Bottom |
| 180 | Right - x / S | Bottom + y / S | Right、Bottom |
| 270 | Right - y / S | Top - x / S | Right、Top |
最后一列解释了这个 bug 在生产里为什么看着随机。只裁掉页面顶部的 CropBox 不动 Left 和 Bottom,所以正立页面完美无缺,只有带 /Rotate 270 的页面漂移。旋转还交换位图尺寸:90 和 270 度时,位图宽 (Top - Bottom) * S 像素、高 (Right - Left) * S 像素
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) 磅
把同一页转起来,误差方向就变了,因为牵涉的边缘不同。/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 组件页