מאמר טכני

OCR מובנה בהתאמת תבניות ב-Delphi עם HotPDF

HotPDF מספקת את THPDFBuiltInOCREngine, מנוע OCR מוגבל להתאמת תבניות שנכתב כולו ב-Object Pascal: הוא מבצע בינאריזציה לעמוד מרונדר באמצעות סף Otsu, מחלץ גליפים כרכיבים קשירים ומחשב ציון לכל גליף לפי כיסוי גווני אפור מול תבניות מרובות-גופנים שבמטמון, כך שאפליקציית Delphi יכולה לבנות שכבת טקסט ניתנת לחיפוש ללא תלות OCR חיצונית. המנוע נבנה מחדש מאפס בגרסה 2.731.0, והסיבה לא הייתה ה-matcher. אלה היו הפיקסלים

המנוע הישן עבר את הבדיקות שלו. הוא זיהה ASCII באותיות גדולות במפות סיביות סינתטיות, וב-Win32 המשיך לעשות זאת במשך חודשים. ואז אותו קוד הורץ תחת Win64 והפיק דבר: לא מילים, לא אבחון מעבר ל-"found no high-contrast foreground", ולא קריסה. התברר שהיו שתי טעויות בלתי תלויות במסלול קריאת הפיקסלים, שביטלו זו את זו, ופירוק שלהן הוא המחשה טובה לכך שקוד OCR נכשל בשקט ולא בקול רם

מדוע מנוע ה-OCR הישן עבד רק במקרה

המנוע הישן עבד מפני שמפות הסיביות של התבניות ושל היעד היו הפוכות באותה דרך, ולכן היפוך אנכי בקורא הפיקסלים לא נראה ל-matcher. TBitmap.ScanLine מחזירה שורות בסדר ההפוך מאמנת ה-DIB עם biHeight חיובי, שעליה מניח שאר נתיב התמונות. מרנדרים M הפוכה, משווים אותה לתבנית שגם היא הפוכה, והפרש ה-L1 זהה להשוואה הנכונה. כל גליף התאים. שום דבר לא היה נכון

הסימטריה הזו היא בדיוק מה שהופך את סוג הבאג הזה ליקר. כל תיקון חד-צדדי שובר את ההתאמה: מתקנים את קריאת היעד ומשאירים את התבניות, והזיהוי קורס לרעש; מתקנים קודם את התבניות ומקבלים את אותה קריסה מהכיוון השני. אין מסלול תיקון הדרגתי. לכן הבנייה מחדש החליפה את כל הקריאה ב-GetDIBits מול BITMAPINFOHEADER שהוגדר במפורש, שבו biHeight חיובי פירושו שורות bottom-up לפי חוזה ולא לפי מוסכמת VCL, והופכת פעם אחת, בכוונה, בעת ההעתקה למאגר גווני האפור

הטעות השנייה היא זו שנחשפה רק ב-Win64. ה-HDC שמועבר ל-GetDIBits לא יכול להיות ה-memory DC של מפת הסיביות עצמה, מפני שמפת הסיביות כבר נבחרה לתוכו ו-Windows מתעדת זאת כלא חוקי. העברת Bitmap.Canvas.Handle נסבלה בתהליך Win32 ונכשלה באופן עקבי בתהליך הבדיקה של Win64. התיקון הוא screen 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;   // חיובי => שורות bottom-up
    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;

בינאריזציה ורכיבים קשירים: מפיקסלים אפורים לתיבות גליפים

HotPDF מבצעת קודם בינאריזציה בשיטת Otsu וחוזרת לסף בחלון מקומי רק כאשר Otsu אינה ישימה. הנתיב הגלובלי דורש היסטוגרמה דו-שיאית אמיתית: המנוע מחשב את מקסימום השונות בין הקבוצות, ובנוסף דורש שטווח האפור יכסה לפחות 64 רמות לפני שהוא סומך על התוצאה. סריקה דהויה, עמוד עם רקע בעל גרדיאנט או מפת סיביות שכמעט כולה דיו נכשלים בבדיקה הזו. לאחר מכן ה-fallback משווה כל פיקסל לממוצע של חלון 31 על 31 עם הטיה של 6 רמות אפור, שמחושב באמצעות סכומי עמודות מצטברים כדי שהחלון הנע יישאר ליניארי במספר הפיקסלים

