مقال تقني

OCR مضمّن بمطابقة القوالب في Delphi مع HotPDF

تأتي HotPDF مع THPDFBuiltInOCREngine، وهو محرك OCR محدود لمطابقة القوالب مكتوب بالكامل بـObject Pascal: يحوّل صفحة مصيّرة إلى صورة ثنائية باستخدام عتبة Otsu، ويستخرج glyphs كمكوّنات متصلة، ويقيّم كل glyph وفق تغطية التدرج الرمادي مقابل قوالب متعددة الخطوط مخزنة مؤقتًا، ولذلك يستطيع تطبيق Delphi بناء طبقة نص قابلة للبحث من دون اعتماد OCR خارجي. وكان لا بد من إعادة بناء المحرك من الصفر في v2.731.0، ولم يكن السبب هو المطابق، بل البكسلات

نجح المحرك القديم في اختباراته. فقد تعرّف على ASCII كبير في صور نقطية اصطناعية، واستمر في ذلك على Win32 لأشهر. ثم شُغّل الكود نفسه تحت Win64 فلم ينتج شيئًا إطلاقًا: لا كلمات، ولا تشخيص يتجاوز «found no high-contrast foreground»، ولا انهيار. واتضح أن الخلل ناتج عن خطأين مستقلين في مسار قراءة البكسلات كانا يلغي أحدهما الآخر، وفك هذا التشابك يوضح جيدًا لماذا تفشل شيفرة OCR بصمت بدلًا من أن تفشل بصوت عالٍ

لماذا لم يعمل محرك OCR القديم إلا بالمصادفة؟

عمل المحرك القديم لأن الصور النقطية لقوالبه وصوره المستهدفة كانت مقلوبة بالطريقة نفسها، ولذلك لم يكن الانعكاس الرأسي في قارئ البكسلات ظاهرًا للمطابق. تعيد TBitmap.ScanLine الصفوف بترتيب معاكس لاتفاقية DIB ذات biHeight الموجب التي يفترضها باقي مسار التصوير. صوّر M مقلوبًا رأسًا على عقب، وقارنه بقالب مقلوب بالطريقة نفسها، فستكون مسافة L1 مطابقة للمقارنة الصحيحة. وقد تطابق كل glyph. لكن لم يكن أي شيء صحيحًا

وهذا التماثل هو بالضبط ما يجعل هذا النوع من الأخطاء مكلفًا. فأي إصلاح من جانب واحد يكسر المطابقة: صحح قراءة الهدف واترك القوالب، فتنهار عملية التعرف إلى ضجيج؛ أو صحح القوالب أولًا، فتحصل على الانهيار نفسه من الاتجاه الآخر. ولا يوجد مسار إصلاح تدريجي. لذلك استبدل البناء الجديد عملية القراءة كاملة باستخدام GetDIBits مقابل BITMAPINFOHEADER مصرّح به صراحة، حيث يعني biHeight الموجب صفوفًا من الأسفل إلى الأعلى بحكم العقد، لا بحكم اصطلاح VCL، ثم يعكس مرة واحدة عن قصد عند النسخ إلى مخزن التدرج الرمادي

أما الخطأ الثاني فلم يظهر إلا تحت Win64. يجب ألا يكون HDC الممرر إلى GetDIBits هو DC الخاص بذاكرة الصورة نفسها، لأن الصورة محددة فيه أصلًا وتوثّق Windows أن ذلك غير صالح. وقد كان تمرير Bitmap.Canvas.Handle مقبولًا في عملية Win32 وفشل بثبات في عملية اختبار Win64. والإصلاح هو DC مؤقت من الشاشة يُنشأ عبر GetDC(0) ويُحرر داخل كتلة finally، ولا تكون له علاقة بأي صورة

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;   // الموجب => صفوف من الأسفل إلى الأعلى
    Info.bmiHeader.biPlanes := 1;
    Info.bmiHeader.biBitCount := 24;
    Info.bmiHeader.biCompression := BI_RGB;
    DC := GetDC(0);            // لا تستخدم Work.Canvas.Handle أبدًا: فـWork محددة هناك
    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;

التقسيم الثنائي والمكوّنات المتصلة: من البكسلات الرمادية إلى صناديق glyph

يحوّل HotPDF الصورة إلى ثنائية باستخدام طريقة Otsu أولًا، ثم يعود إلى عتبة نافذة محلية فقط عندما لا تنطبق طريقة Otsu. ويتطلب المسار العام رسمًا بيانيًا ثنائي النمط فعلًا: يحسب المحرك أقصى تباين بين الفئتين، ويشترط إضافة إلى ذلك أن يمتد نطاق الرمادي عبر 64 مستوى على الأقل قبل الوثوق بالنتيجة. وتفشل في هذا الاختبار صورة ممسوحة باهتة، أو صفحة ذات خلفية متدرجة، أو صورة نقطية يغطيها الحبر تقريبًا بالكامل. ثم تقارن الطريقة الاحتياطية كل بكسل بمتوسط نافذة 31 في 31 بانحياز قدره 6 مستويات رمادية، محسوبًا بمجاميع أعمدة متحركة حتى تبقى النافذة المنزلقة خطية في عدد البكسلات

