Műszaki cikk

Beépített sablonillesztéses OCR Delphiben HotPDF-fel

A HotPDF a THPDFBuiltInOCREngine típust kínálja, egy teljes egészében Object Pascalban írt, korlátozott sablonillesztéses OCR-motort: Otsu-küszöböléssel binarizálja a renderelt oldalt, összefüggő komponensekként nyeri ki a glypheket, majd minden glyphet a szürkeárnyalatos fedettség alapján pontoz a cache-elt, többfontos sablonokhoz képest, így egy Delphi-alkalmazás külső OCR-függőség nélkül készíthet kereshető szövegréteget. A motort a v2.731.0-ban a nulláról kellett újraépíteni, és nem a matcher volt az ok. A pixelek voltak

A régi engine átment a teszteken. Szintetikus bitmapokon felismerte a nagybetűs ASCII-t, és Win32 alatt hónapokig ezt is tette. Ezután ugyanaz a kód Win64 alatt futott, és egyáltalán nem adott eredményt: nem voltak szavak, csak a „found no high-contrast foreground” diagnosztika, összeomlás viszont nem. Kiderült, hogy a pixelolvasási útvonalban két független hiba oltotta ki egymást, ezek szétválasztása pedig jó példa arra, miért bukik el csendben az OCR-kód ahelyett, hogy hangosan hibázna

Miért működött a régi OCR-engine csak véletlenül?

A régi engine azért működött, mert a sablonbitmapok és a célbitmapok ugyanúgy fel voltak fordítva, így a pixelolvasó függőleges invertálása láthatatlan maradt a matcher számára. A TBitmap.ScanLine a sorokat a pozitív biHeight DIB-konvencióval ellentétes sorrendben adja vissza, amelyre a képkezelési út többi része épít. Ha egy M-et fejjel lefelé renderelsz, és egy szintén fejjel lefelé álló sablonhoz hasonlítod, az L1-különbség ugyanaz, mint helyes összehasonlításkor. Minden glyph egyezett. Semmi sem volt helyes

Éppen ez a szimmetria teszi drágává ezt a hibatípust. Bármelyik egyoldalas javítás megtöri az illesztést: ha a célt olvasod helyesen, de a sablonokat nem, a felismerés zajjá omlik; ha előbb a sablonokat javítod, ugyanilyen összeomlást kapsz a másik irányból. Nincs fokozatos javítási út. Az újraépítés ezért a teljes olvasást GetDIBits-re cserélte, explicit deklarált BITMAPINFOHEADER mellett, ahol a pozitív biHeight szerződés szerint bottom-up sorokat jelent, és a grayscale bufferbe másoláskor egyszer, tudatosan fordítja meg a sorrendet

A második hiba csak Win64 alatt került felszínre. A GetDIBits-nek átadott HDC nem lehet a bitmap saját memory DC-je, mert a bitmap már ki van választva benne, amit a Windows érvénytelennek dokumentál. A Bitmap.Canvas.Handle átadása a Win32 folyamatban tolerálható volt, a Win64 tesztfolyamatban viszont következetesen elbukott. A javítás egy eldobható screen DC a GetDC(0)-ból, amelyet finally blokkban szabadítunk fel, és semmi köze egyetlen bitmaphez sem

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;   // pozitív => bottom-up sorok
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // soha ne Work.Canvas.Handle: a Work ott van kiválasztva
    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];   // egy tudatos fordítás
      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álás és összefüggő komponensek: a szürke pixelektől a glyph-dobozokig

A HotPDF először Otsu módszerével binarizál, és csak akkor vált lokális ablakos küszöbre, ha Otsu nem alkalmazható. A globális út valóban bimodális hisztogramot kér: az engine kiszámítja a két osztály közötti variancia maximumát, majd azt is megköveteli, hogy a szürkeérték-tartomány legalább 64 szintet fedjen le, mielőtt megbízik az eredményben. Egy kifakult szkennelés, színátmenetes hátterű oldal vagy szinte teljesen tintával fedett bitmap mind elbukik ezen a teszten. A fallback ilyenkor minden pixelt egy 31×31-es ablak átlagához hasonlít 6 szürkeszintnyi torzítással, futó oszlopösszegekkel számolva, így a csúszó ablak pixelmennyiségben lineáris marad

