Техническая статья

In-process RapidOCR в HotPDF: нативный DLL OCR на Delphi

HotPDF делает отсканированные страницы PDF searchable с помощью in-process RapidOCR через HPDFCreateRapidOCRDLLOCREngine — фабрику, добавленную в v2.774.0: она загружает HotPDFRapidOCR.dll, держит модели детекции, классификации угла и распознавания ONNX резидентно в памяти и возвращает IHPDFOCREngine. Эту фабрику вы передаёте в THotPDF.ApplyLoadedOCRTextLayer, который рендерит каждую страницу, гоняет CPU-инференс без Python и дочернего процесса и фиксирует невидимый Unicode-текстовый слой

Мотивация — цена за страницу. Процессный адаптер RapidOCR, вышедший раньше, HPDFCreateRapidOCREngine, стартует Python-воркер на каждый вызов Recognize, и тот воркер импортирует рантайм и грузит ONNX-модели раньше, чем прочитает хоть один пиксель. На архиве в 500 страниц этот стартовый налог повторяется 500 раз, а деплой означает поставку Python-окружения рядом с Delphi-исполняемым файлом. Нативная DLL грузит модели однажды, при создании движка, а деплой сжимается до DLL, её файлов моделей и словаря символов. Чем вы платите — невозможностью убить зависший распознаватель, и большая часть инженерии в этом адаптере о том, как жить с этим честно

Как сделать отсканированный PDF searchable с RapidOCR DLL?

Создание searchable PDF с нативной RapidOCR DLL занимает один вызов фабрики и тот же вызов ApplyLoadedOCRTextLayer, которым пользуется каждый OCR-движок HotPDF. Фабрика живёт в юните HPDFRapidOCRRecognition и валидирует заранее: DLL и каталог моделей обязаны существовать, каждый файл модели и словаря обязан разрешаться, версия ABI обязана быть 1, и все нужные экспорты обязаны присутствовать до инициализации хоть одной модели. Ошибки конфигурации поднимают EArgumentException; модель, не сумевшая загрузиться, поднимает EInvalidOperation с диагностическим текстом, который записала DLL

Последовательность валидации фабрики RapidOCR DLL в HotPDF для 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 мс. С v2.775.0 THPDFRapidOCRDLLOptions.ForLanguage подставляет подходящую модель распознавания и словарь для традиционного китайского, русского, японского, арабского и других профилей; почему модель и словарь обязаны меняться вместе, разобрано в статье о мультиязычных моделях RapidOCR и словарях CTC в HotPDF. Движок отчитывается как RapidOCR (native DLL) в Info.EngineName, так что логи остаются однозначными рядом с внешним процессным адаптером Tesseract OCR и встроенным OCR-движком на сопоставлении шаблонов

Почему C ABI разговаривает только int32_t и байтами UTF-8?

ABI HotPDFRapidOCR.dll пользуется только целыми фиксированной ширины, голыми указателями и явными байтовыми длинами, потому что 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Гоняет один битмап и выдаёт один callback на строку текстаВ фабрике
HPDFRapidOCRDestroyОсвобождает инстанс моделейВ фабрике
HPDFRapidOCRSetReadingDirectionОпциональный порядок строк справа налево, добавлен в v2.775.0Только когда выставлен RightToLeft

Опциональный экспорт разрешается лениво нарочно: DLL v2.774.0 без него всё ещё обслуживает запросы слева направо. DLL грузится через LoadLibraryEx с поисковыми флагами, покрывающими собственную папку DLL плюс дефолтные безопасные каталоги, так что зависимости ONNX Runtime или OpenCV, положенные рядом с HotPDFRapidOCR.dll, находятся без трогания PATH. Пути моделей и словарей едут как UTF-8, и DLL конвертирует их через MultiByteToWideChar в строгом режиме, прежде чем открывать файлы через wide-символьные API, так что каталог моделей под китайским или кириллическим именем пользователя работает, а не расширяется побайтово в бессмыслицу

Одно правило живёт в сборке, а не в заголовке. DLL статически линкует ONNX Runtime и OpenCV, и дефолтная конфигурация CMake использует статический release CRT (/MT). Статические библиотеки, собранные против /MD и подмешанные в DLL с /MT, дают в лучшем случае ошибки линковки, а в худшем — две независимые кучи, поэтому проставляемые библиотеки обязаны совпадать с тем режимом CRT, который использует DLL

Что происходит между TBitmap и строкой текста?

HotPDF вручает DLL независимый top-down BGR-снимок отрендеренной страницы, а DLL возвращает по одному callback на каждую распознанную строку текста с заёмным UTF-8-текстом, который адаптер обязан скопировать до возврата

