Artykuł techniczny

HotPDF: RapidOCR w procesie, OCR z natywnej DLL w Delphi

HotPDF czyni zeskanowane strony PDF przeszukiwalnymi dzięki RapidOCR w procesie, przez HPDFCreateRapidOCRDLLOCREngine — fabrykę dodaną w v2.774.0, która ładuje HotPDFRapidOCR.dll, trzyma modele ONNX detekcji, klasyfikacji obrotu i rozpoznawania rezydentnie w pamięci i zwraca IHPDFOCREngine. Ten engine przekazujesz do THotPDF.ApplyLoadedOCRTextLayer, które renderuje każdą stronę, wykonuje inferencję na CPU bez Pythona i procesu potomnego i zatwierdza niewidoczną warstwę tekstu Unicode

Motywacją jest koszt na stronę. Adapter procesowy RapidOCR, który wyszedł wcześniej — HPDFCreateRapidOCREngine — odpala pracownika Pythona przy każdym wywołaniu Recognize, a ten pracownik importuje swój runtime i ładuje modele ONNX, zanim przeczyta pierwszy piksel. Na archiwum z 500 stron ten podatek startowy powtarza się 500 razy, a wdrożenie znaczy wysyłanie środowiska Pythona obok pliku wykonywalnego Delphi. Natywna DLL ładuje modele raz, przy tworzeniu engine'a, a wdrożenie kurczy się do DLL, jej plików modeli i słownika znaków. W zamian odpuszczasz możliwość zabicia zawieszonego rozpoznawacza i większość inżynierii w tym adapterze to uczciwe życie z tym faktem

Jak uczynić zeskanowany PDF przeszukiwalnym przez DLL RapidOCR?

Zbudowanie przeszukiwalnego PDF natywną DLL RapidOCR to jedno wywołanie fabryki i to samo wywołanie ApplyLoadedOCRTextLayer, którego używa każdy engine OCR w HotPDF. Fabryka mieszka w jednostce HPDFRapidOCRRecognition i waliduje z góry: DLL i katalog modeli muszą istnieć, każdy plik modelu i słownika musi się rozwiązać, wersja ABI musi być 1, a wszystkie wymagane eksporty muszą być obecne, zanim jakikolwiek model zostanie zainicjalizowany. Błędy konfiguracji podnoszą EArgumentException; model, który nie chce się załadować, podnosi EInvalidOperation z tekstem diagnostycznym, który zapisała DLL

Sekwencja walidacji fabryki DLL RapidOCR w HotPDF dla HPDFCreateRapidOCRDLLOCREngine: ścieżki i pliki modeli muszą istnieć, HPDFRapidOCRAbiVersion musi zwrócić 1, wymagane eksporty muszą się rozwiązać, a HPDFRapidOCRCreate musi zainicjalizować modele; EArgumentException albo EInvalidOperation podnoszone z góry, zanim jakiekolwiek rozpoznawanie ruszy, przy czym to drugie niesie natywny tekst diagnostyczny
walidacja jest z góry celowo: problemy konfiguracji podnoszą wyjątek, zanim jakikolwiek model się zainicjalizuje, więc zła ścieżka albo ABI nigdy nie dociera do deadline'u rozpoznawania
uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // Modele ładują się tutaj, poza jakimkolwiek deadlinem rozpoznawania.
  // Względne nazwy modeli w THPDFRapidOCRDLLOptions.Default rozwiązują się
  // względem katalogu modeli.
  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
    // pusta lista stron znaczy każdą stronę; strony mające już tekst są pomijane
    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 wymienia ch_PP-OCRv3_det_infer.onnx, ch_PP-OCRv3_rec_infer.onnx, ch_ppocr_mobile_v2.0_cls_infer.onnx i ppocr_keys_v1.txt, z jednym wątkiem CPU, limitem wejścia 16 777 216 pikseli i deadlinem rozpoznawania 60 000 ms. Od v2.775.0 THPDFRapidOCRDLLOptions.ForLanguage podstawia pasujący model rozpoznawania i słownik dla chińskiego tradycyjnego, rosyjskiego, japońskiego, arabskiego i innych profili; dlaczego model i słownik muszą się zmieniać razem, opisuje artykuł o wielojęzycznych modelach RapidOCR i słownikach CTC w HotPDF. Engine raportuje się jako RapidOCR (native DLL) w Info.EngineName, co trzyma logi jednoznacznymi obok zewnętrznego procesowego adaptera Tesseract OCR i wbudowanego engine'a OCR z dopasowaniem szablonów