واستخراج glyphs هو وسم للمكوّنات المتصلة ذات 8 اتجاهات فوق القناع الناتج، مع مكدس صريح بدل الاستدعاء الذاتي، لأن قناع صفحة كاملة يستطيع بسهولة استنزاف مكدس خيط Delphi في ملء غمر عميق. ويعمل مرشحان وقت الوسم: فتُسقط المكوّنات الأصغر من 9 بكسلات بوصفها ضجيج تناثر، كما تُسقط أي مكوّن يمتد عبر أكثر من ثلاثة أخماس عرض الصورة وارتفاعها بوصفه إطارًا أو خطًا لا glyph. ثم تدمج تمريرة ثانية الصناديق المكدسة رأسيًا عندما يبلغ تداخلها الأفقي ربع الصندوق الأضيق على الأقل، وهو ما يعيد نقطة i أو j إلى ساقها. وكل هذا يعمل على raster، ويأتي raster من المصيّر نفسه الموصوف في تصيير صفحة PDF محملة إلى صورة نقطية في Delphi، وهو أمر مهم عمليًا لأن جودة OCR لا تتجاوز جودة التصيير، كما أن DPI الافتراضي لطبقة النص وهو 300 مقايضة مقصودة لا حدًا أقصى

ما الذي يجعل I الكبير وl الصغير غير قابلين للحسم؟

في Arial، يتحول الحرف الكبير I والحرف الصغير l إلى شريطين متطابقين بكسليًا، ولذلك لا تستطيع أي خاصية شكلية الفصل بينهما، ويجب أن تأتي حالة الحرف من مكان آخر تمامًا. وإجابة المحرك هي تجميع الارتفاع على مستوى السطر. فتُجمع صناديق glyph في أسطر نصية حسب التداخل الرأسي، ويُحلل كل سطر لاستخراج ارتفاع الحروف الكبيرة المميز وخط الأساس النموذجي، ثم تُقسم الارتفاعات داخل السطر إلى مجموعة قصيرة وأخرى طويلة. ويكون الشريط في المجموعة القصيرة l، بينما يكون الشريط نفسه في المجموعة الطويلة I

التنفيذ الواضح لهذا التقسيم هو عتبة نسبة ثابتة، لكنه لا يعمل. فنسبة ارتفاع x إلى ارتفاع الحرف الكبير في Arial تقارب 0.72، وهي تقع تمامًا فوق القيمتين 0.70 و0.75 اللتين يتجه إليهما الجميع أولًا. حرّك الثابت جزءًا من مئة في أي اتجاه، وسينقلب تحديد الحالة في corpus كامل. وبدل ذلك ينفذ HotPDF تقسيمًا أحادي البعد إلى مجموعتين k=2 يقلل التباين: يرتب الارتفاعات المرشحة، ويجرب كل نقطة قطع، ويحتفظ بالقطع الذي يكون مجموع انحرافاته التربيعية داخل المجموعات عنده أصغر ما يمكن. وهكذا تصبح العتبة خاصية للصفحة بدل أن تكون ثابتًا في المصدر

// ClusterHeights مرتبة تصاعديًا؛ اعثر على تقسيم 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;
// النسبة بين متوسطي المجموعتين هي التي تحدد المجموعة القصيرة
if SmallMean / TallMean <= 0.80 then
  SmallGroup := ggSmall          // نطاق x-height حقيقي: أشكال الحروف الصغيرة
else
  SmallGroup := ggTall;          // نطاق ارتفاع واحد: كل شيء بارتفاع الحروف الكبيرة
Line.LowercaseContext := (SmallGroup = ggSmall);

لا تحمل الأسطر ذات نطاق الارتفاع الواحد أي دليل داخلي. فيبدو العنوان المؤلف كله من حروف كبيرة والتعليق المؤلف كله من حروف صغيرة متطابقين عند عزلهما. ولهذه الحالات يقارن HotPDF وسيط ارتفاع السطر بوسيط ارتفاع x على مستوى الصفحة، مأخوذًا من الأسطر التي انقسمت فعلًا: فالنسبة التي تساوي 1.10 أو تقل عنها تضع السطر في سياق الحروف الصغيرة، والنسبة التي تساوي 1.18 أو تزيد عنها تضعه في سياق الحروف الكبيرة، وما بينهما يبقى بلا قيد. ثم تضيف المطابقة مكافأة تفضيل حالة صغيرة مقدارها 0.03 للمرشح الذي يتفق مع ذلك السياق، فتدفع حالات التعادل قليلًا من دون أن تتجاوز اختلافًا شكليًا واضحًا

