技术文章

HotPDF 进程内 RapidOCR:Delphi 原生 DLL OCR

HotPDF 通过 HPDFCreateRapidOCRDLLOCREngine 以进程内 RapidOCR 让扫描版 PDF 页面变得可搜索——这个 v2.774.0 加入的工厂加载 HotPDFRapidOCR.dll,把 ONNX 的检测、方向分类和识别模型常驻内存,返回一个 IHPDFOCREngine。你把这个引擎交给 THotPDF.ApplyLoadedOCRTextLayer,它渲染每一页、在无 Python 无子进程的前提下跑 CPU 推理,然后提交一层不可见的 Unicode 文本

动机是每页成本。更早发布的 RapidOCR 进程适配器 HPDFCreateRapidOCREngine 每次 Recognize 调用都要起一个 Python worker,而那个 worker 在读进第一个像素之前,得先导入运行时、加载 ONNX 模型。500 页的档案,这笔启动税要交 500 次,部署则意味着在 Delphi 可执行文件旁边再发一套 Python 环境。原生 DLL 在你创建引擎时把模型加载一次,部署缩减为 DLL、模型文件和一个字符字典。代价是没法杀掉卡死的识别器——这个适配器大部分工程量,都在诚实地与这个代价共处

怎么用 RapidOCR DLL 让扫描版 PDF 可搜索?

用原生 RapidOCR DLL 做可搜索 PDF,只需一次工厂调用,加上每个 HotPDF OCR 引擎都用的那个 ApplyLoadedOCRTextLayer。工厂住在 HPDFRapidOCRRecognition 单元里,并且迫不及待地做校验:DLL 与模型目录必须存在,每个模型与字典文件必须可解析,ABI 版本必须是 1,所有必需导出必须就位,然后才初始化任何模型。配置错误抛 EArgumentException;模型加载失败抛 EInvalidOperation,带着 DLL 写下的诊断文本

HotPDF 的 RapidOCR DLL 工厂校验序列(HPDFCreateRapidOCRDLLOCREngine):路径与模型文件必须存在,HPDFRapidOCRAbiVersion 必须返回 1,必需导出必须可解析,HPDFRapidOCRCreate 必须完成模型初始化;EArgumentException 或 EInvalidOperation 在任何识别开始前就抛出,后者携带原生诊断文本
校验刻意做在前面:配置问题在任何模型初始化之前就抛出,坏路径或坏 ABI 永远走不到识别期限那一刻
uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // 模型在这里加载,在任何识别期限之外。
  // THPDFRapidOCRDLLOptions.Default 里的相对模型名
  // 相对模型目录解析。
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models');
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Options := THPDFOCRTextLayerOptions.Default;  // 300 DPI,MinimumConfidence 0.5
    // 空页面列表表示全部页面;已有文本的页面跳过
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' lines accepted, ', Info.DroppedWordCount, ' dropped');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

THPDFRapidOCRDLLOptions.Default 指名 ch_PP-OCRv3_det_infer.onnx、ch_PP-OCRv3_rec_infer.onnx、ch_ppocr_mobile_v2.0_cls_infer.onnx 和 ppocr_keys_v1.txt,单 CPU 线程、16,777,216 像素输入上限、60,000 ms 识别期限。从 v2.775.0 起,THPDFRapidOCRDLLOptions.ForLanguage 为繁体中文、俄语、日语、阿拉伯语等配置换入匹配的识别模型与字典;模型与字典为什么必须一起换,见 HotPDF 的 RapidOCR 多语言模型与 CTC 字典。引擎在 Info.EngineName 里自报 RapidOCR (native DLL),与 外部 Tesseract OCR 进程适配器和内置模板匹配 OCR 引擎并排时日志不生歧义

为什么 C ABI 只说 int32_t 和 UTF-8 字节?

HotPDFRapidOCR.dll 的 ABI 只用定宽整数、裸指针和显式字节长度,因为 Delphi、C++Builder 和 Free Pascal 与 MSVC 之间,除了 C 调用约定之外毫无共享。std::string、std::vector 或 C++ 异常的布局与展开模型都属于某一个编译器、某一个运行时库。放任何一个跨过边界,失败就是栈被踩烂,或堆块被错误的分配器释放——而不是一个干干净净的错误