В Delphi адаптер присваивает битмап страницы приватному TBitmap, форсирует pf24bit и читает строки через GetDIBits с отрицательным biHeight, что даёт top-down строки с выравниванием на четыре байта; этот stride передаётся явно. В FPC чтение идёт через CreateIntfImage, потому что запись scanline в LCL может обновить сырой образ, не обновив GDI-хэндл. Битмап вызывающего никогда не модифицируется, а пиксельный бюджет (MaxPixels, по умолчанию 16 777 216 и настраиваемый до 67 108 864) и лимит 32 767 пикселей на измерение проверяются до выделения буфера снимка

Конвейер RapidOCR DLL в HotPDF от битмапа до текстового слоя: адаптер снимает страницу как top-down pf24bit BGR, DLL паддит, детектирует, упорядочивает и распознаёт кропы, доставляет по одному callback на строку с заёмным UTF-8-текстом, боксом и уверенностью, а адаптер валидирует каждую строку до фиксации текстового слоя
пиксели пересекают ABI один раз как снимок, строки возвращаются по одному callback за раз, и ничто не доходит до searchable-слоя, пока каждая проверка не пройдена

Внутри DLL снимок паддится 50 белыми пикселями, текстовые области детектируются с максимальной стороной 1 024 пикселя, боксы упорядочиваются в горизонтальные строки, и каждый кроп опционально поворачивается классификатором угла перед распознаванием. Каждая строка текста затем идёт через callback, получающий const char*, счётчик байтов, целочисленный бокс в пикселях исходного образа и среднюю уверенность по символам. Указатель на текст валиден только внутри callback, так что адаптер копирует его немедленно, и строг к тому, что принимает:

  • UTF-8 декодируется с MB_ERR_INVALID_CHARS; испорченная последовательность топит страницу вместо порождения символов-замен в searchable-слое
  • Управляющие символы C0 и C1 отвергаются, а строки из одних пробелов пропускаются
  • Бокс обязан лежать внутри битмапа, а уверенность — быть конечным значением от 0 до 1
  • Текст считается против MaxTextCodeUnits запроса с жёстким потолком 1 048 576 UTF-16-юнитов на вызов, и символы supplementary-планов стоят два юнита
  • Любое Pascal-исключение внутри callback ловится там, сохраняется и превращается в возврат 0, что заставляет DLL остановиться и отчитать неудачу; сохранённое сообщение затем становится диагностикой

Для тюнинга важны два следствия. Первое: единица вывода — строка, а не слово; каждая строка съедает один слот MaxWords, Info.AcceptedWordCount и Info.DroppedWordCount считают строки, и подсветка поиска накрывает бокс строки. Второе: MinimumConfidence (по умолчанию 0.5) сравнивается со средней уверенностью строки по символам, так что строка с одним нечитаемым символом среди двадцати чистых обычно выживает. DLL не поставляет базовую линию, поэтому конвейер текстового слоя оценивает её из бокса. Пустая страница завершается успехом с нулём строк, а любая неудача очищает частичные результаты, так что многостраничная фиксация остаётся всё-или-ничего

Владение моделями и thread safety

Каждый движок 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-сессии, и DLL зажимает его по числу активных процессоров. Два потока на одном движке не бегут параллельно; второй ждёт лок. Но это ожидание — не слепой EnterCriticalSection: адаптер зовёт TryEnterCriticalSection каждые 25 мс и между попытками проверяет токен отмены и дедлайн, так что стоящий в очереди запрос всё ещё можно отменить или дать ему истечь. Если нужна настоящая параллельность — создавайте по движку на воркера и принимайте, что каждый движок держит собственную копию моделей в памяти

Порядок разборки фиксирован деструктором движка: сперва HPDFRapidOCRDestroy освобождает инстанс моделей, затем FreeLibrary выгружает DLL. На нативной стороне инициализация моделей столь же аккуратна: когда модель распознавания падает после того, как сессии детектора и классификатора уже построены, эти сессии освобождаются до сообщения об ошибке, а число классов словаря сверяется с выходом модели при инициализации, а не на первой странице

Почему нативный OCR-вызов нельзя убить посреди инференса?

Нативный вызов RapidOCR нельзя убить посреди инференса, потому что он бежит на вашем потоке, внутри вашего процесса, посреди ONNX Runtime-сессии, не принимающей прерываний. Поэтому отмена в DLL-адаптере HotPDF кооперативна: DLL зовёт abort-callback до и после детекции, после классификации и после каждой распознанной строки и останавливается на первом чекпойнте, где callback вернул 0. Запущенный ONNX Run сперва завершится

Альтернативы хуже ожидания. TerminateThread оставил бы лок кучи CRT, пул потоков ONNX Runtime и любое состояние OpenCV в том виде, в каком их застал момент, отравив остаток процесса. FreeLibrary при ещё исполняющемся вызове выгружает код, лежащий на стеке. Ни то, ни другое нельзя сделать безопасным, поэтому адаптер даже не пытается. Дедлайн в TimeoutMilliseconds, следовательно, кооперативный, и истёкший дедлайн всплывает как ошибка движка с диагностикой о таймауте, а отменённый токен — как otlsCancelled:

// Токен создаётся вызывающим и делится с UI-потоком,
// который зовёт Token.Cancel, когда пользователь жмёт Stop
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 и in-process DLL, и ни одна сторона не выигрывает в каждой строке:

Компромиссы OCR-адаптеров HotPDF: процессные адаптеры стартуют воркера и грузят модели на каждую страницу, но их можно убить и они содержат падения, тогда как in-process RapidOCR DLL грузит модели однажды, останавливается только на кооперативных чекпойнтах, делит адресное пространство и деплоится как DLL с моделями и словарём
выбирайте по нагрузке: десктопному приложению со страницей за раз выгодна тёплая DLL, а серверу, глотающему недоверенные сканы, стоит платить за процессную стену
  • Стартовая цена: адаптеры Tesseract и Python RapidOCR запускают процесс и грузят модели на каждую страницу; DLL грузит модели один раз на движок
  • Остановка: дочерний процесс можно оборвать наглухо, и Python-воркер бежит внутри Job Object с kill-on-close, так что уходит всё его дерево процессов; DLL способна остановиться только на границах стадий и строк
  • Изоляция отказов: падение в tesseract.exe топит одну страницу; access violation внутри DLL валит ваш процесс
  • Деплой: процессным адаптерам нужны установленная программа или Python-окружение; DLL нужна сама, её модели и словарь, подогнанные под разрядность приложения
  • Память: процессные адаптеры освобождают всё при выходе потомка; DLL-движок держит модели резидентно, пока не снята последняя ссылка на интерфейс

Интерактивному десктопному приложению, делающему OCR страницу за раз, отзывчивость DLL обычно важнее. Серверу, глотающему недоверенные сканы круглосуточно, процессная граница стоит своей стартовой цены

Сборка и деплой HotPDFRapidOCR.dll

HotPDFRapidOCR.dll собирается из C++-исходников в Native/RapidOCR с MSVC, C++17, Windows SDK и CMake 3.20 или новее, с помощью скрипта-помощника, принимающего каталоги нативных сетевых исходников, ONNX Runtime и OpenCV плюс платформу Win32 или Win64. Собирайте обе, если поставляете обе: 32-битное Delphi-приложение не может загрузить 64-битную DLL, а статические библиотеки, которые вы проставляете, обязаны совпадать и с целевой архитектурой, и с режимом CRT

У модельной стороны свои пределы совместимости. Детектор — это DB text detector; распознаватель принимает CTC-модели в раскладке NCHW с фиксированной высотой входа 32 или 48, а для моделей с динамической высотой использует 48. Вкомпилированный статический ONNX Runtime не может грузить модели, сохранённые с более новой версией IR, так что свежие экспорты PP-OCRv5 падают при инициализации с диагностикой, а не грузятся частично. Словарь обязан быть UTF-8 без BOM, ровно в порядке символов модели, и его число классов обязано совпадать с выходом модели; окончания строк CRLF принимаются. Распознавание офлайновое: DLL никогда не скачивает отсутствующую модель

Шпаргалка

  • Фабрика: HPDFCreateRapidOCRDLLOCREngine(LibraryPath, ModelDirectory[, Options]) в HPDFRapidOCRRecognition, доступна с v2.774.0 в сборках Delphi, C++Builder и Windows FPC/Lazarus
  • Держите возвращённый IHPDFOCREngine живым между страницами и документами; его освобождение уничтожает модели и выгружает DLL
  • Один движок гоняет одно распознавание за раз; для параллельных воркеров создавайте несколько движков и закладывайте память под каждую копию моделей
  • Вывод — одна запись на строку текста со средней уверенностью по символам, отфильтрованная через THPDFOCRTextLayerOptions.MinimumConfidence
  • Отмена и TimeoutMilliseconds кооперативны; запущенный ONNX-прогон всегда завершается
  • Подгоняйте разрядность DLL под приложение, а режим CRT статических библиотек ONNX Runtime и OpenCV — под DLL
  • Выбирайте языковой профиль на движок через THPDFRapidOCRDLLOptions.ForLanguage (v2.775.0); один движок сам языки не детектирует

Нативный адаптер RapidOCR, процессные OCR-адаптеры, страничный рендерер, который их кормит, и писатель невидимого Unicode-текстового слоя поставляются вместе в HotPDF — нативном VCL PDF-компоненте для Delphi и C++Builder. Если вашему приложению захвата или архивирования документов нужен searchable вывод без Python-рантайма на целевой машине, компонент HotPDF Delphi PDF component даёт весь конвейер — остаётся задеплоить лишь DLL и её модели