Odborný článok

Vstavané OCR s párovaním šablón v Delphi s HotPDF

HotPDF dodáva THPDFBuiltInOCREngine, ohraničený OCR engine s párovaním šablón napísaný celý v Object Pascale: vyrenderovanú stránku binarizuje Otsuovým prahovaním, extrahuje glyfy ako súvislé komponenty a každý glyf skóruje podľa pokrytia odtieňmi sivej voči cachovaným šablónam z viacerých fontov, takže aplikácia v Delphi môže vytvoriť prehľadávateľnú textovú vrstvu bez externej OCR závislosti. Engine bolo treba vo v2.731.0 postaviť od nuly a dôvodom nebol matcher. Boli ním pixely

Starý engine prechádzal testami. Na syntetických bitmapách rozpoznával ASCII veľké písmená a na Win32 to celé mesiace pokračovalo. Potom sa rovnaký kód spustil pod Win64 a nevytvoril vôbec nič: žiadne slová, žiadnu inú diagnostiku než „found no high-contrast foreground“, žiadny crash. Ukázalo sa, že v ceste čítania pixelov boli dve nezávislé chyby, ktoré sa navzájom rušili, a ich rozpletenie dobre ukazuje, prečo OCR kód zlyháva potichu namiesto hlasného pádu

Prečo starý OCR engine fungoval iba náhodou?

Starý engine fungoval preto, že jeho bitmapy šablón aj cieľové bitmapy boli prevrátené rovnakým spôsobom, takže vertikálna inverzia v čítačke pixelov bola pre matcher neviditeľná. TBitmap.ScanLine vracia riadky v opačnom poradí než konvencia DIB s kladným biHeight, ktorú predpokladá zvyšok obrazovej cesty. Vyrenderujte M hore nohami, porovnajte ho so šablónou tiež hore nohami a L1 rozdiel bude rovnaký ako pri správnom porovnaní. Každý glyf sa zhodoval. Správne nebolo nič

Práve táto symetria robí triedu chyby drahou. Akákoľvek jednostranná oprava matching rozbije: opravte čítanie cieľa a šablóny nechajte tak a rozpoznávanie sa zrúti do šumu; opravte najprv šablóny a rovnaký kolaps príde z opačného smeru. Neexistuje cesta inkrementálnej opravy. Rebuild preto nahradil celé čítanie volaním GetDIBits proti explicitne deklarovanému BITMAPINFOHEADER, kde kladné biHeight zmluvne znamená riadky zdola nahor, nie iba konvenciu VCL, a pri kopírovaní do grayscale bufferu sa raz, zámerne, vykoná otočenie

Druhá chyba je tá, ktorú odhalil až Win64. HDC odovzdaný do GetDIBits nesmie byť vlastný memory DC bitmapy, pretože bitmapa je doň už vybraná a Windows to dokumentuje ako neplatné. Odovzdanie Bitmap.Canvas.Handle proces Win32 toleroval, ale v testovacom procese Win64 zlyhávalo konzistentne. Opravou je dočasný screen DC z GetDC(0), uvoľnený v bloku finally, ktorý nemá nič spoločné so žiadnou bitmapou

procedure BitmapToGray(Bitmap: TBitmap; out Gray: TBytes);
var
  Work: TBitmap;
  Info: TBitmapInfo;
  Buffer: TBytes;
  DC: HDC;
  P: PByte;
  Stride, X, Y: Integer;
begin
  Work := TBitmap.Create;
  try
    Work.Assign(Bitmap);
    Work.PixelFormat := pf24bit;
    Stride := ((Work.Width * 24 + 31) div 32) * 4;
    SetLength(Buffer, Stride * Work.Height);
    FillChar(Info, SizeOf(Info), 0);
    Info.bmiHeader.biSize := SizeOf(BITMAPINFOHEADER);
    Info.bmiHeader.biWidth := Work.Width;
    Info.bmiHeader.biHeight := Work.Height;   // kladná hodnota => riadky zdola nahor
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // nikdy Work.Canvas.Handle: Work je tam vybraná
    if DC = 0 then
      raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
    try
      if GetDIBits(DC, Work.Handle, 0, Work.Height,
        @Buffer[0], Info, DIB_RGB_COLORS) <> Work.Height then
        raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
    finally
      ReleaseDC(0, DC);
    end;
    SetLength(Gray, Work.Width * Work.Height);
    for Y := 0 to Work.Height - 1 do
    begin
      P := @Buffer[(Work.Height - 1 - Y) * Stride];   // jedno zámerné otočenie
      for X := 0 to Work.Width - 1 do
        Gray[Y * Work.Width + X] :=
          (Integer(P[X * 3]) * 29 + Integer(P[X * 3 + 1]) * 150 +
           Integer(P[X * 3 + 2]) * 77) shr 8;
    end;
  finally
    Work.Free;
  end;