A glyphkinyerés 8-szomszédos komponenscímkézés az így kapott maszkon, explicit stackkel, nem rekurzióval, mert egy teljes oldalas maszk mély flood fillnél könnyen feléli egy Delphi-szál stackjét. Két szűrő fut már a címkézés közben: a 9 pixelnél kisebb komponenseket pöttyzajként eldobja, és kiesik minden olyan komponens, amely a kép szélességének és magasságának több mint háromötödére kiterjed, mert az valószínűleg keret vagy vonal, nem glyph. Egy második menet összeolvasztja azokat a függőlegesen egymás fölött álló dobozokat, amelyek vízszintes átfedése legalább a keskenyebbik doboz negyede, így az i vagy j pontja újra a szárhoz kerül. Mindez raszteren dolgozik, a raszter pedig ugyanabból a rendererből érkezik, amelyet a betöltött PDF-oldal bitmapre rendereléséről szóló leírás ismertet, és ez gyakorlati okból fontos: az OCR minőségét felülről a render minősége korlátozza, a 300-as alapértelmezett text-layer DPI tudatos kompromisszum, nem maximum

Mi teszi eldönthetetlenné a nagy I és a kis l alakját?

Arialban a nagy I és a kis l pixelenként azonos rúddá raszterizálódik, ezért alakjellemzővel nem lehet szétválasztani őket, a kis- és nagybetűs kontextusnak teljesen máshonnan kell érkeznie. Az engine válasza sorméret-szintű klaszterezés. A glyphdobozokat függőleges átfedés alapján text line-okba csoportosítja, minden sort megvizsgál a sapkamagasság és a módusz szerinti baseline alapján, majd a sor magasságait rövid és magas klaszterre bontja. A rövid klaszterben álló rúd l, ugyanez a rúd a magas klaszterben I

A felosztás kézenfekvő megvalósítása egy fix arányküszöb, de nem működik. Az Arial x-magasság és sapkamagasság aránya körülbelül 0,72, vagyis pontosan a mindenki által elsőként választott 0,70 és 0,75 közé esik. Ha a konstanson bármelyik irányban egy századot mozdítasz, egy teljes korpusz kisbetűről nagybetűre vált. A HotPDF ehelyett egydimenziós, k=2 varianciaminimalizáló felosztást végez: sorba rendezi a jelöltmagasságokat, minden vágási pontot kipróbál, és azt tartja meg, amelynél a klasztereken belüli négyzetes eltérések összege a legkisebb. A küszöb így az oldal tulajdonsága lesz, nem a forrásban szereplő konstans

// A ClusterHeights növekvő sorrendű; keresd a legkisebb varianciájú k=2 felosztást
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;
// csak a két klaszter átlagának aránya dönti el, melyik a rövid sáv
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // valódi x-magasságú sáv: kisbetűs alakok
else
  SmallGroup := ggTall;          // egy magassági sáv: minden sapkamagasságú
Line.LowercaseContext := (SmallGroup = ggSmall);

Az egyetlen magassági sávot tartalmazó sorok egyáltalán nem hordoznak belső bizonyítékot. Egy csupa nagybetűs címsor és egy csupa kisbetűs képaláírás elszigetelten ugyanolyannak látszik. Ilyenkor a HotPDF a sor mediánmagasságát ahhoz az oldal-szintű medián x-magassághoz hasonlítja, amelyet a szétvált sorokból nyert: az 1,10 vagy kisebb arány kisbetűs kontextust jelöl, az 1,18 vagy nagyobb nagybetűs kontextust, a köztes érték pedig korlátlan marad. Az illesztés ezután 0,03-as kisbetűs/nagybetűs preferenciabónuszt ad a kontextussal egyező jelöltnek, ami csak holtversenyben billent, tiszta alakbeli különbséget soha nem ír felül

Miért keverte össze a 12×18-as sablonrács a c-t és az o-t?

