مقاله فنی

OCR تطبیق template داخلی در Delphi با HotPDF

HotPDF، THPDFBuiltInOCREngine را ارائه می‌کند؛ یک موتور OCR محدود مبتنی بر template matching که کاملاً با Object Pascal نوشته شده است: page رندرشده را با thresholding از نوع Otsu binarize می‌کند، glyphها را به‌صورت connected component استخراج می‌کند و هر glyph را با مقایسه grayscale coverage با templateهای چندفونتی cache‌شده امتیاز می‌دهد؛ بنابراین یک application از نوع Delphi می‌تواند بدون وابستگی به OCR خارجی، text layer قابل جست‌وجو بسازد. این موتور در v2.731.0 از ابتدا بازسازی شد و دلیل آن matcher نبود؛ pixelها بودند

موتور قدیمی testهای خود را با موفقیت پشت سر می‌گذاشت. روی bitmapهای مصنوعی، ASCII بزرگ را تشخیص می‌داد و در Win32 ماه‌ها همین کار را ادامه داد. سپس همان code زیر Win64 اجرا شد و هیچ خروجی نداد: نه word، نه diagnosticی فراتر از «high-contrast foreground پیدا نشد» و نه crash. معلوم شد دو اشتباه مستقل در مسیر خواندن pixel وجود داشته که یکدیگر را خنثی می‌کردند؛ باز کردن این گره نمونه خوبی از دلیل failure خاموش OCR است، نه failure پرسر‌وصدا

چرا موتور OCR قدیمی فقط از روی شانس کار می‌کرد؟

موتور قدیمی کار می‌کرد چون bitmapهای template و bitmapهای target را به یک شکل flip می‌کردند و در نتیجه inversion عمودی در pixel reader از دید matcher پنهان می‌ماند. TBitmap.ScanLine ردیف‌ها را برعکس convention مربوط به DIB با biHeight مثبت تحویل می‌دهد؛ conventionی که باقی مسیر imaging بر آن فرض شده است. اگر M را upside down render کنید و با templateی مقایسه کنید که آن هم upside down است، L1 difference با مقایسه درست یکسان می‌شود. همه glyphها match می‌شوند، اما هیچ چیز درست نیست

همین تقارن این دسته از bugها را پرهزینه می‌کند. هر fix یک‌طرفه matching را می‌شکند: اگر read مربوط به target را درست کنید و templateها را دست‌نخورده بگذارید، recognition به noise فرو می‌ریزد؛ اگر اول templateها را درست کنید، از جهت دیگر همان collapse رخ می‌دهد. مسیر repair تدریجی وجود ندارد. به همین دلیل rebuild، کل read را با GetDIBits در برابر یک BITMAPINFOHEADER صریح جایگزین کرد؛ در اینجا biHeight مثبت طبق contract به معنای ردیف‌های bottom-up است، نه convention مربوط به VCL، و هنگام copy به grayscale buffer دقیقاً یک بار و به‌صورت عمدی flip انجام می‌شود

اشتباه دوم فقط در Win64 آشکار شد. HDC که به GetDIBits داده می‌شود نباید memory DC خود bitmap باشد، چون bitmap از قبل در آن select شده و Windows این حالت را invalid مستند کرده است. دادن Bitmap.Canvas.Handle در process مربوط به Win32 تحمل می‌شد اما در process تست Win64 به‌طور پایدار fail می‌کرد. fix، یک screen DC موقتی از GetDC(0) است که در block finally release می‌شود و هیچ وابستگی به 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;   // مقدار مثبت => ردیف‌های bottom-up
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // هرگز Work.Canvas.Handle؛ Work آنجا select شده است
    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 component: از pixel خاکستری تا boxهای glyph

HotPDF ابتدا با روش Otsu binarize می‌کند و فقط وقتی Otsu قابل استفاده نباشد به threshold مبتنی بر local window برمی‌گردد. مسیر global به histogram واقعاً bimodal نیاز دارد: engine بیشینه واریانس بین‌کلاسی را حساب می‌کند و علاوه بر آن می‌خواهد دامنه خاکستری دست‌کم 64 level را پوشش دهد تا به نتیجه اعتماد کند. scan شسته‌شده، page با background دارای gradient یا bitmapی که تقریباً کاملاً ink است، همگی این test را fail می‌کنند. سپس fallback هر pixel را با mean یک window 31 در 31 و bias برابر 6 gray level مقایسه می‌کند؛ این mean با running column sum محاسبه می‌شود تا window لغزان نسبت به تعداد pixelها همچنان linear بماند