end;

Binarizácia a súvislé komponenty: od sivých pixelov po boxy glyfov

HotPDF najprv binarizuje pomocou Otsuovej metódy a na lokálny prah s oknom prejde iba vtedy, keď Otsu nie je použiteľný. Globálna cesta vyžaduje skutočný bimodálny histogram: engine vypočíta maximum rozptylu medzi triedami a navyše požaduje, aby rozsah sivej pokrýval aspoň 64 úrovní, kým výsledku uverí. Vypraný sken, stránka s gradientovým pozadím alebo bitmapa takmer úplne pokrytá atramentom týmto testom neprejdú. Fallback potom porovná každý pixel s priemerom okna 31 × 31 s biasom 6 úrovní sivej, vypočítaným cez priebežné súčty stĺpcov, takže posuvné okno zostáva lineárne podľa počtu pixelov

Extrakcia glyfov je označovanie 8-susedných komponentov nad výslednou maskou s explicitným zásobníkom namiesto rekurzie, pretože maska celej stránky ochotne preplní zásobník vlákna Delphi pri hlbokom flood fille. Pri označovaní bežia dva filtre: komponenty menšie než 9 pixelov sa zahodia ako bodkový šum a každý komponent, ktorý zaberá viac než tri pätiny šírky aj výšky obrazu, sa zahodí ako rám alebo čiara, nie ako glyf. Druhý prechod spojí vertikálne uložené boxy, ktorých horizontálny prekryv je aspoň štvrtina užšieho boxu, čím sa bodka i alebo j znovu spojí s kmeňom. Všetko sa odohráva nad rastrom a raster pochádza z rovnakého renderera opísaného v článku o renderovaní načítanej stránky PDF do bitmapy v Delphi, čo má praktický význam: kvalita OCR je zhora ohraničená kvalitou renderu a predvolených 300 DPI textovej vrstvy je zámerný trade-off, nie maximum

Čo robí veľké I a malé l nerozhodnuteľnými?

V Arial sa veľké I a malé l rasterizujú na pixelovo identické čiary, takže žiadna vlastnosť tvaru ich nedokáže oddeliť a case musí prísť úplne odinakiaľ. Odpoveďou engine je klastrovanie výšok na úrovni riadku. Boxy glyfov sa zoskupia do textových riadkov podľa vertikálneho prekryvu, každý riadok sa analyzuje podľa svojej cap height a modálnej baseline a výšky v riadku sa rozdelia na krátky a vysoký klaster. Čiara v krátkom klastri je l; tá istá čiara vo vysokom klastri je I

Očividná implementácia tohto rozdelenia je prah pevného pomeru a nefunguje. Pomer x-height ku cap-height v Arial je približne 0,72, čo trafí priamo hodnoty 0,70 a 0,75, po ktorých každý siahne ako po prvých. Posuňte konštantu o stotinu ktorýmkoľvek smerom a celý korpus zmení case. HotPDF namiesto toho použije jednorozmerné rozdelenie k=2 minimalizujúce rozptyl: zoradí kandidátske výšky, skúsi každý bod rezu a ponechá ten, ktorého súčet štvorcov odchýlok v klastroch je najmenší. Prah sa tak stane vlastnosťou stránky namiesto konštanty v zdroji

// ClusterHeights je zoradené vzostupne; nájdi rozdelenie k=2 s najmenším rozptylom
BestSplit := 1;
BestVariance := 1E18;
for I := 1 to ClusterCount - 1 do
begin
  SumA := 0;
  for J := 0 to I - 1 do SumA := SumA + ClusterHeights[J];
  SumB := 0;
  for J := I to ClusterCount - 1 do SumB := SumB + ClusterHeights[J];
  MeanA := SumA / I;
  MeanB := SumB / (ClusterCount - I);
  Variance := 0;
  for J := 0 to I - 1 do
    Variance := Variance + Sqr(ClusterHeights[J] - MeanA);
  for J := I to ClusterCount - 1 do
    Variance := Variance + Sqr(ClusterHeights[J] - MeanB);
  if Variance < BestVariance then
  begin
    BestVariance := Variance;
    BestSplit := I;
  end;
end;
// o tom, čo tvorí krátke pásmo, rozhoduje iba pomer priemerov oboch klastrov
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // skutočné pásmo x-height: tvary malých písmen
else
  SmallGroup := ggTall;          // jedno výškové pásmo: všetko má cap height
Line.LowercaseContext := (SmallGroup = ggSmall);