חילוץ הגליפים הוא תיוג רכיבים בקישוריות 8 על המסכה שנוצרה, עם stack מפורש במקום רקורסיה, מפני שמסכה של עמוד מלא תשמח לפוצץ stack של thread ב-Delphi במהלך flood fill עמוק. שני מסננים פועלים בזמן התיוג: רכיבים קטנים מ-9 פיקסלים מושלכים כרעש נקודות, וכל רכיב שמשתרע על יותר משלוש חמישיות גם מהרוחב וגם מהגובה של התמונה מושלך כמסגרת או כקו ולא כגליף. מעבר שני מאחד תיבות מוערמות אנכית שהחפיפה האופקית שלהן היא לפחות רבע מהתיבה הצרה יותר, וכך מחבר מחדש את הנקודה של i או j לגבעול שלה. כל זה פועל על raster, וה-raster מגיע מאותו renderer שמתואר ברינדור עמוד PDF טעון למפת סיביות ב-Delphi, ויש לכך סיבה מעשית: איכות ה-OCR מוגבלת מלמעלה באיכות הרינדור, ו-DPI ברירת המחדל של 300 לשכבת הטקסט הוא פשרה מכוונת ולא מקסימום

מה הופך את I הגדולה ואת l הקטנה לבלתי ניתנות להכרעה

ב-Arial, האות הגדולה I והאות הקטנה l עוברות rasterization לסרגלים זהים פיקסל-בפיקסל, ולכן שום תכונת צורה אינה יכולה להפריד ביניהן, וה-case חייב להגיע ממקור אחר לגמרי. התשובה של המנוע היא clustering של גובה ברמת השורה. תיבות הגליפים מקובצות לשורות טקסט לפי חפיפה אנכית, כל שורה מנותחת לפי גובה האות הגדולה שלה ולפי קו הבסיס המודלי, והגבהים בתוך שורה נחלקים לאשכול קצר ולאשכול גבוה. סרגל שנמצא באשכול הקצר הוא l; אותו סרגל באשכול הגבוה הוא I

המימוש המתבקש של החלוקה הזו הוא סף יחס קבוע, והוא אינו עובד. היחס בין גובה ה-x לגובה האות הגדולה ב-Arial הוא בערך 0.72, שנופל בדיוק על הערכים 0.70 ו-0.75 שכולם בוחרים בהם קודם. מזיזים את הקבוע במאית לכל כיוון וקורפוס שלם מחליף case. במקום זאת 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-height ברמת העמוד, שנלקח משורות שבהן כן נמצאה חלוקה: יחס של 1.10 ומטה מסמן הקשר של lowercase, יחס של 1.18 ומעלה מסמן הקשר של אותיות גדולות, וכל מה שביניהם נשאר ללא אילוץ. לאחר מכן ההתאמה מוסיפה bonus קטן של 0.03 להעדפת case לכיוון המועמד שתואם את ההקשר, מה שמטה שוויונות בלי לעקוף אף פעם הבדל צורה ברור

מדוע רשת תבניות בגודל 12x18 בלבלה בין c ל-o

רשת התבניות הורחבה מתאים בגודל 12 על 18 לתאים בגודל 16 על 24, מפני שברזולוציה הקטנה מרווח כיסוי גווני האפור בין c ל-o ירד מתחת ל-0.007, עמוק בתוך סף העמימות של המנוע. כל תיבת גליף עוברת דגימה מחדש לרשת כערכי כיסוי מ-0 עד 255 ולא כמסכה בינארית, לכן תא שהוא שליש דיו נקרא בערך 85 במקום להתעגל לשחור או לבן. ב-12 על 18 הצד הפתוח של c משתרע בקושי על יותר מעמודת תאים אחת והממוצע נגד aliasing שוטף את הפתח. ב-16 על 24 הפתח שורד את הדגימה מחדש, ורוב הזוגות שקל לבלבל ביניהם חוזרים למרחק בטוח

