Articol tehnic

Stratul de text OCR în HotPDF: mapare prin CropBox

Straturile de text OCR, marginile codurilor de bare și casetele de redactare a fețelor decalează pe paginile PDF decupate când pixelii bitmap sunt mapați înapoi prin MediaBox în locul casetei pe care renderer-ul a rasterizat-o de fapt: CropBox-ul decupat la MediaBox (ISO 32000-1 §14.11.2). HotPDF a reparat asta pentru ApplyLoadedOCRTextLayer în v2.770.153 și pentru DecodeLoadedPageBarcodes și DetectLoadedRedactionFindings în v2.770.154

Raportul de bug care sosește de obicei arată așa. O arhivă de contracte scanate trece prin OCR, output-ul e căutabil, iar hit-ul de căutare pentru un număr de clauză e evidențiat la jumătate de țol mai jos și la stânga numărului tipărit. Majoritatea fișierelor din serie sunt bune. Cele stricate au venit toate de la o stație de scanare care scrie un /CropBox pentru a tăia marginea platoului. Acest singur detaliu separă imaginea pe care a văzut-o motorul OCR de cadrul în care a fost plasat stratul de text, iar aceeași nepotrivire mută marginile codurilor de bare și, mai grav, casetele de redactare a fețelor

De ce decalează stratul de text OCR față de cuvintele scanate?

Stratul de text decalează pentru că două jumătăți ale pipeline-ului nu erau de acord asupra dreptunghiului acoperit de bitmap. În v2.766.64, HotPDF a schimbat randarea, exportul SVG, viewer-ul și tipărirea să honoreze CropBox-ul: o pagină e afișată prin CropBox-ul ei decupat la MediaBox, ceea ce prescrie ISO 32000-1 §14.11.2, iar GetLoadedPageVisibleBox a fost adăugat să întoarcă caseta vizibilă aceea. Funcțiile de recunoaștere continuau să-și construiască transformarea de la dispozitiv la pagină din GetLoadedPageBox(PageIndex, pbMediaBox, ...). Raster-ul acoperea acum caseta vizibilă, transformarea presupunea încă MediaBox-ul, iar fiecare poziție recunoscută revenea decalată cu spațiul dintre cele două

Fereastra afectată e deci precisă. ApplyLoadedOCRTextLayer a plasat greșit textul de la v2.766.64 până la v2.770.152. DecodeLoadedPageBarcodes pe pagină întreagă și detecția de fețe din interiorul lui DetectLoadedRedactionFindings au rămas greșite cu un build mai mult, până la v2.770.153. Înainte de v2.766.64 renderer-ul desena tot MediaBox-ul, deci maparea și raster-ul erau de acord, cu prețul recunoașterii de conținut pe care viewer-ele nu îl arată niciodată. Reparările au schimbat împreună trei lucruri pentru fiecare funcție: transformarea, estimarea bugetului de pixeli și caseta de pagină pasată unui motor personalizat în recordul de cerere

Câteva cazuri nu au fost niciodată afectate:

  • Paginile fără /CropBox sau ale căror CropBox egalează MediaBox-ul se mapează identic înainte și după reparare
  • DecodeLoadedPageBarcodes cu HasRegion setat randează exact regiunea pasață și mapează prin aceeași regiune, deci decodarea cu regiune explicită a fost corectă pe tot parcursul; verificarea că regiunea stă în interiorul paginii folosește în continuare MediaBox-ul
  • Constatarele de redactare pe bază de pattern (emailuri, numere de card și așa mai departe) vin din extragerea de text în user space, nu dintr-un raster, deci doar constatarele de detecție de fețe s-au mutat

Trei cadrane de coordonate și ce API-uri HotPDF folosește fiecare

