مقاله فنی

Tesseract OCR برای PDF قابل‌جستجو در Delphi با HotPDF

HotPDF صفحات PDF اسکن‌شده را با Tesseract از طریق HPDFCreateTesseractOCREngine به PDF قابل‌جستجو تبدیل می‌کند، یک factory که فایل اجرایی Tesseract نصب‌شده محلی را در قالب یک IHPDFOCREngine می‌پیچد. آن engine را به ApplyLoadedOCRTextLayer می‌دهی که هر صفحه را رندر می‌کند، به‌ازای هر صفحه یک بار Tesseract را اجرا می‌کند، خروجی TSV سطح کلمه‌اش را parse می‌کند و یک لایهٔ متن Unicode نامرئی را برای همهٔ صفحات درخواستی در یک تراکنش ثبت می‌کند، یا برای هیچ‌کدام

پایپ‌لاین OCR در HotPDF به‌ازای هر صفحه: رندر کردن صفحه در DPI تنظیم‌شده، ذخیرهٔ input.bmp در یک پوشهٔ خصوصی HotPDF-OCR، راه‌اندازی فرایند فرزند Tesseract با tessedit_create_tsv، parse کردن TSV دوازده‌ستونی، فیلتر کردن کلمه‌ها با confidence و ثبت لایهٔ متن نامرئی برای همهٔ صفحات درخواستی یا هیچ‌کدام
آداپتور فقط شناسایی را عوض می‌کند: رندر و parse و اعتبارسنجی و ثبت همه-یا-هیچ در همان پایپ‌لاین لایهٔ متن موجود می‌مانند، پس کد پایین‌دست هرگز عوض نمی‌شود

دلیل وجود این آداپتور دامنه است. موتور OCR مبتنی بر تطبیق قالب داخلی عمداً باریک است: حروف و ارقام ASCII چاپ‌شده با ماشین، همین. فاکتورهایی با نام‌های دارای اعراب، قراردادهای چینی و آرشیوهای چندزبانه یک شناساگر واقعی با مدل‌های زبانی آموزش‌دیده می‌خواهند و Tesseract نامزد بدیهی است، چون یک برنامهٔ خط فرمان است که می‌توانی کنار اپلیکیشنت توشه‌اش کنی. صدا زدن یک برنامهٔ خارجی از داخل یک کتابخانهٔ سند پیش‌پاافتاده به نظر می‌رسد. نیست، و بیشتر کد جالب آداپتور دربارهٔ این است که وقتی برنامه بدرفتاری کند، هنگ کند، لغو شود یا چیزهایی را به ارث ببرد که هرگز نباید ببیند چه اتفاقی می‌افتد

HotPDF چطور Tesseract را از داخل یک اپلیکیشن Delphi می‌راند؟

HotPDF به‌ازای هر صفحه Tesseract را به‌صورت یک فرایند فرزند پنهان اجرا می‌کند، یک بیت‌مپ رندرشده به آن می‌دهد و یک فایل TSV از آن پس می‌گیرد و نتیجه را از همان درز IHPDFOCREngine که موتور داخلی استفاده می‌کند در معرض می‌گذارد. هیچ چیز پایین‌دست عوض نمی‌شود: نگاشت مختصات، هندل کردن چرخش، اعتبارسنجی Unicode، فیلتر confidence و ثبت اتمیک همان پایپ‌لاین لایهٔ متنی است که از قبل داری. factory در unit مربوط به HPDFTesseractRecognition زندگی می‌کند و مشتاقانه اعتبارسنجی می‌کند: فایل اجرایی باید موجود باشد، پوشهٔ tessdata باید موجود باشد، timeout باید بین 1 تا 3,600,000 میلی‌ثانیه باشد و شناسهٔ زبان فقط می‌تواند حروف ASCII و ارقام و _ و + داشته باشد. آن بررسی آخر مهم است چون رشتهٔ زبان سرانجام روی یک خط فرمان می‌نشیند و eng+chi_sim یک مقدار مشروع Tesseract است در حالی که هر چیزی با گیومه یا فاصله نیست

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // برای فایل اجرایی غایب و tessdata غایب و شناسهٔ زبان بد یا timeout
  // بیرون از 1 تا 3600000 میلی‌ثانیه EArgumentException می‌دهد
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // چند مدل به‌هم‌پیوسته با '+'
    120000);            // سقف به‌ازای هر صفحه، پیش‌فرض 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;
    // فهرست صفحهٔ خالی یعنی همهٔ صفحات؛ صفحات دارای متن به‌طور پیش‌فرض رد می‌شوند
    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;

