Tekninen artikkeli

HotPDF:n mallinsovitus-OCR Delphissä

HotPDF sisältää THPDFBuiltInOCREngine-moottorin, kokonaan Object Pascalilla kirjoitetun rajatun mallinsovitus-OCR:n: se binarisoi renderöidyn sivun Otsu-kynnystyksellä, poimii glyfit yhdistettyinä komponentteina ja pisteyttää jokaisen glyfin harmaasävypeiton perusteella välimuistissa olevia monifonttisia malleja vasten, joten Delphi-sovellus voi rakentaa haettavan tekstikerroksen ilman ulkoista OCR-riippuvuutta. Moottori täytyi rakentaa alusta v2.731.0:ssa, eikä syy ollut sovittimessa. Syy olivat pikselit

Vanha moottori läpäisi testinsä. Se tunnisti synteettisten bittikarttojen isot ASCII-kirjaimet, ja Win32:ssa se jatkoi niin kuukausien ajan. Sitten sama koodi ajettiin Win64:ssa ja tuloksena ei ollut mitään: ei sanoja, ei muuta diagnostiikkaa kuin "found no high-contrast foreground", ei kaatumista. Ongelmaksi osoittautui kaksi toisistaan riippumatonta virhettä pikselien lukupolussa, jotka olivat kumonneet toisensa, ja niiden purkaminen näyttää hyvin miksi OCR-koodi epäonnistuu hiljaisesti eikä näyttävästi

Miksi vanha OCR-moottori toimi vain sattumalta?

Vanha moottori toimi, koska sen mallibittikartat ja kohdebittikartat käännettiin samalla tavalla, joten pikselinlukijan pystysuuntainen käännös jäi sovittimelta piiloon. TBitmap.ScanLine palauttaa rivit päinvastaisessa järjestyksessä kuin positiivisen biHeight-arvon DIB-sopimus, johon muu kuvankäsittelypolku luottaa. Renderöi M ylösalaisin, vertaa sitä myös ylösalaisin olevaan malliin ja L1-ero on sama kuin oikeassa vertailussa. Jokainen glyfi sopi. Mikään ei ollut oikein

Juuri tämä symmetria tekee tämän luokan virheistä kalliita. Yksipuolinen korjaus rikkoo sovituksen: korjaa kohteen lukeminen ja jätä mallit ennalleen, niin tunnistus muuttuu kohinaksi; korjaa mallit ensin ja saat saman romahduksen toisesta suunnasta. Vähittäistä korjauspolkua ei ole. Uudelleenrakennus korvasi siksi koko luvun GetDIBits-kutsulla, joka käyttää eksplisiittisesti ilmoitettua BITMAPINFOHEADER-rakennetta, jossa positiivinen biHeight tarkoittaa bottom-up-rivejä sopimuksen eikä VCL:n konvention perusteella, ja kääntää suunnan yhden kerran tarkoituksella kopioidessaan harmaasävydataan

Toinen virhe tuli näkyviin vasta Win64:ssa. GetDIBits-kutsulle annettu HDC ei saa olla bittikartan oma muistilaite, koska bittikartta on jo valittu siihen ja Windows dokumentoi sen virheelliseksi. Bitmap.Canvas.Handle-arvon antaminen toimi Win32-prosessissa siedetysti mutta epäonnistui johdonmukaisesti Win64-testiprosessissa. Korjaus on GetDC(0)-kutsulla saatava väliaikainen näyttölaite, joka vapautetaan finally-lohkossa ja jolla ei ole riippuvuutta mistään bittikartasta

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;   // positiivinen => bottom-up-rivit
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // älä koskaan käytä Work.Canvas.Handle: Work on valittu siihen
    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];   // yksi tarkoituksellinen käännö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;

Binarisointi ja yhdistetyt komponentit: harmaasävyistä glyfilaatikoihin

HotPDF binarisoi ensin Otsun menetelmällä ja käyttää paikallisen ikkunan kynnystä vain, kun Otsu ei sovellu. Globaali polku vaatii aidosti bimodaalisen histogrammin: moottori laskee luokkien välisen varianssin maksimin ja vaatii lisäksi harmaa-alueen kattavan vähintään 64 tasoa ennen kuin se luottaa tulokseen. Haalistunut skannaus, liukuvärinen tausta tai lähes kokonaan mustetta oleva bittikartta epäonnistuu tässä testissä. Varapolku vertaa sitten jokaista pikseliä 31 × 31 -ikkunan keskiarvoon kuuden harmaatasoon suuruisella vinoumalla, joka lasketaan juoksevilla sarakesummilla jotta liukuva ikkuna pysyy pikselimäärään nähden lineaarisena