Codul HotPDF care atinge recunoașterea se ocupă cu trei cadrane, iar majoritatea bug-urilor de mapare vin din amestecarea a două dintre ele

  • Pixelii bitmap: origine stânga-sus, Y crește în jos, unitățile sunt pixeli la DPI-ul cererii. THPDFOCRWord.Left, Top, Right și Bottom sunt în acest cadran, la fel și punctele opționale de baseline, rezultatele pe care le întoarce un IHPDFBarcodeDecoder personalizat și casetele de la un IHPDFFaceDetector personalizat
  • User space PDF al unei pagini încărcate: origine stânga-jos, Y crește în sus, unitățile sunt puncte, cu Bottom < Top. GetLoadedPageBox și GetLoadedPageVisibleBox întorc Left, Bottom, Right, Top în acest cadran, la fel și câmpurile PageLeft, PageBottom, PageRight și PageTop ale lui THPDFOCRRequest, marginile din THPDFDecodedBarcode și dreptunghiurile din THPDFRedactionFinding
  • Coordonatele de desenare a paginii din HotPDF: API-ul folosit pentru a construi pagini noi (output de text, forme, coduri de bare, linkuri, câmpuri de formular) lucrează cu origine stânga-sus și Y crescând în jos. Acest cadran aparține generării de documente și n-are nicio legătură cu API-urile de document încărcat de mai sus, deci nu hrăniți niciodată un dreptunghi în user space de pagină încărcată în el neschimbat

Recordul de cuvinte OCR e bazat deliberat pe pixeli: un motor raportează ce a văzut în imagine, iar ApplyLoadedOCRTextLayer deține conversia. Diviziunea aceea funcționează doar când conversia folosește caseta corectă, ceea ce v2.770.153 a restabilit

Cadranele de coordonate de recunoaștere din HotPDF: pixelii bitmap cu origine stânga-sus folosiți de casetele THPDFOCRWord și de decoderii personalizați, user space-ul PDF cu origine stânga-jos întors de GetLoadedPageBox și GetLoadedPageVisibleBox și API-ul de desenare a paginii cu origine stânga-sus, care nu trebuie să primească niciodată neschimbat un dreptunghi de pagină încărcată
motorii raportează pixeli pentru că asta au văzut, HotPDF îi mapează, iar amestecarea celor două cadrane e felul în care decalează straturile și casetele de redactare

Transformarea de la dispozitiv la pagină din spatele OCR, codurilor de bare și fețelor

HotPDF mapează pixelii bitmap pe pagină cu o singură matrice afină construită din cinci input-uri: rotația, scara DPI / 72, înălțimea bitmap-ului și Left, Bottom, Right și Top ale casetei randate. OCR, decodarea codurilor de bare și detecția de fețe partajează toate o singură rutină pentru asta, motiv pentru care un singur input de casetă greșit a stricat toate trei la fel. Pentru o pagină nerotită matricea pagină-spre-dispozitiv [A B C D E F] e:

  • A = Scale și D = -Scale, unde Scale = DPI / 72; D-ul negativ întoarce user space-ul (Y în sus) în spațiul bitmap (Y în jos)
  • B = C = 0, pentru că o pagină nerotită n-are shear nici swap între axe
  • E = -Left * Scale, care mută latura stângă a casetei la coloana de pixel 0
  • F = BitmapHeight + Bottom * Scale, care mapează latura de jos a casetei la y = BitmapHeight, latura de jos a bitmap-ului, astfel încât latura de sus aterizează pe rândul 0

Pixelii revin pe pagină prin inversa matricei aceleia. Request.PageRotation cară /Rotate-ul paginii normalizat la 0, 90, 180 sau 270 (orice valoare care nu e multiplu de 90 e tratată ca 0), iar renderer-ul întoarce pagina în sens orar, cum cere ISO 32000-1 §7.7.3.3. Sub rotație axele se schimbă între ele, iar o altă pereche de laturi de casetă e fixată la originea bitmap-ului. Scris ca formule inverse, cu S = DPI / 72, x și y în pixeli și H înălțimea bitmap-ului:

