技术文章

Delphi HotPDF 内置模板匹配 OCR

HotPDF 提供 THPDFBuiltInOCREngine,这是完全用 Object Pascal 编写的有界模板匹配 OCR 引擎:它用 Otsu 阈值法二值化渲染页面,将字形提取为连通分量,再根据灰度覆盖率与缓存的多字体模板比较每个字形,因此 Delphi 应用无需外部 OCR 依赖就能建立可搜索文本层。这个引擎在 v2.731.0 中不得不从头重建,原因不在匹配器,而在像素

旧引擎通过了测试。它能在合成位图上识别大写 ASCII,在 Win32 上也持续工作了数月。后来同一份代码在 Win64 下运行,却完全没有输出:没有单词,除了“found no high-contrast foreground”之外没有诊断,也没有崩溃。最终发现,像素读取路径中有两个彼此独立的错误,它们此前恰好相互抵消;拆开这两个错误,正好说明了 OCR 代码为何常常静默失败而不是大声报错

旧 OCR 引擎为什么只是碰巧能工作

旧引擎之所以能工作,是因为模板位图和目标位图以相同方式翻转,所以像素读取器中的垂直倒置对匹配器不可见。TBitmap.ScanLine 返回的行顺序与其余图像路径所假定的正 biHeight DIB 约定相反。把 M 倒置后再与同样倒置的模板比较,L1 差异与正确比较完全相同。每个字形都能匹配,但没有任何一处是正确的

这种对称性正是这类错误代价高昂的原因。只修正目标读取而不修模板,识别就会坍缩成噪声;先修正模板也会从另一个方向得到同样的结果。这里不存在渐进修复路径。因此重建工作用 GetDIBits 完整替换了读取过程,并明确声明 BITMAPINFOHEADER;按照契约,正的 biHeight 表示自底向上的行,然后在复制到灰度缓冲区时有意只翻转一次

第二个错误只有 Win64 暴露出来。传给 GetDIBitsHDC 不能是位图自己的内存 DC,因为位图已经选入其中,而 Windows 将这种用法定义为无效。传入 Bitmap.Canvas.Handle 在 Win32 进程中被容忍,在 Win64 测试进程中则始终失败。修复方案是从 GetDC(0) 获取一个临时屏幕 DC,并在 finally 块中释放它;它与任何位图都没有关系

procedure BitmapToGray(Bitmap: TBitmap; out Gray: TBytes);
var
  Work: TBitmap;
  Info: TBitmapInfo;
  Buffer: TBytes;
  DC: HDC;
  P: PByte;
  Stride, X, Y: Integer;
begin
  Work := TBitmap.Create;
  try
    Work.Assign(Bitmap);
    Work.PixelFormat := pf24bit;
    Stride := ((Work.Width * 24 + 31) div 32) * 4;
    SetLength(Buffer, Stride * Work.Height);
    FillChar(Info, SizeOf(Info), 0);
    Info.bmiHeader.biSize := SizeOf(BITMAPINFOHEADER);
    Info.bmiHeader.biWidth := Work.Width;
    Info.bmiHeader.biHeight := Work.Height;   // 正值 => 自底向上的行
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // 绝不使用 Work.Canvas.Handle:Work 已选入其中
    if DC = 0 then
      raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
    try
      if GetDIBits(DC, Work.Handle, 0, Work.Height,
        @Buffer[0], Info, DIB_RGB_COLORS) <> Work.Height then
        raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
    finally
      ReleaseDC(0, DC);
    end;
    SetLength(Gray, Work.Width * Work.Height);
    for Y := 0 to Work.Height - 1 do
    begin
      P := @Buffer[(Work.Height - 1 - Y) * Stride];   // 只在这里有意翻转一次
      for X := 0 to Work.Width - 1 do
        Gray[Y * Work.Width + X] :=
          (Integer(P[X * 3]) * 29 + Integer(P[X * 3 + 1]) * 150 +
           Integer(P[X * 3 + 2]) * 77) shr 8;
    end;
  finally
    Work.Free;
  end;
end;

二值化和连通分量:从灰度像素到字形框

HotPDF 首先使用 Otsu 方法进行二值化,只有在 Otsu 不适用时才回退到局部窗口阈值。全局路径要求直方图真正呈双峰分布:引擎计算类间方差最大值,并额外要求灰度范围至少跨越 64 个级别,之后才信任结果。褪色的扫描件、带渐变背景的页面,或几乎全是墨迹的位图都会无法通过这个测试。回退路径会将每个像素与 31×31 窗口的均值比较,并减去 6 个灰度级别的偏置;它使用运行中的列和,因此滑动窗口的复杂度仍然与像素数线性相关

