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

Вбудований template-matching OCR у Delphi з HotPDF

HotPDF постачає THPDFBuiltInOCREngine — обмежений template-matching OCR engine, повністю написаний Object Pascal: він бінаризує rendered page методом Otsu, витягує glyphs як connected components і оцінює кожен glyph за grayscale coverage проти cached multi-font templates, тож Delphi application може створити searchable text layer без external OCR dependency. Engine довелося перебудувати з нуля у v2.731.0, і причиною був не matcher. Причиною були pixels

Старий engine проходив свої tests. Він розпізнавав uppercase ASCII на synthetic bitmaps і місяцями продовжував це робити у Win32. Потім той самий code запустили під Win64 і не отримали взагалі нічого: ні words, ні diagnostic, крім "found no high-contrast foreground", ні crash. Виявилося, що в pixel-reading path було дві незалежні помилки, які взаємно компенсували одна одну, і їх розплутування добре показує, чому OCR code мовчки помиляється, а не падає голосно

Чому старий OCR engine працював лише випадково?

Старий engine працював, бо його template bitmaps і target bitmaps були перевернуті однаково, тому vertical inversion у pixel reader була невидимою для matcher-а. TBitmap.ScanLine повертає rows у порядку, протилежному positive-biHeight DIB convention, на яку спирається решта imaging path. Рендерите M догори дриґом, порівнюєте його з template, теж перевернутим догори дриґом, — і L1 difference ідентична правильному порівнянню. Кожен glyph match-ився. Правильним не було нічого

Саме ця симетрія робить такий клас bug дорогим. Будь-яке однобічне виправлення ламає matching: виправте target read, залишивши templates, — і recognition зведеться до noise; спочатку виправте templates — і отримаєте такий самий collapse з іншого боку. Поступового repair path немає. Тому rebuild замінив увесь read на GetDIBits з явно оголошеним BITMAPINFOHEADER, де positive biHeight за contract означає bottom-up rows, а не лише VCL convention, і один раз, навмисно, перевертає їх під час копіювання в grayscale buffer

Друга помилка проявилася лише у Win64. HDC, переданий у GetDIBits, не має бути власним memory DC bitmap, бо bitmap уже selected у нього, а Windows документує це як invalid. Передавання Bitmap.Canvas.Handle терпів Win32 process і стабільно ламався у Win64 test process. Виправлення — тимчасовий screen DC із GetDC(0), звільнений у finally, який не пов’язаний із жодним bitmap

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;   // positive => рядки знизу вгору
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // ніколи Work.Canvas.Handle: Work selected саме там
    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];   // одне навмисне перевертання
      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;

Бінаризація та connected components: від gray pixels до glyph boxes

HotPDF спочатку бінаризує зображення методом Otsu, а до local-window threshold переходить лише тоді, коли Otsu непридатний. Global path потребує справжньої bimodal histogram: engine обчислює максимум between-class variance і додатково вимагає, щоб gray range охоплював щонайменше 64 levels, перш ніж довіритися результату. Вицвілий scan, page із gradient background або bitmap, майже повністю заповнений ink, не проходять цей test. Fallback тоді порівнює кожен pixel із mean вікна 31 на 31 із bias 6 gray levels, обчисленим через running column sums, завдяки чому sliding window залишається лінійним за кількістю pixels

Glyph extraction — це 8-connected component labeling отриманої mask, з explicit stack замість recursion, бо full-page mask легко переповнює Delphi thread stack під час глибокого flood fill. Під час labeling працюють два filters: components менші за 9 pixels відкидаються як speckle noise, а component, що охоплює понад три п’ятих і width, і height image, відкидається як frame або rule, а не glyph. Другий pass об’єднує vertically stacked boxes, чий horizontal overlap становить щонайменше чверть вужчого box, і саме так з’єднуються dot у i чи j зі stem. Усе це працює на raster, а raster походить від того самого renderer, який описано в рендерингу завантаженої PDF page у bitmap у Delphi, і це має практичне значення: OCR quality не може перевищити render quality, а default text-layer DPI 300 — свідомий trade-off, а не максимум

