บทความเทคนิค

OCR แบบจับคู่เทมเพลตในตัวของ HotPDF สำหรับ Delphi

HotPDF มี THPDFBuiltInOCREngine ซึ่งเป็น OCR engine แบบ template matching ที่มีขอบเขตและเขียนด้วย Object Pascal ทั้งหมด มันทำ binarize page ที่ render แล้วด้วย Otsu thresholding, แยก glyph เป็น connected component และให้คะแนน glyph แต่ละตัวจาก grayscale coverage เทียบกับ multi-font template ที่ cache ไว้ ดังนั้นแอป Delphi จึงสร้าง searchable text layer ได้โดยไม่ต้องพึ่ง OCR ภายนอก engine นี้ต้องเขียนขึ้นใหม่ตั้งแต่ต้นใน v2.731.0 และเหตุผลไม่ได้อยู่ที่ matcher แต่อยู่ที่ pixel

engine เดิมผ่าน test ของตัวเอง มันรู้จัก uppercase ASCII บน synthetic bitmap และบน Win32 ก็ทำเช่นนั้นต่อเนื่องหลายเดือน จากนั้นนำ code เดิมไปรันบน Win64 แล้วกลับไม่สร้างอะไรเลย ไม่มี word ไม่มี diagnostic นอกจาก "found no high-contrast foreground" และไม่มี crash สุดท้ายพบว่า pixel-reading path มีความผิดพลาดอิสระกันสองจุดที่หักล้างกันพอดี การแยกมันออกจากกันเป็นตัวอย่างที่ดีว่าทำไม OCR code มัก fail แบบเงียบ ๆ แทนที่จะล้มเสียงดัง

ทำไม OCR engine เดิมจึงทำงานได้เพราะบังเอิญ

engine เดิมทำงานได้เพราะ template bitmap กับ target bitmap ถูกกลับด้านเหมือนกัน ดังนั้น vertical inversion ใน pixel reader จึงมองไม่เห็นสำหรับ matcher TBitmap.ScanLine คืน row ในลำดับตรงข้ามกับ positive-biHeight DIB convention ที่ imaging path ส่วนที่เหลือคาดไว้ หาก render M กลับหัวแล้วเทียบกับ template ที่กลับหัวเหมือนกัน L1 difference จะเท่ากับการเทียบที่ถูกต้อง glyph ทุกตัวจึง match ทั้งที่ไม่มีอะไรถูก

ความสมมาตรนี้เองทำให้ bug ประเภทนี้แก้ยาก การแก้เฉพาะ target read แต่ปล่อย template ไว้จะทำให้ recognition กลายเป็น noise การแก้ template ก่อนก็ล้มแบบเดียวกันจากอีกด้าน ไม่มีเส้นทางซ่อมแบบค่อยเป็นค่อยไป ดังนั้น rebuild จึงเปลี่ยน read ทั้งหมดไปใช้ GetDIBits กับ BITMAPINFOHEADER ที่ประกาศชัดเจน โดย positive biHeight หมายถึง bottom-up row ตาม contract ไม่ใช่ตาม VCL convention และทำการ flip เพียงครั้งเดียวอย่างตั้งใจตอน copy เข้า grayscale buffer

ความผิดพลาดที่สองเพิ่งโผล่บน Win64 HDC ที่ส่งให้ GetDIBits ต้องไม่ใช่ memory DC ของ bitmap เอง เพราะ bitmap ถูก select เข้า DC นั้นอยู่แล้วและ Windows ระบุว่าเป็น invalid การส่ง Bitmap.Canvas.Handle ถูกยอมรับโดย Win32 process แต่ fail อย่างสม่ำเสมอใน Win64 test process วิธีแก้คือใช้ screen DC ชั่วคราวจาก GetDC(0) แล้ว release ใน 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;   // ค่าบวก => row แบบ 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];   // กลับด้านอย่างตั้งใจเพียงครั้งเดียว
      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 จาก gray pixel สู่ glyph box

