Artikel Teknis

OCR Template-Matching Bawaan di Delphi dengan HotPDF

HotPDF menyediakan THPDFBuiltInOCREngine, bounded template-matching OCR engine yang seluruhnya ditulis dalam Object Pascal: engine melakukan binarization halaman hasil render dengan Otsu thresholding, mengekstrak glyph sebagai connected component, lalu memberi skor setiap glyph berdasarkan grayscale coverage terhadap cached multi-font template, sehingga aplikasi Delphi dapat membangun searchable text layer tanpa dependency OCR eksternal. Engine ini harus dibangun ulang dari nol pada v2.731.0, dan alasannya bukan matcher. Masalahnya adalah pixel

Engine lama lulus test-nya. Engine mengenali uppercase ASCII pada synthetic bitmap, dan di Win32 terus melakukan hal yang sama selama berbulan-bulan. Lalu code yang sama dijalankan di Win64 dan tidak menghasilkan apa-apa: tidak ada word, tidak ada diagnostic selain "found no high-contrast foreground", serta tidak ada crash. Bug tersebut ternyata berasal dari dua kesalahan independen pada pixel-reading path yang saling membatalkan, dan mengurai keduanya menjadi ilustrasi bagus tentang alasan OCR code gagal secara diam-diam, bukan dengan suara keras

Mengapa OCR engine lama hanya bekerja secara kebetulan?

Engine lama bekerja karena template bitmap dan target bitmap dibalik dengan cara yang sama, sehingga vertical inversion pada pixel reader tidak terlihat oleh matcher. TBitmap.ScanLine mengembalikan row dalam urutan yang berlawanan dengan konvensi DIB positive-biHeight yang diasumsikan oleh imaging path lainnya. Render M secara terbalik, bandingkan dengan template yang juga terbalik, dan L1 difference-nya identik dengan perbandingan yang benar. Setiap glyph cocok. Tidak ada yang benar

Simetri tersebut tepat menjadi alasan kelas bug ini mahal. Perbaikan satu sisi mana pun mematahkan matching: benarkan target read dan biarkan template, recognition runtuh menjadi noise; benarkan template lebih dulu dan keruntuhan yang sama datang dari arah sebaliknya. Tidak ada jalur perbaikan incremental. Karena itu rebuild mengganti seluruh read dengan GetDIBits terhadap BITMAPINFOHEADER yang dideklarasikan eksplisit, di mana positive biHeight berarti bottom-up row berdasarkan contract, bukan convention VCL, lalu melakukan satu flip yang disengaja ketika menyalin ke grayscale buffer

Kesalahan kedua adalah kesalahan yang hanya muncul di Win64. HDC yang diteruskan ke GetDIBits tidak boleh berupa memory DC milik bitmap sendiri, karena bitmap sudah dipilih ke dalamnya dan Windows mendokumentasikan kondisi itu sebagai invalid. Passing Bitmap.Canvas.Handle ditoleransi oleh proses Win32 tetapi selalu gagal pada Win64 test process. Perbaikannya adalah throwaway screen DC dari GetDC(0), yang di-release dalam blok finally dan tidak memiliki keterikatan apa pun dengan 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;   // positif => bottom-up row
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // jangan Work.Canvas.Handle: Work dipilih di sana
    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];   // satu flip yang disengaja
      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 dan connected component: dari gray pixel ke glyph box

HotPDF lebih dulu melakukan binarization dengan metode Otsu dan hanya turun ke local-window threshold ketika Otsu tidak berlaku. Global path membutuhkan histogram bimodal yang nyata: engine menghitung maximum between-class variance dan juga menuntut gray range membentang sedikitnya 64 level sebelum mempercayai hasilnya. Scan yang washed-out, halaman dengan gradient background, atau bitmap yang hampir seluruhnya berupa ink semuanya gagal pada test tersebut. Fallback kemudian membandingkan setiap pixel dengan mean window 31 x 31 yang bias-nya 6 gray level, dihitung dengan running column sum agar sliding window tetap linear terhadap jumlah pixel

Glyph extraction adalah 8-connected component labeling atas mask hasil tersebut, dengan explicit stack, bukan recursion, karena full-page mask dapat dengan mudah menghabiskan Delphi thread stack pada flood fill yang dalam. Dua filter berjalan saat labeling: component yang lebih kecil dari 9 pixel dibuang sebagai speckle noise, dan component yang membentang lebih dari tiga perlima width sekaligus height image dibuang sebagai frame atau rule, bukan glyph. Pass kedua menggabungkan box yang tersusun vertikal ketika horizontal overlap-nya sedikitnya seperempat dari box yang lebih sempit, sehingga dot pada i atau j kembali bersatu dengan stem-nya. Semua ini bekerja pada raster, dan raster berasal dari renderer yang sama seperti yang dijelaskan dalam merender halaman PDF loaded menjadi bitmap di Delphi, yang penting secara praktis: OCR quality dibatasi oleh render quality, dan default text-layer DPI 300 adalah trade-off yang disengaja, bukan nilai maksimum

