Technický článek

Odsazení OCR textové vrstvy HotPDF: mapování přes CropBox

OCR textové vrstvy, hranice čárových kódů a maskovací boxy obličejů se na oříznutých PDF stránkách rozcházejí, když se pixely bitmapy mapují zpět přes MediaBox místo přes box, který renderer skutečně rasterizoval: CropBox oříznutý na MediaBox (ISO 32000-1 §14.11.2). HotPDF tohle opravil u ApplyLoadedOCRTextLayer ve v2.770.153 a u DecodeLoadedPageBarcodes a DetectLoadedRedactionFindings ve v2.770.154

Hlášení chyby, které obvykle dorazí, vypadá takhle. Naskenovaný archiv smlav projde OCR, výstup je prohledávatelný a zásah vyhledávání pro číslo paragrafu se zvýrazní půl palce pod vtištěným číslem a vlevo od něj. Většina souborů v dávce je v pořádku. Rozbité všechny přišly z jedné skenovací stanice, která zapisuje /CropBox, aby ořízla okraj skla. Tenhle jeden detail odděluje obraz, který OCR engine viděl, od rámu, do kterého se textová vrstva umístila, a stejná neshoda posouvá hranice čárových kódů a, vážněji, maskovací boxy obličejů

Proč se OCR textová vrstva odchyluje od naskenovaných slov?

Textová vrstva se rozchází, protože dvě poloviny pipeline se neshodly v tom, který obdélník bitmapa pokrývá. Ve v2.766.64 změnil HotPDF renderování, SVG export, prohlížeč i tisk tak, aby respektovaly CropBox: stránka se zobrazuje přes svůj CropBox oříznutý na MediaBox, což předepisuje ISO 32000-1 §14.11.2, a GetLoadedPageVisibleBox se přidal, aby tenhle viditelný box vracel. Rozpoznávací funkce dál stavěly svou transformaci ze zařízení do stránky z GetLoadedPageBox(PageIndex, pbMediaBox, ...). Raster teď pokrýval viditelný box, transformace pořád předpokládala MediaBox a každá rozpoznaná pozice se vracela posunutá o mezeru mezi nimi

Dotčené okno je proto přesné. ApplyLoadedOCRTextLayer umisťoval text špatně od v2.766.64 do v2.770.152. Celostránkové DecodeLoadedPageBarcodes a detekce obličejů uvnitř DetectLoadedRedactionFindings zůstaly špatné o build déle, do v2.770.153. Před v2.766.64 renderer kreslil celý MediaBox, takže mapování a raster souhlasily, za cenu rozpoznávání obsahu, který prohlížeče nikdy neukážou. Opravy změnily u každé funkce tři věci naráz: transformaci, odhad pixelového rozpočtu a page box předávaný vlastnímu enginu v request záznamu

Několik případů nikdy dotčených nebylo:

  • Stránky bez /CropBox nebo s CropBoxem rovným MediaBoxu se mapují před opravou i po ní identicky
  • DecodeLoadedPageBarcodes s nastaveným HasRegion renderuje přesně region, který předáte, a mapuje přes týž region, takže dekódování s explicitním regionem bylo správné pořád; kontrola, že region leží uvnitř stránky, stále používá MediaBox
  • Nálezy maskování podle vzorů (e-maily, čísla karet a podobně) vycházejí z extrakce textu v user space, ne z rasteru, takže posunuly se jen nálezy detekce obličejů

Tři souřadné rámce a které HotPDF API používá který

HotPDF kód, který sahá k rozpoznávání, pracuje se třemi rámci a většina chyb mapování vznikne smícháním dvou z nich

  • Pixel bitmapy: počátek vlevo nahoře, Y roste dolů, jednotky jsou pixely při DPI požadavku. THPDFOCRWord.Left, Top, Right a Bottom jsou v tomhle rámu, stejně jako volitelné baseline body, výsledky, které vrací vlastní IHPDFBarcodeDecoder, a boxy od vlastního IHPDFFaceDetector
  • PDF user space načtené stránky: počátek vlevo dole, Y roste nahoru, jednotky jsou body, s Bottom < Top. GetLoadedPageBox a GetLoadedPageVisibleBox vracejí Left, Bottom, Right, Top v tomhle rámu a totéž platí o polích PageLeft, PageBottom, PageRight a PageTop záznamu THPDFOCRRequest, hranicích v THPDFDecodedBarcode a obdélnících v THPDFRedactionFinding
  • Page-drawing souřadnice HotPDF: API, kterým stavíte nové stránky (výstup textu, tvary, čárové kódy, odkazy, formulářová pole), pracuje s počátkem vlevo nahoře a Y rostoucí dolů. Tenhle rámec patří generování dokumentů a s výše uvedenými API načtených dokumentů nemá nic společného, takže do něj nikdy nepouštějte obdélník v user space načtené stránky bez úpravy