于是 ABI 版本 1 遵循一小串规则。每个导出都是 cdecl,返回 int32_t 状态,1 表示成功,0 表示失败。每个可能失败的函数都带一个调用者所有的诊断缓冲区和以字节计的容量;DLL 写入一条 NUL 结尾、按容量截断的 UTF-8 消息,适配器解码时在自己 4,096 字节缓冲的最后一个字节里放硬终止符。每个导出函数体都包在 try 里,同时带 catch (const std::exception &) 和 catch (...),ONNX Runtime 错误、OpenCV 断言或非法字典都变成状态 0 加文本,绝无异常逃进 Pascal 代码

导出职责适配器何时解析
HPDFRapidOCRAbiVersion返回 1;其他任何值都被拒收最先,先于一切
HPDFRapidOCRCreate加载检测、可选分类、识别模型与字典在工厂里
HPDFRapidOCRRecognize跑一幅位图,每个文本行发一个回调在工厂里
HPDFRapidOCRDestroy释放模型实例在工厂里
HPDFRapidOCRSetReadingDirection可选的从右到左行序,v2.775.0 加入仅当设置了 RightToLeft

可选导出刻意延迟解析:缺它的 v2.774.0 DLL 照样服务从左到右的请求。DLL 用 LoadLibraryEx 加载,搜索标志覆盖 DLL 自身文件夹加默认安全目录,所以放在 HotPDFRapidOCR.dll 旁边的 ONNX Runtime 或 OpenCV 依赖不用碰 PATH 就能找到。模型与字典路径以 UTF-8 传递,DLL 打开文件前先用严格模式的 MultiByteToWideChar 转换,再走宽字符 API,于是中文或西里尔用户名下的模型目录也能工作,而不是被逐字节放宽成一堆乱码

有一条规则活在构建里而不是头文件里。DLL 静态链接 ONNX Runtime 和 OpenCV,默认 CMake 配置使用静态 release CRT(/MT)。按 /MD 编译的静态库混进 /MT 的 DLL,最好情况是链接错误,最坏情况是两个互不相认的堆,所以提供的静态库必须与 DLL 用的 CRT 模式匹配

从 TBitmap 到文本行之间发生了什么?

HotPDF 交给 DLL 一幅独立的自顶向下 BGR 页面快照,DLL 则为每个识别出的文本行发回一个回调,附带借用的 UTF-8 文本——适配器必须在返回前把它拷走

在 Delphi 侧,适配器把页面位图赋给一个私有 TBitmap,强制 pf24bit,用负 biHeight 的 GetDIBits 读行,得到按四字节对齐填充的自顶向下行;步距显式传入。FPC 侧经 CreateIntfImage 读取,因为 LCL 的 scanline 写入可能更新原始图像而不刷新 GDI 句柄。调用者的位图绝不被修改,像素预算(MaxPixels,默认 16,777,216,可配置到 67,108,864)与每维 32,767 像素的上限在分配快照缓冲之前检查

HotPDF 的 RapidOCR DLL 从位图到文本层的流水线:适配器以自顶向下 pf24bit BGR 快照页面,DLL 填充、检测、排序并识别裁切,每行发一个回调附带借用的 UTF-8 文本、框与置信度,适配器在文本层提交前逐行校验
像素以快照形式过一次 ABI,文本行一次一个回调地回来,所有检查通过之前什么也进不了可搜索层

DLL 内部,快照先垫上 50 个白像素,文本区域以 1,024 像素最大边检测,框排成水平行,每个裁切可选地先经角度分类器旋转再识别。每个文本行随后进入一个回调,收到 const char*、字节数、以原图像素计的整数框和平均字符置信度。文本指针只在回调期间有效,适配器立刻拷贝,并且对收下的东西很挑剔:

  • UTF-8 用 MB_ERR_INVALID_CHARS 解码;畸形序列让整页失败,而不是往可搜索层里塞替换字符
  • C0 与 C1 控制字符拒收,纯空白行跳过
  • 框必须落在位图之内,置信度必须是 0 到 1 之间的有限值
  • 文本量按请求的 MaxTextCodeUnits 计数,每次调用硬上限 1,048,576 个 UTF-16 单元,增补平面字符算两个
  • 回调里的任何 Pascal 异常都在当地捕获、存储并转成 0 返回,让 DLL 停下并报告失败;存下的消息随后成为诊断

