Bài viết kỹ thuật

Tesseract OCR thành PDF searchable trong Delphi với HotPDF

HotPDF biến các trang PDF scan thành PDF searchable bằng Tesseract qua HPDFCreateTesseractOCREngine, một factory bọc một executable Tesseract cài tại chỗ thành một IHPDFOCREngine. Bạn đưa engine đó cho ApplyLoadedOCRTextLayer, hàm render từng trang, chạy Tesseract mỗi trang một lần, parse output TSV cấp từ của nó, và commit một text layer Unicode vô hình cho tất cả các trang được yêu cầu trong một giao dịch, hoặc chẳng trang nào cả

Pipeline OCR của HotPDF theo từng trang: render trang ở DPI đã cấu hình, lưu input.bmp trong một thư mục HotPDF-OCR riêng, khởi động tiến trình con Tesseract với tessedit_create_tsv, parse TSV mười hai cột, lọc từ theo confidence, và commit text layer vô hình cho tất cả các trang được yêu cầu hoặc chẳng trang nào
Adapter chỉ thay phần nhận dạng: render, parse, xác thực và commit tất-cả-hoặc-không-cả vẫn nằm trong pipeline text layer sẵn có, nên code hạ nguồn chẳng bao giờ phải đổi

Lý do adapter này tồn tại là phạm vi. OCR engine khớp mẫu tích hợp sẵn được siết hẹp một cách chủ đích: chữ và số ASCII in bằng máy, hết. Hóa đơn có tên mang dấu, hợp đồng tiếng Trung, và kho lưu trữ đa ngôn ngữ cần một recognizer thật với các language model đã huấn luyện, và Tesseract là ứng viên hiển nhiên vì nó là một chương trình command-line bạn có thể đặt cạnh ứng dụng. Gọi một chương trình ngoài từ một thư viện tài liệu nghe qua thì dễ. Thực ra không, và phần code thú vị nhất trong adapter là về chuyện gì xảy ra khi chương trình nghịch ngợm, treo, bị hủy, hay thừa hưởng những thứ nó không bao giờ nên thấy

HotPDF điều khiển Tesseract từ một ứng dụng Delphi thế nào?

HotPDF chạy Tesseract như một tiến trình con ẩn cho từng trang, nạp cho nó một bitmap đã render và đọc lại một tệp TSV, rồi phơi kết quả qua đúng đường nối IHPDFOCREngine mà engine tích hợp dùng. Chẳng gì hạ nguồn thay đổi: map tọa độ, xử lý xoay, xác thực Unicode, lọc confidence, và commit nguyên tử là pipeline text layer bạn đã có sẵn. Factory nằm trong unit HPDFTesseractRecognition và xác thực ngay từ đầu: executable phải tồn tại, thư mục tessdata phải tồn tại, timeout phải nằm giữa 1 và 3.600.000 milli giây, còn identifier ngôn ngữ chỉ được chứa chữ cái ASCII, chữ số, _ và +. Phép kiểm tra cuối đáng giá vì chuỗi ngôn ngữ sẽ nằm trên một dòng lệnh, và eng+chi_sim là một giá trị Tesseract hợp lệ còn bất cứ thứ gì có dấu ngoặc kép hay khoảng trắng thì không

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // ném EArgumentException khi thiếu executable, thiếu tessdata,
  // identifier ngôn ngữ hỏng, hay timeout ngoài khoảng 1..3600000 ms
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // vài model nối với nhau bằng '+'
    120000);            // giới hạn mỗi trang, mặc định là 60000
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Options := THPDFOCRTextLayerOptions.Default;  // 300 DPI, MinimumConfidence 0.5
    Options.CancellationToken := Token;
    // danh sách trang rỗng nghĩa là mọi trang; các trang có sẵn văn bản bị bỏ qua theo mặc định
    if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words accepted, ', Info.DroppedWordCount, ' dropped');
      Doc.SaveLoadedDocument(TargetFile);
    end
    else
      case Info.Status of
        otlsCancelled:      Writeln('Cancelled, document unchanged');
        otlsEngineError:    Writeln('Engine: ', string(Info.Diagnostic));
        otlsBudgetExceeded: Writeln('Budget: ', string(Info.Diagnostic));
      else
        Writeln(string(Info.Diagnostic));
      end;
  finally
    Doc.Free;
  end;
end;