Що робить capital I та lowercase l нерозрізнюваними?

В Arial capital I і lowercase l rasterize-яться в pixel-identical bars, тому жодна shape feature не може їх розділити, і case має прийти зовсім з іншого місця. Відповідь engine — line-level height clustering. Glyph boxes групуються в text lines за vertical overlap, для кожного line аналізуються cap height і modal baseline, а heights усередині line розділяються на short cluster і tall cluster. Bar у short cluster — це l; той самий bar у tall cluster — I

Очевидна реалізація такого split — fixed ratio threshold, але вона не працює. Співвідношення x-height до cap-height в Arial близьке до 0.72, тобто потрапляє просто на значення 0.70 і 0.75, до яких усі звертаються першими. Посуньте constant на соту в будь-який бік — і цілий corpus змінить case. Натомість HotPDF використовує one-dimensional k=2 variance-minimizing split: сортує candidate heights, пробує кожну cut point і залишає cut із найменшою within-cluster sum of squared deviations. Threshold стає властивістю page, а не constant у source

// ClusterHeights відсортовано за зростанням; знайти k=2 split із найменшою variance
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;
// лише співвідношення між двома cluster means вирішує, яка band є short
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // справжня x-height band: lowercase shapes
else
  SmallGroup := ggTall;          // одна height band: усе має cap height
Line.LowercaseContext := (SmallGroup = ggSmall);

Lines з однією height band не мають внутрішніх evidence. Заголовок all-caps і caption all-lowercase ізольовано виглядають однаково. Для них HotPDF порівнює median height line з page-level median x-height, взятим із lines, які справді розділилися: ratio не вище 1.10 позначає line як lowercase context, ratio не нижче 1.18 — як cap context, а проміжок між ними залишає unconstrained. Після цього matching додає невеликий case-preference bonus 0.03 до candidate, що відповідає context, — це зсуває ties, але ніколи не перекриває чітку shape difference

Чому template grid 12x18 плутав c та o?

Template grid розширили з 12 на 18 cells до 16 на 24, бо на меншій resolution grayscale coverage margin між c та o падав нижче 0.007, далеко всередині engine ambiguity threshold. Кожен glyph box resample-иться в grid як coverage values від 0 до 255, а не як binary stencil, тому cell із третиною ink має приблизно 85, замість того щоб округлитися до black або white. На 12 на 18 open side c ледь перевищує одну cell column, і antialiased average змиває gap. На 16 на 24 gap переживає resampling, і більшість пар, які легко плутати, знову відходять на безпечну відстань

Scoring — це normalized L1 distance між двома coverage grids плюс penalty 0.30 від log aspect-ratio difference і 0.16 від ink-density difference, з hard prefilter, який пропускає template, якщо його aspect ratio відрізняється більш ніж у 2.6 раза. Templates rasterize-яться один раз на process із п’яти system fonts (Arial, Times New Roman, Courier New, Tahoma і Segoe UI) на alphabet із 62 characters, cache-уються за critical section і повторно використовуються кожним наступним call

Останній constant найцікавіший. Коли runner-up character набирає score у межах 0.018 від winner, HotPDF обмежує glyph confidence до 0.5, тобто нижче acceptance gate 0.55, і glyph просто не виводиться. Це навмисний fail-closed cut, а не tuning artifact: bounded engine, який вгадує, створює searchable layer, чий text не відповідає image, а неправильне слово в text layer гірше за відсутнє, бо його не бачить людина, яка переглядає scan

Розділення words без fixed gap threshold