Záznam OCR slova je záměrně postavený na pixelech: engine hlásí, co viděl v obraze, a konverzi vlastní ApplyLoadedOCRTextLayer. Tohle dělení funguje jen tehdy, když konverze používá správný box, a o to se postarala v2.770.153

Souřadné rámce rozpoznávání HotPDF: pixely bitmapy s počátkem vlevo nahoře, které používají boxy THPDFOCRWord a vlastní dekodéry, PDF user space s počátkem vlevo dole, který vrací GetLoadedPageBox a GetLoadedPageVisibleBox, a page-drawing API s levým horním počátkem, které nesmí nikdy dostat obdélník načtené stránky bez úpravy
engine hlásí pixely, protože to je to, co viděly, HotPDF je mapuje a smícháním obou rámců se vrstvy i maskovací boxy rozcházejí

Transformace ze zařízení do stránky za OCR, čárovými kódy a obličeji

HotPDF mapuje pixely bitmapy na stránku jedinou afinní maticí postavenou z pěti vstupů: rotace, měřítka DPI / 72, výšky bitmapy a Left, Bottom, Right a Top vyrenderovaného boxu. OCR, dekódování čárových kódů i detekce obličejů sdílejí pro ni jednu rutinu, proto jeden špatný boxový vstup rozbil všechny tři stejným způsobem. Pro nerotovanou stránku je matice ze stránky do zařízení [A B C D E F] takováto:

  • A = Scale a D = -Scale, kde Scale = DPI / 72; záporné D překlopí user space (Y nahoru) do prostoru bitmapy (Y dolů)
  • B = C = 0, protože nerotovaná stránka nemá žádné střižení ani prohození os
  • E = -Left * Scale, což posune levou hranu boxu na pixelový sloupec 0
  • F = BitmapHeight + Bottom * Scale, což mapuje spodní hranu boxu na y = BitmapHeight, dolní hranu bitmapy, takže horní hrana dopadne na řádek 0

Pixely se na stránku vracejí přes inverzi té matice. Request.PageRotation nese /Rotate stránky normalizované na 0, 90, 180 nebo 270 (jakákoli hodnota, která není násobkem 90, se bere jako 0) a renderer otáčí stránku po směru hodinových ručiček, jak vyžaduje ISO 32000-1 §7.7.3.3. Při rotaci se osy prohodí a na počátek bitmapy se připne jiný pár hran boxu. Vepsáno jako inverzní vzorce, s S = DPI / 72, x a y v pixelech a H jako výškou bitmapy:

/RotateX na stránceY na stránceHrany boxu, na kterých mapování stojí
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

Poslední sloupec vysvětluje, proč ta chyba vypadala v produkci náhodně. CropBox, který ořízne jen horní část stránky, nechá Left a Bottom nedotčené, takže vzpřímené stránky vyšly dokonale a posouvaly se jen stránky nesoucí /Rotate 270. Rotace taky prohodí rozměry bitmapy: při 90 a 270 je bitmapa (Top - Bottom) * S pixelů široká a (Right - Left) * S pixelů vysoká

Tabulka inverzního mapování HotPDF pro rotaci stránky: při /Rotate 0 a 90 připne transformace hrany Left a Bottom vyrenderovaného boxu, při 180 Right a Bottom a při 270 Right a Top, proto se oříznutá stránka posouvá jiným směrem pro každou orientaci ve smíšeném dokumentu
týž půlpalcový ořez vypadá jako tři různé chyby, jakmile stránky nesou různé hodnoty /Rotate, protože každá orientace připíná jiný pár hran boxu

Co se pokazí u MediaBox [0 0 612 792] a CropBox [36 36 576 756]?

S půlpalcovým ořezem na každé straně dopadne textová vrstva nerotované stránky přesně 36 bodů vlevo od naskenovaných slov a 36 bodů pod nimi, když se použije MediaBox. Vezměte stránku US Letter, jejíž CropBox ořízne 36 bodů (0,5 palce) z každé hrany. Viditelný box je 540 krát 720 bodů, takže při defaultním OCR rozlišení 300 DPI je měřítko 300 / 72 ≈ 4.1667 a bitmapa má 2250 krát 3000 pixelů