A sablonrács 12×18 celláról 16×24-re bővült, mert kisebb felbontáson a c és o közötti szürke fedettségi különbség 0,007 alá esett, jóval az engine kétértelműségi küszöbén belülre. Minden glyphdoboz fedettségi értékként, 0 és 255 között kerül újramintavételezésre, nem bináris maszkként, így egy egyharmad részben tintás cella körülbelül 85-öt jelent, nem pedig fekete vagy fehér lesz kerekítve. 12×18-nál a c nyitott oldala alig több mint egy cellaoszlopot fed le, az antialiasing átlaga pedig kimossa a rést. 16×24-nél a rés túléli az újramintavételezést, a könnyen összekeverhető párok többsége pedig ismét biztonságos távolságra kerül

A pontozás a két fedettségi rács normalizált L1-távolsága, amelyhez az aspect ratio különbségének logaritmusából 0,30-szoros, a tintasűrűség különbségéből pedig 0,16-szoros büntetés adódik, és van egy kemény előszűrő, amely kihagy minden olyan sablont, amelynek aspect ratio-ja 2,6-szorosnál jobban eltér. A sablonok folyamatonként egyszer rasterizálódnak öt rendszerfontból (Arial, Times New Roman, Courier New, Tahoma és Segoe UI), egy 62 karakteres ábécéhez, kritikus szakasz mögött cache-elve, majd minden későbbi hívás újrahasználja őket

Az utolsó konstans az érdekes. Ha a második helyezett karakter pontszáma 0,018-on belül van a győzteséhez képest, a HotPDF 0,5-re korlátozza a glyph konfidenciáját, ami a 0,55-ös elfogadási kapu alatt van, ezért a glyph egyszerűen nem kerül kiírásra. Ez tudatos fail-closed vágás, nem hangolási melléktermék: egy korlátozott engine, amely találgat, olyan kereshető réteget hoz létre, amely nem egyezik a képpel, és egy rossz szó a text layerben rosszabb, mint egy hiányzó, mert a szkennelt oldalt átnéző ember nem látja

Szavak felbontása fix gapküszöb nélkül

A HotPDF soronként, a glyphök közötti rések eloszlásából vezeti le a szóközküszöböt, nem a glyphszélesség átlagának fix többségéből. A klasszikus heurisztika, miszerint „a 0,75 átlagos advance-nél szélesebb rés szó”, azonnal elromlik, amint egy sor számjegyeket és keskeny betűket kever, mert az átlagos advance többé nem ír le semmi valósat. Az engine ehelyett sorba rendezi a réseket, és a szomszédos, rendezett értékek közötti legnagyobb ugrást keresi, amely a szón belüli és a szavak közötti klaszter határa, ha egyáltalán létezik. Három őr védi a zajos aktiválást: az ugrásnak el kell érnie az átlagos glyphszélesség 0,22-szeresét, az első küszöb feletti résnek legalább 0,32-szeresnek kell lennie, a küszöb alatti utolsó rés pedig nem lehet nagyobb 0,65-szörösnél. Ha bármelyik őr elbukik, a küszöb MaxInt marad, és az egész sor egyetlen szó lesz. Éppen az utolsó őr akadályozza meg, hogy egy szokatlanul széles kerningpár kettévágjon egy szót, ami sokkal károsabb hiba, mint két szó összeolvasztása, mert az összevont tokenben a részszöveg-kereséshez még a helyes karakterek maradnak helyes sorrendben

A láthatatlan text layer ráírása a szkennelt képre

A ApplyLoadedOCRTextLayer a felismert szavakból kereshető réteget készít úgy, hogy 3-as text rendering mode-ban, az ISO 32000-1 §9.3.6 szerinti se kitöltést, se körvonalat nem rajzoló módban a forrásként szolgáló szkennelt kép fölé helyezi őket. A content stream BT-vel, majd 3 Tr-rel indul, minden szó pedig a jelentett baseline-ből, a kérési DPI alapján pixelekből átszámított sapkamagasságból és egy vízszintes skálából felépített text matrixszal kerül a helyére, amely a szintetikus glyph-runt a mért szó-szélességre nyújtja. Az eredmény másolható és kereshető szöveg, amely nem fest ki semmit