برای هر صفحه، Recognize زیر مسیر temp یک پوشهٔ خصوصی به نام HotPDF-OCR-{GUID} می‌سازد، بیت‌مپ رندرشده را به‌شکل input.bmp ذخیره می‌کند و tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1 را راه می‌اندازد، با کوتیشن کردن هر آرگومان مسیر مطابق قواعد escape خط فرمان ویندوز برای بک‌اسلش‌ها و گیومه‌های نهفته. مقدار --dpi همان DPI رندر از THPDFOCRTextLayerOptions.DPI است، پس Tesseract هرگز مجبور نیست وضوح را از متادیتای تصویر حدس بزند، و --psm 3 تقسیم‌بندی صفحهٔ تمام‌خودکار را می‌خواهد. engine خودش را Tesseract (local CLI) گزارش می‌کند که همان چیزی است که در Info.EngineName می‌نشیند. Tesseract و مدل‌های زبانی‌اش همراه HotPDF بسته‌بندی نشده‌اند؛ نصبشان کار اپلیکیشن است

چرا parser مربوط به TSV این‌قدر سخت‌گیر است؟

parser مربوط به TSV در HotPDF به‌ازای هر سطر بدفرم کل صفحه را می‌بازاند، چون یک فهرست کلمهٔ نیمه‌parse‌شده لایهٔ متنی تولید می‌کند که بی‌سروصدا با تصویر ناخوان است. خروجی TSV مربوط به Tesseract یک هدر دوازده‌ستونی ثابت دارد، از level تا text، و HotPDF بعد از کنار گذاشتن یک byte order mark اختیاری، سطر اول را با همان هدر دقیق مقایسه می‌کند. هر سطر بعدی باید دقیقاً به دوازده فیلد بشکند و شکستن بعد از یازدهمین tab متوقف می‌شود تا یک tab داخل متن شناسایی‌شده بخشی از کلمه بماند به‌جای آنکه ستون سیزدهمی بسازد. فقط سطرهای سطح 5 کلمه‌اند؛ سطح‌های 1 تا 4 صفحه و بلوک و پاراگراف و خط را توصیف می‌کنند و رد می‌شوند. سطرهای سطح 5 که متنشان خالی یا فقط فاصله است هم رد می‌شوند، چون یک کلمهٔ خالی جعبه دارد ولی هیچ چیزی برای مکان‌یابی یا جستجو. بقیه چیزها سخت بررسی می‌شوند: هندسهٔ صحیح، یک confidence که با قالب تغییرناپذیر en-US parse می‌شود تا یک locale آلمانی 93.5 را آشغال نبیند، جعبه‌ای که کاملاً داخل بیت‌مپ می‌افتد، و confidence بین 0 و 100. یک شکست منفرد استثنا می‌دهد، engine مقدار False برمی‌گرداند و آرایهٔ کلمه‌ها پاک می‌شود. تست‌های رگرسیون دقیقاً همان حالت را شامل می‌شوند: یک کلمهٔ معتبر بعد از یک سطر خراب باید صفر کلمه بدهد، نه یک

شش گیتی که هر سطر TSV مربوط به Tesseract در HotPDF از آن‌ها می‌گذرد: هدر دقیقاً دوازده‌ستونی، دقیقاً دوازده فیلد، فقط سطح 5، متن غیرخالی، جعبه‌ای داخل بیت‌مپ، و confidence از 0 تا 100 که تغییرناپذیر parse می‌شود؛ جایی که یک سطر خراب کل صفحه را تا صفر کلمه می‌بازاند
یک فهرست کلمهٔ نیمه‌parse‌شده بی‌سروصدا با تصویر ناخوان می‌شد، پس parser به‌جای نگه داشتن کلمه‌هایی که از قبل خوانده، به اولین سطر بدفرم کل صفحه را رد می‌کند
// فشرده‌شده از حلقهٔ سطح 5 در 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;                 // سطرهای صفحه/بلوک/پاراگراف/خط
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // کلمه‌های فاصله‌ای هیچ موقعیتی ندارند
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;  // پایپ‌لاین 0 تا 1 انتظار دارد