Dlaczego ABI C mówi tylko int32_t i bajtami UTF-8?

ABI HotPDFRapidOCR.dll używa wyłącznie liczb o stałej szerokości, gołych wskaźników i jawnych długości w bajtach, bo Delphi, C++Builder i Free Pascal nie dzielą z MSVC niczego poza konwencją wywołań C. std::string, std::vector albo wyjątek C++ ma układ i model rozwijania stosu należące do jednego kompilatora i jednej biblioteki uruchomieniowej. Przepuść cokolwiek z tego przez granicę, a awarią będzie popsuty stos albo blok sterty zwolniony przez niewłaściwy alokator, nie czysty błąd

Wersja ABI 1 trzyma się więc krótkiej listy reguł. Każdy eksport jest cdecl i zwraca status int32_t, gdzie 1 znaczy sukces, a 0 porażkę. Każda funkcja, która może się nie udać, przyjmuje bufor diagnostyczny należący do wołającego i jego pojemność w bajtach; DLL zapisuje komunikat UTF-8 zakończony NUL, obcięty tak, by się zmieścił, a adapter dekoduje go z twardym terminatorem w ostatnim bajcie własnego bufora 4096 bajtów. Ciało każdego eksportu jest owinięte w try z i catch (const std::exception &), i catch (...), więc błąd ONNX Runtime, asercja OpenCV albo niewłaściwy słownik staje się statusem 0 plus tekstem, nigdy wyjątkiem uciekającym do kodu Pascal

EksportRolaKiedy adapter go rozwiązuje
HPDFRapidOCRAbiVersionZwraca 1; każda inna wartość jest odrzucanaPierwszy, przed czymkolwiek innym
HPDFRapidOCRCreateŁaduje modele detekcji, opcjonalnej klasyfikacji i rozpoznawania oraz słownikW fabryce
HPDFRapidOCRRecognizePrzetwarza jedną bitmapę i emituje jedno wywołanie zwrotne na linię tekstuW fabryce
HPDFRapidOCRDestroyZwalnia instancję modeliW fabryce
HPDFRapidOCRSetReadingDirectionOpcjonalna kolejność wierszy od prawej do lewej, dodane w v2.775.0Tylko gdy ustawiono RightToLeft

Opcjonalny eksport jest rozwiązywany leniwie celowo: DLL z v2.774.0, która go nie ma, wciąż obsługuje żądania od lewej do prawej. DLL jest ładowana przez LoadLibraryEx z flagami wyszukiwania pokrywającymi własny folder DLL plus domyślne bezpieczne katalogi, więc zależności ONNX Runtime albo OpenCV położone obok HotPDFRapidOCR.dll zostaną znalezione bez ruszania PATH. Ścieżki modeli i słowników podróżują jako UTF-8, a DLL konwertuje je przez MultiByteToWideChar w trybie ścisłym, zanim otworzy pliki przez API szerokich znaków, więc katalog modeli pod chińską albo cyryliczną nazwą użytkownika działa, zamiast być rozszerzany bajt po bajcie w bezsens

Jedna reguła mieszka w buildzie, a nie w nagłówku. DLL linkuje statycznie ONNX Runtime i OpenCV, a domyślna konfiguracja CMake używa statycznego CRT release (/MT). Biblioteki statyczne skompilowane pod /MD wmieszane w DLL /MT dają w najlepszym razie błędy linkowania, a w najgorszym dwie niezależne sterty, więc dostarczone biblioteki muszą pasować do tego, którego trybu CRT używa DLL

Co się dzieje między TBitmap a linią tekstu?

HotPDF wręcza DLL niezależną migawkę wyrenderowanej strony w BGR, od góry do dołu, a DLL oddaje jedno wywołanie zwrotne na każdą rozpoznaną linię tekstu z pożyczonym tekstem UTF-8, który adapter musi skopiować przed powrotem