Apa yang membuat capital I dan lowercase l tidak dapat dibedakan?

Di Arial, capital I dan lowercase l dirasterisasi menjadi bar yang pixel-nya identik, sehingga tidak ada shape feature yang dapat memisahkan keduanya dan case harus datang dari tempat lain. Jawaban engine adalah line-level height clustering. Glyph box dikelompokkan ke text line berdasarkan vertical overlap, setiap line dianalisis untuk cap height dan modal baseline, lalu height di dalam satu line dipisah menjadi short cluster dan tall cluster. Bar yang berada di short cluster adalah l; bar yang sama di tall cluster adalah I

Implementasi split yang terlihat jelas adalah fixed ratio threshold, dan itu tidak bekerja. Rasio x-height terhadap cap-height Arial sekitar 0.72, tepat berada di sekitar 0.70 dan 0.75 yang pertama kali dipilih semua orang. Geser constant seper seratus ke arah mana pun dan seluruh corpus berubah case. Sebagai gantinya HotPDF melakukan one-dimensional k=2 variance-minimizing split: sort candidate height, coba setiap cut point, dan simpan cut yang within-cluster sum of squared deviation-nya paling kecil. Threshold menjadi property halaman, bukan constant di source

// ClusterHeights sudah diurutkan ascending; cari k=2 split dengan variance terkecil
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;
// hanya rasio antara dua cluster mean yang menentukan short band
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // x-height band nyata: bentuk lowercase
else
  SmallGroup := ggTall;          // satu height band: semuanya cap height
Line.LowercaseContext := (SmallGroup = ggSmall);

Line dengan satu height band tidak memiliki evidence internal sama sekali. Heading all-caps dan caption all-lowercase tampak sama ketika berdiri sendiri. Untuk kasus tersebut, HotPDF membandingkan median height line dengan page-level median x-height yang diambil dari line yang berhasil terpisah: rasio paling tinggi 1.10 menandai line sebagai lowercase context, rasio setidaknya 1.18 menandainya sebagai cap context, sedangkan nilai di antaranya dibiarkan unconstrained. Matching kemudian menerapkan case-preference bonus kecil 0.03 ke candidate yang sesuai dengan context tersebut, yang hanya mendorong tie tanpa pernah menimpa shape difference yang jelas

Mengapa template grid 12x18 membingungkan c dan o?

Template grid diperlebar dari 12 x 18 cell menjadi 16 x 24 karena pada resolusi lebih kecil grayscale coverage margin antara c dan o turun di bawah 0.007, jauh di dalam ambiguity threshold engine. Setiap glyph box di-resample ke grid sebagai coverage value 0 sampai 255, bukan binary stencil, sehingga cell dengan sepertiga ink membaca kira-kira 85, bukan dibulatkan menjadi black atau white. Pada 12 x 18, sisi terbuka c hanya membentang sedikit lebih dari satu column cell dan antialiased average menghapus gap. Pada 16 x 24, gap tetap ada setelah resampling dan sebagian besar pair yang mudah tertukar kembali ke jarak aman

Scoring adalah normalized L1 distance antara dua coverage grid, ditambah penalty 0.30 kali log aspect-ratio difference dan 0.16 kali ink-density difference, dengan hard prefilter yang melewati template bila aspect ratio-nya berbeda lebih dari faktor 2.6. Template dirasterisasi satu kali per process dari lima system font (Arial, Times New Roman, Courier New, Tahoma, dan Segoe UI) untuk alphabet 62 karakter, di-cache di balik critical section, dan digunakan ulang oleh setiap pemanggilan berikutnya

Constant terakhir adalah bagian yang menarik. Ketika score runner-up berada dalam jarak 0.018 dari pemenang, HotPDF membatasi confidence glyph menjadi 0.5, lebih rendah daripada acceptance gate 0.55, sehingga glyph tersebut tidak diterbitkan. Ini adalah fail-closed cut yang disengaja, bukan tuning artifact: engine bounded yang menebak menghasilkan searchable layer dengan teks yang tidak cocok dengan image, dan word yang salah pada text layer lebih buruk daripada word yang hilang karena tidak terlihat oleh orang yang memeriksa scan

Memisahkan word tanpa fixed gap threshold

