Articol tehnic

OCR chinezesc și multilingv în Delphi cu RapidOCR HotPDF

HotPDF face OCR în chineză și multilingv în Delphi prin adapter-ul lui nativ RapidOCR DLL: THPDFRapidOCRDLLOptions.ForLanguage mapează un tag de limbă precum 'zh-CN', 'zh-TW', 'ru' sau 'ar' către un model de recunoaștere și un dicționar de caractere potrivite, iar THotPDF.ApplyLoadedOCRTextLayer transformă liniile recunoscute într-un strat de text Unicode invizibil și căutabil pe paginile PDF scanate

Să faci un demo în scriere latină să funcționeze e partea ușoară. Eșecurile interesante încep când treci la chineză tradițională sau rusă și output-ul devine o prostie încrezătoare și bine formată, sau când fiecare linie își pierde în tăcere ultimul caracter, sau când o pagină arabă revine cu casetele de text în ordinea greșită. Niciunul dintre acestea nu ridică o excepție de la sine. Presetările de limbă adăugate în HotPDF v2.775.0 există mai ales pentru a închide acele goluri, iar cele patru capcane de mai jos merită înțelese chiar dacă nu atingeți niciodată codul nativ, pentru că fiecare explică un simptom după care ai putea altfel alerga o zi întreagă

Cum alege ForLanguage un model și un dicționar?

THPDFRapidOCRDLLOptions.ForLanguage rezolvă un tag către unul dintre cele nouă profile și întoarce opțiuni care arată către <profile>/recognition.onnx și <profile>/dictionary.txt sub directorul propriu de modele, păstrând în același timp detector-ul partajat, clasificatorul opțional de unghiuri și implicitele de thread, pixeli și timeout din THPDFRapidOCRDLLOptions.Default. Metoda trece tag-ul la minuscule, transformă underscore-urile în cratime și taie spațiile albe din jur, deci 'zh_TW', 'ZH-tw' și ' zh-tw ' aterizează toate pe același profil. Aliasurile sunt o listă explicită, nu o potrivire de prefix: 'zh-Hant-TW' e acceptat pentru că e listat, în timp ce o variantă regională arbitrară nelistată ridică EArgumentException înainte să se încarce vreun model

Rezolvarea profilului ForLanguage în HotPDF pentru THPDFRapidOCRDLLOptions: tag-uri precum zh_TW, ZH-tw și zh-TW sunt normalizate și potrivite contra a nouă profile listate, fiecare fixând un model de recunoaștere și un dicționar setate mereu împreună, în timp ce un tag nelistat ridică EArgumentException înaintea încărcării oricărui model
un singur tag alege o pereche fixată model-dicționar; detector-ul, clasificatorul și bugetele rămân partajate, iar un tag necunoscut eșuează repede în loc să încarce ceva
ProfilLimbiTag-uri exempluModel fixat
chChineză simplificată și englezăzh, zh-CN, zh-Hans, chi_simPP-OCRv4
chinese_chtChineză tradiționalăzh-TW, zh-HK, zh-Hant, chi_traPP-OCRv3
enEnglezăen, en-US, en-GB, engPP-OCRv4
latinFranceză, germană, spaniolă, portugheză, italiană, neerlandeză, turcăfr, de, es-419, pt-BR, trPP-OCRv3
japanJaponezăja, ja-JP, jpnPP-OCRv4
koreanCoreeanăko, ko-KR, korPP-OCRv4
cyrillicRusă, ucraineană, bulgară, belarusăru, ru-RU, uk, bgPP-OCRv3
arabicArabă, persană, urduar, ar-SA, fa, urPP-OCRv4
devanagariHindi, marathi, nepalezăhi, mr, nePP-OCRv4