آخرین سطر با یک پیش‌فرض سروکار دارد که شاید انتظارش را نداشته باشی. confidence مربوط به Tesseract از 0 تا 100 می‌رود، پایپ‌لاین در بازهٔ 0 تا 1 کار می‌کند و THPDFOCRTextLayerOptions.MinimumConfidence پیش‌فرضش 0.5 است، پس هر کلمهٔ Tesseract زیر 50 در Info.DroppedWordCount شمرده می‌شود و هرگز به صفحه نمی‌رسد. روی یک اسکن تمیز 300 DPI این کف معقولی است. روی یک فکس پرنویز می‌تواند سهم شگفت‌انگیزی از صفحه را بیرون بیندازد و کار درست این است که قبل از پایین آوردن آستانه به تعداد ردشده‌ها نگاه کنی، چون کلمه‌های کم-confidence دقیقاً همان‌هایی هستند که به احتمال زیاد غلط‌اند

فرایند فرزند Tesseract چه چیزهایی را به ارث می‌برد؟

فرایند فرزند Tesseract دقیقاً دو handle از HotPDF به ارث می‌برد: یک handle از جنس NUL برای ورودی و خروجی استاندارد، و یک file handle برای خطای استاندارد. همین دقت، نکتهٔ ماجرا است. CreateProcess با bInheritHandles = True راهی است که handleهای استاندارد را به فرزند می‌دهی، اما به‌خودی‌خود همهٔ handleهای قابل‌ارث فرایند میزبان را پاس می‌دهد، شامل فایل‌ها و pipeها و eventهایی که کدهای نامربوط در اپلیکیشنت باز کرده‌اند. فرزند بعد آن objectها را تا خروجش زنده نگه می‌دارد، پس یک فایل قفل می‌ماند یا یک pipe هرگز پایانش را نمی‌بیند در حالی که Tesseract دارد روی یک صفحه زحمت می‌کشد. HotPDF این شکاف را با یک رکورد راه‌اندازی گسترده می‌بندد: STARTUPINFOEX، یک فهرست ویژگی حامل PROC_THREAD_ATTRIBUTE_HANDLE_LIST و فلگ ساخت EXTENDED_STARTUPINFO_PRESENT. با فهرست handle روی جا، bInheritHandles همچنان باید True باشد اما فقط handleهای فهرست‌شده از مرز رد می‌شوند. همان تفکر مهارکننده، ایزوله کردن codecهای تصویر PDF در فرایندهای کارگر را پیش می‌برد، جایی که فرزند کد غیرقابل‌اعتماد است؛ اینجا فرزند قابل‌اعتماد است اما میزبان تنها مالک جدول handle خودش نیست

ارث بردن handle در فرایند فرزند Tesseract در HotPDF: یک CreateProcess ساده با bInheritHandles همهٔ handleهای قابل‌ارث فایل و pipe و event را به فرزند می‌دهد، در حالی که STARTUPINFOEX با PROC_THREAD_ATTRIBUTE_HANDLE_LIST مجموعه را به یک handle از جنس NUL برای stdin و stdout به‌علاوهٔ file handle مربوط به stderr محدود می‌کند
بدون فهرست ویژگی‌ها فرزند objectهای نامربوط را تا خروجش زنده نگه می‌دارد، فایل‌ها را قفل و pipeها را گرسنه می‌کند؛ با آن، فقط دو handle فهرست‌شده از مرز رد می‌شوند
// ثابت‌ها به اسم نشان داده شده‌اند؛ منبع مقادیر عددی‌شان را پاس می‌دهد
// هر دو handle با bInheritHandle = True ساخته می‌شوند
InheritedHandles[0] := NullHandle;    // stdin و stdout
InheritedHandles[1] := ErrorHandle;   // stderr.txt داخل پوشهٔ خصوصی
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,                                        // الزامی توسط فهرست handle
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