/RotateX paginăY paginăLaturi de casetă de care depinde maparea
0Left + x / SBottom + (H - y) / SLeft, Bottom
90Left + y / SBottom + x / SLeft, Bottom
180Right - x / SBottom + y / SRight, Bottom
270Right - y / STop - x / SRight, Top

Ultima coloană explică de ce bug-ul părea aleator în producție. Un CropBox care taie doar partea de sus a paginii lasă Left și Bottom neatinse, deci paginile drepte ieșeau perfect și doar paginile care cară /Rotate 270 decalau. Rotația schimbă și dimensiunile bitmap-ului: la 90 și 270 bitmap-ul e lat de (Top - Bottom) * S pixeli și înalt de (Right - Left) * S pixeli

Tabela de mapare inversă din HotPDF pentru rotația paginii: la /Rotate 0 și 90 transformarea fixează laturile Left și Bottom ale casetei randate, la 180 fixează Right și Bottom, la 270 Right și Top, motiv pentru care o pagină decupată decalează într-o direcție diferită la fiecare orientare într-un document mixt
același tăietură de jumătate de țol arată ca trei bug-uri diferite odată ce paginile cară valori /Rotate diferite, pentru că fiecare orientare fixează o altă pereche de laturi de casetă

Ce merge prost cu MediaBox [0 0 612 792] și CropBox [36 36 576 756]?

Cu o tăietură de jumătate de țol pe fiecare parte, stratul de text al unei pagini nerotite aterizează exact cu 36 de puncte la stânga și cu 36 de puncte mai jos de cuvintele scanate când e folosit MediaBox-ul. Luați o pagină US Letter al cărei CropBox taie 36 de puncte (0,5 țol) de pe fiecare latură. Caseta vizibilă e de 540 pe 720 de puncte, deci la rezoluția OCR implicită de 300 DPI scara e 300 / 72 ≈ 4,1667, iar bitmap-ul e de 2250 pe 3000 de pixeli

Să presupunem că motorul raportează un cuvânt cu casetă de pixeli Left 450, Top 600, Right 900, Bottom 660 și fără baseline. HotPDF plasează apoi baseline-ul la 20 la sută din înălțimea cuvântului deasupra laturii de jos, la rândul de pixel 648, și mapează punctul de start (450, 648):

  • Prin caseta vizibilă: x = 36 + 450 / 4,1667 = 144,0 și y = 36 + (3000 - 648) / 4,1667 = 600,48, adică locul unde cuvântul e tipărit
  • Prin MediaBox: x = 0 + 108,0 = 108,0 și y = 0 + 564,48 = 564,48, o deplasare uniformă de (-36, -36) puncte
Anatomia decalajului CropBox în HotPDF pe o pagină US Letter cu MediaBox 0 0 612 792 și CropBox 36 36 576 756: renderer-ul rasterizează caseta vizibilă la 300 DPI, astfel încât maparea pixelului de cuvânt 450 prin GetLoadedPageVisibleBox dă 144,0 și 600,48, în timp ce transformarea prin MediaBox aterizează la 108,0 și 564,48
raster-ul acoperă CropBox-ul, deci orice transformare construită din MediaBox mută fiecare cuvânt recunoscut exact cu marginea de tăiere

Rotiți aceeași pagină și direcția erorii se schimbă, pentru că sunt implicate laturi diferite. La /Rotate 180 termenul X folosește Right, iar 612 în loc de 576 împinge stratul cu 36 de puncte la dreapta, în timp ce Bottom îl trage în continuare cu 36 de puncte în jos. La /Rotate 270 atât Right, cât și Top sunt prea mari, deci stratul se mută cu 36 de puncte la dreapta și cu 36 de puncte în sus. Un document cu orientări mixte poate arăta decalajul în trei direcții, o amprentă sigură pentru bug-ul acesta. Codul scris de mână care derivează scara din casetă, precum Bitmap.Width / (Right - Left), întinde și el fiecare coordonată cu 612 / 540, cam 13 la sută, peste decalaj

