Artykuł techniczny

Warstwa tekstu OCR w HotPDF: mapowanie przez CropBox

Warstwy tekstu OCR, granice kodów kreskowych i boxy zacierania twarzy dryfują na przyciętych stronach PDF, gdy piksele bitmapy są mapowane z powrotem przez MediaBox zamiast przez box, który renderer faktycznie zrastryzował: CropBox przycięty do MediaBoxa (ISO 32000-1 §14.11.2). HotPDF naprawiło to dla ApplyLoadedOCRTextLayer w v2.770.153, a dla DecodeLoadedPageBarcodes i DetectLoadedRedactionFindings w v2.770.154

Zgłoszenie błędu, które zwykle przychodzi, wygląda tak. Zeskanowane archiwum umów przechodzi przez OCR, wyjście jest przeszukiwalne, a trafienie wyszukiwania dla numeru paragrafu podświetla się pół cala poniżej i na lewo od wydrukowanego numeru. Większość plików w partii jest w porządku. Zepsute wszystkie przyszły z jednej stacji skanującej, która zapisuje /CropBox, żeby ściąć margines płyty. Ten jeden szczegół rozdziela obraz, który widział engine OCR, od ramki, w której osadzono warstwę tekstu, a ta sama niezgodność przesuwa granice kodów kreskowych i, poważniej, boxy zacierania twarzy

Dlaczego warstwa tekstu OCR odjeżdża od zeskanowanych słów?

Warstwa tekstu odjeżdża, bo dwie połówki potoku nie zgodziły się, który prostokąt pokrywa bitmapa. W v2.766.64 HotPDF zmieniło renderowanie, eksport SVG, przeglądarkę i drukowanie, żeby honorować CropBox: strona jest wyświetlana przez swój CropBox przycięty do MediaBoxa, co nakazuje ISO 32000-1 §14.11.2, i dodano GetLoadedPageVisibleBox, by zwracał ten widoczny box. Funkcje rozpoznawania dalej budowały swoją transformację urządzenie–strona z GetLoadedPageBox(PageIndex, pbMediaBox, ...). Raster pokrywał teraz widoczny box, transformacja zakładała wciąż MediaBox, i każda rozpoznana pozycja wracała przesunięta o szczelinę między nimi

Dotknięte okno jest więc precyzyjne. ApplyLoadedOCRTextLayer gubiło tekst od v2.766.64 do v2.770.152 włącznie. Całostronicowe DecodeLoadedPageBarcodes i detekcja twarzy wewnątrz DetectLoadedRedactionFindings zostawały złe o jedną kompilację dłużej, do v2.770.153. Przed v2.766.64 renderer rysował cały MediaBox, więc mapowanie i raster się zgadzały — kosztem rozpoznawania treści, której przeglądarki nigdy nie pokazują. Poprawki zmieniły trzy rzeczy naraz dla każdej funkcji: transformację, szacunek budżetu pikseli i box strony wręczany własnemu engine'owi w rekordzie żądania

Kilka przypadków nie było nigdy dotkniętych:

  • Strony bez /CropBox albo o CropBoxie równym MediaBoxowi mapują się identycznie przed i po poprawce
  • DecodeLoadedPageBarcodes z ustawionym HasRegion renderuje dokładnie region, który podasz, i mapuje przez ten sam region, więc dekodowanie z jawnym regionem było poprawne przez cały czas; sprawdzenie, że region leży wewnątrz strony, wciąż używa MediaBoxa
  • Wyniki zacierania oparte na wzorcach (e-maile, numery kart i tak dalej) pochodzą z ekstrakcji tekstu w przestrzeni użytkownika, nie z rastra, więc ruszyły się tylko wyniki detekcji twarzy

Trzy układy współrzędnych i których API HotPDF używa każdego

Kod HotPDF dotykający rozpoznawania operuje na trzech układach, a większość błędów mapowania bierze się z zmieszania dwóch z nich

  • Piksele bitmapy: początek lewy górny, Y rośnie w dół, jednostką są piksele przy żądanym DPI. THPDFOCRWord.Left, Top, Right i Bottom są w tym układzie, tak samo opcjonalne punkty linii bazowej, wyniki, które zwraca własny IHPDFBarcodeDecoder, i boxy od własnego IHPDFFaceDetector
  • Przestrzeń użytkownika PDF wczytanej strony: początek lewy dolny, Y rośnie w górę, jednostką są punkty, z Bottom < Top. GetLoadedPageBox i GetLoadedPageVisibleBox zwracają Left, Bottom, Right, Top w tym układzie, podobnie pola PageLeft, PageBottom, PageRight i PageTop w THPDFOCRRequest, granice w THPDFDecodedBarcode i prostokąty w THPDFRedactionFinding
  • Współrzędne rysowania stron HotPDF: API, którym budujesz nowe strony (tekst, kształty, kody kreskowe, linki, pola formularzy), pracuje z początkiem lewym górnym i Y rosnącym w dół. Ten układ należy do generowania dokumentów i nie ma nic wspólnego z powyższymi API dokumentów wczytanych, więc nigdy nie podawaj mu prostokąta w przestrzeni użytkownika strony wczytanej bez zmian