字形提取是在生成的掩码上进行 8 连通分量标记,并明确使用栈而不是递归,因为整页掩码很容易在深度泛洪填充时耗尽 Delphi 线程栈。标记阶段会运行两个过滤器:小于 9 个像素的分量作为斑点噪声丢弃,同时横向和纵向都超过图像五分之三的分量作为边框或线条丢弃,而不是作为字形。第二遍会合并垂直堆叠的框,只要它们的水平重叠至少达到较窄框的四分之一,这样 ij 的点就能重新与竖干合在一起。所有这些处理都作用于光栅,而光栅来自Delphi 中把已加载 PDF 页面渲染为位图所描述的同一个渲染器;这在实践中很重要:OCR 质量的上限由渲染质量决定,默认的 300 DPI 文本层是有意的取舍,而不是最大值

大写 I 和小写 l 为什么无法仅凭形状判定

在 Arial 中,大写 I 和小写 l 会光栅化成像素完全相同的竖条,因此任何形状特征都无法将二者分开,大小写只能从完全不同的地方推断。引擎采用行级高度聚类。它先按垂直重叠把字形框分组为文本行,再分析每一行的帽高和基线众数,并把行内高度拆成短簇和高簇。处于短簇的竖条是 l,同一个竖条处于高簇时就是 I

这种拆分最直观的实现是固定比例阈值,但它并不可行。Arial 的 x 高与帽高比例约为 0.72,正好落在大家最先会尝试的 0.70 和 0.75 之间。常量向任一方向移动百分之一,整个语料库的大小写就会翻转。HotPDF 改用一维 k=2 方差最小化拆分:将候选高度排序,尝试每个切分点,保留簇内离差平方和最小的切分点。于是阈值成为页面属性,而不是源代码中的常量

// ClusterHeights 已按升序排列,寻找方差最小的 k=2 切分
BestSplit := 1;
BestVariance := 1E18;
for I := 1 to ClusterCount - 1 do
begin
  SumA := 0;
  for J := 0 to I - 1 do SumA := SumA + ClusterHeights[J];
  SumB := 0;
  for J := I to ClusterCount - 1 do SumB := SumB + ClusterHeights[J];
  MeanA := SumA / I;
  MeanB := SumB / (ClusterCount - I);
  Variance := 0;
  for J := 0 to I - 1 do
    Variance := Variance + Sqr(ClusterHeights[J] - MeanA);
  for J := I to ClusterCount - 1 do
    Variance := Variance + Sqr(ClusterHeights[J] - MeanB);
  if Variance < BestVariance then
  begin
    BestVariance := Variance;
    BestSplit := I;
  end;
end;
// 只有两个簇均值的比例决定哪个是短高度带
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // 真正的 x 高带:小写形状
else
  SmallGroup := ggTall;          // 只有一个高度带:全部是帽高
Line.LowercaseContext := (SmallGroup = ggSmall);

只有一个高度带的行不包含任何内部证据。全大写标题和全小写说明单独看起来完全一样。对于这些行,HotPDF 会将行的中位高度与页面级中位 x 高比较,页面级中位 x 高来自确实完成拆分的行:比例不超过 1.10 时将该行标记为小写上下文,不低于 1.18 时标记为大写上下文,介于两者之间则不作约束。匹配阶段随后会向符合上下文的候选增加 0.03 的小型大小写偏好加分,只会推动平局结果,不会覆盖明确的形状差异

为什么 12×18 模板网格会混淆 c 和 o

模板网格从 12×18 个单元扩大到 16×24 个,是因为在较低分辨率下,co 的灰度覆盖率间隔跌到 0.007 以下,远低于引擎的歧义阈值。每个字形框会被重新采样到网格中,覆盖值为 0 到 255,而不是二值模板,因此一个三分之一被墨迹覆盖的单元读作大约 85,而不会被舍入成黑或白。在 12×18 网格中,c 的开口侧只略宽于一个单元列,抗锯齿平均会把间隙冲掉。在 16×24 网格中,重新采样后间隙得以保留,大多数容易混淆的字符对又回到了安全距离

评分是两个覆盖网格之间的归一化 L1 距离,加上 0.30 倍的宽高比差异对数和 0.16 倍的墨迹密度差异;另外还有一个硬预过滤器,会跳过宽高比相差超过 2.6 倍的模板。模板会针对五种系统字体(Arial、Times New Roman、Courier New、Tahoma 和 Segoe UI)的 62 字符字母表在每个进程中光栅化一次,缓存在临界区之后,供后续每次调用复用

最后一个常量很有意思。当亚军字符的得分与胜者相差不超过 0.018 时,HotPDF 会把字形置信度限制为 0.5,低于 0.55 的接受门槛,于是该字形根本不会输出。这是有意的失败关闭策略,而不是调参痕迹:一个会猜测的有界引擎会生成与图像不一致的可搜索层,而文本层中的错误单词比缺失单词更糟,因为审核扫描件的人看不见它

不使用固定间距阈值拆分单词