Létezik engine-mentes overload is, amely helyetted példányosítja a beépített recognizert, és ezt érdemes használnia a built-in útvonal legtöbb hívójának. A felismerés, a Unicode-ellenőrzés, a budget-számítás és a content felépítése mind lezárul a copy-on-write tranzakció megnyitása előtt, ezért egy megszakítás, budget-túllépés vagy engine-hiba érintetlenül hagyja az objektumgráfot és a verziószámot. A szavakat kétszer szűri: az engine saját 0,55-ös glyph-konfidencia kapuja eldobja a gyengéket, majd a THPDFOCRTextLayerOptions.MinimumConfidence (alapértelmezésben 0,5) eldob minden, a hívó küszöbe alatti teljes szót

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;             // a digitális oldalakat hagyd érintetlenül
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // engine-mentes overload: a HotPDF adja a beépített, korlátozott recognizert
    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;

Egy korlátot érdemes egyenesen kimondani, nem később felfedezni. A láthatatlan réteg közös, be nem ágyazott szintetikus Type0 fontot használ, ami minden viewerben elég a kereséshez és a másoláshoz, de nem teljesíti az ISO 19005 fontbeágyazási követelményét. Ha a kimenetnek PDF/A-nak kell lennie, a hívónak külön kell beágyaznia egy megfelelő fontot. Az OCR text layer geometriát hordoz, struktúrát nem, ezért az olvasási sorrend kizárólag a glyphpozíciókból származik; ha olyan oldal logikai sorrendje kell, amelyen már valódi szöveg van, a tag tree által vezérelt, struktúrasorrendű szövegkinyerés másik problémára való eszköz

Hol ér véget a beépített engine?

A beépített engine szándékosan szűk, és a használhatóságát éppen a széleinek ismerete tartja meg. Nagy kontrasztú, géppel nyomtatott ASCII-t céloz meg, az öt sablonfontjához közel álló fontokból, és minden ezen kívüli bemenet találgatás helyett nulla szót ad. A konkrét határok:

  • Legfeljebb 4096×4096 méretű, összesen 4 194 304 pixeles képek, 2000 ms felismerési határidővel és a THPDFCancellationToken segítségével kooperatív megszakítással
  • 62 karakteres ASCII betű- és számjegy-ábécé; nincs írásjel, ékezetes karakter vagy CJK
  • Csak tengelyhez igazított szöveg, a renderer által már normalizált oldalszögben; a ferde szkenneléseket nem deskeweli
  • A kétértelmű glyphpárok feloldatlanok maradnak, ezért egy oldal részleges szavakat vagy a „found no unambiguous ASCII words” diagnosztikát adhatja

Ha ez a keret túl szűk, az IHPDFOCREngine a csatlakozási pont. Valósítsd meg a Recognize-t a saját engine-edben, add át a háromargumentumos ApplyLoadedOCRTextLayer overloadnak, és minden további réteg — koordinátaleképezés, forgatáskezelés, Unicode-ellenőrzés, budgetek, atomi commit — változatlan marad. A bitmap a szinkron hívás idejére kölcsönzött, nem tarthatod meg. A réteg helyes elhelyezésének ellenőrzéséhez töltsd újra a mentett fájlt, és futtasd a betöltött PDF-ből végzett szokásos szövegkinyerést; ha a szavak visszajönnek, a réteg valódi

A beépített sablonillesztéses OCR, a láthatatlan text layer, az őket tápláló oldalszerver és a réteget ellenőrző betöltöttdokumentum-szövegkinyerés ugyanabban a natív VCL-komponensben érhető el, külső OCR-runtime és az alkalmazás mellett telepítendő DLL nélkül. Ha Delphi vagy C++Builder alatt dokumentumrögzítést, archiválást vagy szkennelt PDF-ek keresését építed, a HotPDF Delphi PDF component egyetlen függőségben adja a teljes pipeline-t