Care dintre documentele PDF dvs. sunt afectate?

Un document PDF e expus când cel puțin o pagină are o casetă vizibilă diferită de MediaBox-ul ei, iar HotPDF vă poate spune asta în câteva rânduri. Comparați GetLoadedPageBox cu pbMediaBox contra GetLoadedPageVisibleBox pentru fiecare pagină și tipăriți alături GetLoadedPageRotation, astfel încât să puteți prezice direcția decalajului din tabelul de mai sus. THPDFPageBoundary oferă și pbCropBox, pbBleedBox, pbTrimBox și pbArtBox, dar GetLoadedPageBox(pbCropBox) cade înapoi pe MediaBox când nu există casetă de crop și nu decupează, deci caseta vizibilă e lucrul corect cu care se compară

uses
  System.SysUtils, HPDFDoc;

procedure ReportCroppedPages(const FileName: string);
var
  Pdf: THotPDF;
  I: Integer;
  ML, MB, MR, MT, VL, VB, VR, VT, Tmp: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) < 1 then
      raise Exception.Create('Cannot load ' + FileName);
    for I := 0 to Pdf.LoadedPageCount - 1 do
    begin
      if not Pdf.GetLoadedPageBox(I, pbMediaBox, ML, MB, MR, MT) then
        Continue;
      // tabloul stocat poate lista colțurile lui în orice ordine
      if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
      if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
      // deja normalizat și decupat la MediaBox
      if not Pdf.GetLoadedPageVisibleBox(I, VL, VB, VR, VT) then
        Continue;
      if (Abs(VL - ML) > 0.01) or (Abs(VB - MB) > 0.01) or
         (Abs(VR - MR) > 0.01) or (Abs(VT - MT) > 0.01) then
        Writeln(Format('Page %d  MediaBox [%g %g %g %g]  visible [%g %g %g %g]  /Rotate %d',
          [I + 1, ML, MB, MR, MT, VL, VB, VR, VT,
           Pdf.GetLoadedPageRotation(I)]));
    end;
  finally
    Pdf.Free;
  end;
end;

Două detalii ale lui GetLoadedPageVisibleBox contează pentru scripturi ca acesta. Funcția își lasă parametrii out neatinși când eșuează, deci pre-setarea unei mărimi de pagină implicite înaintea apelului e un tipar sigur. Iar când un CropBox malformat nu intersectează deloc MediaBox-ul, funcția întoarce MediaBox-ul, nu un dreptunghi gol. Dacă raportul listează pagini, iar build-ul livrat e mai vechi decât v2.770.153 pentru OCR sau v2.770.154 pentru coduri de bare și fețe, rulați din nou recunoașterea pe acele pagini după upgrade. Un strat OCR comis de un build afectat rămâne în fișierul salvat, iar opțiunea implicită SkipPagesWithText va sări acele pagini la o a doua trecere, decât dacă o dezactivați sau eliminați întâi vechiul strat

Cum ar trebui un IHPDFOCREngine personalizat să mapeze pixelii înapoi în spațiul PDF?

Un IHPDFOCREngine personalizat ar trebui să întoarcă casete de cuvinte în pixeli bitmap și să lase HotPDF să facă maparea; convertiți în user space doar pentru propriile decizii, iar atunci folosiți caseta din cerere, niciodată MediaBox-ul. Din v2.770.153, PageLeft, PageBottom, PageRight și PageTop ale cererii descriu caseta vizibilă randată, deci se potrivesc exact cu Request.Bitmap. Helper-ul de mai jos e inversul transformării bibliotecii, inclusiv folosirea lui de înălțimea reală a bitmap-ului pentru paginile drepte, deci e de acord cu HotPDF până la pixel