Adapter-ul însuși nu descarcă nimic. Provizionați fișierele o dată cu helper-ul inclus, de exemplu tools/Install-RapidOCRModels.ps1 -Destination C:/OCR/models -Language ch,chinese_cht,cyrillic (sau -Language All pentru toate cele nouă profile), iar helper-ul plasează un detector și un clasificator partajate la numele de fișier de rădăcină pe care le așteaptă Default. După aceea, un scan în chineză simplificată devine căutabil cu câteva rânduri de cod. Instalația de motor e aceeași cusătură IHPDFOCREngine descrisă în articolul despre DLL-ul RapidOCR în proces și frontiera lui de ABI, deci acesta rămâne concentrat pe limbi

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeChineseScanSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Models: THPDFRapidOCRDLLOptions;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // ch/recognition.onnx + ch/dictionary.txt, detector și clasificator partajate
  Models := THPDFRapidOCRDLLOptions.ForLanguage('zh-CN');
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Layer := THPDFOCRTextLayerOptions.Default;  // 300 DPI, MinimumConfidence 0.5
    // o listă de pagini goală înseamnă toate paginile; paginile care au deja text sunt sărite
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Layer, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' lines, ', Info.UniqueScalarCount, ' distinct characters');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

Două detalii din output-ul acela merită o mențiune. Pipeline-ul nativ întoarce un rezultat per linie de text detectată, nu per cuvânt, deci AcceptedWordCount numără aici linii, iar MinimumConfidence e comparat cu confidence-ul mediu de caracter al întregii linii: o linie care mediază 0,45 e aruncată ca unitate. UniqueScalarCount raportează câți scalari Unicode distincți a trebuit să mapeze stratul de text în fontul și tabela lui ToUnicode, o verificare de bun simț utilă că textul CJK chiar a ajuns, nu o mână de fallback-uri latine. Țineți interfața motorului în viață peste documente, pentru că inițializarea modelelor se întâmplă în fabrică și e pasul scump

De ce schimbarea doar a modelului de recunoaștere produce gunoi?

Un model de recunoaștere CTC nu emite niciodată caractere, ci doar indici de clasă, iar dicționarul e singurul lucru care transformă indicele 1.204 într-un glyph. Schimbați ch/recognition.onnx cu cyrillic/recognition.onnx dar păstrați dicționarul chinezesc, iar modelul va emita fericit indici chirilici valizi pe care vechiul dicționar îi traduce în caractere Han aleatorii. Rezultatul arată a text, trece validarea UTF-8 și e căutabil pentru exact nimic. De aceea ForLanguage setează mereu RecognitionModel și CharacterDictionary împreună, iar opțiunile construite de mână nu ar trebui să schimbe niciodată unul fără celălalt

Verificarea evidentă de siguranță, compararea mărimii dicționarului cu lățimea de output a modelului, e necesară, dar nu și suficientă. Două dicționare pot avea același număr de intrări într-o ordine diferită, iar un off-by-one în ordine deplasează fiecare caracter cu un code point. HotPDF verifică deci în două etape când fabrica inițializează modelul. Mai întâi, numărul de clase de output trebuie să egaleze intrările dicționarului plus doi. Apoi, când fișierul ONNX încorporează o listă de metadate character, fiecare intrare a dicționarului e comparată cu ea în ordine, iar o nepotrivire eșuează inițializarea cu EInvalidOperation și un diagnostic nativ în loc să producă gunoi plauzibil mai târziu

„Plus doi” vine din layout-ul de clase. Clasa 0 e CTC blank, clasele 1 până la N sunt liniile dicționarului în ordinea din fișier, iar clasa finală e un spațiu. Unele dicționare cară și o intrare de spațiu proprie, iar linia aceea trebuie păstrată exact cum e. Aici un Trim bine intenționat face pagube reale: transformă o intrare cu un singur spațiu într-un șir gol și deplasează sau rupe tabela. Singura normalizare sigură e eliminarea unui carriage return final, astfel încât un dicționar salvat cu terminații de linie CRLF se încarcă corect, în timp ce un byte order mark UTF-8, o linie goală sau o intrare care conține un tab sunt respinse. Schița de mai jos arată layout-ul în Pascal; e cod explicativ, nu un API HotPDF

Layout-ul tabelei de clase CTC din HotPDF pentru dicționarele RapidOCR: clasa 0 e blank-ul, clasele 1 până la N sunt liniile dicționarului în ordinea din fișier cu orice intrare de spațiu singuratic păstrată, iar clasa finală e un spațiu, dând N plus 2 clase de output pe care fabrica le verifică contra modelului, metadatele incluse
dicționarul e singurul lucru care transformă indicii de clasă în caractere, deci mărimea, ordinea și intrarea de spațiu a lui sunt verificate înainte să fie recunoscută o singură pagină
// Doar ilustrație: tabela de clase pe care o așteaptă un recunoscător CTC
uses
  SysUtils, IOUtils;