لماذا أربكت شبكة القوالب 12x18 الحرفين c وo؟

وُسّعت شبكة القوالب من خلايا 12 في 18 إلى 16 في 24، لأن هامش تغطية التدرج الرمادي بين c وo هبط عند الدقة الأصغر إلى أقل من 0.007، وهو داخل عتبة الالتباس في المحرك بكثير. ويُعاد أخذ عينات كل صندوق glyph إلى الشبكة على شكل قيم تغطية من 0 إلى 255 بدل stencil ثنائي، ولذلك تُقرأ الخلية التي يغطيها الحبر ثلثًا تقريبًا بقيمة 85 بدل تقريبها إلى الأسود أو الأبيض. وعند 12 في 18 لا يمتد الجانب المفتوح من c إلا بالكاد عبر عمود خلية واحد، ويغسل المتوسط المضاد للتعرج الفجوة. أما عند 16 في 24 فتبقى الفجوة بعد إعادة أخذ العينات، وتعود معظم الأزواج سهلة الالتباس إلى مسافة آمنة

والتقييم هو مسافة L1 مطبّعة بين شبكتي التغطية، مضافًا إليها جزاء قدره 0.30 مضروبًا في لوغاريتم فرق نسبة الأبعاد، و0.16 مضروبًا في فرق كثافة الحبر، مع مرشح أولي صارم يتجاوز أي قالب تختلف نسبة أبعاده بأكثر من عامل 2.6. وتُصيّر القوالب مرة واحدة لكل عملية من خمسة خطوط نظام (Arial وTimes New Roman وCourier New وTahoma وSegoe UI) عبر أبجدية من 62 محرفًا، ثم تُخزن خلف قسم حرج وتُعاد الاستفادة منها في كل استدعاء لاحق

والثابت الأخير هو المثير للاهتمام. فعندما يسجل الحرف الذي يأتي في المرتبة الثانية نتيجة تقع ضمن 0.018 من الفائز، يخفض HotPDF ثقة glyph إلى 0.5، وهي أقل من بوابة القبول 0.55، ولذلك لا يصدر glyph أصلًا. وهذا قطع متعمد يفشل بأمان، لا أثر ضبط عشوائي: فالمحرك المحدود الذي يخمّن ينتج طبقة قابلة للبحث لا يطابق نصها الصورة، والكلمة الخاطئة في طبقة النص أسوأ من المفقودة لأنها غير مرئية لمن يراجع المسح

تقسيم الكلمات من دون عتبة فجوة ثابتة

يستنتج HotPDF عتبة المسافة بين الكلمات لكل سطر من توزيع الفجوات بين glyphs بدل مضاعف ثابت لمتوسط عرض glyph. وتنهار القاعدة الكلاسيكية، «الفجوة الأوسع من 0.75 من متوسط التقدم هي مسافة»، بمجرد أن يخلط السطر أرقامًا بحروف ضيقة، لأن متوسط التقدم يتوقف عن وصف أي شيء حقيقي. وبدلًا من ذلك يرتب المحرك فجوات السطر ويبحث عن أكبر قفزة بين قيم مرتبة متتالية، وهي الحد الفاصل بين مجموعة الفجوات داخل الكلمة ومجموعة الفجوات بين الكلمات إذا وجدت. وتمنع ثلاثة حواجز إطلاق ذلك بسبب الضجيج: يجب أن تبلغ القفزة 0.22 من متوسط عرض glyph على الأقل، ويجب أن تبلغ أول فجوة فوق القطع 0.32 منه على الأقل، ويجب ألا تتجاوز آخر فجوة تحته 0.65 منه. وإذا فشل أي حاجز، تبقى العتبة عند MaxInt ويتحول السطر كله إلى كلمة واحدة. وهذا الحاجز الأخير هو ما يمنع زوج تقارب واحدًا عريضًا بصورة غير معتادة من تقسيم كلمة إلى كلمتين، وهو خطأ أشد ضررًا من دمج كلمتين، لأن الرمز المدمج لا يزال يحتوي المحارف الصحيحة بالترتيب الصحيح لبحث السلسلة الفرعية

كتابة طبقة النص غير المرئية فوق الصورة الممسوحة

تحوّل ApplyLoadedOCRTextLayer الكلمات المتعرف عليها إلى طبقة قابلة للبحث برسمها في وضع تصيير النص 3، أي وضع لا يملأ ولا يخطط كما تعرّفه ISO 32000-1 §9.3.6، ووضعها فوق الصورة الممسوحة التي جاءت منها. يبدأ تدفق المحتوى بـBT يليه 3 Tr، ثم توضع كل كلمة بمصفوفة نص مبنية من خط أساسها المبلغ عنه، ومن ارتفاع الحروف الكبيرة المحول من البكسلات عند DPI المطلوب، ومن مقياس أفقي يمدد مسار glyph الاصطناعي إلى عرض الكلمة المقاس. والنتيجة تنسخ وتُبحث مثل النص ولا ترسم شيئًا