استخراج glyph، connected-component labeling هشت‌اتصاله روی mask حاصل است و به‌جای recursion از stack صریح استفاده می‌کند، چون mask یک page کامل به‌راحتی می‌تواند stack thread دلفی را هنگام flood fill عمیق سرریز کند. در زمان labeling دو filter اجرا می‌شود: componentهای کوچک‌تر از 9 pixel به‌عنوان speckle noise حذف می‌شوند و componentی که بیش از سه‌پنجم width و height تصویر را پوشش دهد به‌جای glyph، frame یا rule در نظر گرفته می‌شود. یک pass دوم boxهای عمودی روی هم را زمانی merge می‌کند که overlap افقی آن‌ها دست‌کم یک‌چهارم box باریک‌تر باشد؛ همین کار نقطه i یا j را به stem آن برمی‌گرداند. همه این مراحل روی raster کار می‌کنند و raster از همان rendererای می‌آید که در رندر کردن page بارگذاری‌شده PDF به bitmap در Delphi توضیح داده شده است؛ این نکته از نظر عملی مهم است، چون کیفیت OCR حداکثر به اندازه کیفیت render است و DPI پیش‌فرض text layer یعنی 300، یک trade عمدی است نه بیشینه ممکن

چه چیزی capital I و lowercase l را غیرقابل‌تشخیص می‌کند؟

در Arial، capital I و lowercase l به barهایی pixel-identical rasterize می‌شوند، بنابراین هیچ feature شکلی نمی‌تواند آن‌ها را جدا کند و case باید کاملاً از جای دیگری بیاید. پاسخ engine، clustering ارتفاع در سطح line است. boxهای glyph بر اساس overlap عمودی در text line گروه‌بندی می‌شوند، هر line از نظر cap height و modal baseline تحلیل می‌شود و heightهای داخل آن به یک cluster کوتاه و یک cluster بلند تقسیم می‌شوند. barی که در cluster کوتاه قرار دارد l است و همان bar در cluster بلند I است

پیاده‌سازی بدیهی این split، thresholdی با ratio ثابت است و کار نمی‌کند. نسبت x-height به cap-height در Arial حدود 0.72 است و دقیقاً روی مقادیر 0.70 و 0.75 می‌افتد که همه ابتدا سراغشان می‌روند. constant را فقط یک صدم به هر طرف جابه‌جا کنید، case کل corpus برمی‌گردد. HotPDF به‌جای آن یک split یک‌بعدی k=2 را با کمینه کردن variance انجام می‌دهد: heightهای candidate را sort می‌کند، هر نقطه cut را امتحان می‌کند و cutی را نگه می‌دارد که کمترین مجموع انحراف‌های مربعی داخل cluster را دارد. در نتیجه threshold ویژگی page می‌شود، نه یک constant داخل source

// ClusterHeights مرتب‌شده صعودی است؛ split کم‌واریانس k=2 را پیدا کن
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 بین mean دو cluster تعیین می‌کند band کوتاه کدام است
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // یک band واقعی x-height: شکل‌های lowercase
else
  SmallGroup := ggTall;          // یک band ارتفاعی: همه cap height هستند
Line.LowercaseContext := (SmallGroup = ggSmall);

lineهایی که فقط یک height band دارند، هیچ evidence داخلی‌ای ندارند. یک heading تماماً uppercase و یک caption تماماً lowercase در حالت جداگانه یکسان به نظر می‌رسند. برای این موارد، HotPDF median height line را با median سطح page برای x-height مقایسه می‌کند؛ این median از lineهایی می‌آید که split شده‌اند. ratio برابر یا کمتر از 1.10، line را lowercase context علامت می‌زند، ratio برابر یا بیشتر از 1.18 آن را cap context می‌کند و هر مقدار بین این دو بدون قید باقی می‌ماند. سپس matching یک case-preference bonus کوچک برابر 0.03 به candidateای می‌دهد که با این context سازگار است؛ این bonus tieها را کمی جابه‌جا می‌کند اما هرگز تفاوت واضح شکل را override نمی‌کند

چرا grid template دوازده‌در‌هجده، c و o را اشتباه می‌گرفت؟

