Технічна стаття

Зсув OCR-шару тексту HotPDF: мапінг крізь CropBox

Шари тексту OCR, межі штрихкодів і бокси redaction облич зсуваються на обрізаних сторінках PDF, коли пікселі бітмапа мапляться назад через MediaBox замість бокса, який рендерер фактично растеризував: CropBox, притиснутий до MediaBox (ISO 32000-1 §14.11.2). HotPDF полагодив це для ApplyLoadedOCRTextLayer у v2.770.153, а для DecodeLoadedPageBarcodes і DetectLoadedRedactionFindings — у v2.770.154

Баг-репорт, який зазвичай приходить, виглядає так. Архів відсканованих контрактів проходить OCR, вихід пошуковий, і збіг пошуку за номером пункту підсвічується на пів дюйма нижче та лівіше надрукованого числа. Більшість файлів у партії в порядку. Зламані всі прийшли з однієї сканувальної станції, яка пише /CropBox, щоб обрізати поле планшета. Та одна деталь відділяє картину, яку бачив OCR-рушій, від рамки, у яку покладено текстовий шар, і та сама невідповідність зсуває межі штрихкодів і, серйозніше, бокси redaction облич

Чому шар тексту OCR відпливає від відсканованих слів?

Шар тексту відпливає тому, що дві половини конвеєра розходилися в думках про те, який прямокутник покриває бітмап. У v2.766.64 HotPDF змінив рендеринг, SVG-експорт, переглядач і друк, щоб шанувати CropBox: сторінка показується крізь свій CropBox, притиснутий до MediaBox, — саме те, що приписує ISO 32000-1 §14.11.2, — і було додано GetLoadedPageVisibleBox, щоб повертати той видимий бокс. Функції розпізнавання продовжували будувати перетворення пристрій-сторінка з GetLoadedPageBox(PageIndex, pbMediaBox, ...). Растр тепер покривав видимий бокс, перетворення досі припускав MediaBox, і кожна розпізнана позиція поверталася зі зсувом на проміжок між ними

Постраждале вікно відтак точне. ApplyLoadedOCRTextLayer клав текст не туди з v2.766.64 по v2.770.152. Загальносторінкові DecodeLoadedPageBarcodes і детекція облич усередині DetectLoadedRedactionFindings лишалися неправильними на одну збірку довше, по v2.770.153. До v2.766.64 рендерер малював увесь MediaBox, тож мапінг і растр сходилися — ціною розпізнавання вмісту, якого переглядачі ніколи не показують. Виправлення змінювали разом три речі для кожної функції: перетворення, оцінку піксельного бюджету та бокс сторінки, що вручається власному рушію в записі запиту

Кілька випадків не постраждали ніколи:

  • Сторінки без /CropBox чи з CropBox, рівним MediaBox, мапляться ідентично до і після виправлення
  • DecodeLoadedPageBarcodes зі встановленим HasRegion рендерить точно той регіон, який ви передаєте, і мапить крізь той самий регіон, тож декодування за явним регіоном було правильним увесь час; перевірка, що регіон лежить усередині сторінки, досі користується MediaBox
  • Результати redaction за патернами (електронні пошти, номери карток тощо) походять із екстракції тексту в user space, а не з растру, тож зсунулися лише результати детекції облич

Три системи координат і те, які HotPDF API яку вживають

Код HotPDF, що торкається розпізнавання, має діло з трьома системами, і більшість багів мапінгу походить зі змішування двох із них

  • Пікселі бітмапа: початок згори ліворуч, Y зростає вниз, одиниці — пікселі при запитаному DPI. THPDFOCRWord.Left, Top, Right і Bottom — у цій системі, як і необов'язкові точки базової лінії, результати, які повертає власний IHPDFBarcodeDecoder, і бокси від власного IHPDFFaceDetector
  • PDF user space завантаженої сторінки: початок знизу ліворуч, Y зростає вгору, одиниці — пункти, з Bottom < Top. GetLoadedPageBox і GetLoadedPageVisibleBox повертають Left, Bottom, Right, Top у цій системі, як і поля PageLeft, PageBottom, PageRight і PageTop у THPDFOCRRequest, межі в THPDFDecodedBarcode та прямокутники в THPDFRedactionFinding
  • Координати малювання сторінок HotPDF: API, яким ви будуєте нові сторінки (текстовий вихід, фігури, штрихкоди, посилання, поля форм), працює з початком згори ліворуч і Y, що зростає вниз. Та система належить генерації документів і не має нічого спільного з описаними вище API завантаженого документа, тож ніколи не годуйте в неї прямокутник у user space завантаженої сторінки без змін