两条后果与调参有关。第一,输出单位是行不是词:每行占一个 MaxWords 槽位,Info.AcceptedWordCount 和 Info.DroppedWordCount 数的是行,搜索高亮跨整个行框。第二,MinimumConfidence(默认 0.5)对照的是行的平均字符置信度,所以二十个干净字符里夹一个认不出的,整行通常还能活。DLL 不给基线,文本层流水线从框里估一个。空页以零行成功;任何失败都清掉部分结果,多页提交保持全有或全无

模型所有权与线程安全

每个 RapidOCR DLL 引擎终生恰好持有一个模型实例,对该引擎的 Recognize 调用由临界区串行化。握着 IHPDFOCREngine 接口,模型才保持热身,所以批量作业的正确姿势是创建引擎一次,跨文档复用

procedure OcrBatch(const Files: TStrings; const OutputDir: string);
var
  Models: THPDFRapidOCRDLLOptions;
  Engine: IHPDFOCREngine;
  Doc: THotPDF;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
  I: Integer;
begin
  Models := THPDFRapidOCRDLLOptions.Default;
  Models.UseAngleClassifier := False;    // 摆正的扫描件:不加载分类器模型
  Models.Threads := 4;                   // 1..64,封顶在逻辑处理器数
  Models.TimeoutMilliseconds := 120000;  // 每次 Recognize 调用,协作式
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
  Options := THPDFOCRTextLayerOptions.Default;
  for I := 0 to Files.Count - 1 do
  begin
    Doc := THotPDF.Create(nil);
    try
      Doc.AutoLaunch := False;
      if (Doc.LoadFromFile(Files[I]) > 0) and
        Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
        Doc.SaveLoadedDocument(IncludeTrailingPathDelimiter(OutputDir) +
          ExtractFileName(Files[I]))
      else
        Writeln(Files[I], ': ', string(Info.Diagnostic));
    finally
      Doc.Free;
    end;
  end;
end;  // 最后一个引用释放:模型销毁,随后 DLL 卸载

Threads 同时设定每个 ONNX 会话的 intra-op 与 inter-op 线程数,DLL 把它钳到活动处理器数。两个线程共享一个引擎并不并行;第二个等锁。但这个等待不是盲目的 EnterCriticalSection:适配器每 25 ms 调一次 TryEnterCriticalSection,两次尝试之间检查取消令牌和期限,所以排队中的请求仍可取消或超时。真要并行,就每个 worker 一个引擎,并接受每个引擎在内存里各持一份模型

拆除顺序由引擎析构器固定:HPDFRapidOCRDestroy 先释放模型实例,然后 FreeLibrary 卸载 DLL。原生侧的模型初始化同样讲究:检测器与分类器会话都建好之后识别模型才失败的话,那些会话先释放再报错;字典类数在初始化时就对照模型输出检查,而不是等到第一页

为什么原生 OCR 调用没法在推理中途被杀?

原生 RapidOCR 调用没法在推理中途被杀,因为它跑在你的线程上、你的进程里、一个不接受打断的 ONNX Runtime 会话中间。所以 HotPDF DLL 适配器的取消是协作式的:DLL 在检测前后、分类之后、每识别完一行之后调用一个中止回调,在回调返回 0 的第一个检查点停下。已经开始的 ONNX Run 总要跑完

备选方案都比等待更糟。TerminateThread 会把 CRT 堆锁、ONNX Runtime 的线程池和任何 OpenCV 状态留在它碰巧所处的状态下,毒害进程的其余部分。调用还在执行时 FreeLibrary 卸载的是栈上正在执行的代码。两者都做不成安全版,适配器从不尝试。TimeoutMilliseconds 里的期限因此是协作式期限,过期以带超时诊断的引擎错误浮现,取消的令牌则以 otlsCancelled 浮现:

// Token 由调用者创建并与 UI 线程共享,
// 用户按下停止时 UI 线程调用 Token.Cancel
Options := THPDFOCRTextLayerOptions.Default;
Options.CancellationToken := Token;
if not Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
  case Info.Status of
    otlsCancelled:
      // 在下一个阶段或行边界返回;文档不变
      Writeln('Cancelled');
    otlsEngineError:
      // 包含协作式期限到期与原生诊断
      Writeln('Engine: ', string(Info.Diagnostic));
    otlsBudgetExceeded:
      Writeln('Budget: ', string(Info.Diagnostic));
  else
    Writeln(string(Info.Diagnostic));
  end;