uses
  System.SysUtils, System.Math, Vcl.Graphics, HPDFDoc;

// Pixel bitmap (origine stânga-sus, Y în jos) în user space PDF
// (origine stânga-jos, Y în sus), prin caseta din care bitmap-ul a fost randat
procedure HotPixelToPage(Rotation, DPI, BitmapHeight: Integer;
  Left, Bottom, Right, Top: Single; X, Y: Double;
  out PageX, PageY: Double);
var
  S: Double;
begin
  S := DPI / 72.0;
  case Rotation of
    90:  begin PageX := Left + Y / S;  PageY := Bottom + X / S; end;
    180: begin PageX := Right - X / S; PageY := Bottom + Y / S; end;
    270: begin PageX := Right - Y / S; PageY := Top - X / S; end;
  else
    PageX := Left + X / S;
    PageY := Bottom + (BitmapHeight - Y) / S;
  end;
end;

Un motiv realist de a avea nevoie de user space în interiorul unui motor e o regulă de zonă: facturi a căror antet nu vreți niciodată căutabil sau o zonă de ștampilă care încurcă recunoscătorul. Motorul de mai jos, scris cu TInterfacedObject astfel încât reference counting-ul să-i gestioneze durata de viață, filtrează cuvintele după unde le cad centrele pe pagină, apoi întoarce supraviețuitorii neatinși în coordonate de pixel. RunRecognizer ține locul apelului propriu de recunoscător

type
  TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
  private
    FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single;  // user space
    function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
      out Words: THPDFOCRWords): boolean;  // recunoscătorul propriu, casete în pixeli
  public
    constructor Create(SkipLeft, SkipBottom, SkipRight, SkipTop: Single);
    function GetName: AnsiString;
    function Recognize(const Request: THPDFOCRRequest;
      out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
  end;

function TZoneFilterOCREngine.Recognize(const Request: THPDFOCRRequest;
  out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
var
  Raw: THPDFOCRWords;
  I, Count: Integer;
  CX, CY: Double;
begin
  Diagnostic := '';
  SetLength(Words, 0);
  if not RunRecognizer(Request.Bitmap, Request.MaxWords, Raw) then
  begin
    Diagnostic := 'recognizer failed';
    Exit(False);
  end;
  SetLength(Words, Length(Raw));
  Count := 0;
  for I := 0 to High(Raw) do
  begin
    HotPixelToPage(Request.PageRotation, Request.DPI,
      Request.Bitmap.Height, Request.PageLeft, Request.PageBottom,
      Request.PageRight, Request.PageTop,
      (Raw[I].Left + Raw[I].Right) / 2, (Raw[I].Top + Raw[I].Bottom) / 2,
      CX, CY);
    if (CX >= FSkipLeft) and (CX <= FSkipRight) and
       (CY >= FSkipBottom) and (CY <= FSkipTop) then
      Continue;
    Words[Count] := Raw[I];  // tot pixeli: HotPDF îi mapează singur
    Inc(Count);
  end;
  SetLength(Words, Count);
  Result := True;
end;

Pasați motorul către ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info) ca oricărui alt motor. Biblioteca validează ce revine înainte să aibă încredere în el: un cuvânt e aruncat și numărat în Info.DroppedWordCount când caseta lui iese din bitmap, când Right <= Left sau Bottom <= Top, sau când Confidence e în afara lui 0..1 sau sub MinimumConfidence. Întoarcerea mai multor cuvinte decât MaxWordsPerPage sau împingerea totalului trecător peste MaxTotalWords eșuează tot apelul cu o eroare de buget, deci honorați Request.MaxWords în motor. Nu convertiți casetele de cuvinte în user space înainte de a le întoarce; HotPDF ar trata valorile de punct ca pixeli, iar stratul s-ar colapsa către originea bitmap-ului

Maparea output-ului propriului detector

Același helper servește un pipeline proprie construit pe RenderLoadedPageToBitmap, care randează caseta vizibilă și aplică /Rotate exact cum o fac funcțiile de recunoaștere. Citiți caseta cu GetLoadedPageVisibleBox, normalizați rotația la fel cum face HotPDF și mapați două colțuri opuse ale fiecărei casete de pixeli. Axa Y se întoarce și, la 90 și 270 de grade, axele se schimbă între ele, deci colțurile mapate ies într-o ordine nesigură; luați minimul și maximul punctelor mapate, ceea ce e și felul în care HotPDF construiește marginile codurilor de bare

const
  DPI = 200;
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  VL, VB, VR, VT: Single;
  Rotation: Integer;
  PxL, PxT, PxR, PxB, X1, Y1, X2, Y2: Double;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('scanned-ids.pdf');
    if not Pdf.GetLoadedPageVisibleBox(0, VL, VB, VR, VT) then Exit;
    Rotation := Pdf.GetLoadedPageRotation(0) mod 360;
    if Rotation < 0 then Inc(Rotation, 360);
    if (Rotation <> 90) and (Rotation <> 180) and (Rotation <> 270) then
      Rotation := 0;
    Bmp := Pdf.RenderLoadedPageToBitmap(0, DPI);
    if Bmp = nil then Exit;
    try
      MyDetector(Bmp, PxL, PxT, PxR, PxB);  // codul propriu, casetă în pixeli
      HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
        PxL, PxT, X1, Y1);
      HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
        PxR, PxB, X2, Y2);
      Writeln(Format('User-space box [%.2f %.2f %.2f %.2f]',
        [Min(X1, X2), Min(Y1, Y2), Max(X1, X2), Max(Y1, Y2)]));
    finally
      Bmp.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Comportamentul de rotație e acoperit mai în profunzime în aplanarea rotației de pagină fără a rupe casetele de pagină, iar pipeline-ul de decodare a codurilor de bare care consumă aceeași transformare în decodarea codurilor QR rotite de pe pagini PDF. Dacă motorul propriu înfășoară un recunoscător extern, adapter-ul Tesseract OCR pentru PDF căutabil arată partea de izolare de proces și anulare a aceleiași interfețe