Запис слова OCR свідомо піксельний: рушій звітує те, що він бачив на зображенні, а конверсією володіє ApplyLoadedOCRTextLayer. Той поділ працює лише тоді, коли конверсія користується правильним боксом — саме те, що повернув собі v2.770.153

Системи координат розпізнавання HotPDF: пікселі бітмапа з початком згори ліворуч, які вживають бокси THPDFOCRWord і власні декодери, PDF user space з початком знизу ліворуч, який повертають GetLoadedPageBox і GetLoadedPageVisibleBox, і API малювання сторінки згори ліворуч, який ніколи не мусить отримувати прямокутник завантаженої сторінки без змін
рушії звітують пікселі, бо саме те вони бачили, HotPDF їх мапить, а змішування двох систем — це те, як шари і бокси redaction відпливають

Перетворення пристрій-сторінка за OCR, штрихкодами та обличчями

HotPDF мапить пікселі бітмапа на сторінку однією афінною матрицею, збудованою з п'яти входів: ротація, масштаб DPI / 72, висота бітмапа і Left, Bottom, Right, Top відрендереного бокса. OCR, декодування штрихкодів і детекція облич ділять одну підпрограму для того, саме тому один неправильний бокс-вхід зламав усі три однаково. Для неповерненої сторінки матриця сторінка-пристрій [A B C D E F] така:

  • A = Scale і D = -Scale, де Scale = DPI / 72; від'ємний D перевертає user space (Y вгору) у простір бітмапа (Y вниз)
  • B = C = 0, бо неповернена сторінка не має ні зсуву, ні перестановки осей
  • E = -Left * Scale, що пересуває лівий край бокса в піксельну колонку 0
  • F = BitmapHeight + Bottom * Scale, що мапить нижній край бокса в y = BitmapHeight — нижній край бітмапа, тож верхній край приземляється на рядок 0

Пікселі повертаються на сторінку через обернену матрицю. Request.PageRotation несе /Rotate сторінки, нормалізований до 0, 90, 180 чи 270 (будь-яке значення, що не кратне 90, трактується як 0), а рендерер повертає сторінку за годинниковою стрілкою, як вимагає ISO 32000-1 §7.7.3.3. Під ротацією осі міняються місцями, і до початку координат бітмапа пришивається інша пара країв бокса. Записано оберненими формулами, з S = DPI / 72, x і y у пікселях і H — висотою бітмапа:

/RotateX сторінкиY сторінкиКраї бокса, від яких залежить мапінг
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

Остання колонка пояснює, чому баг здавався випадковим у проді. CropBox, що обрізає лише верх сторінки, лишає Left і Bottom недоторканими, тож прямі сторінки виходили ідеально, і лише сторінки з /Rotate 270 відпливали. Ротація ще й міняє місцями розміри бітмапа: при 90 і 270 бітмап має (Top - Bottom) * S пікселів завширшки і (Right - Left) * S заввишки

Таблиця оберненого мапінгу HotPDF для ротації сторінки: при /Rotate 0 і 90 перетворення пришиває краї Left і Bottom відрендереного бокса, при 180 — Right і Bottom, при 270 — Right і Top, чому обрізна сторінка відпливає в різний бік для кожної орієнтації в змішаному документі
той самий півдюймовий обріз виглядає як три різні баги, щойно сторінки несуть різні значення /Rotate, бо кожна орієнтація пришиває іншу пару країв бокса

Що ламається з MediaBox [0 0 612 792] і CropBox [36 36 576 756]?

З півдюймовим обрізом з кожного боку шар тексту неповерненої сторінки приземлюється рівно на 36 пунктів лівіше та на 36 пунктів нижче відсканованих слів, коли користується MediaBox. Візьміть сторінку US Letter, чий CropBox обрізає 36 пунктів (0,5 дюйма) з кожного краю. Видимий бокс — 540 на 720 пунктів, тож при усталеній OCR-роздільності 300 DPI масштаб становить 300 / 72 ≈ 4,1667, а бітмап — 2250 на 3000 пікселів