HotPDF ทำ binarize ด้วย Otsu ก่อน และ fallback ไปใช้ local-window threshold เฉพาะเมื่อ Otsu ใช้ไม่ได้ global path ต้องการ histogram ที่เป็น bimodal จริง engine จะคำนวณค่าสูงสุดของ between-class variance และยังบังคับให้ gray range กว้างอย่างน้อย 64 level ก่อนเชื่อผลลัพธ์ scan ที่จาง, page ที่พื้นหลังเป็น gradient หรือ bitmap ที่แทบเต็มไปด้วยหมึกจะไม่ผ่านการทดสอบนี้ จากนั้น fallback จะเทียบแต่ละ pixel กับค่าเฉลี่ยของ window ขนาด 31 คูณ 31 โดยมี bias 6 gray level และคำนวณด้วย running column sum เพื่อให้ sliding window มีความซับซ้อนเชิงเส้นตามจำนวน pixel

การแยก glyph เป็น 8-connected component labeling บน mask ที่ได้ โดยใช้ explicit stack แทน recursion เพราะ full-page mask สามารถทำให้ Delphi thread stack ล้นได้ง่ายระหว่าง flood fill ที่ลึก มี filter สองตัวทำงานตั้งแต่ตอน labeling component ที่เล็กกว่า 9 pixel จะถูกทิ้งเป็น speckle noise และ component ที่กินพื้นที่มากกว่าสามในห้าของทั้ง width และ height ของ image จะถูกทิ้งในฐานะ frame หรือ rule ไม่ใช่ glyph pass ที่สองจะ merge box ที่วางซ้อนในแนวตั้งเมื่อ horizontal overlap อย่างน้อยหนึ่งในสี่ของ box ที่แคบกว่า ซึ่งช่วยรวม dot ของ i หรือ j กลับเข้ากับ stem ทั้งหมดนี้ทำงานบน raster และ raster มาจาก renderer เดียวกับที่อธิบายใน การ render PDF page ที่โหลดแล้วเป็น bitmap ใน Delphi ซึ่งสำคัญในทางปฏิบัติ เพราะคุณภาพ OCR ถูกจำกัดด้วยคุณภาพ render และค่า text-layer DPI เริ่มต้นที่ 300 เป็น trade-off ที่ตั้งใจ ไม่ใช่ค่าสูงสุด

อะไรทำให้ capital I กับ lowercase l แยกกันไม่ได้

ใน Arial, capital I กับ lowercase l rasterize เป็นแท่งที่เหมือนกันทุก pixel ดังนั้น shape feature ใด ๆ ก็แยกมันไม่ได้ และ case ต้องอาศัยข้อมูลจากที่อื่น engine ใช้ line-level height clustering โดย group glyph box ตาม vertical overlap วิเคราะห์ cap height กับ modal baseline ของแต่ละ text line แล้วแบ่ง height ภายใน line เป็น short cluster กับ tall cluster แท่งที่อยู่ใน short cluster คือ l ส่วนแท่งเดียวกันใน tall cluster คือ I

วิธีแบ่งที่ดูชัดเจนคือใช้ fixed ratio threshold แต่ใช้ไม่ได้ Arial มีอัตราส่วน x-height ต่อ cap-height ราว 0.72 ซึ่งตกอยู่ตรงค่าที่ทุกคนมักลองก่อนคือ 0.70 และ 0.75 ขยับค่าคงที่ไปทางใดทางหนึ่งเพียงหนึ่งในร้อยก็ทำให้ทั้ง corpus สลับ case HotPDF จึงใช้ one-dimensional k=2 variance-minimizing split โดย sort candidate height ลองทุก cut point และเลือก cut ที่มี within-cluster sum of squared deviation ต่ำที่สุด threshold จึงกลายเป็นคุณสมบัติของ page ไม่ใช่ค่าคงที่ใน source

// ClusterHeights เรียงจากน้อยไปมาก หาจุดแบ่ง k=2 ที่มี 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 ที่ตัดสินว่า band สั้นคืออะไร
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // เป็น x-height band จริง: รูปร่าง lowercase
else
  SmallGroup := ggTall;          // มี band เดียว: ทุกอย่างมีความสูงระดับ cap
Line.LowercaseContext := (SmallGroup = ggSmall);

line ที่มี height band เดียวไม่มีหลักฐานภายในเลย heading ที่เป็นตัวพิมพ์ใหญ่ทั้งหมดกับ caption ที่เป็นตัวพิมพ์เล็กทั้งหมดดูเหมือนกันเมื่อมองแยกเดี่ยว สำหรับกรณีนี้ HotPDF เทียบ median height ของ line กับ page-level median x-height ที่ได้จาก line ซึ่งแบ่งได้ ratio ที่ไม่เกิน 1.10 จะ mark line เป็น lowercase context ส่วน ratio ที่ไม่น้อยกว่า 1.18 จะ mark เป็น cap context และค่าระหว่างนั้นจะไม่กำหนดอะไร matching จะเพิ่ม case-preference bonus เล็กน้อยที่ 0.03 ให้ candidate ที่สอดคล้องกับ context เพื่อขยับกรณีคะแนนเสมอโดยไม่ override shape difference ที่ชัดเจน