这就是 HotPDF 进程适配器与进程内 DLL 之间的核心取舍,没有哪一边在每个维度都赢:

HotPDF OCR 适配器的取舍:进程适配器每页都启动 worker 并加载模型,但可以被杀掉、能隔离崩溃;进程内 RapidOCR DLL 只加载一次模型,只在协作检查点停下,共享地址空间,部署时带上自己的模型与字典即可
按工作负载选:一页一停的桌面应用受益于热身的 DLL,全天吞不可信扫描件的服务器应当为进程墙买单
  • 启动成本:Tesseract 与 Python RapidOCR 适配器每页都要起进程、加载模型;DLL 每个引擎只加载一次
  • 停止:子进程可以直接终止,Python worker 跑在关闭即杀的 Job Object 里、整棵进程树一起走;DLL 只能在阶段与行边界停下
  • 故障隔离:tesseract.exe 崩溃只废一页;DLL 内部的访问违例带走你的整个进程
  • 部署:进程适配器需要装好的程序或 Python 环境;DLL 需要它自己、模型和字典,并与应用的位数匹配
  • 内存:子进程退出时进程适配器全部释放;DLL 引擎的模型常驻到最后一个接口引用释放

对一次 OCR 一页的交互式桌面应用,DLL 的响应速度通常胜出。对全天候吞不可信扫描件的服务器,进程边界值那点启动成本

构建与部署 HotPDFRapidOCR.dll

HotPDFRapidOCR.dll 用 MSVC、C++17、Windows SDK 和 CMake 3.20 或更高,从 Native/RapidOCR 的 C++ 源码构建,辅助脚本接收原生网络源码、ONNX Runtime 与 OpenCV 目录,外加 Win32 或 Win64 平台。两个位宽都发就都构建,因为 32 位 Delphi 应用加载不了 64 位 DLL,你准备的静态库也得同时匹配目标架构与 CRT 模式

模型一侧有自己的兼容边界。检测器是 DB 文本检测器;识别器接受 NCHW 布局、固定输入高度 32 或 48 的 CTC 模型,动态高度模型用 48。捆绑的静态 ONNX Runtime 加载不了以更新 IR 版本保存的模型,所以新的 PP-OCRv5 导出会带着诊断初始化失败,而不是加载一半。字典必须是无 BOM 的 UTF-8,字符顺序与模型严格一致,类数必须与模型输出匹配;CRLF 行尾可接受。识别是离线的:DLL 从不下载缺失的模型

速查

  • 工厂:HPDFRapidOCRRecognition 里的 HPDFCreateRapidOCRDLLOCREngine(LibraryPath, ModelDirectory[, Options]),Delphi、C++Builder 与 Windows FPC/Lazarus 构建自 v2.774.0 起可用
  • 让返回的 IHPDFOCREngine 跨页面、跨文档保持存活;释放它会销毁模型并卸载 DLL
  • 一个引擎一次跑一个识别;并行 worker 就建多个引擎,并为每份模型副本预算内存
  • 输出是每个文本行一个条目、带平均字符置信度,由 THPDFOCRTextLayerOptions.MinimumConfidence 过滤
  • 取消与 TimeoutMilliseconds 都是协作式;进行中的 ONNX 运行总会跑完
  • DLL 位数与应用匹配,静态 ONNX Runtime 与 OpenCV 库的 CRT 模式与 DLL 匹配
  • 用 THPDFRapidOCRDLLOptions.ForLanguage(v2.775.0)按引擎选语言配置;一个引擎自己不会检测语言

原生 RapidOCR 适配器、基于进程的 OCR 适配器、喂养它们的页面渲染器,以及不可见 Unicode 文本层写入器,都在 HotPDF 里一起交付——一个面向 Delphi 与 C++Builder 的原生 VCL PDF 组件。如果你的文档采集或归档应用需要可搜索输出、又不想在目标机器上装 Python 运行时,HotPDF Delphi PDF 组件提供整条流水线,剩下的部署只有 DLL 和它的模型