Rekord słowa OCR jest celowo pikselowy: engine raportuje to, co widział na obrazie, a konwersją rządzi ApplyLoadedOCRTextLayer. Ten podział działa tylko wtedy, gdy konwersja używa właściwego boxa — to przywróciło v2.770.153

Układy współrzędnych rozpoznawania w HotPDF: piksele bitmapy z początkiem lewym górnym używane przez boxy THPDFOCRWord i własne dekodery, przestrzeń użytkownika PDF z początkiem lewym dolnym zwracana przez GetLoadedPageBox i GetLoadedPageVisibleBox oraz topowe API rysowania stron, które nigdy nie może dostać prostokąta strony wczytanej bez zmian
engine'y raportują piksele, bo to widziały, HotPDF je mapuje, a zmieszanie dwóch układów to właśnie sposób, w jaki warstwy i boxy zacierania dryfują

Transformacja urządzenie–strona stojąca za OCR, kodami i twarzami

HotPDF mapuje piksele bitmapy na stronę jedną macierzą afiniczną zbudowaną z pięciu wejść: obrotu, skali DPI / 72, wysokości bitmapy oraz Left, Bottom, Right i Top wyrenderowanego boxa. OCR, dekodowanie kodów kreskowych i detekcja twarzy dzielą na to jedną rutynę, dlatego jedno złe wejście boxa wywaliło wszystkie trzy tak samo. Dla strony bez obrotu macierz strona–urządzenie [A B C D E F] to:

  • A = Scale i D = -Scale, gdzie Scale = DPI / 72; ujemne D przewraca przestrzeń użytkownika (Y w górę) w przestrzeń bitmapy (Y w dół)
  • B = C = 0, bo strona bez obrotu nie ma ścinania ani zamiany osi
  • E = -Left * Scale, co przenosi lewą krawędź boxa na kolumnę pikseli 0
  • F = BitmapHeight + Bottom * Scale, co mapuje dolną krawędź boxa na y = BitmapHeight, dolną krawędź bitmapy, więc górna krawędź ląduje na wierszu 0

Piksele wracają na stronę przez odwrotność tej macierzy. Request.PageRotation niesie /Rotate strony znormalizowane do 0, 90, 180 albo 270 (każda wartość niebędąca wielokrotnością 90 jest traktowana jako 0), a renderer obraca stronę zgodnie z ruchem wskazówek zegara, jak wymaga ISO 32000-1 §7.7.3.3. Przy obrocie osie się zamieniają i inna para krawędzi boxa jest przypięta do początku bitmapy. Zapisane jako formuły odwrotne, przy S = DPI / 72, x i y w pikselach i H wysokości bitmapy:

/RotateX stronyY stronyKrawędzie boxa, od których zależy mapowanie
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

Ostatnia kolumna wyjaśnia, dlaczego błąd wyglądał na losowy w produkcji. CropBox ścinający tylko górę strony zostawia Left i Bottom nietknięte, więc strony w pionie wychodziły idealnie, a dryfowały tylko strony niosące /Rotate 270. Obrót przy okazji zamienia wymiary bitmapy: przy 90 i 270 bitmapa jest szeroka na (Top - Bottom) * S pikseli i wysoka na (Right - Left) * S pikseli

Tabela odwrotnego mapowania przy obrocie strony w HotPDF: przy /Rotate 0 i 90 transformacja przypina krawędzie Left i Bottom wyrenderowanego boxa, przy 180 Right i Bottom, przy 270 Right i Top, dlatego przycięta strona dryfuje w innym kierunku przy każdej orientacji w dokumencie mieszanym
to samo ścinanie o pół cala wygląda jak trzy różne błędy, gdy strony niosą różne wartości /Rotate, bo każda orientacja przypina inną parę krawędzi boxa

Co się psuje przy MediaBox [0 0 612 792] i CropBox [36 36 576 756]?

Przy półcalowym ścinaniu z każdej strony warstwa tekstu strony bez obrotu ląduje dokładnie 36 punktów na lewo i 36 punktów poniżej zeskanowanych słów, gdy użyto MediaBoxa. Weźmy stronę US Letter, której CropBox ścina 36 punktów (0,5 cala) z każdej krawędzi. Widoczny box to 540 na 720 punktów, więc przy domyślnej rozdzielczości OCR 300 DPI skala wynosi 300 / 72 ≈ 4,1667, a bitmapa 2250 na 3000 pikseli