Припустімо, рушій звітує слово з піксельним боксом Left 450, Top 600, Right 900, Bottom 660 і без базової лінії. HotPDF тоді кладе базову лінію на 20 відсотків висоти слова вище нижнього краю, в піксельному рядку 648, і мапить стартову точку (450, 648):

  • Крізь видимий бокс: x = 36 + 450 / 4.1667 = 144.0 і y = 36 + (3000 - 648) / 4.1667 = 600.48 — там, де слово надруковане
  • Крізь MediaBox: x = 0 + 108.0 = 108.0 і y = 0 + 564.48 = 564.48 — рівномірний зсув на (-36, -36) пункту
Анатомія зсуву CropBox у HotPDF на сторінці US Letter з MediaBox 0 0 612 792 і CropBox 36 36 576 756: рендерер растеризує видимий бокс при 300 DPI, тож мапінг пікселя слова 450 крізь GetLoadedPageVisibleBox дає 144.0 і 600.48, тоді як перетворення MediaBox приземлюється на 108.0 і 564.48
растр покриває CropBox, тож будь-яке перетворення, збудоване з MediaBox, зсуває кожне розпізнане слово рівно на величину поля обрізу

Поверніть ту саму сторінку — і напрямок помилки зміниться, бо залучені різні краї. При /Rotate 180 X-член користується Right, і 612 замість 576 штовхає шар на 36 пунктів праворуч, поки Bottom досі тягне його на 36 пунктів униз. При /Rotate 270 і Right, і Top завеликі, тож шар зсувається на 36 пунктів праворуч і на 36 пунктів угору. Документ зі змішаними орієнтаціями може показувати зсув у трьох напрямках — надійний відбиток цього бага. Рукописний код, що виводить масштаб із бокса, як-от Bitmap.Width / (Right - Left), ще й розтягує кожну координату на 612 / 540, приблизно 13 відсотків, зверху зсуву

Які з ваших PDF-документів постраждали?

Документ PDF під загрозою, коли щонайменше одна сторінка має видимий бокс, що відрізняється від MediaBox, і HotPDF може сказати вам це кількома рядками. Порівнюйте GetLoadedPageBox з pbMediaBox проти GetLoadedPageVisibleBox для кожної сторінки і друкуйте поруч GetLoadedPageRotation, щоб передбачити напрямок зсуву з таблиці вище. THPDFPageBoundary пропонує ще й pbCropBox, pbBleedBox, pbTrimBox і pbArtBox, але GetLoadedPageBox(pbCropBox) падає назад до MediaBox, коли crop 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;
      // збережений масив може перелічувати свої кути в будь-якому порядку
      if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
      if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
      // уже нормалізовано і притиснуто до 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;

Дві деталі GetLoadedPageVisibleBox важать для скриптів на кшталт цього. Функція лишає свої out-параметри недоторканими при невдачі, тож передустановлення усталеного розміру сторінки перед викликом — безпечний шаблон. А коли деформований CropBox узагалі не перетинає MediaBox, функція повертає MediaBox, а не порожній прямокутник. Якщо звіт перелічує сторінки, а ваша розгорнута збірка старіша за v2.770.153 для OCR чи v2.770.154 для штрихкодів і облич, переганяйте розпізнавання на тих сторінках після оновлення. Шар OCR, закомічений постраждалою збіркою, лишається у збереженому файлі, і усталена опція SkipPagesWithText пропустить ті сторінки на другому прогоні, якщо ви її не вимкнете чи не приберете старий шар спершу

Як власний IHPDFOCREngine мусить мапити пікселі назад у простір PDF?

Власний IHPDFOCREngine має повертати бокси слів у пікселях бітмапа і дозволяти HotPDF робити мапінг; конвертуйте в user space лише для власних рішень, і тоді користуйтеся боксом із запиту, ніколи — MediaBox. З v2.770.153 поля PageLeft, PageBottom, PageRight і PageTop запиту описують відрендерений видимий бокс, тож вони збігаються з Request.Bitmap точно. Хелпер нижче — обернена бібліотечного перетворення, з його використанням фактичної висоти бітмапа для прямих сторінок, тож він сходиться з HotPDF до пікселя

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

// Піксель бітмапа (початок згори ліворуч, Y вниз) у PDF user space
// (початок знизу ліворуч, Y вгору), крізь бокс, з якого рендерився бітмап
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;