Với từng trang, Recognize tạo một thư mục riêng dưới đường temp tên HotPDF-OCR-{GUID}, lưu bitmap đã render thành input.bmp, và khởi chạy tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, với mọi tham số đường được quote theo luật escape dòng lệnh Windows cho dấu backslash và dấu ngoặc kép lồng nhau. Giá trị --dpi là DPI render lấy từ THPDFOCRTextLayerOptions.DPI, nên Tesseract chẳng phải đoán độ phân giải từ metadata ảnh, còn --psm 3 xin phân đoạn trang hoàn toàn tự động. Engine tự báo tên là Tesseract (local CLI), thứ sẽ nằm trong Info.EngineName. Tesseract cùng các language model của nó không được gói kèm HotPDF; cài chúng là việc của ứng dụng

Vì sao bộ parser TSV lại nghiêm ngặt đến vậy?

Bộ parser TSV trong HotPDF gục cả trang trước bất kỳ hàng dị dạng nào, vì một danh sách từ parse nửa chừng cho ra một text layer lặng lẽ lệch khỏi ảnh. Output TSV của Tesseract có một header mười hai cột cố định, từ level tới text, và HotPDF so dòng đầu tiên với đúng header ấy sau khi gỡ một byte order mark tùy chọn. Mọi hàng theo sau phải tách thành đúng mười hai trường, và phép tách dừng sau tab thứ mười một để một tab nằm trong văn bản nhận dạng được vẫn là một phần của từ thay vì tạo ra cột thứ mười ba. Chỉ các hàng level 5 mới là từ; level 1 tới 4 mô tả trang, khối, đoạn và dòng, và chúng bị bỏ qua. Các hàng level 5 mà văn bản rỗng hay toàn khoảng trắng cũng bị bỏ qua, vì một từ trống có box nhưng chẳng có gì để định vị hay tìm kiếm. Mọi thứ khác bị soi rất kỹ: hình học số nguyên, một confidence được parse với định dạng en-US bất biến để một locale Đức không đọc 93.5 thành rác, một box nằm trọn trong bitmap, và một confidence giữa 0 và 100. Một thất bại duy nhất là ném lỗi, engine trả về False, và mảng từ bị xóa sạch. Các test hồi quy có đúng ca ấy: một từ hợp lệ theo sau một hàng hỏng phải cho ra 0 từ, chứ không phải một

Sáu cổng mà mọi hàng TSV Tesseract phải qua trong HotPDF: header đúng mười hai cột, đúng mười hai trường, chỉ level 5, văn bản không trống, một box nằm trong bitmap, và confidence từ 0 tới 100 được parse bất biến, nơi một hàng hỏng gục cả trang xuống còn 0 từ
Một danh sách từ parse nửa chừng sẽ lặng lẽ lệch khỏi ảnh, nên parser từ chối nguyên trang ngay hàng dị dạng đầu tiên thay vì giữ lại những từ đã đọc được
// lược từ vòng lặp level-5 trong HPDFLocalTSVRecognition
if (Fields.Count <> 12) or not TryStrToInt(Fields[0], Level) then
  raise EConvertError.Create('Invalid Local OCR TSV row');
if Level <> 5 then Continue;                 // các hàng trang/khối/đoạn/dòng
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // các từ toàn khoảng trắng không có vị trí
if not TryStrToInt(Fields[6], X) or not TryStrToInt(Fields[7], Y) or
  not TryStrToInt(Fields[8], W) or not TryStrToInt(Fields[9], H) or
  not TryStrToFloat(Fields[10], Confidence, Settings) then
  raise EConvertError.Create('Invalid Local OCR word geometry');
if (X < 0) or (Y < 0) or (W <= 0) or (H <= 0) or
  (Int64(X) + W > Request.Bitmap.Width) or
  (Int64(Y) + H > Request.Bitmap.Height) or
  not ((Confidence >= 0) and (Confidence <= 100)) then
  raise EConvertError.Create('Local OCR word is outside the image');
Words[Count].Confidence := Confidence / 100;  // pipeline mong 0..1

Dòng cuối ấy đụng độ với một mặc định bạn có thể không ngờ tới. Confidence của Tesseract chạy từ 0 tới 100, pipeline làm việc trong khoảng 0 tới 1, còn THPDFOCRTextLayerOptions.MinimumConfidence mặc định là 0.5, nên bất kỳ từ Tesseract nào dưới 50 được tính vào Info.DroppedWordCount và không bao giờ chạm tới trang. Với một bản scan sạch 300 DPI, đó là một sàn hợp lý. Với một bản fax nhiễu, nó có thể đánh rơi một tỉ lệ trang đáng ngạc nhiên, và nước đi đúng là nhìn số từ bị rơi trước khi hạ ngưỡng, vì các từ confidence thấp chính là những từ có nhiều khả năng sai nhất