Załóżmy, że engine raportuje słowo z pikselowym boxem Left 450, Top 600, Right 900, Bottom 660 i bez linii bazowej. HotPDF kładzie potem linię bazową 20 procent wysokości słowa nad dolną krawędzią, w wierszu pikseli 648, i mapuje punkt startowy (450, 648):

  • Przez widoczny box: x = 36 + 450 / 4,1667 = 144,0 i y = 36 + (3000 - 648) / 4,1667 = 600,48, czyli tam, gdzie słowo jest wydrukowane
  • Przez MediaBox: x = 0 + 108,0 = 108,0 i y = 0 + 564,48 = 564,48 — równomierne przesunięcie o (-36, -36) punktu
Anatomia dryfu CropBox w HotPDF na stronie US Letter z MediaBox 0 0 612 792 i CropBox 36 36 576 756: renderer rasteryzuje widoczny box przy 300 DPI, więc zmapowanie piksela słowa 450 przez GetLoadedPageVisibleBox daje 144,0 i 600,48, podczas gdy transformacja MediaBoxa ląduje na 108,0 i 564,48
raster pokrywa CropBox, więc każda transformacja zbudowana z MediaBoxa przesuwa każde rozpoznane słowo dokładnie o margines ścinania

Obróć tę samą stronę, a kierunek błędu się zmienia, bo w grę wchodzą inne krawędzie. Przy /Rotate 180 składnik X używa Right, a 612 zamiast 576 pcha warstwę 36 punktów w prawo, podczas gdy Bottom wciąż ciągnie ją 36 punktów w dół. Przy /Rotate 270 i Right, i Top są za duże, więc warstwa przesuwa się 36 punktów w prawo i 36 punktów w górę. Dokument o mieszanych orientacjach potrafi pokazać dryf w trzech kierunkach — pewny odcisk palca tego błęda. Kod pisany ręcznie, który wyprowadza skalę z boxa, jak Bitmap.Width / (Right - Left), rozciąga przy okazji każdą współrzędną o 612 / 540, czyli grubo 13 procent, do tego przesunięcia

Które z twoich dokumentów PDF są dotknięte?

Dokument PDF jest narażony, gdy przynajmniej jedna strona ma widoczny box różny od swojego MediaBoxa, a HotPDF powie ci to w kilku liniach. Porównaj GetLoadedPageBox z pbMediaBox względem GetLoadedPageVisibleBox dla każdej strony i wypisz obok GetLoadedPageRotation, żebyś mógł przewidzieć kierunek dryfu z tabeli powyżej. THPDFPageBoundary oferuje też pbCropBox, pbBleedBox, pbTrimBox i pbArtBox, ale GetLoadedPageBox(pbCropBox) cofa się do MediaBoxa, gdy crop boxa nie ma, i nie przycina, więc widoczny box to właściwy punkt odniesienia

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;
      // zapisana tablica może wypisać swoje narożniki w dowolnej kolejności
      if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
      if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
      // już znormalizowany i przycięty do MediaBoxa
      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;

Dwa szczegóły GetLoadedPageVisibleBox mają znaczenie dla skryptów takich jak ten. Funkcja zostawia swoje parametry out nietknięte, gdy zawodzi, więc wcześniejsze ustawienie domyślnego rozmiaru strony przed wywołaniem to bezpieczny wzorzec. A gdy zniekształcony CropBox w ogóle nie przecina MediaBoxa, funkcja zwraca MediaBox zamiast pustego prostokąta. Jeśli raport wypisze strony, a twoja wdrożona kompilacja jest starsza niż v2.770.153 dla OCR albo v2.770.154 dla kodów i twarzy, przelicz rozpoznawanie tych stron po aktualizacji. Warstwa OCR zatwierdzona przez dotkniętą kompilację zostaje w zapisanym pliku, a domyślna opcja SkipPagesWithText pominie te strony przy drugim przebiegu, dopóki jej nie wyłączysz albo nie usuniesz starej warstwy

Jak własny IHPDFOCREngine powinien mapować piksele z powrotem do przestrzeni PDF?

Własny IHPDFOCREngine powinien zwracać boxy słów w pikselach bitmapy i zostawić mapowanie HotPDF; przeliczaj do przestrzeni użytkownika tylko na własne decyzje, a wtedy używaj boxa z żądania, nigdy MediaBoxa. Od v2.770.153 pola żądania PageLeft, PageBottom, PageRight i PageTop opisują wyrenderowany widoczny box, więc pasują do Request.Bitmap dokładnie. Helper poniżej to odwrotność transformacji biblioteki, łącznie z użyciem rzeczywistej wysokości bitmapy dla stron w pionie, więc zgadza się z HotPDF co do piksela

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

// Piksel bitmapy (początek lewy górny, Y w dół) do przestrzeni użytkownika PDF
// (początek lewy dolny, Y w górę), przez box, z którego bitmapa została wyrenderowana
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;