HotPDF 根据每行字形间距的分布推导单词空格阈值,而不是使用平均字形宽度的固定倍数。经典启发式“间距大于平均前进宽度的 0.75 就是空格”在一行混合数字和窄字母时立即失效,因为平均前进宽度不再代表任何真实量。引擎会对该行间距排序,并寻找连续排序值之间最大的跳跃;如果存在明显边界,它就是词内簇和词间簇的分界。三个保护条件可以阻止噪声触发:跳跃至少要达到平均字形宽度的 0.22,切分点以上的第一个间距至少要达到其 0.32,切分点以下的最后一个间距不得超过其 0.65。如果任一保护条件失败,阈值保持为 MaxInt,整行成为一个单词。最后一个保护条件能阻止一个异常宽的字距对把单词拆成两半,这种错误比把两个单词合并更有害,因为合并后的词仍然包含顺序正确的字符,子串搜索仍能找到它

在扫描图像上写入不可见文本层

ApplyLoadedOCRTextLayer 使用文本渲染模式 3,也就是 ISO 32000-1 第 9.3.6 节定义的既不填充也不描边模式,将识别出的单词绘制成位于原始扫描图像之上的可搜索层。内容流以 BT 开始,后跟 3 Tr;每个单词都使用由报告的基线、按请求 DPI 从像素换算出的帽高,以及将合成字形串拉伸到测量单词宽度的水平缩放共同构建的文本矩阵定位。结果可以复制和搜索,但不会绘制任何内容

有一个不需要引擎的重载,会替调用方实例化内置识别器,这也是大多数调用内置路径时应该使用的重载。识别、Unicode 验证、预算核算和内容构建都会在写时复制事务打开之前完成,因此取消、预算超限或引擎失败都不会改变对象图和版本号。单词会经过两次过滤:引擎先丢弃低于自身 0.55 字形置信度门槛的内容,然后 THPDFOCRTextLayerOptions.MinimumConfidence(默认值 0.5)再丢弃低于调用方门槛的整词

var
  Doc: THotPDF;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile('scan.pdf') < 1 then
      Exit;
    Options := THPDFOCRTextLayerOptions.Default;   // DPI 300,MinimumConfidence 0.5
    Options.SkipPagesWithText := True;             // 保留原生数字页面不变
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // 无引擎重载:HotPDF 提供内置的有界识别器
    if Doc.ApplyLoadedOCRTextLayer([0], Options, Info) then
    begin
      Writeln(Info.AcceptedWordCount, ' words accepted by ',
        string(Info.EngineName));
      Doc.SaveLoadedDocument('scan-searchable.pdf');
    end
    else
      Writeln('No text layer written: ', string(Info.Diagnostic));
  finally
    Doc.Free;
  end;
end;

有一个限制值得直接说清楚,而不是等到之后才发现。不可见层使用共享的合成未嵌入 Type0 字体,这足以让所有查看器进行搜索和复制,却不满足 ISO 19005 的字体嵌入要求。如果输出必须是 PDF/A,调用方就必须另外嵌入符合要求的字体。OCR 文本层还只携带几何信息,不携带结构,因此阅读顺序完全来自字形位置;如果需要从已经带有真实文本的页面获得逻辑顺序,由标签树驱动的结构顺序文本提取是另一种工具,用来解决另一类问题

内置引擎的边界

内置引擎的范围有意保持狭窄,了解边界才能让它真正有用。它面向高对比度、机器打印、接近五种模板字体的 ASCII 文本,超出范围时会返回无单词,而不是猜测。具体边界如下:

  • 图像最大为 4096×4096 和 4,194,304 个像素,识别期限为 2000 ms,并通过 THPDFCancellationToken 协作式取消
  • 包含 ASCII 字母和数字的 62 字符字母表;不支持标点、重音字符或 CJK
  • 只支持轴对齐文本,使用渲染器已经规范化的页面旋转;不会对倾斜扫描件去倾斜
  • 有歧义的字形对保持未解析,因此页面可能返回部分单词,或返回诊断“found no unambiguous ASCII words”

当这个范围太小时,接缝就是 IHPDFOCREngine。针对自己的引擎实现 Recognize,将其交给三参数的 ApplyLoadedOCRTextLayer 重载,后续所有功能(坐标映射、旋转处理、Unicode 验证、预算和原子提交)都会保持不变。位图在同步调用期间由调用方借用,不得保留。要确认文本层正确落盘,可以重新加载保存的文件,然后运行Delphi 中从已加载 PDF 提取文本所描述的普通文本路径;如果能取回这些单词,文本层就是真实存在的

内置模板匹配 OCR、不可见文本层、为它们提供位图的页面渲染器,以及用于验证结果的已加载文档文本提取,都属于同一个原生 VCL 组件,不需要外部 OCR 运行时,也不需要随应用部署 DLL。如果你在 Delphi 或 C++Builder 中构建文档采集、归档或扫描 PDF 搜索,HotPDF Delphi PDF 组件可以用一个依赖提供完整流水线