چرا یک اجرای لغوشدهٔ OCR می‌تواند شبیه شکست engine به نظر برسد؟

یک اجرای لغوشدهٔ OCR شبیه شکست engine به نظر می‌رسد چون IHPDFOCREngine.Recognize فقط یک Boolean برمی‌گرداند و False هم یعنی «Tesseract شکست خورد» و هم یعنی «کاربر Cancel را زد». آداپتور وقتی فرزند در حال اجراست هر 25 میلی‌ثانیه یک بار توکن لغو و timeout را بررسی می‌کند و وقتی توکن عمل کند داخل Recognize استثنا می‌دهد، استثنای خودش را می‌گیرد، پاک‌سازی می‌کند و False با یک diagnostic برمی‌گرداند. اگر پایپ‌لاین آن را شکست engine می‌گرفت، فراخوانی‌کننده برای کاری که کاربر عمداً متوقفش کرد otlsEngineError می‌دید. پس ApplyLoadedOCRTextLayer هر وقت Recognize مقدار False برگرداند اول توکن را بررسی می‌کند و فقط اگر توکن ست نباشد نتیجه را به شکست engine تبدیل می‌کند. همان ترتیب قرارداد چندصفحه‌ای را حفظ می‌کند: شناسایی و اعتبارسنجی و حسابداری بودجه و ساخت محتوا برای همهٔ صفحات درخواستی قبل از باز شدن تراکنش گراف اجرا می‌شوند، پس یک لغو در صفحهٔ 40 از 50 گزارش otlsCancelled می‌دهد و سند را — از جمله 39 صفحهٔ اول — دست‌نخورده می‌گذارد. هیچ فایل نیمه‌قابل‌جستجویی نیست که بعداً باید توضیحش بدهی، و بقیهٔ هندل خطا هم همان سبک محدود را دنبال می‌کند:

  • timeout به‌ازای هر فراخوانی Recognize است و از شروعش اندازه گرفته می‌شود، پس پیش‌فرض 60,000 میلی‌ثانیه به هر صفحه تعلق می‌گیرد نه به کل سند
  • فرزندی که هنگام timeout یا لغو هنوز در حال اجراست خاتمه می‌یابد، تا 5 ثانیه منتظرش می‌مانند و پوشهٔ خصوصی‌اش در یک بلوک finally حذف می‌شود
  • output.tsv روی 64 MiB و stderr.txt روی 1 MiB سقف می‌خورد، هم وقتی فرزند در حال اجراست و هم بعد از خروجش بررسی می‌شود
  • شمار کلمه‌ها و واحد کد UTF-16 به‌ازای هر صفحه با بودجه‌های باقی‌ماندهٔ MaxWordsPerPage و MaxTotalWords و MaxTextCodeUnits سقف می‌خورد و عبور از آن‌ها به‌جای بریدن فهرست کلمه‌ها اجرا را می‌بازاند
  • خروجی استاندارد به NUL می‌رود چون Tesseract خودش output.tsv را می‌نویسد، در حالی که خطای استاندارد به فایل می‌رود تا یک کد خروج غیرصفر با تا 4,096 نویسه از شکایت خود engine گزارش شود، که معمولاً سریع‌ترین راه فهمیدن این است که یک فایل .traineddata غایب است

کلمه‌های شناسایی‌شده چطور لایهٔ متن نامرئی می‌شوند

HotPDF کلمه‌های Tesseract را با حالت رندر متن 3 به‌عنوان متن نامرئی می‌نویسد، یعنی حالت بدون fill و بدون stroke که در ISO 32000-1 §9.3.6 تعریف شده، پس صفحه همچنان تصویر اسکن‌شده را نشان می‌دهد در حالی که جستجو و کپی روی کلمه‌های شناسایی‌شده کار می‌کند. content stream با BT و 3 Tr باز می‌شود و هر کلمه یک ماتریس Tm روی خط کرسی‌اش می‌گیرد، یک اندازهٔ فونت مشتق از ارتفاع جعبه بر حسب پیکسل در DPI رندر، و یک مقیاس افقی Tz که رشتهٔ گلایف را به عرض اندازه‌گیری‌شدهٔ جعبه می‌کشد، و به همین دلیل هایلایت جستجو روی خود کلمه در تصویر می‌نشیند به‌جای آنکه رویش سرخ بخورد