وهناك صيغة overload لا تحتاج إلى محرك، تنشئ المتعرف المضمّن من أجلك، وهي التي ينبغي لمعظم مستدعي المسار المضمّن استخدامها. ويكتمل التعرف والتحقق من Unicode وحساب الميزانية وبناء المحتوى قبل فتح معاملة النسخ عند الكتابة، ولذلك يترك الإلغاء أو تجاوز الميزانية أو فشل المحرك شجرة الكائنات ورقم الإصدار من دون تغيير. وتُرشّح الكلمات مرتين: يسقط المحرك كل ما يقل عن بوابة ثقته الخاصة لكل glyph وهي 0.55، ثم يسقط THPDFOCRTextLayerOptions.MinimumConfidence، وقيمتها الافتراضية 0.5، الكلمات الكاملة الواقعة تحت حد المستدعي

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;             // اترك الصفحات الرقمية الأصلية كما هي
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // صيغة overload الخالية من المحرك: توفر HotPDF المتعرف المضمّن المحدود
    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;

هناك حد يستحق ذكره بوضوح بدل اكتشافه لاحقًا. تستخدم الطبقة غير المرئية خط Type0 اصطناعيًا مشتركًا غير مضمّن، وهو كافٍ للبحث والنسخ في كل عارض لكنه لا يفي بمتطلب تضمين الخط في ISO 19005. فإذا كان الإخراج يجب أن يكون PDF/A، فعلى المستدعي تضمين خط مطابق بصورة منفصلة. كما أن طبقة OCR النصية تحمل الهندسة لا البنية، ولذلك يأتي ترتيب القراءة من مواضع glyph وحدها؛ وإذا احتجت إلى ترتيب منطقي من صفحة فيها نص حقيقي أصلًا، فإن استخراج النص بترتيب البنية المدفوع بشجرة الوسوم أداة مختلفة لمشكلة مختلفة

أين يتوقف المحرك المضمّن؟

المحرك المضمّن ضيق عمدًا، ومعرفة حدوده هي ما يبقيه مفيدًا. فهو يستهدف ASCII مطبوعًا آليًا عالي التباين من خطوط قريبة من أوجه القوالب الخمسة، وكل ما يقع خارج ذلك يعيد عدم وجود كلمة بدل التخمين. والحدود العملية هي:

  • صور تصل إلى 4096 في 4096 و4,194,304 بكسل، مع مهلة تعرف قدرها 2000 ms وإلغاء تعاوني عبر THPDFCancellationToken
  • أبجدية من 62 محرفًا من حروف وأرقام ASCII؛ لا علامات ترقيم، ولا محارف ذات علامات صوتية، ولا CJK
  • نص بمحاذاة محورية فقط، عند دوران الصفحة الذي طبّعه المصيّر مسبقًا؛ ولا تُصحح عمليات المسح المائلة
  • تبقى أزواج glyph الملتبسة بلا حسم، ولذلك قد تعيد الصفحة كلمات جزئية أو التشخيص «found no unambiguous ASCII words»

عندما يكون هذا النطاق أصغر من المطلوب، تكون IHPDFOCREngine هي الوصلة. نفّذ Recognize على محركك الخاص، ومرره إلى صيغة overload ذات المعاملات الثلاثة من ApplyLoadedOCRTextLayer، ويبقى كل ما بعد ذلك، من ربط الإحداثيات ومعالجة الدوران والتحقق من Unicode والميزانيات والالتزام الذري، كما هو. وتُستعار الصورة النقطية طوال مدة الاستدعاء المتزامن ولا يجوز الاحتفاظ بها. وللتأكد من وصول الطبقة بصورة صحيحة، أعد تحميل الملف المحفوظ وشغّل مسار النص العادي الموصوف في استخراج النص من PDF محمل في Delphi؛ فإذا عادت الكلمات، فالطبقة حقيقية

تأتي مطابقة القوالب المضمنة لـOCR، وطبقة النص غير المرئية، ومصيّر الصفحة الذي يغذيهما، واستخراج النص من المستند المحمل الذي يتحقق منه، كلها في مكوّن VCL أصلي واحد، من دون بيئة OCR خارجية ومن دون DLL يجب نشرها بجانب تطبيقك. وإذا كنت تبني التقاط مستندات أو أرشفة أو بحثًا فوق ملفات PDF الممسوحة في Delphi أو C++Builder، فإن مكوّن HotPDF PDF لـDelphi يمنحك المسار كاملًا في اعتماد واحد