Riadky s jediným výškovým pásmom nemajú vôbec žiadny interný dôkaz. Nadpis s veľkými písmenami a popis iba s malými vyzerajú izolovane rovnako. Pri nich HotPDF porovná mediánovú výšku riadku s mediánovou x-height celej stránky získanou z riadkov, ktoré sa rozdeliť dali: pomer najviac 1,10 označí riadok ako lowercase context, pomer najmenej 1,18 ako cap context a čokoľvek medzi tým zostane bez obmedzenia. Matching potom pridá malý bonus 0,03 za preferovaný case smerom ku kandidátovi, ktorý súhlasí s týmto kontextom, čím sa rozhodnú remízy bez toho, aby kedy prebil jasný rozdiel tvaru

Prečo mriežka šablón 12x18 poplietla c a o?

Mriežka šablón sa rozšírila z 12 × 18 buniek na 16 × 24, pretože pri menšom rozlíšení klesol rozdiel pokrytia odtieňmi sivej medzi c a o pod 0,007, hlboko do prahu nejednoznačnosti engine. Každý box glyfu sa do mriežky prevzorkuje ako hodnoty pokrytia od 0 do 255, nie ako binárna maska, takže bunka pokrytá atramentom do jednej tretiny má približne 85 namiesto zaokrúhlenia na čiernu alebo bielu. Pri 12 × 18 otvorená strana c zaberie sotva viac než jeden stĺpec buniek a antialiasovaný priemer medzeru zmyje. Pri 16 × 24 medzera prevzorkovanie prežije a väčšina ľahko zameniteľných dvojíc sa vráti do bezpečnej vzdialenosti

Skórovanie je normalizovaná L1 vzdialenosť medzi dvoma mriežkami pokrytia plus penalizácia 0,30-násobku logaritmu rozdielu pomerov strán a 0,16-násobku rozdielu hustoty atramentu, s tvrdým predfiltrom, ktorý preskočí každú šablónu, ktorej pomer strán sa líši viac než faktorom 2,6. Šablóny sa rasterizujú raz za proces z piatich systémových fontov (Arial, Times New Roman, Courier New, Tahoma a Segoe UI) pre abecedu 62 znakov, cachujú sa za critical section a znovu sa použijú pri každom ďalšom volaní

Posledná konštanta je zaujímavá. Keď skóre druhého kandidáta leží do 0,018 od víťaza, HotPDF zníži confidence glyfu na 0,5, čo je pod akceptačnou hranicou 0,55, takže glyf sa jednoducho nevydá. Je to zámerná konzervatívna hranica, ktorá pri neistote zlyhá uzavreto, nie artefakt ladenia: ohraničený engine, ktorý háda, vytvorí prehľadávateľnú vrstvu, ktorej text nezodpovedá obrazu, a nesprávne slovo v textovej vrstve je horšie než chýbajúce, pretože človek kontrolujúci sken ho nevidí

Rozdeľovanie slov bez pevného prahu medzery

HotPDF odvodzuje prah medzery medzi slovami pre každý riadok z rozdelenia medzier medzi glyfmi, nie z pevného násobku priemernej šírky glyfu. Klasická heuristika „medzera širšia než 0,75 priemerného advance je medzera“ sa rozbije hneď, ako riadok zmieša číslice s úzkymi písmenami, pretože priemerný advance prestane opisovať čokoľvek skutočné. Engine namiesto toho zoradí medzery pre riadok a hľadá najväčší skok medzi po sebe idúcimi zoradenými hodnotami, čo je hranica medzi klastrom vnútri slova a klastrom medzi slovami, ak taká existuje. Tri ochrany bránia spusteniu na šume: skok musí byť aspoň 0,22 priemernej šírky glyfu, prvá medzera nad rozdelením musí byť aspoň 0,32 tejto hodnoty a posledná medzera pod rozdelením nesmie prekročiť 0,65 tejto hodnoty. Ak niektorá ochrana zlyhá, prah zostane na MaxInt a celý riadok sa stane jediným slovom. Práve posledná ochrana zabráni, aby jeden nezvyčajne široký kerningový pár rozdelil slovo na dve časti, čo je oveľa škodlivejšia chyba než spojenie dvoch slov, pretože spojený token stále obsahuje správne znaky v správnom poradí pre vyhľadávanie podreťazca

Zápis neviditeľnej textovej vrstvy cez naskenovaný obraz

ApplyLoadedOCRTextLayer zmení rozpoznané slová na prehľadávateľnú vrstvu tak, že ich kreslí v režime vykresľovania textu 3, teda v režime bez fill aj stroke podľa ISO 32000-1 §9.3.6, umiestnené nad naskenovaným obrazom, z ktorého pochádzajú. Content stream sa otvorí pomocou BT nasledovaného 3 Tr a každé slovo sa umiestni textovou maticou zostavenou z nahlásenej baseline, cap height prepočítanej z pixelov podľa požadovaného DPI a horizontálneho scale, ktorý natiahne syntetický beh glyfov na nameranú šírku slova. Výsledok sa kopíruje a vyhľadáva ako text, ale nič nemaľuje