TSV مربوط به Tesseract جعبه دارد اما خط کرسی ندارد، پس آداپتور هر کلمه را بدون خط کرسی گزارش می‌کند و پایپ‌لاین خط کرسی را در یک پنجم ارتفاع جعبه بالاتر از لبهٔ پایین تخمین می‌زند. خود متن از یک فونت Type0 مشترک embed‌نشده با کدگذاری Identity-H و یک CMap تازه‌ساختهٔ ToUnicode می‌گذرد، به‌ازای هر scalar متمایز Unicode در کل اجرا یک CID، و به همین دلیل چینی و لاتین دارای اعراب و نویسه‌های صفحهٔ مکمل همگی از کپی و جستجو جان به در می‌برند. این طراحی دو محدودیت دارد که از همان اول بگوییم: یک اجرا حداکثر 65,535 scalar متمایز می‌تواند حمل کند و فونت embed‌نشده الزام embed فونت در ISO 19005 را برآورده نمی‌کند، پس خروجی PDF/A به یک فونت همخوان embed شده جداگانه نیاز دارد. بررسی نتیجه ساده است و ارزش خودکارسازی دارد: ذخیره کن، دوباره بارگذاری کن و مسیر معمولی متن سند بارگذاری‌شده از استخراج متن از یک PDF بارگذاری‌شده در Delphi را اجرا کن؛ اگر کلمه‌ها در صفحات انتظار برگشتند، لایه واقعی است

RapidOCR و بقیهٔ engineها روی همان پروتکل TSV

HotPDF همان runner فرایند و parser مربوط به TSV را برای RapidOCR از طریق HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) دوباره به کار می‌برد که برای اسکن‌های چینی ساده‌شده انتخاب مفیدتری است. خط فرمان یکسان است جز اینکه مسیر bridge script بعد از فایل اجرایی Python درج می‌شود و زبان روی chi_sim ثابت است. HotPDF bridge را به‌شکل tools/OCR/rapidocr_tsv.py عرضه می‌کند؛ انتظار بسته‌های rapidocr و onnxruntime به‌علاوهٔ سه مدل ONNX محلی را دارد، دانلود خودکار مدل را غیرفعال می‌کند و TSV به شکل Tesseract می‌نویسد تا سمت Delphi به parser دوم نیاز پیدا نکند. نام engine که در Info.EngineName گزارش می‌شود RapidOCR (local ONNX) است. آن شکل دستور کلی را پیشنهاد می‌دهد: هر شناساگری که بتوانی در یک اسکریپت کوچک بپیچانی که فهرست آرگومان به سبک Tesseract را بپذیرد و TSV دوازده‌ستونی صادر کند، ایزوله‌سازی handle و timeout و لغو و بودجه‌های خروجی و ثبت همه-یا-هیچ را مجانی به ارث می‌برد. آداپتورها فقط ویندوزی‌اند، در هر لحظه یک صفحه را همگام اجرا می‌کنند و فراتر از خروجی رندرکننده، تصویر را deskew یا پیش‌پردازش نمی‌کنند، پس کیفیت تصویر ورودی همچنان سقف خروجی را تعیین می‌کند

آداپتورهای Tesseract و RapidOCR، writer لایهٔ متن نامرئی، رندرکنندهٔ صفحه‌ای که به آن‌ها غذا می‌دهد و استخراج متنی که نتیجه را تأیید می‌کند، همه در همان کامپوننت بومی VCL برای Delphi و C++Builder عرضه می‌شوند. اگر OCR را به یک اپلیکیشن اسنادگیری یا آرشیو اضافه می‌کنی، کامپوننت PDF در Delphi از HotPDF پایپ‌لاین را می‌دهد و فقط خود موتور OCR مانده که نصبش کنی