Реалістична причина потребувати user space всередині рушія — зональне правило: накладні, чию шапку ви ніколи не хочете пошуковою, чи штампова зона, що бентежить розпізнавач. Рушій нижче, написаний на TInterfacedObject, щоб лічильник посилань керував його життям, фільтрує слова за тим, куда падають їхні центри на сторінці, а потім повертає виживших недоторканими в піксельних координатах. RunRecognizer стоїть замість вашого власного виклику розпізнавача

type
  TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
  private
    FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single;  // user space
    function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
      out Words: THPDFOCRWords): boolean;  // ваш розпізнавач, піксельні бокси
  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];  // досі пікселі: HotPDF мапить їх сам
    Inc(Count);
  end;
  SetLength(Words, Count);
  Result := True;
end;

Вручіть рушій ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info), як і будь-якому іншому рушієві. Бібліотека валідує те, що повертається, перш ніж довіряти: слово скидається і лічиться в Info.DroppedWordCount, коли його бокс виходить з бітмапа, коли Right <= Left чи Bottom <= Top, або коли Confidence поза 0..1 чи нижче MinimumConfidence. Повернення більше слів, ніж MaxWordsPerPage, чи виштовхування сумарного рахунка за MaxTotalWords валить увесь виклик із бюджетною помилкою, тож шануйте Request.MaxWords у рушії. Не конвертуйте бокси слів у user space перед поверненням; HotPDF трактував би ті точкові значення як пікселі, і шар стиснувся б до початку координат бітмапа

Мапінг виходу власного детектора

Той самий хелпер обслуговує власний конвеєр на базі RenderLoadedPageToBitmap, який рендерить видимий бокс і застосовує /Rotate так само, як і функції розпізнавання. Читайте бокс через GetLoadedPageVisibleBox, нормалізуйте ротацію так само, як HotPDF, і мапте два протилежні кути кожного піксельного бокса. Вісь Y перевертається, а при 90 і 270 градусах осі міняються місцями, тож змаплені кути виходять у ніякому фіксованому порядку; беріть мінімум і максимум змаплених точок — саме так HotPDF будує межі штрихкодів

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);  // ваш код, піксельний бокс
      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;

Поведінка ротації висвітлена глибше в розплющуванні ротації сторінки без ламання боксів сторінки, а конвеєр декодування штрихкодів, який споживає те саме перетворення, — в декодуванні повернених QR-кодів зі сторінок PDF. Якщо ваш рушій загортає зовнішній розпізнавач, адаптер Tesseract OCR для пошукового PDF показує бік ізоляції процесу та скасування того самого інтерфейсу

Шпаргалка: мапінг координат, безпечний щодо CropBox

  • Рендерер растеризує видимий бокс — CropBox, притиснутий до MediaBox (ISO 32000-1 §14.11.2); кожен мапінг піксель-сторінка мусить користуватися тим боксом, прочитаним через GetLoadedPageVisibleBox
  • HotPDF v2.770.153 полагодив ApplyLoadedOCRTextLayer; v2.770.154 полагодив загальносторінкові DecodeLoadedPageBarcodes і результати облич із DetectLoadedRedactionFindings; постраждалі збірки від v2.766.64 до тих версій
  • Бокси THPDFOCRWord — це пікселі бітмапа з початком згори ліворуч; GetLoadedPageBox і GetLoadedPageVisibleBox повертають PDF user space з початком знизу ліворуч і Bottom < Top
  • Масштаб — це DPI / 72; виводьте його з DPI, ніколи не з бокса сторінки, поділеного на ширину бітмапа
  • /Rotate вирішує, які краї важать: Left і Bottom при 0 і 90, Right і Bottom при 180, Right і Top при 270
  • Поверніть слова OCR у пікселях і дозвольте HotPDF мапити їх; конвертуйте лише для власної фільтрувальної логіки
  • Переганяйте OCR на обрізаних сторінках, оброблених постраждалою збіркою, і пам'ятайте, що SkipPagesWithText пропускає сторінки, які вже несуть старий шар

Функції розпізнавання, запити боксів сторінок і рендеринг завантаженого документа, ужиті тут, постачаються в компоненті HotPDF для Delphi і C++Builder; ліцензування, пробні завантаження і повний список можливостей — на сторінці компонента HotPDF Delphi PDF