Existuje overload bez engine, ktorý vstavaný recognizer vytvorí za vás, a ten by mala použiť väčšina volajúcich vstavanej cesty. Rozpoznávanie, validácia Unicode, účtovanie rozpočtu aj vytvorenie obsahu sa dokončia pred otvorením copy-on-write transakcie, takže zrušenie, prekročenie rozpočtu alebo zlyhanie engine nechá objektový graf aj číslo verzie nedotknuté. Slová sa filtrujú dvakrát: engine zahodí všetko pod vlastnou hranicou confidence 0,55 na glyf a potom THPDFOCRTextLayerOptions.MinimumConfidence (predvolene 0,5) zahodí celé slová pod latkou volajúceho

var
  Doc: THotPDF;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile('scan.pdf') < 1 then
      Exit;
    Options := THPDFOCRTextLayerOptions.Default;   // DPI 300, MinimumConfidence 0.5
    Options.SkipPagesWithText := True;             // digitálne vytvorené stránky nechaj tak
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // overload bez engine: HotPDF dodá vstavaný ohraničený recognizer
    if Doc.ApplyLoadedOCRTextLayer([0], Options, Info) then
    begin
      Writeln(Info.AcceptedWordCount, ' words accepted by ',
        string(Info.EngineName));
      Doc.SaveLoadedDocument('scan-searchable.pdf');
    end
    else
      Writeln('No text layer written: ', string(Info.Diagnostic));
  finally
    Doc.Free;
  end;
end;

Jeden limit treba povedať otvorene namiesto toho, aby ste ho objavili neskôr. Neviditeľná vrstva používa spoločný syntetický neembedovaný Type0 font, čo stačí na vyhľadávanie a kopírovanie v každom vieweri, ale nespĺňa požiadavku na vloženie fontu z ISO 19005. Ak má byť výstup PDF/A, volajúci musí samostatne vložiť vyhovujúci font. A OCR textová vrstva nesie geometriu, nie štruktúru, takže poradie čítania pochádza iba z pozícií glyfov; ak potrebujete logické poradie zo stránky, ktorá už má skutočný text, extrakcia textu v poradí štruktúry riadená tag tree je iný nástroj pre iný problém

Kde sa vstavaný engine končí

Vstavaný engine je zámerne úzky a poznanie jeho okrajov ho udržiava užitočným. Zameriava sa na vysokokontrastné strojovo tlačené ASCII z fontov blízkych jeho piatim šablónovým rezom a všetko mimo toho vráti bez slova namiesto hádania. Konkrétne hranice sú:

  • Obrazy do 4096 × 4096 a 4,194,304 pixelov s limitom rozpoznávania 2000 ms a kooperatívnym zrušením cez THPDFCancellationToken
  • Abeceda 62 ASCII písmen a číslic; bez interpunkcie, bez diakritiky, bez CJK
  • Iba text zarovnaný podľa osí pri otočení stránky, ktoré renderer už normalizoval; skewed skeny sa nedeskewujú
  • Nejednoznačné dvojice glyfov zostávajú nevyriešené, takže stránka môže vrátiť čiastočné slová alebo diagnostiku „found no unambiguous ASCII words“

Keď je tento envelope príliš malý, rozhraním je IHPDFOCREngine. Implementujte Recognize nad vlastným engine, odovzdajte ho trojargumentovému overloadu ApplyLoadedOCRTextLayer a všetko downstream (mapovanie súradníc, spracovanie otočenia, validácia Unicode, rozpočty, atomický commit) zostane rovnaké. Bitmapa je požičaná na dobu synchrónneho volania a nesmie sa uchovávať. Ak chcete potvrdiť, že vrstva pristála správne, znovu načítajte uložený súbor a spustite bežnú textovú cestu opísanú v článku o extrakcii textu z načítaného PDF v Delphi; ak sa slová vrátia, vrstva je skutočná

Vstavané OCR s párovaním šablón, neviditeľná textová vrstva, renderer stránok, ktorý ich zásobuje, aj extrakcia textu z načítaného dokumentu, ktorá ich overuje, sa dodávajú v tom istom natívnom VCL komponente bez externého OCR runtime a bez DLL, ktorú by ste museli distribuovať s aplikáciou. Ak v Delphi alebo C++Builderi budujete capture dokumentov, archiváciu alebo vyhľadávanie v skenovaných PDF, HotPDF Delphi PDF component vám dá celý pipeline v jednej závislosti