grid template از 12 در 18 cell به 16 در 24 تغییر کرد، چون در resolution کوچک‌تر margin مربوط به grayscale coverage بین c و o به کمتر از 0.007 می‌رسید و کاملاً داخل ambiguity threshold engine قرار می‌گرفت. هر box مربوط به glyph به‌صورت coverage value از 0 تا 255 در grid resample می‌شود، نه به‌شکل stencil باینری؛ بنابراین cellی که یک‌سوم آن ink است تقریباً 85 خوانده می‌شود، نه اینکه به black یا white round شود. در 12 در 18، سمت باز c barely بیشتر از یک column cell را می‌پوشاند و میانگین antialias فاصله را می‌شوید. در 16 در 24، فاصله پس از resample باقی می‌ماند و بیشتر pairهای به‌راحتی اشتباه‌شونده دوباره فاصله امنی پیدا می‌کنند

scoring، فاصله normalized L1 بین دو coverage grid است و به‌علاوه penaltyای برابر 0.30 برابر log تفاوت aspect ratio و 0.16 برابر تفاوت ink density دارد؛ یک prefilter سخت نیز هر templateی را که aspect ratio آن بیش از ضریب 2.6 فرق کند skip می‌کند. templateها یک بار در هر process از پنج system font یعنی Arial، Times New Roman، Courier New، Tahoma و Segoe UI، روی alphabetی 62 کاراکتری rasterize می‌شوند، پشت یک critical section cache می‌شوند و هر call بعدی از آن‌ها استفاده می‌کند

آخرین constant جالب است. وقتی score کاراکتر دوم در فاصله 0.018 از برنده قرار بگیرد، HotPDF confidence glyph را روی 0.5 clamp می‌کند که پایین‌تر از acceptance gate برابر 0.55 است؛ بنابراین glyph اصلاً emit نمی‌شود. این یک cut عمدی fail-closed است، نه artifact ناشی از tuning: موتور محدود اگر حدس بزند، text layer قابل جست‌وجویی تولید می‌کند که با تصویر match نیست و یک word اشتباه در text layer از یک word غایب بدتر است، چون برای فردی که scan را review می‌کند قابل مشاهده نیست

جدا کردن wordها بدون threshold ثابت برای gap

HotPDF threshold مربوط به word-space را برای هر line از distribution فاصله‌های inter-glyph به دست می‌آورد، نه از ضریب ثابتی از میانگین glyph width. heuristic کلاسیک، یعنی «gapی بزرگ‌تر از 0.75 میانگین advance یک space است»، به محض اینکه line ارقام را با حروف باریک ترکیب کند می‌شکند، چون mean advance دیگر هیچ چیز واقعی را توصیف نمی‌کند. engine به‌جای آن gapهای line را sort می‌کند و بزرگ‌ترین jump بین valueهای مرتب‌شده متوالی را پیدا می‌کند؛ اگر cluster وجود داشته باشد، این نقطه مرز cluster درون word و cluster بین wordهاست. سه guard مانع فعال شدن آن بر اثر noise می‌شوند: jump باید دست‌کم 0.22 میانگین glyph width باشد، نخستین gap بالاتر از split باید دست‌کم 0.32 آن باشد و آخرین gap پایین split نباید از 0.65 آن بیشتر باشد. اگر هر guard fail شود، threshold روی MaxInt باقی می‌ماند و کل line یک word می‌شود. guard آخر مانع آن است که یک جفت kerning غیرعادیِ پهن، word را دو قسمت کند؛ خطایی که از merge شدن دو word بسیار مخرب‌تر است، چون token ادغام‌شده هنوز کاراکترهای درست را به ترتیب درست برای substring search در خود دارد

نوشتن text layer نامرئی روی تصویر scan‌شده

ApplyLoadedOCRTextLayer wordهای شناخته‌شده را با draw کردن در text rendering mode 3 به یک layer قابل جست‌وجو تبدیل می‌کند؛ این همان حالت neither-fill-nor-stroke است که در ISO 32000-1 §9.3.6 تعریف شده و روی تصویر scan‌شده‌ای قرار می‌گیرد که wordها از آن آمده‌اند. content stream با BT و سپس 3 Tr باز می‌شود و هر word با text matrixای قرار می‌گیرد که از baseline گزارش‌شده، cap height تبدیل‌شده از pixelها در DPI درخواستی و horizontal scaleای ساخته شده که synthetic glyph run را به عرض اندازه‌گیری‌شده word می‌کشد. نتیجه مانند text copy و search می‌شود و هیچ چیز paint نمی‌کند