ทำไม template grid ขนาด 12x18 จึงสับสน c กับ o

template grid ถูกขยายจาก 12 คูณ 18 cell เป็น 16 คูณ 24 เพราะที่ resolution เล็กกว่า grayscale coverage margin ระหว่าง c กับ o ต่ำกว่า 0.007 ซึ่งอยู่ใน ambiguity threshold ของ engine อย่างสบาย glyph box แต่ละตัวถูก resample เข้า grid เป็น coverage value ตั้งแต่ 0 ถึง 255 ไม่ใช่ binary stencil ดังนั้น cell ที่มีหมึกหนึ่งในสามจะอ่านได้ราว 85 แทนการปัดเป็นดำหรือขาว ที่ 12 คูณ 18 ด้านเปิดของ c กว้างกว่าหนึ่ง column เล็กน้อยและ antialiased average จะล้างช่องว่างไป ที่ 16 คูณ 24 ช่องว่างยังอยู่หลัง resample และคู่ที่สับสนได้ง่ายส่วนใหญ่กลับมาอยู่ห่างกันพอ

การให้คะแนนคือ normalized L1 distance ระหว่าง coverage grid สองชุด บวก penalty เท่ากับ 0.30 คูณ log aspect-ratio difference และ 0.16 คูณ ink-density difference พร้อม hard prefilter ที่ข้าม template หาก aspect ratio ต่างเกิน factor 2.6 template ถูก rasterize ครั้งเดียวต่อ process จาก system font ห้าตัว ได้แก่ Arial, Times New Roman, Courier New, Tahoma และ Segoe UI ครอบคลุม alphabet 62 ตัว เก็บไว้หลัง critical section และ reuse ใน call ถัดไปทุกครั้ง

ค่าคงที่ตัวสุดท้ายน่าสนใจ เมื่อคะแนนของ character อันดับสองอยู่ห่างจากผู้ชนะไม่เกิน 0.018 HotPDF จะ clamp confidence ของ glyph เป็น 0.5 ซึ่งต่ำกว่า acceptance gate ที่ 0.55 ดังนั้น glyph จะไม่ถูก emit เลย นี่เป็น fail-closed cut ที่ตั้งใจ ไม่ใช่ tuning artifact engine แบบมีขอบเขตที่เดาจะสร้าง searchable layer ซึ่ง text ไม่ตรงกับภาพ และคำผิดใน text layer แย่กว่าคำที่หายไป เพราะคนที่กำลังตรวจ scan จะมองไม่เห็นความผิดนั้น

แยกคำโดยไม่ใช้ fixed gap threshold

HotPDF derive word-space threshold ต่อ line จาก distribution ของ inter-glyph gap แทนการใช้ fixed multiple ของ average glyph width heuristic แบบคลาสสิกที่ว่า "gap กว้างกว่า 0.75 ของ mean advance คือ space" จะพังทันทีเมื่อ line ผสม digit กับตัวอักษรแคบ เพราะ mean advance ไม่ได้อธิบายอะไรที่เป็นจริงอีก engine จะ sort gap ของ line แล้วมองหา jump ที่ใหญ่ที่สุดระหว่างค่าที่เรียงติดกัน ซึ่งเป็น boundary ระหว่าง 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 ที่กว้างผิดปกติหนึ่งคู่ผ่า word ออกเป็นสองส่วน ซึ่งเป็นความผิดพลาดที่เสียหายกว่าการรวมสอง word เข้าด้วยกันมาก เพราะ merged token ยังมี character ถูกต้องและอยู่ในลำดับเดิมสำหรับ substring search

เขียน invisible text layer ทับภาพ scan