Referință rapidă: mapare de coordonate sigură la CropBox

  • Renderer-ul rasterizează caseta vizibilă, CropBox-ul decupat la MediaBox (ISO 32000-1 §14.11.2); fiecare mapare de la pixel la pagină trebuie să folosească caseta aceea, citită cu GetLoadedPageVisibleBox
  • HotPDF v2.770.153 a reparat ApplyLoadedOCRTextLayer; v2.770.154 a reparat DecodeLoadedPageBarcodes pe pagină întreagă și constatarele de fețe din DetectLoadedRedactionFindings; build-urile de la v2.766.64 până la acele versiuni sunt afectate
  • Casetele THPDFOCRWord sunt pixeli bitmap cu origine stânga-sus; GetLoadedPageBox și GetLoadedPageVisibleBox întorc user space PDF cu origine stânga-jos și Bottom < Top
  • Scara e DPI / 72; derivează-o din DPI, niciodată dintr-o casetă de pagină împărțită la lățimea bitmap-ului
  • /Rotate decide care laturi contează: Left și Bottom la 0 și 90, Right și Bottom la 180, Right și Top la 270
  • Întoarceți cuvintele OCR în pixeli și lăsați HotPDF să le mapeze; convertiți doar pentru logica proprie de filtrare
  • Rulați din nou OCR pe paginile decupate procesate de un build afectat și rețineți că SkipPagesWithText sare paginile care cară deja vechiul strat

Funcțiile de recunoaștere, interogările de casete de pagină și randarea de document încărcat folosite aici vin toate în componenta HotPDF pentru Delphi și C++Builder; licențierea, descărcările de probă și lista completă de funcții sunt pe pagina componentei HotPDF Delphi PDF