Glyfien poiminta on 8-yhdistettyä komponenttimerkintää syntyneen maskin yli ja käyttää eksplisiittistä pinoa rekursion sijaan, koska koko sivun maski voi helposti täyttää Delphin säikeen pinon syvässä flood fill -ajossa. Merkintävaiheessa ajetaan kaksi suodatinta: alle 9 pikselin komponentit pudotetaan pilkkukohinana, ja komponentti joka kattaa yli kolme viidesosaa sekä kuvan leveydestä että korkeudesta pudotetaan kehikkona tai viivana eikä glyfinä. Toinen vaihe yhdistää pystysuoraan pinotut laatikot, joiden vaakasuuntainen limitys on vähintään neljännes kapeamman laatikon leveydestä, mikä palauttaa i- tai j-merkin pisteen yhteen varren kanssa. Kaikki tämä toimii rasterilla, ja rasteri tulee samasta renderöijästä kuin ladatun PDF-sivun renderöinti bittikartaksi Delphissä, millä on käytännön merkitys: OCR:n laatu ei voi ylittää renderöinnin laatua, ja tekstikerroksen oletus-DPI 300 on harkittu vaihtokauppa eikä maksimi

Mikä tekee isosta I:stä ja pienestä l:stä erottamattomat?

Arialissa iso I ja pieni l rasteroituvat pikselintarkoiksi palkeiksi, joten mikään muotopiirre ei voi erottaa niitä ja kirjainkoon täytyy tulla kokonaan muualta. Moottorin vastaus on rivitason korkeusklusterointi. Glyfilaatikot ryhmitellään tekstiriveiksi pystysuuntaisen limityksen perusteella, jokaisesta rivistä analysoidaan versaalikorkeus ja tyypillinen baseline, ja rivin korkeudet jaetaan lyhyeen ja pitkään klusteriin. Lyhyeen klusteriin kuuluva palkki on l; samainen palkki pitkässä klusterissa on I

Jaon ilmeinen toteutus on kiinteä suhdekynnys, eikä se toimi. Arialin x-korkeuden ja versaalikorkeuden suhde on noin 0,72, mikä osuu suoraan arvoihin 0,70 ja 0,75 joita kaikki kokeilevat ensin. Siirrä vakiota sadasosan kumpaan tahansa suuntaan ja kokonainen aineisto vaihtaa kirjainkoon. HotPDF käyttää sen sijaan yksiulotteista k=2-varianssia minimoivaa jakoa: lajittele ehdokaskorkeudet, kokeile jokaista katkaisukohtaa ja pidä katkaisukohta jonka sisäinen neliöpoikkeamien summa on pienin. Kynnys muuttuu sivun ominaisuudeksi lähdekoodin vakion sijaan

// ClusterHeights on lajiteltu nousevasti; etsi pienimmän varianssin k=2-jako
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;
// vain klusterien keskiarvojen suhde päättää kumpi kaista on lyhyt
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // todellinen x-korkeuskaista: pienaakkosmuodot
else
  SmallGroup := ggTall;          // yksi korkeuskaista: kaikki ovat versaalia
Line.LowercaseContext := (SmallGroup = ggSmall);

Rivit joilla on vain yksi korkeuskaista eivät sisällä lainkaan sisäistä näyttöä. Pelkillä versaaleilla kirjoitettu otsikko ja kokonaan pienaakkosinen kuvateksti näyttävät irrallaan samoilta. Niissä HotPDF vertaa rivin mediaanikorkeutta sivutason mediaaniseen x-korkeuteen, joka saadaan jakautuneilta riveiltä: enintään 1,10 oleva suhde merkitsee riviä pienaakkoskontekstiksi, vähintään 1,18 oleva suhde versaali-kontekstiksi ja niiden väliin jäävä alue pysyy rajoittamattomana. Sovitus lisää sitten pienen 0,03:n kirjainkokubonuksen kontekstin kanssa yhtenevälle ehdokkaalle, mikä ratkaisee tasatilanteita mutta ei koskaan ohita selvää muotoeroa

Miksi 12x18-malliruudukko sekoitti c:n ja o:n?