W Delphi adapter przypisuje bitmapę strony do prywatnego TBitmap, wymusza pf24bit i czyta wiersze przez GetDIBits z ujemnym biHeight, co daje wiersze od góry do dołu wypełnione do wyrównania czterobajtowego; ten stride jest przekazywany jawnie. Na FPC czyta przez CreateIntfImage, bo zapisy scanline w LCL potrafią zaktualizować surowy obraz bez odświeżenia uchwytu GDI. Bitmapa wołającego nie jest nigdy modyfikowana, a budżet pikseli (MaxPixels, domyślnie 16 777 216, konfigurowalny do 67 108 864) i limit 32 767 pikseli na wymiar są sprawdzane, zanim bufor migawki zostanie przydzielony

Potok DLL RapidOCR w HotPDF od bitmapy do warstwy tekstu: adapter robi migawkę strony jako pf24bit BGR od góry do dołu, DLL dopełnia, wykrywa, porządkuje i rozpoznaje wycinki, doręcza jedno wywołanie zwrotne na linię z pożyczonym tekstem UTF-8, boxem i pewnością, a adapter waliduje każdą linię przed zatwierdzeniem warstwy tekstu
piksele przechodzą przez ABI raz, jako migawka, linie wracają po jednym wywołaniu zwrotnym, a nic nie dociera do warstwy przeszukiwalnej, dopóki każde sprawdzenie nie przejdzie

Wewnątrz DLL migawka jest dopełniana 50 białymi pikselami, regiony tekstu są wykrywane z maksymalnym bokiem 1024 piksele, boxy są układane w poziome wiersze, a każdy wycinek jest opcjonalnie obracany przez klasyfikator obrotu przed rozpoznawaniem. Każda linia tekstu przechodzi potem przez wywołanie zwrotne, które dostaje const char*, liczbę bajtów, całkowitoliczbowy box w pikselach oryginalnego obrazu i średnią pewność znaków. Wskaźnik tekstu jest ważny tylko w trakcie wywołania zwrotnego, więc adapter kopiuje go natychmiast i jest surowy w tym, co przyjmuje:

  • UTF-8 jest dekodowany z MB_ERR_INVALID_CHARS; zniekształcona sekwencja wywala stronę zamiast produkować znaki zastępcze w warstwie przeszukiwalnej
  • Znaki kontrolne C0 i C1 są odrzucane, a linie z samych białych znaków są pomijane
  • Box musi leżeć wewnątrz bitmapy, a pewność musi być skończoną wartością od 0 do 1
  • Tekst jest liczony względem MaxTextCodeUnits żądania z twardym sufitem 1 048 576 jednostek UTF-16 na wywołanie, a znaki z płaszczyzny uzupełniającej kosztują dwie jednostki
  • Każdy wyjątek Pascal wewnątrz wywołania zwrotnego jest tam łapany, przechowywany i zamieniany w zwrot 0, co każe DLL stanąć i zgłosić porażkę; przechowany komunikat staje się potem diagnostyką

Dwie konsekwencje mają znaczenie przy strojeniu. Po pierwsze, jednostką wyjścia jest linia, nie słowo: każda linia zjada jeden slot MaxWords, Info.AcceptedWordCount i Info.DroppedWordCount liczą linie, a podświetlanie w wyszukiwarce rozciąga się na box linii. Po drugie, MinimumConfidence (domyślnie 0,5) jest porównywane ze średnią pewnością znaków linii, więc linia z jednym nieczytelnym znakiem na dwadzieścia czystych zwykle przeżywa. DLL nie dostarcza linii bazowej, więc potok warstwy tekstu szacuje ją z boxa. Pusta strona kończy się sukcesem z zerem linii, a każda porażka czyści wyniki częściowe, więc zatwierdzanie wielu stron pozostaje albo wszystko, albo nic

Własność modeli i bezpieczeństwo wątkowe

Każdy engine DLL RapidOCR posiada dokładnie jedną instancję modeli przez całe swoje życie, a wywołania Recognize na tym engine'ie są serializowane sekcją krytyczną. To trzymanie interfejsu IHPDFOCREngine trzyma modele ciepłymi, więc właściwym wzorcem dla pracy wsadowej jest utworzenie engine'a raz i używanie go w poprzek dokumentów

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;    // skany w pionie: żaden model klasyfikatora nie jest ładowany
  Models.Threads := 4;                   // 1..64, dociskane do liczby procesorów logicznych
  Models.TimeoutMilliseconds := 120000;  // na wywołanie Recognize, kooperatywnie
  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;  // ostatnia referencja zwolniona: modele zniszczone, potem DLL jest wyładowywany