HotPDF виводить word-space threshold для кожного line з distribution inter-glyph gaps, а не з fixed multiple середньої glyph width. Класична heuristic "gap, ширший за 0.75 від mean advance, є space" ламається, щойно line змішує digits із narrow letters, бо mean advance перестає описувати щось реальне. Натомість engine сортує gaps line і шукає найбільший jump між сусідніми sorted values — це boundary між intra-word cluster та inter-word cluster, якщо такий існує. Три guards не дають спрацювати на noise: jump має бути щонайменше 0.22 від average glyph width, перший gap над split — щонайменше 0.32 від неї, а останній gap під split не має перевищувати 0.65 від неї. Якщо будь-який guard не проходить, threshold залишається MaxInt, і весь line стає одним word. Саме останній guard не дає одній незвично широкій kerning pair розділити word навпіл, що значно гірше за об’єднання двох words, бо merged token усе ще містить правильні characters у правильному порядку для substring search

Запис невидимого text layer поверх scanned image

ApplyLoadedOCRTextLayer перетворює recognized words на searchable layer, малюючи їх у text rendering mode 3 — режимі neither-fill-nor-stroke, визначеному в ISO 32000-1 §9.3.6, — поверх scanned image, з якого вони походять. Content stream починається з BT, за яким іде 3 Tr, а кожне word позиціонується text matrix, побудованою з reported baseline, cap height, перетвореної з pixels за request DPI, і horizontal scale, яка розтягує synthetic glyph run до measured word width. Результат копіюється та шукається як text, але нічого не малює

Існує engine-free overload, який сам створює built-in recognizer, і саме його зазвичай мають використовувати callers built-in path. Recognition, Unicode validation, budget accounting і content construction завершуються до відкриття copy-on-write transaction, тому cancellation, budget overrun або engine failure залишають object graph і version number без змін. Words фільтруються двічі: engine відкидає все нижче власного per-glyph confidence gate 0.55, а потім THPDFOCRTextLayerOptions.MinimumConfidence (default 0.5) відкидає цілі words нижче caller bar

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;             // залишати born-digital pages без змін
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // engine-free overload: HotPDF надає built-in bounded recognizer
    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;

Одне обмеження краще назвати прямо, а не дізнатися про нього пізніше. Invisible layer використовує shared synthetic unembedded Type0 font, якого достатньо для search і copy в кожному viewer, але недостатньо для font-embedding requirement ISO 19005. Якщо output має бути PDF/A, caller повинен окремо embed conforming font. І OCR text layer несе geometry, а не structure, тому reading order походить лише з glyph positions; якщо потрібен logical order зі сторінки, яка вже має справжній text, structure-order text extraction на основі tag tree — інструмент для іншої задачі

Де завершується built-in engine

Built-in engine навмисно вузький, і знання його меж саме й робить його корисним. Він орієнтований на high-contrast machine-printed ASCII зі шрифтів, близьких до п’яти template faces, а все за межами цього повертає no word, а не guess. Конкретні межі такі:

  • Images до 4096 на 4096 і 4,194,304 pixels, із recognition deadline 2000 ms та cooperative cancellation через THPDFCancellationToken
  • Alphabet із 62 ASCII letters і digits; без punctuation, accented characters і CJK
  • Лише axis-aligned text, у page rotation, яку renderer уже нормалізував; skewed scans не deskew-яться
  • Ambiguous glyph pairs залишаються unresolved, тому page може повернути partial words або diagnostic "found no unambiguous ASCII words"

Коли цей envelope замалий, seam — це IHPDFOCREngine. Реалізуйте Recognize власним engine, передайте його в three-argument overload ApplyLoadedOCRTextLayer, і все downstream (coordinate mapping, rotation handling, Unicode validation, budgets, atomic commit) залишиться тим самим. Bitmap позичається на час synchronous call і не має зберігатися. Щоб підтвердити, що layer справді записаний, перезавантажте saved file та запустіть звичайний text path, описаний у витягуванні text із loaded PDF у Delphi; якщо words повернулися, layer реальний

Built-in template-matching OCR, invisible text layer, page renderer, який їх живить, і loaded-document text extraction, що їх перевіряє, постачаються в одному native VCL component, без external OCR runtime і DLL для deployment разом із application. Якщо ви будуєте document capture, archival або search над scanned PDFs у Delphi чи C++Builder, HotPDF Delphi PDF component дає весь pipeline в одній dependency