Malliruudukko kasvatettiin koosta 12 × 18 kokoon 16 × 24, koska pienemmässä tarkkuudessa harmaasävypeiton ero c:n ja o:n välillä putosi alle 0,007:n, selvästi moottorin epäselvyyskynnyksen sisälle. Jokainen glyfilaatikko näytteistetään ruudukkoon peittoarvoina väliltä 0–255 binäärisen maskin sijaan, joten kolmanneksen mustetta sisältävä solu saa arvoksi noin 85 eikä pyöristy mustaksi tai valkoiseksi. Koossa 12 × 18 c:n avoin puoli kattaa juuri ja juuri yhden solusarakkeen ja antialiasoitu keskiarvo pesee aukon pois. Koossa 16 × 24 aukko säilyy näytteistyksessä ja useimmat helposti sekoittuvat parit palaavat turvallisen etäisyyden päähän toisistaan

Pisteytys on kahden peittoruudukon normalisoitu L1-etäisyys, johon lisätään 0,30 kertaa sivusuhteen eron logaritmi ja 0,16 kertaa musteen tiheyden ero, sekä kova esisuodatus joka ohittaa mallin jos sen sivusuhde eroaa yli kertoimella 2,6. Mallit rasteroidaan kerran prosessia kohti viidestä järjestelmäfontista (Arial, Times New Roman, Courier New, Tahoma ja Segoe UI) 62 merkin aakkoston yli, tallennetaan kriittisen osion taakse ja käytetään uudelleen jokaisessa myöhemmässä kutsussa

Viimeinen vakio on kiinnostava. Kun toiseksi paras merkki saa tuloksen joka on enintään 0,018 voittajan tulosta suurempi, HotPDF rajoittaa glyfin luottamuksen arvoon 0,5, joka jää alle 0,55:n hyväksymisrajan, joten glyfiä ei yksinkertaisesti tuoteta. Tämä on tarkoituksellinen fail-closed-raja eikä säätöjäännös: rajattu moottori joka arvaa tuottaa haettavan tekstikerroksen jonka teksti ei vastaa kuvaa, ja väärä sana tekstikerroksessa on pahempi kuin puuttuva sana koska skannausta tarkasteleva henkilö ei huomaa sitä

Sanojen jakaminen ilman kiinteää välirajaa

HotPDF johtaa sanavälin kynnyksen rivikohtaisesti glyfivälien jakaumasta eikä keskimääräisen glyfileveyden kiinteästä kertoimesta. Klassinen heuristiikka "keskiarvon etenemästä yli 0,75:n suuruinen väli on välilyönti" rikkoutuu heti kun rivillä on numeroita ja kapeita kirjaimia sekaisin, koska keskimääräinen etenemä lakkaa kuvaamasta mitään todellista. Moottori lajittelee rivin välit ja etsii suurimman hypyn peräkkäisten lajiteltujen arvojen välillä, mikä on sisäisen sanaklusterin ja sanojen välisen klusterin raja jos sellainen on olemassa. Kolme suojaa estää laukaisun kohinasta: hypyn on oltava vähintään 0,22 keskimääräisestä glyfileveydestä, jaon jälkeisen ensimmäisen välin vähintään 0,32 siitä ja jaon alapuolinen viimeinen väli ei saa ylittää arvoa 0,65 siitä. Jos yksikin suoja pettää, kynnys pysyy arvossa MaxInt ja koko rivistä tulee yksi sana. Viimeinen suoja estää yksittäistä epätavallisen leveää kernausparia jakamasta sanaa kahtia, mikä on paljon haitallisempi virhe kuin kahden sanan yhdistäminen, koska yhdistetty token sisältää silti oikeat merkit oikeassa järjestyksessä alimerkkijonohakua varten

Näkymättömän tekstikerroksen kirjoittaminen skannatun kuvan päälle

ApplyLoadedOCRTextLayer muuttaa tunnistetut sanat haettavaksi kerrokseksi piirtämällä ne tekstin renderöintitilassa 3, ISO 32000-1 §9.3.6:ssa määritellyssä tilassa joka ei täytä eikä piirrä reunaa, niiden lähteenä olevan skannatun kuvan päälle. Sisältöstream alkaa BT-operaattorilla jota seuraa 3 Tr, ja jokainen sana sijoitetaan tekstimatriisilla joka rakennetaan ilmoitetun baselinen, pyynnön DPI:stä pikseleistä muunnetun versaalikorkeuden ja synteettisen glyfiajon mitattuun sanaleveyteen venyttävän vaakasuuntaisen skaalan avulla. Tulos kopioituu ja on haettavissa tekstinä mutta ei maalaa mitään