הציון הוא מרחק L1 מנורמל בין שתי רשתות הכיסוי, בתוספת קנס של 0.30 כפול הפרש הלוגריתמים ביחס-הצורה ושל 0.16 כפול הפרש צפיפות הדיו, עם prefilter קשיח שמדלג על כל תבנית שיחס הצורה שלה שונה ביותר מגורם 2.6. התבניות עוברות rasterization פעם אחת לכל תהליך מחמישה גופני מערכת (Arial, Times New Roman, Courier New, Tahoma ו-Segoe UI) על פני אלפבית של 62 תווים, נשמרות במטמון מאחורי מקטע קריטי ונעשה בהן שימוש חוזר בכל קריאה שלאחר מכן

הקבוע האחרון הוא המעניין. כאשר ציון הסגנית נמצא בטווח של 0.018 מציון המנצחת, HotPDF מהדקת את confidence של הגליף ל-0.5, שהוא מתחת לשער הקבלה 0.55, ולכן הגליף פשוט אינו נפלט. זהו חיתוך מכוון במצב fail-closed ולא תוצר של כוונון: מנוע מוגבל שמנחש מייצר שכבת חיפוש שהטקסט בה אינו תואם לתמונה, ומילה שגויה בשכבת טקסט גרועה יותר ממילה חסרה מפני שהיא בלתי נראית לאדם שבודק את הסריקה

פיצול מילים בלי סף רווח קבוע

HotPDF גוזרת את סף הרווח בין מילים לכל שורה מהתפלגות הרווחים בין גליפים ולא ממכפלה קבועה של רוחב גליף ממוצע. היוריסטיקה הקלאסית, "רווח שרחב מ-0.75 מה-advance הממוצע הוא space", נשברת ברגע ששורה מערבבת ספרות עם אותיות צרות, מפני שה-advance הממוצע מפסיק לתאר משהו ממשי. במקום זאת המנוע ממיין את הרווחים של השורה ומחפש את הקפיצה הגדולה ביותר בין ערכים עוקבים ממוינים, שהיא הגבול בין אשכול תוך-המילה לאשכול בין-המילים כאשר קיים כזה. שלושה guards מונעים מהדבר לפעול על רעש: הקפיצה חייבת להיות לפחות 0.22 מרוחב הגליף הממוצע, הרווח הראשון שמעל הפיצול חייב להיות לפחות 0.32 ממנו, והרווח האחרון שמתחת לפיצול לא יכול לעלות על 0.65 ממנו. אם guard כלשהו נכשל, הסף נשאר MaxInt וכל השורה הופכת למילה אחת. ה-guard האחרון הוא זה שמונע מזוג kerning רחב במיוחד לפצל מילה לשתיים, שגיאה מזיקה בהרבה מאיחוד שתי מילים, מפני שטוקן מאוחד עדיין מכיל את התווים הנכונים בסדר הנכון לחיפוש תת-מחרוזת

כתיבת שכבת הטקסט הבלתי נראית מעל התמונה הסרוקה

ApplyLoadedOCRTextLayer הופכת מילים שזוהו לשכבה ניתנת לחיפוש על ידי ציור שלהן במצב רינדור טקסט 3, מצב ללא fill וללא stroke שמוגדר ב-ISO 32000-1 §9.3.6, וממקמת אותן מעל התמונה הסרוקה שממנה הגיעו. זרם התוכן נפתח ב-BT ואחריו 3 Tr, וכל מילה ממוקמת באמצעות מטריצת טקסט שנבנית מקו הבסיס המדווח שלה, מגובה ה-cap שהומר מפיקסלים לפי DPI הבקשה ומ-scale אופקי שמותח את רצף הגליפים הסינתטי לרוחב המילה שנמדד. התוצאה מועתקת ונחפשת כטקסט ואינה מציירת דבר

