HotPDF cung cấp THPDFBuiltInOCREngine, một engine OCR đối sánh template có phạm vi giới hạn được viết hoàn toàn bằng Object Pascal: nó nhị phân hóa page đã render bằng ngưỡng Otsu, tách glyph thành connected component và chấm điểm từng glyph theo grayscale coverage so với template nhiều font đã cache, để ứng dụng Delphi dựng text layer có thể search mà không cần OCR dependency bên ngoài. Engine phải được rebuild từ đầu ở v2.731.0, và nguyên nhân không nằm ở matcher. Nó nằm ở pixel
Engine cũ pass các test của nó. Nó nhận dạng ASCII hoa trên bitmap tổng hợp, và trên Win32 vẫn làm được việc đó suốt nhiều tháng. Sau đó cùng code chạy dưới Win64 và không tạo ra gì cả: không word, không diagnostic nào ngoài "found no high-contrast foreground", không crash. Bug hóa ra là hai lỗi độc lập trong đường đọc pixel đã vô tình triệt tiêu lẫn nhau, và việc gỡ chúng là minh họa rõ cho lý do code OCR thường fail im lặng thay vì fail ồn ào
Vì sao OCR engine cũ chỉ hoạt động nhờ tình cờ?
Engine cũ hoạt động vì bitmap template và bitmap target bị lật theo cùng một cách, nên đảo dọc trong pixel reader không thể hiện ra với matcher. TBitmap.ScanLine trả về các row theo thứ tự ngược với quy ước DIB có biHeight dương mà phần imaging còn lại giả định. Render M lộn ngược rồi so với template cũng lộn ngược, L1 difference sẽ giống hệt phép so sánh đúng. Glyph nào cũng match. Nhưng không thứ gì thực sự đúng
Chính sự đối xứng đó khiến lớp bug này tốn kém. Sửa một phía sẽ phá matching: sửa cách đọc target mà để template nguyên thì recognition sụp thành noise; sửa template trước thì cũng sụp theo hướng ngược lại. Không có đường sửa tăng dần. Vì vậy bản rebuild thay toàn bộ phần read bằng GetDIBits trên một BITMAPINFOHEADER được khai báo rõ, nơi biHeight dương có nghĩa row bottom-up theo contract thay vì theo convention của VCL, rồi lật đúng một lần có chủ ý khi copy vào grayscale buffer
Lỗi thứ hai chỉ lộ ra trên Win64. HDC truyền cho GetDIBits không được là memory DC của chính bitmap, vì bitmap đã được select vào đó và Windows ghi rõ trường hợp này là invalid. Truyền Bitmap.Canvas.Handle được process Win32 dung thứ nhưng thất bại đều đặn trong process test Win64. Bản sửa dùng screen DC tạm từ GetDC(0), release trong block finally, không phụ thuộc bitmap nào
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; // dương => row bottom-up
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // không dùng 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]; // một lần lật có chủ ý
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;
Nhị phân hóa và connected component: từ pixel xám đến box glyph
HotPDF trước hết nhị phân hóa bằng phương pháp Otsu rồi chỉ fallback sang threshold theo cửa sổ cục bộ khi Otsu không áp dụng được. Đường global yêu cầu histogram thực sự bimodal: engine tính cực đại của between-class variance, đồng thời yêu cầu gray range trải trên ít nhất 64 level trước khi tin kết quả. Scan bị wash-out, page có background gradient hoặc bitmap gần như toàn ink đều fail test đó. Fallback sau đó so sánh từng pixel với mean của cửa sổ 31 x 31, bias 6 gray level, được tính bằng running column sum để sliding window vẫn tuyến tính theo số pixel
Glyph extraction là connected-component labeling 8-connected trên mask kết quả, dùng stack tường minh thay vì recursion, vì mask cả page có thể dễ dàng làm tràn thread stack Delphi trong một flood fill sâu. Hai filter chạy ngay lúc labeling: component nhỏ hơn 9 pixel bị loại như speckle noise, còn component chiếm hơn ba phần năm cả width lẫn height của image bị loại như frame hoặc rule chứ không phải glyph. Pass thứ hai merge các box xếp dọc khi horizontal overlap ít nhất bằng một phần tư box hẹp hơn, nhờ đó nối lại dot của i hoặc j với stem. Tất cả diễn ra trên raster, và raster đến từ cùng renderer được mô tả trong render page PDF đã load thành bitmap trong Delphi, điều này quan trọng vì lý do thực tế: chất lượng OCR bị chặn trên bởi chất lượng render, còn text-layer DPI mặc định 300 là một trade-off có chủ ý chứ không phải mức tối đa
Điều gì khiến I hoa và l thường không thể phân biệt?
Trong Arial, I hoa và l thường rasterize thành các thanh pixel giống hệt nhau, nên không feature hình dạng nào tách được chúng, còn case phải đến từ một nguồn hoàn toàn khác. Câu trả lời của engine là clustering chiều cao ở cấp line. Các glyph box được nhóm thành text line theo vertical overlap, mỗi line được phân tích cap height và modal baseline, còn các height trong line được chia thành cluster ngắn và cluster cao. Thanh nằm trong cluster ngắn là l; chính thanh đó trong cluster cao là I
Cách hiển nhiên để chia là threshold theo ratio cố định, nhưng không hoạt động. Tỷ lệ x-height trên cap-height của Arial khoảng 0.72, nằm đúng vào các giá trị 0.70 và 0.75 mà ai cũng thử đầu tiên. Dịch constant một phần trăm theo bất kỳ hướng nào cũng làm cả corpus đảo case. Thay vào đó HotPDF dùng phép chia k=2 một chiều để tối thiểu hóa variance: sort các height ứng viên, thử mọi điểm cắt và giữ điểm có tổng bình phương độ lệch trong cluster nhỏ nhất. Threshold trở thành thuộc tính của page thay vì constant trong source
// ClusterHeights đã được sort tăng dần; tìm split k=2 có variance nhỏ nhất
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;
// chỉ ratio giữa hai cluster mean quyết định band ngắn là gì
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // band x-height thật: hình dạng chữ thường
else
SmallGroup := ggTall; // một band chiều cao: mọi thứ đều là cap height
Line.LowercaseContext := (SmallGroup = ggSmall);
Line chỉ có một band chiều cao không mang bằng chứng nội tại nào. Heading toàn chữ hoa và caption toàn chữ thường trông giống nhau khi đứng riêng. Với các line đó, HotPDF so sánh median height của line với median x-height cấp page lấy từ những line đã tách được: ratio không quá 1.10 đánh dấu lowercase context, ratio từ 1.18 trở lên đánh dấu cap context, còn khoảng giữa thì để unconstrained. Sau đó matching cộng một case-preference bonus nhỏ 0.03 cho candidate phù hợp context, chỉ đẩy tie chứ không bao giờ override khác biệt hình dạng rõ ràng
Vì sao grid template 12x18 làm c và o bị nhầm?
Grid template được mở từ 12 x 18 cell lên 16 x 24 vì ở độ phân giải nhỏ hơn, khoảng cách grayscale coverage giữa c và o xuống dưới 0.007, nằm sâu trong ambiguity threshold của engine. Mỗi glyph box được resample vào grid dưới dạng coverage value từ 0 đến 255 thay vì binary stencil, nên cell có một phần ba là ink sẽ đọc khoảng 85 chứ không bị làm tròn thành đen hoặc trắng. Ở 12 x 18, phía mở của c chỉ rộng hơn một cột cell chút ít và antialiased average làm mất gap. Ở 16 x 24, gap sống sót qua resampling, và phần lớn cặp dễ nhầm trở lại khoảng cách an toàn
Scoring là normalized L1 distance giữa hai coverage grid, cộng penalty bằng 0.30 lần log aspect-ratio difference và 0.16 lần ink-density difference, kèm prefilter cứng bỏ qua mọi template có aspect ratio lệch hơn hệ số 2.6. Template được rasterize một lần mỗi process từ năm system font (Arial, Times New Roman, Courier New, Tahoma và Segoe UI) trên alphabet 62 ký tự, cache sau critical section và tái sử dụng cho mọi call sau đó
Constant cuối mới đáng chú ý. Khi điểm của ký tự đứng thứ hai nằm trong phạm vi 0.018 so với winner, HotPDF kẹp confidence của glyph xuống 0.5, thấp hơn acceptance gate 0.55, nên glyph đơn giản không được emit. Đây là cut fail-closed có chủ ý chứ không phải tuning artifact: engine có giới hạn mà đoán sẽ tạo text layer search được nhưng text không khớp image, và một word sai trong text layer tệ hơn word bị thiếu vì người review scan không nhìn thấy nó
Tách word mà không dùng gap threshold cố định
HotPDF suy ra word-space threshold cho từng line từ phân phối inter-glyph gap thay vì một bội số cố định của glyph width trung bình. Heuristic kinh điển "gap rộng hơn 0.75 lần mean advance là space" hỏng ngay khi line trộn digit với chữ hẹp, vì mean advance không còn mô tả điều gì thật. Thay vào đó engine sort gap của line rồi tìm jump lớn nhất giữa hai value liên tiếp, đó là boundary giữa cluster intra-word và inter-word nếu cluster tồn tại. Ba guard ngăn nó kích hoạt vì noise: jump phải ít nhất bằng 0.22 glyph width trung bình, gap đầu tiên trên split ít nhất bằng 0.32 của nó, và gap cuối dưới split không được vượt 0.65 của nó. Nếu guard nào fail, threshold giữ ở MaxInt và cả line trở thành một word. Chính guard cuối ngăn một kerning pair bất thường rộng chia word thành hai, lỗi này gây hại hơn merge hai word vì token bị merge vẫn có đúng character theo đúng thứ tự cho substring search
Viết text layer vô hình trên ảnh scan
ApplyLoadedOCRTextLayer biến word đã nhận dạng thành layer có thể search bằng cách vẽ chúng ở text rendering mode 3, mode neither-fill-nor-stroke được định nghĩa trong ISO 32000-1 §9.3.6, đặt trên ảnh scan mà chúng đến từ đó. Content stream mở bằng BT rồi 3 Tr, mỗi word được đặt bằng text matrix dựng từ baseline đã báo cáo, cap height đổi từ pixel theo DPI yêu cầu và horizontal scale kéo synthetic glyph run vừa với word width đo được. Kết quả copy và search như text nhưng không paint gì
Có overload không cần engine, tự instantiate recognizer tích hợp cho bạn, và đó là overload mà phần lớn caller của built-in path nên dùng. Recognition, Unicode validation, budget accounting và content construction đều hoàn tất trước khi mở copy-on-write transaction, nên cancellation, budget overrun hay engine failure đều để object graph và version number nguyên vẹn. Word được filter hai lần: engine bỏ mọi thứ dưới gate confidence 0.55 cho từng glyph, sau đó THPDFOCRTextLayerOptions.MinimumConfidence (mặc định 0.5) bỏ cả word dưới ngưỡng 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; // để nguyên page born-digital
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// overload không cần engine: HotPDF cung cấp bounded recognizer tích hợp
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;
Có một giới hạn cần nói thẳng thay vì để phát hiện sau. Layer vô hình dùng shared synthetic Type0 font chưa embed, đủ cho search và copy trong mọi viewer nhưng không đáp ứng yêu cầu font embedding của ISO 19005. Nếu output phải là PDF/A, caller phải embed riêng một font phù hợp. OCR text layer cũng mang geometry chứ không mang structure, nên reading order chỉ đến từ vị trí glyph; nếu cần logical order từ page đã có text thật, structure-order text extraction dựa trên tag tree là công cụ khác cho bài toán khác
Engine tích hợp dừng ở đâu
Engine tích hợp cố ý hẹp, và biết ranh giới của nó là điều giữ cho nó hữu ích. Nó nhắm tới ASCII machine-printed tương phản cao từ font gần với năm template face, còn mọi thứ ngoài phạm vi đó trả về không có word thay vì đoán. Các boundary cụ thể là:
- Image tối đa 4096 x 4096 và 4,194,304 pixel, deadline recognition 2000 ms và cooperative cancellation qua
THPDFCancellationToken - Alphabet 62 ký tự gồm ASCII letter và digit; không punctuation, không ký tự có dấu, không CJK
- Chỉ text axis-aligned, ở page rotation mà renderer đã normalize; scan skew không được deskew
- Cặp glyph mơ hồ vẫn unresolved, nên page có thể trả word một phần hoặc diagnostic "found no unambiguous ASCII words"
Khi envelope đó quá nhỏ, IHPDFOCREngine là seam cần dùng. Implement Recognize trên engine của bạn, truyền nó cho overload ba tham số của ApplyLoadedOCRTextLayer, mọi thứ downstream (coordinate mapping, rotation handling, Unicode validation, budget, atomic commit) vẫn giữ nguyên. Bitmap chỉ được borrow trong thời gian synchronous call và không được retain. Để xác nhận layer đã vào đúng chỗ, reload file đã save rồi chạy text path thông thường được mô tả trong extract text từ PDF đã load trong Delphi; nếu word quay trở lại thì layer là thật
OCR đối sánh template tích hợp, text layer vô hình, page renderer cấp dữ liệu cho chúng và loaded-document text extraction dùng để verify đều nằm trong cùng native VCL component, không cần external OCR runtime hay DLL để deploy cạnh application. Nếu bạn xây dựng document capture, archive hoặc search trên PDF scan bằng Delphi hay C++Builder, HotPDF Delphi PDF component cung cấp toàn bộ pipeline trong một dependency