function BuildCTCClassTable(const FileName: string): TArray<string>;
var
  Text, Entry: string;
  Lines: TArray<string>;
  I, Last: Integer;
begin
  Text := TEncoding.UTF8.GetString(TFile.ReadAllBytes(FileName));
  if (Text <> '') and (Text[1] = #$FEFF) then
    raise EArgumentException.Create('Dictionary must be UTF-8 without a BOM');
  Lines := Text.Split([#10]);
  Last := High(Lines);
  if (Last >= 0) and (Lines[Last] = '') then
    Dec(Last);                                   // newline la finalul fișierului
  SetLength(Result, Last + 3);
  Result[0] := '';                               // clasa 0: CTC blank
  for I := 0 to Last do
  begin
    Entry := Lines[I];
    if (Entry <> '') and (Entry[Length(Entry)] = #13) then
      SetLength(Entry, Length(Entry) - 1);       // CRLF: aruncă doar CR-ul
    if (Entry = '') or (Pos(#9, Entry) > 0) then
      raise EArgumentException.Create('Invalid dictionary entry');
    Result[I + 1] := Entry;                      // niciodată Trim: ' ' e o clasă
  end;
  Result[Last + 2] := ' ';                       // clasa finală: spațiu
  // Length(Result) trebuie să egaleze numărul de clase de output al modelului
end;

Ce face de fapt decodarea CTC greedy?

Decodarea CTC greedy alege clasa cu scorul cel mai mare la fiecare time step, colapsează repetițiile consecutive într-un singur caracter și aruncă clasa blank; blank-ul e ceea ce permite literelor dublate cu adevărat să supraviețuiască. Un model de recunoaștere privește o linie de text ca o secvență de felii verticale înguste, iar pentru fiecare felie, sau time step, emite o probabilitate pentru fiecare clasă. O linie care conține AA中 ar putea produce secvența argmax A A blank A 中 space. Colapsarea primilor doi pași A dă un singur A, blank-ul îl separă de următorul A, iar rezultatul e AA中 cu spațiul final intact. Fără regula blank, book și bok ar fi de nedistins

Parcursul GreedyCTCDecode din HotPDF: șase time steps votează clase argmax A, A, blank, A, un caracter Han și spațiu, repetițiile consecutive se colapsează, blank-ul resetează garda de repetiție astfel încât o literă dublată cu adevărat supraviețuiește, iar trei bug-uri de frontieră aruncă în tăcere spațierea cuvintelor, ultimul caracter sau caracterele dublate
decoder-ul e de vreo duzină de linii și fiecare frontieră contează: includeți ultima clasă, includeți ultimul pas și lăsați doar un blank să separe repetițiile

Fiindcă decoder-ul are doar o duzină de linii, e ușor să greșești frontierele, iar eșecurile sunt tăcute. Dacă bucla argmax interioară se oprește cu o clasă mai devreme, clasa de spațiu nu poate câștiga niciodată și fiecare linie revine fără spațiere de cuvinte, ceea ce strică căutarea de fraze pe paginile engleze și latine. Dacă bucla exterioară se oprește cu un time step mai devreme, ultimul caracter al fiecărei linii dispare, ceea ce pentru o linie scurtă poate fi o treime din text. Iar dacă garda de repetiție nu e resetată de un blank, caracterele dublate precum ll sau reduplicările chinezești precum 谢谢 se colapsează într-unul. Decoder-ul HotPDF include ultima clasă și ultimul time step, păstrează repetițiile separate de blank și respinge în plus scorurile care nu sunt finite sau ies din 0 până la 1, precum și orice număr de clase care nu se potrivește cu dicționarul. Iată aceeași logică ca ilustrație Pascal

// Doar ilustrație: decodare CTC greedy cu frontiere corecte.
// Scores ține Steps * Classes probabilități, câte un rând per time step
function GreedyCTCDecode(const Scores: array of Single;
  Steps, Classes: Integer; const Characters: array of string): string;
var
  Step, C, Best, Previous: Integer;
  BestScore: Single;
begin
  if (Classes < 3) or (Length(Characters) <> Classes) or
    (Length(Scores) <> Steps * Classes) then
    raise EArgumentException.Create('Model output does not match the dictionary');
  Result := '';
  Previous := 0;                            // clasa 0 e CTC blank
  for Step := 0 to Steps - 1 do             // include ultimul time step
  begin
    Best := 0;
    BestScore := Scores[Step * Classes];
    for C := 1 to Classes - 1 do            // include ultima clasă (spațiu)
      if Scores[Step * Classes + C] > BestScore then
      begin
        Best := C;
        BestScore := Scores[Step * Classes + C];
      end;
    if (Best <> 0) and (Best <> Previous) then
      Result := Result + Characters[Best];
    Previous := Best;                       // un blank resetează garda de repetiție
  end;
end;

Decodarea greedy nu e cea mai precisă strategie CTC disponibilă; beam search cu un model de limbă poate repara unele felii ambigue. Pentru documente tipărite la 300 DPI rezultatul greedy e de obicei tot ce modelul are de oferit, iar decoder-ul nu e locul unde se compensează slăbiciunile modelului. Modelul latin PP-OCRv3, de pildă, poate citi ñ ca n chiar și pe input curat. HotPDF nu acoperă asta cu înlocuiri de caractere în post-procesare, pentru că o tabelă de substituție care repară spaniola strică altceva, iar un caracter greșit într-un strat căutabil e mai rău decât un miss sincer

Cum ordonează HotPDF liniile de text, inclusiv araba de la dreapta la stânga?

HotPDF sortează casetele de text detectate de sus în jos, grupează casetele într-un rând când se suprapun vertical cu cel puțin jumătate din înălțimea casetei mai mici și ordonează fiecare rând de la stânga la dreapta, sau de la dreapta la stânga când RightToLeft e activat; caracterele din interiorul fiecărei linii recunoscute nu sunt niciodată inversate. Gruparea contează pentru că un detector adesea sparge o linie vizuală în mai multe casete, de exemplu o etichetă și o valoare separate de un spațiu lat, iar un sortare pură după coordonata de sus le-ar intercala cu linia vecină ori de câte ori vârfurile lor diferă cu un pixel sau doi

Presetarea arabă setează RightToLeft := True, ceea ce îi spune DLL-ului să ordone casetele din fiecare rând după latura lor dreaptă, de la marginea dreaptă spre interior. Asta e tot efectul. Textul pe care modelul îl întoarce pentru o linie e deja în ordine logică Unicode, ordinea în care un cititor de arabă citește și tastează, și aceea e și ordinea pe care o așteaptă extragerea și căutarea de text PDF. Inversarea mecanică a șirului ca să „pară corect” într-un debugger ar rupe căutarea, copierea, lipirea și screen reader-ele. Afișarea bidirecțională și modelarea glyph-urilor sunt treaba viewer-ului

Un motor servește un profil de limbă. Nu există detecție automată de scriere, deci un document care amestecă scrieri are nevoie de câte un motor per profil, aplicat paginilor care îl folosesc. Pentru că ApplyLoadedOCRTextLayer primește o listă explicită de pagini și comite fiecare apel ca propria lui tranzacție tot-sau-nimic, asta e simplu de făcut

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

function CreateRapidEngine(const Tag: string): IHPDFOCREngine;
var
  Models: THPDFRapidOCRDLLOptions;
begin
  // ridică EArgumentException pentru un tag necunoscut, înainte să se încarce vreun model
  Models := THPDFRapidOCRDLLOptions.ForLanguage(Tag);
  Models.MaxPixels := 33554432;          // loc pentru pagini A3 la 300 DPI
  Result := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
end;

procedure OCRMixedArchive(Doc: THotPDF);
var
  Chinese, Arabic: IHPDFOCREngine;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  Chinese := CreateRapidEngine('zh-TW');  // profilul chinese_cht
  Arabic := CreateRapidEngine('ar-SA');   // profilul arabic, RightToLeft = True
  Layer := THPDFOCRTextLayerOptions.Default;
  if not Doc.ApplyLoadedOCRTextLayer([0, 1, 2], Chinese, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
  if not Doc.ApplyLoadedOCRTextLayer([3], Arabic, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
end;

Linia MaxPixels e acolo cu un motiv. Opțiunile DLL au implicit 16.777.216 de pixeli per cerere, ceea ce acoperă confortabil A4 și US Letter la 300 DPI, dar o pagină A3 la 300 DPI are cam 3508 pe 4961 de pixeli, cam 17,4 milioane, iar cererea e refuzată ca peste buget. Ridicați MaxPixels (plafonul e 67.108.864) sau coborâți THPDFOCRTextLayerOptions.DPI pentru formatele mari. Ordinea de la dreapta la stânga folosește exportul opțional HPDFRapidOCRSetReadingDirection al versiunii ABI 1; adapter-ul îl cere doar când RightToLeft e setat, deci un DLL mai vechi continuă să servească limbile de la stânga la dreapta și eșuează la crearea motorului cu un EArgumentException care numește exportul lipsă pentru arabă

De ce modelele OCR mai noi eșuează la încărcare?

DLL-ul RapidOCR din HotPDF leagă un ONNX Runtime static 1.14, care nu poate citi modele salvate cu versiunea IR 10 a ONNX, iar exporturile mai noi precum modelele PP-OCRv5 pot cere un runtime mai nou decât atât; un asemenea model eșuează la crearea motorului cu un diagnostic nativ. Constrângerea aceasta e motivul pentru care pachetele de limbă sunt fixate la perechi specifice de recunoscător și dicționar PP-OCRv3 și PP-OCRv4 în loc de „cel mai nou” și de ce tabelul de mai sus amestecă cele două generații: fiecare pereche fixată e una care se încarcă și se verifică sub runtime-ul acela

Instalatorul impune perechile. Fiecare fișier din manifestul lui cară un hash SHA256, un fișier existent cu alt hash oprește instalarea în loc să fie suprascris, iar fiecare descărcare aterizează sub un nume temporar și se mută la loc doar după ce hash-ul lui se potrivește. Asta protejează contra versiunii tăcute a problemei de dicționar: cineva aruncă de mână un recognition.onnx mai nou într-un folder de profil, numărul de clase se întâmplă să se potrivească, iar nimic nu eșuează până când un client raportează căutarea care nu găsește cuvinte pe care le vede clar. La runtime adapter-ul rămâne offline și nu aduce niciodată un model lipsă. Recunoscătorul validează și forma modelului la încărcare, acceptând input NCHW cu o înălțime fixă de 32 sau 48 de pixeli sau o înălțime dinamică, pe care o rulează la 48

Dacă aveți nevoie de o scriere pe care niciunul dintre cele nouă profile nu o acoperă, puteți arăta în continuare RecognitionModel și CharacterDictionary către fișierele proprii. Se aplică aceleași verificări, iar asta e chiar ideea: o pereche nepotrivită eșuează la inițializare, nu în arhiva clientului dvs. Pentru paginile unde niciun profil RapidOCR nu se potrivește, adapter-ul Tesseract pentru PDF căutabil se prinde în același apel ApplyLoadedOCRTextLayer, iar pentru formulare ASCII tipărite la mașină motorul OCR integrat de potrivire prin șabloane nu are nevoie de modele deloc

Referință rapidă: lista de verificare RapidOCR multilingv

  • Creați opțiunile cu THPDFRapidOCRDLLOptions.ForLanguage și tratați EArgumentException ca un tag nesuportat, nu ca o defecțiune de runtime
  • Schimbați RecognitionModel și CharacterDictionary împreună, niciodată unul singur; numărări de clase egale nu dovedesc o ordine de caractere egală
  • Păstrați dicționarele ca UTF-8 fără BOM, nu tăiați niciodată intrările și așteptați-vă ca modelul să aibă N + 2 clase: blank, N intrări, spațiu
  • Un decoder CTC personalizat trebuie să acopere ultima clasă și ultimul time step și să păstreze repetițiile separate de un blank
  • Folosiți un motor per profil de limbă și pasați liste explicite de pagini pentru documentele cu scrieri mixte
  • RightToLeft schimbă doar ordinea casetelor; textul recunoscut rămâne în ordine logică Unicode
  • Instalați modelele cu Install-RapidOCRModels.ps1 astfel încât fixările SHA256 să țină perechea model-dicționar; setați UseAngleClassifier := False dacă ați instalat cu -SkipClassifier
  • Ridicați MaxPixels peste implicitul de 16.777.216 înainte să rulați pagini A3 sau mai mari la 300 DPI

Presetările de limbă RapidOCR, adapter-ul DLL nativ și pipeline-ul de strat de text OCR fac parte din HotPDF Delphi PDF Component pentru Delphi, C++Builder și FPC/Lazarus Windows, începând cu v2.775.0 pentru profilele multilingve