HotPDF menurunkan word-space threshold per line dari distribusi inter-glyph gap, bukan dari fixed multiple terhadap average glyph width. Heuristic klasik, "gap yang lebih lebar dari 0.75 mean advance adalah space", langsung rusak ketika sebuah line mencampur digit dengan narrow letter karena mean advance tidak lagi menjelaskan sesuatu yang nyata. Sebagai gantinya engine mengurutkan gap untuk line lalu mencari lompatan terbesar di antara value berurutan, yaitu boundary antara intra-word cluster dan inter-word cluster jika cluster tersebut ada. Tiga guard mencegah aturan ini aktif akibat noise: lompatan harus sedikitnya 0.22 dari average glyph width, gap pertama di atas split harus sedikitnya 0.32 dari nilai itu, dan gap terakhir di bawah split tidak boleh melebihi 0.65 dari nilai itu. Jika guard mana pun gagal, threshold tetap MaxInt dan seluruh line menjadi satu word. Guard terakhir mencegah satu kerning pair yang unusually wide memecah word menjadi dua, sebuah error yang jauh lebih merusak daripada menggabungkan dua word karena merged token masih memuat karakter yang benar dalam urutan yang benar untuk substring search

Menulis invisible text layer di atas scanned image

ApplyLoadedOCRTextLayer mengubah word yang dikenali menjadi searchable layer dengan menggambarnya dalam text rendering mode 3, mode neither-fill-nor-stroke yang didefinisikan ISO 32000-1 §9.3.6, diposisikan di atas scanned image asalnya. Content stream dibuka dengan BT lalu 3 Tr, dan setiap word ditempatkan dengan text matrix yang dibangun dari baseline yang dilaporkan, cap height yang dikonversi dari pixel pada request DPI, serta horizontal scale yang meregangkan synthetic glyph run agar sesuai dengan measured word width. Hasilnya dapat di-copy dan di-search seperti teks, tetapi tidak melukis apa pun

Ada engine-free overload yang membuat built-in recognizer untuk Anda, dan itulah yang seharusnya digunakan sebagian besar caller built-in path. Recognition, Unicode validation, budget accounting, dan content construction semuanya selesai sebelum copy-on-write transaction dibuka, sehingga cancellation, budget overrun, atau engine failure membiarkan object graph serta version number tidak berubah. Word difilter dua kali: engine membuang apa pun di bawah confidence gate per-glyph miliknya sendiri sebesar 0.55, lalu THPDFOCRTextLayerOptions.MinimumConfidence (default 0.5) membuang seluruh word yang berada di bawah ambang 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;             // biarkan halaman born-digital
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // engine-free overload: HotPDF menyediakan bounded recognizer bawaan
    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;

Satu limit perlu dinyatakan terus terang, bukan ditemukan belakangan. Invisible layer menggunakan shared synthetic unembedded Type0 font, yang cukup untuk search dan copy pada setiap viewer tetapi tidak memenuhi font-embedding requirement ISO 19005. Jika output harus berupa PDF/A, caller harus menanamkan conforming font secara terpisah. Selain itu OCR text layer membawa geometri, bukan struktur, sehingga reading order hanya berasal dari posisi glyph; jika Anda membutuhkan logical order dari halaman yang sudah memiliki real text, structure-order text extraction yang digerakkan tag tree adalah tool berbeda untuk masalah berbeda

Di mana built-in engine berhenti?

Built-in engine sengaja sempit, dan mengetahui batasnya membuatnya tetap berguna. Engine menargetkan high-contrast machine-printed ASCII dari font yang dekat dengan lima template face-nya, dan segala sesuatu di luar itu menghasilkan no word, bukan tebakan. Batas konkretnya adalah:

  • Image sampai 4096 x 4096 dan 4,194,304 pixel, dengan recognition deadline 2000 ms dan cooperative cancellation melalui THPDFCancellationToken
  • Alphabet 62 karakter yang terdiri dari ASCII letter dan digit; tanpa punctuation, accented character, atau CJK
  • Text axis-aligned saja, pada page rotation yang sudah dinormalisasi renderer; skewed scan tidak di-deskew
  • Ambiguous glyph pair tetap unresolved, sehingga halaman dapat mengembalikan partial word atau diagnostic "found no unambiguous ASCII words"

Ketika envelope itu terlalu kecil, IHPDFOCREngine adalah seam-nya. Implementasikan Recognize terhadap engine Anda sendiri, berikan kepada three-argument ApplyLoadedOCRTextLayer overload, dan seluruh hal downstream (coordinate mapping, rotation handling, Unicode validation, budget, atomic commit) tetap sama. Bitmap dipinjam selama synchronous call dan tidak boleh disimpan. Untuk memastikan layer benar-benar masuk, load ulang file yang disimpan dan jalankan text path biasa yang dijelaskan dalam mengekstrak teks dari PDF loaded di Delphi; jika word kembali, layer tersebut nyata

Built-in template-matching OCR, invisible text layer, page renderer yang memberi input kepadanya, serta loaded-document text extraction yang memverifikasinya semuanya dikirim dalam VCL component native yang sama, tanpa external OCR runtime dan tanpa DLL yang harus dideploy bersama aplikasi. Jika Anda membangun document capture, archival, atau search atas scanned PDF di Delphi atau C++Builder, HotPDF Delphi PDF component memberi Anda seluruh pipeline dalam satu dependency