Техническа статия

Вграден template-matching OCR в Delphi с HotPDF

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

Старият engine минаваше тестовете си. Разпознаваше uppercase ASCII върху synthetic bitmap-и, а в Win32 продължи да прави това с месеци. После същият code беше пуснат под Win64 и не произведе нищо: нито думи, нито diagnostic освен „found no high-contrast foreground“, нито crash. Оказа се, че две независими грешки в pixel-reading path-а са се компенсирали взаимно, а разплитането им е добър пример защо OCR code се проваля тихо, вместо шумно

Защо старият OCR engine работеше само по случайност?

Старият engine работеше, защото template bitmap-ите и target bitmap-ите се обръщаха по един и същ начин, така че vertical inversion в pixel reader-а оставаше невидима за matcher-а. TBitmap.ScanLine връща rows в обратен ред спрямо positive-biHeight DIB convention-а, който останалата imaging path предполага. Render-нете M upside down, сравнете го с template, който също е upside down, и L1 difference е идентична с тази при правилното сравнение. Всеки glyph съвпадаше. Нищо не беше правилно

Тази симетрия е точно причината този клас bug да е скъп. Едностранна поправка чупи matching-а: коригирайте target read-а и оставете template-ите, и recognition-ът се срива в noise; коригирайте template-ите първо и получавате същия collapse от другата посока. Няма incremental repair path. Затова rebuild-ът замени целия read с GetDIBits спрямо изрично деклариран BITMAPINFOHEADER, където positive biHeight означава bottom-up rows по contract, а не по VCL convention, и обръща веднъж, съзнателно, при копиране в grayscale buffer-а

Втората грешка е тази, която се показа само в Win64. HDC-то, подадено на GetDIBits, не трябва да е собственият memory DC на bitmap-а, защото bitmap-ът вече е selected в него и Windows документира това като invalid. Подаването на Bitmap.Canvas.Handle беше tolerated от Win32 process-а и се проваляше последователно в Win64 test process-а. Поправката е disposable screen DC от GetDC(0), освободен във finally block, който няма нищо общо с 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 => bottom-up rows
    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];   // един съзнателен flip
      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;

Binarization и connected components: от gray pixels до glyph boxes

HotPDF binarize-ва с метода на Otsu първо и преминава към local-window threshold само когато Otsu не е приложим. Global path-ът изисква реална bimodal histogram: engine-ът изчислява максимума на between-class variance и допълнително изисква gray range-ът да обхваща поне 64 нива, преди да приеме резултата. Избледнял scan, page с gradient background или bitmap, почти изцяло покрит с ink, не минават този test. Тогава fallback-ът сравнява всеки pixel със средното на 31 на 31 window с bias от 6 gray levels, изчислено с running column sums, така че sliding window-ът остава linear спрямо броя pixels

Glyph extraction-ът е 8-connected component labeling върху получения mask с explicit stack вместо recursion, защото full-page mask с лекота може да изчерпи Delphi thread stack при deep flood fill. Два filter-а работят още по време на labeling: 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, описан в rendering на заредена PDF page до bitmap в Delphi, което има практическо значение: OCR quality е ограничено отгоре от render quality, а default text-layer DPI от 300 е съзнателен trade, не максимум

Какво прави 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

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

// ClusterHeights е сортиран ascending; намираме 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;
// само ratio между двата cluster mean-а решава коя е short band-ата
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. Heading само с capitals и caption само с 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-а, което разрешава tie-ове, без никога да override-ва ясна shape разлика

Защо template grid от 12x18 обърква c и o?

Template grid-ът беше разширен от 12 на 18 cells до 16 на 24, защото при по-малката resolution grayscale coverage margin-ът между c и o падаше под 0.007, далеч в рамките на ambiguity threshold-а на engine-а. Всеки 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 се различава с повече от factor 2.6. Template-ите се rasterize-ват веднъж на process от пет system fonts (Arial, Times New Roman, Courier New, Tahoma и Segoe UI) за alphabet от 62 characters, кешират се зад critical section и се използват от всяко следващо извикване

Последната constant е интересната. Когато runner-up character-ът има score в рамките на 0.018 от winner-а, HotPDF clamp-ва glyph confidence-а до 0.5, което е под acceptance gate-а 0.55, така че glyph-ът просто не се emit-ва. Това е съзнателен fail-closed cut, а не tuning artifact: bounded engine, който гадае, произвежда searchable layer, чийто text не съвпада с image-а, а грешна дума в text layer е по-лоша от липсваща, защото остава невидима за човека, който преглежда scan-а

Разделяне на думи без fixed gap threshold

HotPDF извежда word-space threshold-а per line от distribution-а на inter-glyph gaps, а не от fixed multiple на average glyph width. Класическата heuristic „gap, по-широк от 0.75 от mean advance, е space“ се чупи, щом line смесва digits с narrow letters, защото mean advance вече не описва нищо реално. Вместо това engine-ът сортира gaps за line-а и търси най-големия jump между последователни sorted values, който е границата между intra-word cluster и inter-word cluster, ако такъв съществува. Три guard-а не позволяват това да се задейства от noise: jump-ът трябва да е поне 0.22 от average glyph width, първият gap над split-а трябва да е поне 0.32 от него, а последният gap под split-а не трябва да надхвърля 0.65 от него. Ако някой guard fail-не, threshold-ът остава MaxInt и цялата line става една word. Точно последният guard предотвратява един необичайно широк kerning pair да раздели дума на две, което е много по-вредна грешка от сливането на две думи, защото merged token все още съдържа правилните characters в правилния ред за substring search

Записване на невидимия text layer върху scanned image

ApplyLoadedOCRTextLayer превръща разпознатите 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 при заявения DPI, и horizontal scale, който разтяга synthetic glyph run-а до измерената word width. Резултатът се copy-ва и search-ва като text и не paint-ва нищо

Съществува engine-free overload, който инстанцира built-in recognizer-а вместо вас и това е overload-ът, който повечето caller-и на 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-ът отхвърля всичко под собствената си 0.55 per-glyph confidence gate, а след това THPDFOCRTextLayerOptions.MinimumConfidence (default 0.5) отхвърля цели words под прага на caller-а

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 от page, която вече има real text, structure-order text extraction, задвижен от tag tree, е различен tool за различен problem

Къде спира built-in engine-ът

Built-in engine-ът е умишлено тесен и познаването на границите му е това, което го запазва полезен. Той цели high-contrast machine-printed ASCII от fonts, близки до петте 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 е твърде малък, IHPDFOCREngine е seam-ът. Implement-нете Recognize спрямо собствения си engine, подайте го на three-argument ApplyLoadedOCRTextLayer overload-а и всичко надолу по веригата (coordinate mapping, rotation handling, Unicode validation, budgets, atomic commit) остава същото. Bitmap-ът е borrowed за продължителността на synchronous call и не трябва да се задържа. За да потвърдите, че layer-ът е landed правилно, reload-нете saved file-а и изпълнете обикновения text path, описан в extracting 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 PDF-и в Delphi или C++Builder, HotPDF Delphi PDF component ви дава целия pipeline в една dependency