یک overload بدون engine وجود دارد که recognizer داخلی را برای شما instantiate می‌کند و بیشتر callerهای مسیر built-in باید از همین استفاده کنند. Recognition، validation یونیکد، محاسبه budget و construction محتوا همگی پیش از باز شدن copy-on-write transaction کامل می‌شوند؛ بنابراین cancellation، عبور از budget یا failure engine، object graph و version number را دست‌نخورده باقی می‌گذارد. wordها دو بار filter می‌شوند: engine هر چیزی را پایین‌تر از gate مربوط به confidence هر glyph، یعنی 0.55، حذف می‌کند و سپس THPDFOCRTextLayerOptions.MinimumConfidence که پیش‌فرضش 0.5 است، wordهای کامل پایین‌تر از حد 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;             // pageهای born-digital را دست‌نخورده بگذار
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // overload بدون engine: HotPDF recognizer محدود built-in را فراهم می‌کند
    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;

یک محدودیت را بهتر است همین حالا روشن بگوییم، نه اینکه بعداً کشف شود. layer نامرئی از یک Type0 font مصنوعی مشترک و unembedded استفاده می‌کند که برای search و copy در همه viewerها کافی است، اما شرط embedding فونت در ISO 19005 را برآورده نمی‌کند. اگر output باید PDF/A باشد، caller باید یک font منطبق را جداگانه embed کند. همچنین OCR text layer فقط geometry دارد، نه structure؛ reading order صرفاً از positionهای glyph به دست می‌آید. اگر به logical order از pageی نیاز دارید که از قبل text واقعی دارد، text extraction با structure order مبتنی بر tag tree ابزار دیگری برای مسئله‌ای دیگر است

موتور داخلی کجا متوقف می‌شود؟

موتور built-in عمداً محدود است و دانستن مرزهایش چیزی است که مفید نگهش می‌دارد. هدف آن ASCII ماشین‌چاپ high-contrast از fontهایی نزدیک به پنج face template است و هر چیزی بیرون از این محدوده به‌جای حدس، هیچ wordی برنمی‌گرداند. مرزهای دقیق آن این‌ها هستند:

  • تصویرهایی تا 4096 در 4096 و 4,194,304 pixel، با deadline تشخیص 2000 ms و cancellation تعاونی از طریق THPDFCancellationToken
  • alphabet شصت‌ودوکاراکتری از حروف و رقم‌های ASCII؛ بدون punctuation، بدون کاراکتر accentدار و بدون CJK
  • فقط text محورمحور در rotationی که renderer از قبل normalize کرده است؛ scanهای skew‌شده deskew نمی‌شوند
  • pairهای glyph مبهم resolve‌نشده باقی می‌مانند، پس page می‌تواند wordهای ناقص یا diagnostic «هیچ word مبهم‌نبودۀ ASCII پیدا نشد» برگرداند

وقتی این envelope کوچک است، seam مناسب IHPDFOCREngine است. Recognize را با engine خودتان پیاده کنید، آن را به overload سه‌آرگومانی ApplyLoadedOCRTextLayer بدهید و همه چیز در downstream، از coordinate mapping و rotation handling تا Unicode validation، budgetها و atomic commit، همان‌طور باقی می‌ماند. bitmap در طول synchronous call قرض گرفته شده و نباید نگه‌داری شود. برای اطمینان از اینکه layer درست روی document قرار گرفته، file ذخیره‌شده را reload کنید و text path معمولی را که در استخراج text از PDF بارگذاری‌شده در Delphi توضیح داده شده اجرا کنید؛ اگر wordها برگردند، layer واقعی است

template-matching OCR داخلی، text layer نامرئی، page renderer تأمین‌کننده آن‌ها و text extraction از document بارگذاری‌شده که آن‌ها را verify می‌کند، همگی در همان VCL component بومی ارائه می‌شوند؛ بدون runtime خارجی OCR و بدون DLL اضافی در کنار application. اگر در Delphi یا C++Builder روی document capture، archival یا search روی PDFهای scan‌شده کار می‌کنید، HotPDF Delphi PDF component کل pipeline را در یک dependency در اختیار شما می‌گذارد