Předpokládejme, že engine hlásí slovo s pixelovým boxem Left 450, Top 600, Right 900, Bottom 660 a bez baseline. HotPDF pak umístí baseline 20 procent výšky slova nad spodní hranu, na pixelový řádek 648, a zmapuje počáteční bod (450, 648):

  • Přes viditelný box: x = 36 + 450 / 4.1667 = 144.0 a y = 36 + (3000 - 648) / 4.1667 = 600.48, což je místo, kde je slovo vytištěné
  • Přes MediaBox: x = 0 + 108.0 = 108.0 a y = 0 + 564.48 = 564.48, jednotný posun o (-36, -36) bodů
Anatomie posunu CropBox v HotPDF na stránce US Letter s MediaBox 0 0 612 792 a CropBox 36 36 576 756: renderer rasterizuje viditelný box při 300 DPI, takže zmapování slova na pixelu 450 přes GetLoadedPageVisibleBox dá 144.0 a 600.48, zatímco transformace přes MediaBox dopadne na 108.0 a 564.48
raster pokrývá CropBox, takže jakákoli transformace postavená na MediaBoxu posune každé rozpoznané slovo přesně o ořezový okraj

Otočte tutéž stránku a směr chyby se změní, protože hrají roli jiné hrany. Při /Rotate 180 používá X člen Right a 612 místo 576 tlačí vrstvu 36 bodů doprava, zatímco Bottom ji pořád táhne 36 bodů dolů. Při /Rotate 270 jsou Right i Top příliš velké, takže vrstva se posune 36 bodů doprava a 36 bodů nahoru. Dokument se smíšenými orientacemi umí ukázat posun ve třech směrech, spolehlivý otisk téhle chyby. Ručně psaný kód, který odvozuje měřítko z boxu, například Bitmap.Width / (Right - Left), navíc natáhne každou souřadnici 612 / 540, zhruba o 13 procent, k tomu posunu

Které z vašich PDF dokumentů se týká?

PDF dokument je ohrožený, když má aspoň jedna stránka viditelný box lišící se od jeho MediaBoxu, a HotPDF vám to řekne v pár řádcích. Porovnejte GetLoadedPageBox s pbMediaBox proti GetLoadedPageVisibleBox pro každou stránku a vytiskněte vedle toho GetLoadedPageRotation, abyste mohli z tabulky výše předpovědět směr posunu. THPDFPageBoundary taky nabízí pbCropBox, pbBleedBox, pbTrimBox a pbArtBox, ale GetLoadedPageBox(pbCropBox) spadne zpátky na MediaBox, když crop box neexistuje, a neořezává, takže správným etalonem pro srovnání je viditelný box

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;
      // uložené pole může vypsat své rohy v libovolném pořadí
      if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
      if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
      // už normalizované a oříznuté na 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;

U skriptů jako tenhle záleží na dvou detailech GetLoadedPageVisibleBox. Funkce nechá své out parametry nedotčené, když selže, takže přednastavení defaultní velikosti stránky před voláním je bezpečný vzor. A když poškozený CropBox neprotíná MediaBox vůbec, funkce vrátí MediaBox místo prázdného obdélníku. Pokud výpis uvádí nějaké stránky a váš nasazený build je starší než v2.770.153 pro OCR, respektive v2.770.154 pro čárové kódy a obličeje, pusťte rozpoznávání na těchhle stránkách po upgradu znovu. OCR vrstva zapsaná dotčeným buildem zůstává v uloženém souboru a defaultní volba SkipPagesWithText tyhle stránky při druhém průchodu přeskočí, pokud ji nevypnete, nebo nejdřív neodstraníte starou vrstvu

Jak by měl vlastní IHPDFOCREngine mapovat pixely zpět do PDF prostoru?

Vlastní IHPDFOCREngine má vracet boxy slov v pixelech bitmapy a nechat mapování na HotPDF; do user space převádějte jen pro vlastní rozhodování a pak použijte box z požadavku, nikdy MediaBox. Od v2.770.153 popisují PageLeft, PageBottom, PageRight a PageTop požadavku vyrenderovaný viditelný box, takže odpovídají Request.Bitmap přesně. Pomocník níže je inverzí transformace knihovny, včetně použití skutečné výšky bitmapy pro vzpřímené stránky, takže souhlasí s HotPDF na pixel

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