ApplyLoadedOCRTextLayer เปลี่ยน word ที่ recognize แล้วให้เป็น searchable layer ด้วยการวาดใน text rendering mode 3 ซึ่งเป็น mode ที่ไม่ fill และไม่ stroke ตาม ISO 32000-1 §9.3.6 โดยวางทับภาพ scan ที่เป็นต้นทาง content stream เปิดด้วย BT ตามด้วย 3 Tr และแต่ละ word ถูกวางด้วย text matrix ที่สร้างจาก baseline ที่รายงาน, cap height ที่แปลงจาก pixel ตาม request DPI และ horizontal scale ที่ยืด synthetic glyph run ให้เท่ากับ word width ที่วัดได้ ผลลัพธ์ copy และ search ได้แต่ไม่ paint อะไร

มี overload ที่ไม่ต้องสร้าง engine เอง ซึ่งจะ instantiate built-in recognizer ให้ นี่คือสิ่งที่ caller ส่วนใหญ่ของ built-in path ควรใช้ recognition, Unicode validation, budget accounting และ content construction จะเสร็จก่อนเปิด copy-on-write transaction ดังนั้น cancellation, budget overrun หรือ engine failure จะปล่อย object graph และ version number ไว้เหมือนเดิม word ถูก filter สองครั้ง engine จะทิ้งทุกอย่างที่ต่ำกว่า per-glyph confidence gate 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 จัดหา 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 ใช้ synthetic unembedded Type0 font ร่วมกัน ซึ่งพอสำหรับ search และ copy ใน viewer ทุกตัว แต่ไม่ผ่านข้อกำหนดเรื่อง font embedding ของ ISO 19005 หาก output ต้องเป็น PDF/A caller ต้อง embed font ที่สอดคล้องแยกต่างหาก และ OCR text layer มีเพียง geometry ไม่ใช่ structure ดังนั้น reading order มาจาก glyph position เท่านั้น หากต้องการ logical order จาก page ที่มี real text อยู่แล้ว structure-order text extraction ที่ขับเคลื่อนด้วย tag tree เป็นเครื่องมือคนละตัวสำหรับปัญหาคนละแบบ

ขอบเขตของ engine ในตัว

engine ในตัวมีขอบเขตแคบอย่างตั้งใจ และการรู้ขอบเขตนี้ทำให้ใช้งานได้จริง มันมุ่งเป้าไปที่ machine-printed ASCII ที่ contrast สูงจาก font ซึ่งใกล้กับ template face ห้าตัวของมัน และทุกอย่างนอกขอบเขตนี้จะคืน no word แทนการเดา ขอบเขตที่เป็นรูปธรรมมีดังนี้

  • ภาพขนาดไม่เกิน 4096 x 4096 และ 4,194,304 pixel พร้อม deadline recognition 2000 ms และ cooperative cancellation ผ่าน THPDFCancellationToken
  • alphabet 62 ตัวที่เป็น ASCII letter และ digit ไม่มี punctuation ไม่มี accented character และไม่มี CJK
  • text ต้องวางตามแกนเท่านั้น และอยู่ที่ page rotation ที่ renderer normalize แล้ว scan ที่เอียงจะไม่ถูก deskew
  • glyph pair ที่กำกวมจะคงสถานะ unresolved ดังนั้น page อาจคืน partial word หรือ diagnostic "found no unambiguous ASCII words"

เมื่อ envelope นี้เล็กเกินไป IHPDFOCREngine คือ seam ให้ implement Recognize ด้วย engine ของคุณเอง แล้วส่งให้ overload ApplyLoadedOCRTextLayer แบบสาม argument ทุกอย่างหลังจากนั้น ได้แก่ coordinate mapping, rotation handling, Unicode validation, budget และ atomic commit ยังคงเหมือนเดิม bitmap ถูกยืมตลอด synchronous call และห้ามเก็บไว้ หลังจากนั้นให้ reload ไฟล์ที่ save แล้วและรัน text path ปกติตามที่อธิบายใน การ extract text จาก PDF ที่โหลดแล้วใน Delphi หาก word กลับมา แสดงว่า layer อยู่จริง

template-matching OCR ในตัว, invisible text layer, page renderer ที่ป้อนข้อมูลให้มัน และ loaded-document text extraction ที่ใช้ตรวจสอบ ล้วนอยู่ใน VCL component native ชุดเดียวกัน โดยไม่มี external OCR runtime และไม่มี DLL ให้ deploy ข้างแอป หากคุณสร้าง document capture, archival หรือ search บน scanned PDF ด้วย Delphi หรือ C++Builder HotPDF Delphi PDF component ให้ pipeline ทั้งหมดใน dependency เดียว