קיימת overload שאינה דורשת engine ומייצרת עבורכם את המזהה המובנה, וזה מה שרוב הקוראים בנתיב המובנה צריכים להשתמש בו. זיהוי, אימות Unicode, חשבון תקציב ובניית התוכן מסתיימים לפני פתיחת עסקת copy-on-write, ולכן ביטול, חריגה מתקציב או כשל מנוע משאירים את גרף האובייקטים ואת מספר הגרסה ללא שינוי. מילים מסוננות פעמיים: המנוע מסיר כל דבר שמתחת לשער ה-confidence שלו, 0.55 לגליף, ואז THPDFOCRTextLayerOptions.MinimumConfidence (ברירת מחדל 0.5) מסיר מילים שלמות שמתחת לסף של ה-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;             // השאר עמודים שנולדו דיגיטליים ללא שינוי
    Options.UseOptionalContentGroup := True;
    Options.OptionalContentGroupName := 'OCR Text Layer';
    // overload ללא engine: 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 סינתטי משותף שאינו מוטמע, וזה מספיק לחיפוש ולהעתקה בכל viewer אך אינו עומד בדרישת הטמעת הגופן של ISO 19005. אם הפלט חייב להיות PDF/A, על ה-caller להטמיע בנפרד גופן תואם. וגם שכבת OCR נושאת גיאומטריה ולא מבנה, ולכן סדר הקריאה מגיע ממיקומי הגליפים בלבד; אם אתם זקוקים לסדר לוגי מעמוד שכבר יש בו טקסט אמיתי, חילוץ טקסט לפי סדר המבנה שמונחה על ידי עץ התגיות הוא כלי אחר לבעיה אחרת

היכן המנוע המובנה נעצר

המנוע המובנה צר בכוונה, והכרת הקצוות שלו היא מה ששומר עליו שימושי. הוא מכוון ל-ASCII מודפס במכונה ובניגודיות גבוהה, בגופנים הקרובים לחמשת פני התבנית שלו, וכל מה שמחוץ לכך מחזיר no word ולא ניחוש. הגבולות המעשיים הם:

  • תמונות עד 4096 על 4096 ו-4,194,304 פיקסלים, עם deadline של 2000 ms לזיהוי וביטול שיתופי דרך THPDFCancellationToken
  • אלפבית בן 62 תווים של אותיות וספרות ASCII; ללא סימני פיסוק, ללא תווים עם accent וללא CJK
  • טקסט מיושר לצירים בלבד, בסיבוב העמוד שה-renderer כבר נרמל; סריקות מוטות אינן עוברות deskew
  • זוגות גליפים עמומים נשארים ללא הכרעה, ולכן עמוד יכול להחזיר מילים חלקיות או את האבחון "found no unambiguous ASCII words"

כאשר המעטפת הזו קטנה מדי, IHPDFOCREngine הוא התפר. ממשו את Recognize מול engine משלכם, העבירו אותו ל-overload בת שלושת הארגומנטים של ApplyLoadedOCRTextLayer, וכל מה שלאחר מכן (מיפוי קואורדינטות, טיפול בסיבוב, אימות Unicode, תקציבים וה-commit האטומי) נשאר זהה. מפת הסיביות מושאלת למשך הקריאה הסינכרונית ואסור לשמור אותה. כדי לוודא שהשכבה נחתה כראוי, טענו מחדש את הקובץ השמור והריצו את נתיב הטקסט הרגיל שמתואר בחילוץ טקסט מ-PDF טעון ב-Delphi; אם המילים חוזרות, השכבה אמיתית

OCR מובנה בהתאמת תבניות, שכבת הטקסט הבלתי נראית, ה-renderer שמזין אותם וחילוץ הטקסט ממסמך טעון שמאמת אותם נשלחים כולם באותו רכיב VCL טבעי, ללא runtime OCR חיצוני וללא DLL להפצה לצד האפליקציה. אם אתם בונים capture של מסמכים, ארכוב או חיפוש ב-PDF סרוקים ב-Delphi או C++Builder, רכיב ה-PDF של HotPDF ל-Delphi נותן לכם את כל הצינור בתלות אחת