Realistyczny powód, by potrzebować przestrzeni użytkownika wewnątrz engine'a, to reguła stref: faktury, których papieru firmowego nigdy nie chcesz przeszukiwalnego, albo pole pieczątki, które myli rozpoznawacz. Engine poniżej, napisany na TInterfacedObject, żeby licznikiem referencji zajął się jego czas życia, filtruje słowa według tego, gdzie padają ich środki na stronie, po czym zwraca ocalałe nietknięte we współrzędnych pikselowych. RunRecognizer zastępuje wywołanie twojego własnego rozpoznawacza

type
  TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
  private
    FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single;  // przestrzeń użytkownika
    function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
      out Words: THPDFOCRWords): boolean;  // twój rozpoznawacz, boxy pikselowe
  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];  // wciąż piksele: HotPDF mapuje je sam
    Inc(Count);
  end;
  SetLength(Words, Count);
  Result := True;
end;

Przekaż engine do ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info) jak każdy inny. Biblioteka waliduje to, co wraca, zanim jej zaufa: słowo jest upuszczane i liczone w Info.DroppedWordCount, gdy jego box wychodzi z bitmapy, gdy Right <= Left albo Bottom <= Top, albo gdy Confidence jest poza 0..1 albo poniżej MinimumConfidence. Zwrócenie więcej słów niż MaxWordsPerPage albo przepchnięcie biejącej sumy poza MaxTotalWords wywala całe wywołanie błędem budżetu, więc respektuj Request.MaxWords w engine'ie. Nie przeliczaj boxów słów do przestrzeni użytkownika przed ich zwróceniem; HotPDF wziąłby wartości punktowe za piksele i warstwa zapadłaby się w stronę początku bitmapy

Mapowanie wyjścia własnego detektora

Ten sam helper obsłuży domowy potok zbudowany na RenderLoadedPageToBitmap, który renderuje widoczny box i stosuje /Rotate dokładnie tak jak funkcje rozpoznawania. Przeczytaj box przez GetLoadedPageVisibleBox, znormalizuj obrót tak samo jak HotPDF i zmapuj dwa przeciwległe narożniki każdego pikselowego boxa. Oś Y się odwraca, a przy 90 i 270 stopniach osie się zamieniają, więc zmapowane narożniki wychodzą w żadnej stałej kolejności; weź minimum i maksimum zmapowanych punktów — tak samo HotPDF buduje granice kodów kreskowych

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);  // twój kod, box pikselowy
      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;

Zachowanie przy obrocie jest opisane głębiej w spłaszczaniu obrotu strony bez psucia boxów stron, a potok dekodowania kodów kreskowych, który zjada tę samą transformację, w dekodowaniu obróconych kodów QR ze stron PDF. Jeśli twój engine opakowuje zewnętrzny rozpoznawacz, adapter Tesseract OCR dla przeszukiwalnego PDF pokazuje stronę izolacji procesów i anulowania tego samego interfejsu

Ściąga: mapowanie współrzędnych bezpieczne względem CropBoxa

  • Renderer rasteryzuje widoczny box, czyli CropBox przycięty do MediaBoxa (ISO 32000-1 §14.11.2); każde mapowanie piksel–strona musi używać tego boxa, czytanego przez GetLoadedPageVisibleBox
  • HotPDF v2.770.153 naprawiło ApplyLoadedOCRTextLayer; v2.770.154 naprawiło całostronicowe DecodeLoadedPageBarcodes i wyniki twarzy z DetectLoadedRedactionFindings; kompilacje od v2.766.64 do tych wersji są dotknięte
  • Boxy THPDFOCRWord to piksele bitmapy z początkiem lewym górnym; GetLoadedPageBox i GetLoadedPageVisibleBox zwracają przestrzeń użytkownika PDF z początkiem lewym dolnym i Bottom < Top
  • Skala to DPI / 72; wyprowadzaj ją z DPI, nigdy z boxa strony dzielonego w szerokość bitmapy
  • /Rotate rozstrzyga, które krawędzie się liczą: Left i Bottom przy 0 i 90, Right i Bottom przy 180, Right i Top przy 270
  • Zwracaj słowa OCR w pikselach i pozwól mapować HotPDF; przeliczaj tylko na własną logikę filtrowania
  • Przelicz OCR na przyciętych stronach przetworzonych przez dotkniętą kompilację i pamiętaj, że SkipPagesWithText pomija strony, które wciąż niosą starą warstwę

Funkcje rozpoznawania, zapytania o boxy stron i renderowanie dokumentów wczytanych użyte tutaj jadą wszystkie w komponencie HotPDF dla Delphi i C++Buildera; licencjonowanie, wersje próbne i pełną listę funkcji znajdziesz na stronie komponentu HotPDF Delphi PDF