Tiến trình con Tesseract thừa hưởng gì?

Tiến trình con Tesseract thừa hưởng đúng hai handle từ HotPDF: một handle NUL cho standard input và output, và một file handle cho standard error. Sự chính xác đó chính là điểm mấu chốt. CreateProcess với bInheritHandles = True là cách bạn trao các standard handle cho một tiến trình con, nhưng một mình nó trao mọi handle kế thừa được trong tiến trình chủ, kể cả các tệp, pipe và event được mở bởi code chẳng liên quan trong ứng dụng của bạn. Tiến trình con khi đó giữ các object ấy sống cho tới khi nó thoát, nên một tệp cứ bị khóa hay một pipe chẳng bao giờ thấy điểm kết thúc trong khi Tesseract gặm nhấm một trang. HotPDF bịt lỗ hổng đó bằng một record khởi chạy mở rộng: STARTUPINFOEX, một danh sách attribute mang PROC_THREAD_ATTRIBUTE_HANDLE_LIST, và cờ khởi tạo EXTENDED_STARTUPINFO_PRESENT. Với danh sách handle tại chỗ, bInheritHandles vẫn phải là True, nhưng chỉ những handle được liệt kê mới vượt qua ranh giới. Tư duy khoanh vùng đó còn thúc đẩy bài cô lập codec ảnh PDF trong worker process, nơi tiến trình con là code không đáng tin; ở đây tiến trình con đáng tin, nhưng host không phải chủ sở hữu duy nhất của bảng handle của chính mình

Thừa hưởng handle của tiến trình con Tesseract trong HotPDF: một CreateProcess thường với bInheritHandles trao mọi handle tệp, pipe và event kế thừa được cho tiến trình con, trong khi STARTUPINFOEX với PROC_THREAD_ATTRIBUTE_HANDLE_LIST thu gọn tập hợp xuống một handle NUL cho stdin và stdout cùng file handle stderr
Thiếu danh sách attribute, tiến trình con giữ các object không liên quan sống tới khi nó thoát, khóa tệp và làm pipe đói; có nó, chỉ hai handle được liệt kê vượt qua ranh giới
// hằng số hiển thị theo tên; mã nguồn truyền giá trị số của chúng
// cả hai handle được tạo với bInheritHandle = True
InheritedHandles[0] := NullHandle;    // stdin và stdout
InheritedHandles[1] := ErrorHandle;   // stderr.txt trong thư mục riêng
InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, AttributeBytes);
UpdateProcThreadAttribute(Startup.AttributeList, 0,
  PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
  @InheritedHandles[0], SizeOf(InheritedHandles), nil, nil);