Wartość Threads ustawia i intra-op, i inter-op liczbę wątków każdej sesji ONNX, a DLL dociska ją do liczby aktywnych procesorów. Dwa wątki dzielące jeden engine nie biegną równolegle; drugi czeka na blokadę. To czekanie nie jest ślepym EnterCriticalSection: adapter woła TryEnterCriticalSection co 25 ms i sprawdza token anulowania i deadline między próbami, więc żądanie w kolejce wciąż może zostać anulowane albo przekroczyć czas. Jeśli potrzebujesz prawdziwej równoległości, utwórz po jednym engine na pracownika i zaakceptuj, że każdy engine trzyma własną kopię modeli w pamięci

Kolejność rozbiórki jest zaszyta w destruktorze engine'a: HPDFRapidOCRDestroy zwalnia najpierw instancję modeli, potem FreeLibrary wyładowuje DLL. Po stronie natywnej inicjalizacja modeli jest równie staranna: gdy model rozpoznawania zawodzi po tym, jak sesje detektora i klasyfikatora już powstały, te sesje są zwalniane przed zgłoszeniem błędu, a liczba klas słownika jest sprawdzana względem wyjścia modelu podczas inicjalizacji, a nie na pierwszej stronie

Dlaczego natywnego wywołania OCR nie da się zabić w środku inferencji?

Natywnego wywołania RapidOCR nie da się zabić w środku inferencji, bo biegnie ono na twoim wątku, wewnątrz twojego procesu, w środku sesji ONNX Runtime, która nie przyjmuje przerwań. Anulowanie w adapterze DLL HotPDF jest więc kooperatywne: DLL woła wywołanie zwrotne przerwania przed detekcją i po niej, po klasyfikacji i po każdej rozpoznanej linii i zatrzymuje się na pierwszym punkcie kontrolnym, gdzie zwrot zwróci 0. Pojedynczy Run ONNX, który się zaczął, dokończy się najpierw

Alternatywy są gorsze niż czekanie. TerminateThread zostawiłby blokadę sterty CRT, pulę wątków ONNX Runtime i każdy stan OpenCV w takim stanie, w jakim akurat były, zatruwając resztę procesu. FreeLibrary w trakcie wciąż wykonującego się wywołania wyładowuje kod, który jest na stosie. Żadnego nie da się zrobić bezpiecznie, więc adapter nigdy nie próbuje. Deadline w TimeoutMilliseconds jest zatem deadlinem kooperatywnym, przeterminowany deadline wychodzi jako błąd engine'a z diagnostyką przekroczenia czasu, a anulowany token jako otlsCancelled:

// Token tworzy wołający i dzieli go z wątkiem UI,
// który woła Token.Cancel, gdy użytkownik naciśnie Stop
Options := THPDFOCRTextLayerOptions.Default;
Options.CancellationToken := Token;
if not Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
  case Info.Status of
    otlsCancelled:
      // zwracane na następnej granicy etapu albo linii; dokument nietknięty
      Writeln('Cancelled');
    otlsEngineError:
      // zawiera wygaśnięcie kooperatywnego deadline'u i natywne diagnostyki
      Writeln('Engine: ', string(Info.Diagnostic));
    otlsBudgetExceeded:
      Writeln('Budget: ', string(Info.Diagnostic));
  else
    Writeln(string(Info.Diagnostic));
  end;

To centralny kompromis między adapterami procesowymi HotPDF a DLL w procesie i żadna strona nie wygrywa w każdym wierszu:

Kompromisy adapterów OCR w HotPDF: adaptery procesowe odpalają pracownika i ładują modele przy każdej stronie, ale można je zabić i izolują crashe, podczas gdy DLL RapidOCR w procesie ładuje modele raz, zatrzymuje się tylko na kooperatywnych punktach kontrolnych, dzieli przestrzeń adresową i wdraża się jako DLL z modelami i słownikiem
wybieraj per obciążenie: desktopowa aplikacja strona-po-stronie korzysta z ciepłej DLL, a serwer połykający niezaufane skany powinien zapłacić za ścianę procesu
  • Koszt startowy: adaptery Tesseract i Python RapidOCR odpalają proces i ładują modele przy każdej stronie; DLL ładuje modele raz na engine
  • Zatrzymywanie: proces potomny można zakończyć wprost, a pracownik Pythona biegnie wewnątrz Job Object z kill-on-close, więc całe jego drzewo procesów idzie z nim; DLL może stanąć tylko na granicach etapów i linii
  • Izolacja awarii: crash w tesseract.exe wywala jedną stronę; naruszenie dostępu wewnątrz DLL bierze z sobą twój proces
  • Wdrażanie: adaptery procesowe potrzebują zainstalowanego programu albo środowiska Pythona; DLL potrzebuje samej siebie, swoich modeli i słownika, dopasowanych do bitowości aplikacji
  • Pamięć: adaptery procesowe zwalniają wszystko, gdy potomek wychodzi; engine DLL trzyma swoje modele rezydentnie, dopóki nie zostanie zwolniona ostatnia referencja interfejsu

Dla interaktywnej aplikacji desktopowej, która robi OCR strony po stronie, responsywność DLL zwykle wygrywa. Dla serwera, który dniem i nocą połyka niezaufane skany, granica procesu jest warta swojego kosztu startowego

Budowanie i wdrażanie HotPDFRapidOCR.dll

HotPDFRapidOCR.dll buduje się ze źródeł C++ w Native/RapidOCR przez MSVC, C++17, Windows SDK i CMake 3.20 albo nowszy, używając skryptu pomocniczego, który przyjmuje katalogi natywnych źródeł sieciowych, ONNX Runtime i OpenCV plus platformę Win32 albo Win64. Zbuduj obie, jeśli wysyłasz obie, bo 32-bitowa aplikacja Delphi nie załaduje 64-bitowej DLL, a biblioteki statyczne, które dostarczasz, muszą pasować do architektury docelowej i do trybu CRT

Strona modeli ma własne granice zgodności. Detektor to tekstowy detektor DB; rozpoznawacz przyjmuje modele CTC w układzie NCHW o stałej wysokości wejścia 32 albo 48, a 48 używa dla modeli o dynamicznej wysokości. Dołączony statyczny ONNX Runtime nie załaduje modeli zapisanych w nowszej wersji IR, więc świeże eksporty PP-OCRv5 zawodzą na inicjalizacji z diagnostyką zamiast ładować się częściowo. Słownik musi być w UTF-8 bez BOM, w dokładnie tej kolejności znaków co model, a jego liczba klas musi pasować do wyjścia modelu; zakończenia wierszy CRLF są przyjmowane. Rozpoznawanie jest offline: DLL nigdy nie pobiera brakującego modelu

Ściąga

  • Fabryka: HPDFCreateRapidOCRDLLOCREngine(LibraryPath, ModelDirectory[, Options]) w HPDFRapidOCRRecognition, dostępna od v2.774.0 w kompilacjach Delphi, C++Builder i Windows FPC/Lazarus
  • Trzymaj zwrócony IHPDFOCREngine żywym w poprzek stron i dokumentów; zwolnienie go niszczy modele i wyładowuje DLL
  • Jeden engine wykonuje jedno rozpoznawanie naraz; utwórz kilka engine'ów dla równoległych pracowników i zaplanuj pamięć na każdą kopię modeli
  • Wyjście to jeden wpis na linię tekstu ze średnią pewnością znaków, filtrowany przez THPDFOCRTextLayerOptions.MinimumConfidence
  • Anulowanie i TimeoutMilliseconds są kooperatywne; biegnący przebieg ONNX zawsze się dokończy
  • Dopasuj bitowość DLL do aplikacji, a tryb CRT statycznych bibliotek ONNX Runtime i OpenCV do DLL
  • Wybieraj profil językowy per engine przez THPDFRapidOCRDLLOptions.ForLanguage (v2.775.0); jeden engine nie wykrywa języków sam z siebie

Natywny adapter RapidOCR, procesowe adaptery OCR, renderer stron, który je zasila, i zapisywacz niewidocznej warstwy tekstu Unicode jadą razem w HotPDF, natywnym komponencie PDF VCL dla Delphi i C++Buildera. Jeśli twoja aplikacja do akwizcji albo archiwizacji dokumentów potrzebuje przeszukiwalnego wyjścia bez runtime'u Pythona na maszynie docelowej, komponent HotPDF Delphi PDF dostarcza cały potok, zostawiając do wdrożenia tylko DLL i jej modele