Moottoriton overload luo sisäänrakennetun tunnistimen puolestasi, ja sitä useimpien tämän polun kutsujien tulisi käyttää. Tunnistus, Unicoden validointi, budjetin laskenta ja sisällön rakentaminen valmistuvat kaikki ennen copy-on-write-transaktion avaamista, joten peruutus, budjetin ylitys tai moottorivirhe jättää objektigraafin ja versionumeron ennalleen. Sanat suodatetaan kahdesti: moottori pudottaa kaiken oman 0,55:n glyfikohtaisen luottamusrajansa alapuolelta, minkä jälkeen THPDFOCRTextLayerOptions.MinimumConfidence (oletus 0,5) pudottaa kutsujan rajaa heikommat kokonaiset sanat

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;             // jätä digitaalisina syntyneet sivut ennalleen
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // moottoriton overload: HotPDF toimittaa sisäänrakennetun rajatun tunnistimen
    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;

Yksi rajoitus kannattaa sanoa suoraan eikä löytää vasta myöhemmin. Näkymätön kerros käyttää jaettua synteettistä upottamatonta Type0-fonttia, joka riittää hakuun ja kopiointiin jokaisessa katseluohjelmassa mutta ei täytä ISO 19005:n fontin upotusvaatimusta. Jos tulosteen täytyy olla PDF/A, kutsujan täytyy upottaa vaatimukset täyttävä fontti erikseen. OCR-tekstikerros sisältää myös geometrian eikä rakennetta, joten lukujärjestys tulee pelkistä glyfipositioista; jos tarvitset loogisen järjestyksen sivulta jolla on jo oikeaa tekstiä, tagipuusta ohjattu rakenteen mukainen tekstinpoiminta on eri työkalu eri ongelmaan

Mihin sisäänrakennettu moottori pysähtyy

Sisäänrakennettu moottori on tarkoituksella kapea, ja sen rajojen tunteminen pitää sen hyödyllisenä. Se kohdistuu korkean kontrastin koneella painettuun ASCII-tekstiin, joka tulee lähelle sen viittä mallifonttia, ja kaikki rajojen ulkopuolinen palauttaa sanattoman tuloksen arvauksen sijaan. Konkreettiset rajat ovat:

  • Kuvat enintään koolla 4096 × 4096 ja 4 194 304 pikseliä, 2000 ms:n tunnistusmääräajalla ja yhteistyöhön perustuvalla peruutuksella THPDFCancellationToken-tokenin kautta
  • 62 merkin ASCII-kirjain- ja numeromerkkien aakkosto; ei välimerkkejä, aksenttimerkkejä eikä CJK-merkkejä
  • Vain akselien suuntainen teksti siinä sivukierrossa johon renderöijä on jo normalisoinut sen; vinoutuneita skannauksia ei suoristeta
  • Epäselvät glyfiparit jätetään ratkaisematta, joten sivu voi palauttaa osittaisia sanoja tai diagnostiikan "found no unambiguous ASCII words"

Kun tämä kehys on liian pieni, IHPDFOCREngine on saumakohta. Toteuta Recognize oman moottorisi päälle, anna se kolmeargumenttiselle ApplyLoadedOCRTextLayer-overloadille ja kaikki alavirran toiminta (koordinaattien kartoitus, kiertojen käsittely, Unicoden validointi, budjetit ja atominen commit) pysyy samana. Bittikartta lainataan synkronisen kutsun ajaksi eikä sitä saa säilyttää. Varmista kerroksen oikea asettuminen lataamalla tallennettu tiedosto uudelleen ja ajamalla tavallinen tekstipolku joka on kuvattu artikkelissa tekstin poiminta ladatusta PDF:stä Delphissä; jos sanat palaavat, kerros on todellinen

Sisäänrakennettu mallinsovitus-OCR, näkymätön tekstikerros, niitä syöttävä sivurenderöijä ja niitä varmistava ladatun dokumentin tekstinpoiminta toimitetaan kaikki samassa natiivissa VCL-komponentissa ilman ulkoista OCR-ajonaikaista riippuvuutta ja ilman sovelluksen rinnalle toimitettavaa DLL:ää. Jos rakennat dokumenttien kaappausta, arkistointia tai skannausten hakua Delphillä tai C++Builderilla, HotPDF Delphi PDF -komponentti antaa koko putken yhtenä riippuvuutena