CreateProcess(PChar(Executable), PChar(Command), nil, nil,
  True,                                        // đòi hỏi bởi danh sách handle
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

Vì sao một lần OCR bị hủy có thể trông như lỗi engine?

Một lần OCR bị hủy trông như một lỗi engine vì IHPDFOCREngine.Recognize trả về một Boolean duy nhất, và False nghĩa là cả "Tesseract gục" lẫn "người dùng bấm Cancel". Adapter thăm token hủy và timeout mỗi 25 milli giây trong lúc tiến trình con chạy, và khi token bắn, nó ném lỗi bên trong Recognize, bắt exception của chính mình, dọn dẹp, rồi trả về False kèm một thông tin chẩn đoán. Nếu pipeline coi đó là lỗi engine, bên gọi sẽ thấy otlsEngineError cho một công việc mà người dùng chủ động dừng. Vì thế ApplyLoadedOCRTextLayer kiểm tra token trước bất cứ khi nào Recognize trả về False, và chỉ chuyển kết quả thành lỗi engine nếu token chưa được đặt. Thứ tự đó giữ đúng hợp đồng đa trang: nhận dạng, xác thực, đối chiếu budget và dựng nội dung chạy cho mọi trang được yêu cầu trước khi giao dịch đồ thị mở ra, nên một lần hủy ở trang 40 trên 50 báo otlsCancelled và để nguyên tài liệu, kể cả 39 trang đầu. Không có tệp searchable một nửa nào phải giải thích về sau, và phần còn lại của xử lý thất bại theo cùng phong cách có chặn:

  • Timeout tính theo từng lệnh gọi Recognize, đo từ lúc bắt đầu, nên 60.000 ms mặc định áp dụng cho từng trang chứ không phải cho cả tài liệu
  • Một tiến trình con còn chạy khi timeout hay hủy bị kết liễu, được chờ tối đa 5 giây, và thư mục riêng của nó bị xóa trong một khối finally
  • output.tsv bị trần ở 64 MiB và stderr.txt ở 1 MiB, được kiểm tra cả trong lúc tiến trình con chạy lẫn sau khi nó thoát
  • Số từ và số code unit UTF-16 bị trần theo trang bởi các budget còn lại MaxWordsPerPage, MaxTotalWords và MaxTextCodeUnits, và vượt qua là gục cả lượt chạy thay vì cắt cụt danh sách từ
  • Standard output đổ vào NUL vì Tesseract tự viết output.tsv, còn standard error đổ vào một tệp để một exit code khác 0 được báo kèm tới 4.096 ký tự tiếng than của chính engine, thường là con đường nhanh nhất để biết rằng một tệp .traineddata đang thiếu

Các từ nhận dạng được hóa thành một text layer vô hình thế nào

HotPDF ghi các từ Tesseract thành văn bản vô hình bằng text rendering mode 3, chế độ không-tô-không-vệt định nghĩa trong ISO 32000-1 §9.3.6, nên trang vẫn hiện ảnh scan trong khi tìm kiếm và copy làm việc trên các từ đã nhận dạng. Content stream mở BT với 3 Tr, và mỗi từ nhận một ma trận Tm tại baseline của nó, một cỡ chữ suy ra từ chiều cao box tính bằng pixel ở DPI render, và một scale ngang Tz kéo dãn chuỗi glyph tới chiều rộng box đã đo, vì thế một highlight tìm kiếm đỗ đúng lên từ trong ảnh thay vì trôi qua nó

TSV của Tesseract có box nhưng không có baseline, nên adapter báo mọi từ không kèm baseline và pipeline ước lượng baseline ở một phần năm chiều cao box phía trên mép dưới. Bản thân văn bản đi qua một font Type0 không nhúng dùng chung với mã hóa Identity-H và một CMap ToUnicode sinh tự động, một CID cho mỗi Unicode scalar khác nhau trên toàn bộ lượt chạy, nhờ vậy tiếng Trung, chữ Latin mang dấu, và các ký tự supplementary plane đều sống sót qua copy và tìm kiếm. Thiết kế đó có hai giới hạn đáng nói trước: một lượt chạy mang tối đa 65.535 scalar khác nhau, và font không nhúng không thỏa yêu cầu nhúng font của ISO 19005, nên output PDF/A cần một font tuân thủ được nhúng riêng. Kiểm tra kết quả thì đơn giản và đáng tự động hóa: lưu, nạp lại, và chạy đường văn bản loaded-document thông thường từ bài trích xuất văn bản từ một PDF đã nạp trong Delphi; nếu các từ quay về đúng những trang mong đợi, layer là thật

RapidOCR và các engine khác trên cùng giao thức TSV

HotPDF tái dùng cùng bộ chạy tiến trình và bộ parser TSV cho RapidOCR qua HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds), lựa chọn hữu dụng hơn cho các bản scan tiếng Trung giản thể. Dòng lệnh giống hệt trừ việc đường script bridge được chèn vào sau executable Python, và ngôn ngữ được cố định là chi_sim. HotPDF phát hành bridge là tools/OCR/rapidocr_tsv.py; nó đòi các package rapidocr và onnxruntime cộng ba model ONNX cục bộ, tắt tải model tự động, và viết TSV theo hình dáng Tesseract nên phía Delphi chẳng cần một parser thứ hai. Tên engine báo trong Info.EngineName là RapidOCR (local ONNX). Hình dáng đó gợi ra công thức chung: bất kỳ recognizer nào bạn bọc được trong một script nhỏ chấp nhận danh sách tham số kiểu Tesseract và phát TSV mười hai cột đều thừa hưởng cô lập handle, timeout, khả năng hủy, budget output, và commit tất-cả-hoặc-không-cả miễn phí. Các adapter chỉ chạy trên Windows, chạy đồng bộ một trang mỗi lần, và không nghiêng sửa hay tiền xử lý ảnh ngoài những gì renderer tạo ra, nên chất lượng ảnh đầu vào vẫn đặt trần cho thứ chui ra

Các adapter Tesseract và RapidOCR, bộ ghi text layer vô hình, renderer trang nuôi chúng, và phần trích xuất văn bản xác minh kết quả đều có mặt trong cùng một component VCL thuần cho Delphi và C++Builder. Nếu bạn đang thêm OCR vào một ứng dụng số hóa hay lưu trữ tài liệu, HotPDF Delphi PDF component trao cho bạn nguyên pipeline với chỉ bản thân OCR engine là còn phải cài