// Pixel bitmapy (počátek vlevo nahoře, Y dolů) do PDF user space
// (počátek vlevo dole, Y nahoru), přes box, ze kterého se bitmapa renderovala
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;

Reálný důvod, proč potřebovat user space uvnitř engine, je zónové pravidlo: faktury, jejichž hlavičkový papír nikdy nechcete prohledávatelný, nebo oblast razítka, která recognizer zmate. Engine níže, napsaný s TInterfacedObject, takže o jeho životnost se stará počítání referencí, filtruje slova podle toho, kam spadnou jejich středy na stránce, a přeživší vrátí nedotčené v pixelových souřadnicích. RunRecognizer zastupuje vaše vlastní volání recognizeru

type
  TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
  private
    FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single;  // user space
    function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
      out Words: THPDFOCRWords): boolean;  // váš recognizer, pixelové boxy
  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];  // stále pixely: HotPDF je zmapuje sám
    Inc(Count);
  end;
  SetLength(Words, Count);
  Result := True;
end;

Engine předejte ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info) jako kterýmkoli jiným. Knihovna validuje, co se vrátí, dřív než tomu věří: slovo se upustí a započítá do Info.DroppedWordCount, když jeho box opustí bitmapu, když je Right <= Left nebo Bottom <= Top, nebo když je Confidence mimo 0..1 nebo pod MinimumConfidence. Vrácení víc slov než MaxWordsPerPage nebo přetažení běžného součtu přes MaxTotalWords nechá selhat celé volání chybou rozpočtu, takže respektujte Request.MaxWords v enginu. Nepřevádějte boxy slov do user space před vrácením; HotPDF by hodnoty bral jako pixely a vrstva by se zhroutila k počátku bitmapy

Mapování výstupu vlastního detektoru

Týž pomocník poslouží vlastní pipeline postavené na RenderLoadedPageToBitmap, která renderuje viditelný box a aplikuje /Rotate úplně stejně jako rozpoznávací funkce. Přečtěte box přes GetLoadedPageVisibleBox, normalizujte rotaci stejně jako HotPDF a zmapujte dva protilehlé rohy každého pixelového boxu. Osa Y se překlopí a při 90 a 270 stupních se osy prohodí, takže zmapované rohy vycházejí v žádném pevném pořadí; vezměte minimum a maximum ze zmapovaných bodů, taky tak HotPDF staví hranice čárových kódů

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);  // váš kód, pixelový box
      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;

Chování rotace do hloubky rozebírá článek vyrovnání rotace stránek bez rozbití page boxů a pipeline dekódování čárových kódů, která tutéž transformaci spotřebovává, článek dekódování otočených QR kódů z PDF stránek. Pokud váš engine obaluje externí recognizer, Tesseract OCR adapter pro prohledávatelné PDF ukazuje tu stranou izolaci procesu a zrušení téhož interface

Rychlá reference: mapování souřadnic bezpečné vůči CropBoxu

  • Renderer rasterizuje viditelný box, CropBox oříznutý na MediaBox (ISO 32000-1 §14.11.2); každé mapování z pixelů na stránku musí použít tenhle box, přečtený přes GetLoadedPageVisibleBox
  • HotPDF v2.770.153 opravil ApplyLoadedOCRTextLayer; v2.770.154 opravil celostránkové DecodeLoadedPageBarcodes a nálezy obličejů z DetectLoadedRedactionFindings; buildy od v2.766.64 až po tyhle verze jsou dotčené
  • Boxy THPDFOCRWord jsou pixely bitmapy s počátkem vlevo nahoře; GetLoadedPageBox a GetLoadedPageVisibleBox vrací PDF user space s počátkem vlevo dole a Bottom < Top
  • Měřítko je DPI / 72; odvozujte ho z DPI, nikdy z page boxu děleného do šířky bitmapy
  • /Rotate rozhoduje, které hrany se počítají: Left a Bottom při 0 a 90, Right a Bottom při 180, Right a Top při 270
  • Vracejte OCR slova v pixelech a mapování nechte na HotPDF; převádějte jen pro vlastní filtrovací logiku
  • Pusťte OCR na oříznutých stránkách zpracovaných dotčeným buildem znovu a pamatujte, že SkipPagesWithText přeskakuje stránky, které už starou vrstvu nesou

Rozpoznávací funkce, dotazy na page boxy i renderování načtených dokumentů použité tady lodí všechny v komponentě HotPDF pro Delphi a C++Builder; licencování, zkušební stahování a celý seznam funkcí jsou na stránce HotPDF Delphi PDF komponenty