Технічна стаття

RapidOCR у процесі в HotPDF: OCR через нативний DLL у Delphi

HotPDF робить відскановані сторінки PDF пошуковими за допомогою RapidOCR у процесі через HPDFCreateRapidOCRDLLOCREngine — фабрику, додану в v2.774.0, яка завантажує HotPDFRapidOCR.dll, тримає ONNX-моделі детекції, класифікації кута та розпізнавання резидентними в пам'яті й повертає IHPDFOCREngine. Той рушій ви передаєте в THotPDF.ApplyLoadedOCRTextLayer, який рендерить кожну сторінку, ганяє CPU-виведення без Python чи дочірнього процесу та комітить невидимий Unicode-текстовий шар

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

Як зробити відсканований PDF пошуковим через RapidOCR DLL?

Створення пошукового 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 на template matching

Чому 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-повідомлення, обрізане під ємність, а адаптер декодує його з жорстким термінатором в останньому байті власного 4096-байтового буфера. Тіло кожного експорту загорнуте в 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 у строгому режимі перед відкриттям файлів через 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 за раз, і нічого не досягає пошукового шару, доки не пройде кожна перевірка

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

  • UTF-8 декодується з MB_ERR_INVALID_CHARS; деформована послідовність валить сторінку замість породження замінних символів у пошуковому шарі
  • Керувальні символи C0 і C1 відхиляються, а рядки лише з пробілами пропускаються
  • Бокс мусить лежати всередині бітмапа, а впевненість мусить бути скінченним значенням від 0 до 1
  • Текст лічиться проти MaxTextCodeUnits запиту з жорсткою стелею 1 048 576 UTF-16-одиниць на виклик, причому символи додаткових площин коштують дві одиниці
  • Будь-який 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 і DLL у процесі, і жоден бік не перемагає в кожному рядку:

Компроміси OCR-адаптерів HotPDF: процесні адаптери стартують робітника і завантажують моделі на кожну сторінку, але їх можна вбити і вони містять падіння, тоді як 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; розпізнавач приймає 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. Якщо вашій програмі захоплення чи архівування документів потрібен пошуковий вихід без Python-runtime на цільовій машині, компонент HotPDF Delphi PDF дає весь конвеєр, залишаючи в деплої лише DLL та його моделі