מאמר טכני

OCR של סינית ורב-לשוני עם RapidOCR ב-HotPDF ל-Delphi

HotPDF מבצע OCR של סינית ושל שפות רבות ב-Delphi דרך מתאם ה-DLL המקורי של RapidOCR שלו: THPDFRapidOCRDLLOptions.ForLanguage ממפה תג שפה כמו 'zh-CN', 'zh-TW', 'ru' או 'ar' אל מודל זיהוי ומילון תווים תואמים, ו-THotPDF.ApplyLoadedOCRTextLayer הופך את השורות שזוהו לשכבת טקסט Unicode בלתי נראית וניתנת לחיפוש על עמודי PDF סרוקים

לגרום לדמו בכתב לטיני לעבוד הוא החלק הקל. הכישלונות המעניינים מתחילים כשעוברים לסינית מסורתית או רוסית והפלט הופך לגיבוב משכנע ותקין-צורה, או כשכל שורה מאבדת בשקט את התו האחרון שלה, או כשעמוד ערבי חוזר עם תיבות הטקסט שלו בסדר הלא נכון. אף אחד מאלה לא מעלה חריגה מעצמו. פרופילי השפה שנוספו ב-HotPDF v2.775.0 קיימים בעיקר כדי לסגור את הפערים האלה, וארבע המלכודות שלהלן שוות היכרות גם אם לעולם לא נוגעים בקוד המקורי, כי כל אחת מסבירה סימפטום שאפשר לבזבז עליו יום במרדף אחרת

איך ForLanguage בוחר מודל ומילון?

THPDFRapidOCRDLLOptions.ForLanguage פותר תג אל אחד מתוך תשעה פרופילים ומחזיר אפשרויות שמצביעות על <profile>/recognition.onnx ו-<profile>/dictionary.txt מתחת לספריית המודלים שלכם, תוך שמירה על ה-detector המשותף, מסווג הזוויות האופציונלי, וברירות המחדל של thread, פיקסלים ופסק-זמן מתוך THPDFRapidOCRDLLOptions.Default. המתודה מורידה את התג לאותיות קטנות, הופכת קווים תחתונים למקפים וגוזמת רווחים מסביב, כך ש-'zh_TW', 'ZH-tw' ו-' zh-tw ' כולם נוחתים על אותו פרופיל. כינויים הם רשימה מפורשת ולא התאמת קידומת: 'zh-Hant-TW' מתקבל כי הוא רשום, בזמן שוריאנט אזורי שרירותי שאינו רשום מעלה EArgumentException לפני שמודל כלשהו נטען

פתרון הפרופיל של ForLanguage ב-HotPDF עבור THPDFRapidOCRDLLOptions: תגים כמו zh_TW, ZH-tw ו-zh-TW מנורמלים ומושווים מול תשעה פרופילים רשומים, כל אחד קובע מודל זיהוי ומילון שתמיד מוגדרים יחד, בזמן שתג לא רשום מעלה EArgumentException לפני שמודל כלשהו נטען
תג אחד בוחר זוג מודל-מילון קבוע אחד; ה-detector, המסווג והתקציבים נשארים משותפים, ותג לא מוכר נכשל מהר במקום לטעון כלום
פרופילשפותתגים לדוגמהמודל קבוע
chסינית מפושטת ואנגליתzh, zh-CN, zh-Hans, chi_simPP-OCRv4
chinese_chtסינית מסורתיתzh-TW, zh-HK, zh-Hant, chi_traPP-OCRv3
enאנגליתen, en-US, en-GB, engPP-OCRv4
latinצרפתית, גרמנית, ספרדית, פורטוגזית, איטלקית, הולנדית, טורקיתfr, de, es-419, pt-BR, trPP-OCRv3
japanיפניתja, ja-JP, jpnPP-OCRv4
koreanקוריאניתko, ko-KR, korPP-OCRv4
cyrillicרוסית, אוקראינית, בולגרית, בלארוסיתru, ru-RU, uk, bgPP-OCRv3
arabicערבית, פרסית, אורדוar, ar-SA, fa, urPP-OCRv4
devanagariהינדית, מראטהית, נפאליתhi, mr, nePP-OCRv4

המתאם עצמו לעולם לא מוריד דבר. את הקבצים מספקים פעם אחת עם ה-helper המצורף, למשל tools/Install-RapidOCRModels.ps1 -Destination C:/OCR/models -Language ch,chinese_cht,cyrillic (או -Language All עבור כל תשעת הפרופילים), וה-helper מציב detector ו-classifier משותפים בשמות הקבצים בשורש ש-Default מצפה להם. אחרי כך, סריקה בסינית מפושטת הופכת ניתנת לחיפוש בכמה שורות. צנרת המנוע היא אותו תפר IHPDFOCREngine שמתואר ב-המאמר על ה-DLL של RapidOCR בתוך התהליך וגבול ה-ABI שלו, ולכן המאמר הזה נשאר ממוקד בשפות

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeChineseScanSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Models: THPDFRapidOCRDLLOptions;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // ch/recognition.onnx + ch/dictionary.txt, detector ו-classifier משותפים
  Models := THPDFRapidOCRDLLOptions.ForLanguage('zh-CN');
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Layer := THPDFOCRTextLayerOptions.Default;  // 300 DPI, MinimumConfidence 0.5
    // רשימת עמודים ריקה פירושה כל עמוד; עמודים שכבר יש להם טקסט מדולגים
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Layer, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' lines, ', Info.UniqueScalarCount, ' distinct characters');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

שני פרטים בפלט הזה ראויים להערה. הצינור המקורי מחזיר תוצאה אחת לכל שורת טקסט שזוהתה, לא לכל מילה, ולכן AcceptedWordCount סופר שורות כאן, ו-MinimumConfidence מושווה מול ממוצע ביטחון התווים של השורה כולה: שורה שממוצעה 0.45 מושמטת בשלמותה. UniqueScalarCount מדווח כמה scalars נבדלים של Unicode נאלצה שכבת הטקסט למפות אל הגופן שלה ואל טבלת ה-ToUnicode, בדיקת היגיון שימושית שטקסט CJK באמת הגיע במקום חופן fallbacks לטיניים. משאירים את ממשק המנוע חי על פני מסמכים, כי אתחול המודלים קורה במפעל והוא הצעד היקר

למה החלפה של מודל הזיהוי בלבד מייצרת גיבוב?

מודל זיהוי CTC לעולם לא פולט תווים, רק אינדקסי מחלקות, והמילון הוא הדבר היחיד שהופך אינדקס 1,204 ל-glyph. מחליפים את ch/recognition.onnx ב-cyrillic/recognition.onnx אבל משאירים את המילון הסיני, והמודל יפלוט בהנאה אינדקסי קירילית תקינים שהמילון הישן מתרגם לתווי Han אקראיים. התוצאה נראית כמו טקסט, עוברת אימות UTF-8, וניתנת לחיפוש עבור בדיוק כלום. לכן ForLanguage תמיד מגדיר RecognitionModel ו-CharacterDictionary יחד, ולכן אפשרויות שנבנות ביד לעולם לא אמורות לשנות את האחד בלי השני

בדיקת הבטיחות המובנת מאליה, השוואת גודל המילון מול רוחב הפלט של המודל, נחוצה אך אינה מספקת. שני מילונים יכולים להחזיק את אותו מספר רשומות בסדר אחר, וסטייה של אחד בסדר מזיזה כל תו בנקודת קוד אחת. לכן HotPDF בודק בשני שלבים כשהמפעל מאתחל את המודל. ראשון, מניין המחלקות בפלט חייב להיות שווה לרשומות המילון בתוספת שתיים. שני, כשקובץ ה-ONNX משבץ רשימת metadata של character, כל רשומת מילון מושווית איתו בסדר, ואי-התאמה מכשילה את האתחול עם EInvalidOperation ודיאגנוסטיקה מקורית במקום לייצר גיבוב סביר מאוחר יותר

הביטוי "ועוד שתיים" מגיע מפריסת המחלקות. מחלקה 0 היא ה-blank של ה-CTC, המחלקות 1 עד N הן שורות המילון בסדר הקובץ, והמחלקה האחרונה היא רווח. חלק מהמילונים נושאים גם רשומת רווח משלהם, ואת השורה הזאת חייבים לשמור בדיוק כפי שהיא. כאן Trim מתוך כוונה טובה עושה נזק אמיתי: הוא הופך רשומת רווח-בודד למחרוזת ריקה ומזיז או שובר את הטבלה. הנרמול היחיד שבטוח הוא השמטת carriage return בסוף שורה, כך שמילון שנשמר עם סיומות שורה של CRLF נטען נכון, בזמן שסימן סדר בייטים של UTF-8, שורה ריקה, או רשומה שמכילה טאב נדחים. השרטוט להלן מציג את הפריסה בפסקל; זהו קוד הסברתי, לא API של HotPDF

פריסת טבלת המחלקות של CTC ב-HotPDF עבור מילוני RapidOCR: מחלקה 0 היא ה-blank, המחלקות 1 עד N הן שורות המילון בסדר הקובץ עם שמירת רשומת רווח בודדת אם קיימת, והמחלקה האחרונה היא רווח, מה שנותן N בתוספת 2 מחלקות פלט שהמפעל מאמת מול המודל, metadata כלולה
המילון הוא הדבר היחיד שהופך אינדקסי מחלקות לתווים, ולכן גודלו, סדרו ורשומת הרווח שלו נאמתים לפני שעמוד אחד מזוהה
// המחשה בלבד: טבלת המחלקות שמזהה CTC מצפה לה
uses
  SysUtils, IOUtils;

function BuildCTCClassTable(const FileName: string): TArray<string>;
var
  Text, Entry: string;
  Lines: TArray<string>;
  I, Last: Integer;
begin
  Text := TEncoding.UTF8.GetString(TFile.ReadAllBytes(FileName));
  if (Text <> '') and (Text[1] = #$FEFF) then
    raise EArgumentException.Create('Dictionary must be UTF-8 without a BOM');
  Lines := Text.Split([#10]);
  Last := High(Lines);
  if (Last >= 0) and (Lines[Last] = '') then
    Dec(Last);                                   // שורה חדשה בסוף הקובץ
  SetLength(Result, Last + 3);
  Result[0] := '';                               // class 0: CTC blank
  for I := 0 to Last do
  begin
    Entry := Lines[I];
    if (Entry <> '') and (Entry[Length(Entry)] = #13) then
      SetLength(Entry, Length(Entry) - 1);       // CRLF: משמיטים רק את ה-CR
    if (Entry = '') or (Pos(#9, Entry) > 0) then
      raise EArgumentException.Create('Invalid dictionary entry');
    Result[I + 1] := Entry;                      // לעולם לא Trim: ' ' הוא מחלקה
  end;
  Result[Last + 2] := ' ';                       // final class: space
  // Length(Result) חייב להיות שווה למניין המחלקות בפלט המודל
end;

מה פענוח CTC החמדני באמת עושה?

פענוח CTC חמדני בוחר במחלקה בעלת הציון הגבוה ביותר בכל time step, מכווץ חזרות עוקבות לתו אחד, ומשמיט את מחלקת ה-blank; ה-blank הוא מה שמאפשר לאותיות שהוכפלו באמת לשרוד. מודל זיהוי מביט בשורת טקסט בתור רצף של פרוסות אנכיות צרות, ועבור כל פרוסה, או time step, הוא פולט הסתברות עבור כל מחלקה. שורה שמכילה AA中 עשויה לייצר רצף argmax של A A blank A 中 space. כיווץ שני ה-A הראשונים נותן A אחד, ה-blank מפריד בינו לבין ה-A הבא, והתוצאה היא AA中 עם הרווח הנגרר שלם. בלי כלל ה-blank, book ו-bok היו בלתי נבדלים

הליכה על GreedyCTCDecode ב-HotPDF: שישה time steps מצביעים על מחלקות argmax של A, A, blank, A, תו Han ורווח, חזרות עוקבות מתכווצות, ה-blank מאפס את שומר החזרות כך שאות שהוכפלה באמת שורדת, ושלושה באגי גבולות משמיטים בשקט ריווח מילים, את התו האחרון, או תווים מוכפלים
המפענח הוא כעשור שורות וכל גבול חשוב: לכלול את המחלקה האחרונה, לכלול את ה-step האחרון, ולתת רק ל-blank להפריד חזרות

מכיוון שהמפענח הוא רק כעשור שורות, קל לטעות בגבולות, והכישלונות שקטים. אם לולאת ה-argmax הפנימית עוצרת מחלקה אחת לפני הסוף, מחלקת הרווח לעולם לא יכולה לנצח וכל שורה חוזרת בלי ריווח מילים, מה שהורס חיפוש ביטויים בעמודי אנגלית ולטינית. אם הלולאה החיצונית עוצרת time step אחד לפני הסוף, התו האחרון של כל שורה נעלם, מה שעבור שורה קצרה יכול להיות שליש מהטקסט. ואם שומר החזרות לא מאופס על ידי blank, תווים מוכפלים כמו ll או הכפלות סיניות כמו 谢谢 מתכווצים לאחד. המפענח של HotPDF כולל את המחלקה האחרונה ואת ה-time step האחרון, משמר חזרות מופרדות-blank, ודוחה בנוסף ציונים שאינם סופיים או שחורגים מ-0 עד 1, וכל מניין מחלקות שאינו תואם את המילון. הנה אותה לוגיקה בתור המחשה בפסקל

// המחשה בלבד: פענוח CTC חמדני עם גבולות נכונים.
// Scores מחזיק Steps * Classes הסתברויות, שורה אחת לכל time step
function GreedyCTCDecode(const Scores: array of Single;
  Steps, Classes: Integer; const Characters: array of string): string;
var
  Step, C, Best, Previous: Integer;
  BestScore: Single;
begin
  if (Classes < 3) or (Length(Characters) <> Classes) or
    (Length(Scores) <> Steps * Classes) then
    raise EArgumentException.Create('Model output does not match the dictionary');
  Result := '';
  Previous := 0;                            // מחלקה 0 היא ה-blank של ה-CTC
  for Step := 0 to Steps - 1 do             // כוללים את ה-time step האחרון
  begin
    Best := 0;
    BestScore := Scores[Step * Classes];
    for C := 1 to Classes - 1 do            // כוללים את המחלקה האחרונה (רווח)
      if Scores[Step * Classes + C] > BestScore then
      begin
        Best := C;
        BestScore := Scores[Step * Classes + C];
      end;
    if (Best <> 0) and (Best <> Previous) then
      Result := Result + Characters[Best];
    Previous := Best;                       // blank מאפס את שומר החזרות
  end;
end;

פענוח חמדני אינו אסטרטגיית ה-CTC המדויקת ביותר הקיימת; beam search עם מודל שפה יכול לתקן חלק מהפרוסות הדו-משמעיות. עבור מסמכים מודפסים ב-300 DPI התוצאה החמדנית היא בדרך כלל מה שיש למודל להציע, והמפענח אינו המקום לפצות על חולשות המודל. מודל ה-PP-OCRv3 הלטיני, למשל, יכול לקרוא ñ בתור n גם על קלט נקי. HotPDF לא מכסה על זה בהחלפות תווים בפוסט-עיבוד, כי טבלת החלפות שמתקנת ספרדית שוברת משהו אחר, ותו שגוי בשכבה ניתנת לחיפוש גרוע מהחטאה כנה

איך HotPDF מסדר שורות טקסט, כולל ערבית מימין לשמאל?

HotPDF ממיין תיבות טקסט שזוהו מלמעלה למטה, מקבץ תיבות לשורה כשהן חופפות אנכית בלפחות חצי מגובה התיבה הקטנה מביניהן, ומסדר כל שורה משמאל לימין, או מימין לשמאל כש-RightToLeft מופעל; את התווים בתוך כל שורה שזוהתה לעולם לא הופכים. הקיבוץ חשוב כי detector לעיתים קרובות מפצל שורה ויזואלית אחת לכמה תיבות, למשל תווית וערך שמופרדים בפער רחב, ומיון טהור לפי קואורדינטה עליונה היה שוזר אותן עם השורה השכנה בכל פעם שהראשים שלהן שונים בפיקסל או שניים

פריסת הערבית מגדירה RightToLeft := True, מה שאומר ל-DLL לסדר את התיבות בכל שורה לפי הקצה הימני שלהן, מהשוליים הימניים פנימה. זהו האפקט כולו. הטקסט שהמודל מחזיר עבור שורה כבר נמצא בסדר הלוגי של Unicode, הסדר שבו קורא ערבית קורא ומקליד אותו, והוא גם הסדר שחילוץ הטקסט והחיפוש של PDF מצפים לו. היפוך מכני של המחרוזת כדי שת"יראה נכון" ב-debugger היה שובר חיפוש, העתקה והדבקה, וקוראי מסך. תצוגה דו-כיוונית ועיצוב גליפים הם עבודת הצופן

מנוע אחד משרת פרופיל שפה אחד. אין detection אוטומטי של כתב, ולכן מסמך שמערבב כתבים זקוק למנוע אחד לכל פרופיל, שמיושם על העמודים שמשתמשים בו. מכיוון ש-ApplyLoadedOCRTextLayer מקבל רשימת עמודים מפורשת וקובע כל קריאה בתור טרנזקציית הכול-או-כלום משלה, זה פשוט

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

function CreateRapidEngine(const Tag: string): IHPDFOCREngine;
var
  Models: THPDFRapidOCRDLLOptions;
begin
  // מעלה EArgumentException עבור תג לא מוכר, לפני שמודל כלשהו נטען
  Models := THPDFRapidOCRDLLOptions.ForLanguage(Tag);
  Models.MaxPixels := 33554432;          // מקום לעמודי A3 ב-300 DPI
  Result := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
end;

procedure OCRMixedArchive(Doc: THotPDF);
var
  Chinese, Arabic: IHPDFOCREngine;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  Chinese := CreateRapidEngine('zh-TW');  // chinese_cht profile
  Arabic := CreateRapidEngine('ar-SA');   // arabic profile, RightToLeft = True
  Layer := THPDFOCRTextLayerOptions.Default;
  if not Doc.ApplyLoadedOCRTextLayer([0, 1, 2], Chinese, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
  if not Doc.ApplyLoadedOCRTextLayer([3], Arabic, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
end;

שורת ה-MaxPixels שם מסיבה. אפשרויות ה-DLL בוררות כברירת מחדל 16,777,216 פיקסלים לבקשה, מה שמכסה A4 ו-US Letter ב-300 DPI בנוחות, אבל עמוד A3 ב-300 DPI הוא כ-3508 על 4961 פיקסלים, קרוב ל-17.4 מיליון, והבקשה נדחית בתור חריגה מהתקציב. מרימים את MaxPixels (התקרה היא 67,108,864) או מורידים את THPDFOCRTextLayerOptions.DPI עבור פורמטים גדולים. הסדר מימין לשמאל משתמש ב-export האופציונלי HPDFRapidOCRSetReadingDirection של גרסת ABI 1; המתאם דורש אותו רק כש-RightToLeft מוגדר, כך ש-DLL ישן ממשיך לשרת שפות משמאל לימין ונכשל ביצירת המנוע עם EArgumentException שמציין את ה-export החסר עבור ערבית

למה מודלי OCR חדשים נכשלים בטעינה?

ה-DLL של RapidOCR של HotPDF מקשר ONNX Runtime סטטי בגרסה 1.14, שאינו יכול לקרוא מודלים שנשמרו עם גרסת ONNX IR 10, וexports חדשים יותר כמו מודלי PP-OCRv5 יכולים לדרוש ראנטיים חדש יותר מזה; מודל כזה נכשל ביצירת המנוע עם דיאגנוסטיקה מקורית. האילוץ הזה הוא הסיבה שחבילות השפה קבועות על זוגות ספציפיים של מזהה ומילון של PP-OCRv3 ו-PP-OCRv4 במקום על "החדש ביותר", ולכן הטבלה שלמעלה מערבבת את שני הדורות: כל זוג קבוע הוא כזה שנטען ונאמת תחת אותו ראנטיים

המתקין אוכף את הזיווג. כל קובץ ב-manifest שלו נושא hash של SHA256, קובץ קיים עם hash אחר עוצר את ההתקנה במקום להידרס, וכל הורדה נוחתת תחת שם זמני ועוברת למקומה רק אחרי שה-hash שלה מתאים. זה מגן מפני הגרסה השקטה של בעיית המילון: מישהו משליך recognition.onnx חדש יותר ביד לתוך תיקיית פרופיל, המניין המחלקות במקרה תואם, ושום דבר לא נכשל עד שלקוח מדווח שהחיפוש לא מוצא מילים שהוא רואה בבירור. בזמן ריצה המתאם נשאר אופליין ולעולם לא מושיט יד אחר מודל חסר. המזהה גם מאמת את צורת המודל בטעינה, מקבל קלט NCHW בגובה קבוע של 32 או 48 פיקסלים או בגובה דינמי, שאותו הוא מריץ ב-48

אם זקוקים לכתב שאף אחד מתשעת הפרופילים לא מכסה, אפשר עדיין לכוון את RecognitionModel ואת CharacterDictionary אל הקבצים שלכם. אותן בדיקות חלות, וזו המטרה: זוג לא תואם נכשל באתחול, ולא בארכיון של הלקוח שלכם. עבור עמודים שאף פרופיל RapidOCR לא מתאים להם, מתאם ה-Tesseract עבור PDF ניתן לחיפוש מתחבר לאותה קריאת ApplyLoadedOCRTextLayer, ועבור טפסי ASCII מודפסי-מכונה מנוע ה-OCR מבוסס התאמת התבניות המובנה לא זקוק למודלים כלל

עזר זריז: צ'קליסט RapidOCR רב-לשוני

  • יוצרים אפשרויות עם THPDFRapidOCRDLLOptions.ForLanguage ומתייחסים ל-EArgumentException בתור תג לא נתמך, לא תקלת ראנטיים
  • משנים RecognitionModel ו-CharacterDictionary יחד, לעולם לא אחד לבדו; מנייני מחלקות שווים אינם מוכיחים סדר תווים זהה
  • משאירים מילונים בתור UTF-8 ללא BOM, לעולם לא גוזמים רשומות, ומצפים שלמודל יהיו N + 2 מחלקות: blank, N רשומות, רווח
  • מפענח CTC מותאם חייב לכסות את המחלקה האחרונה ואת ה-time step האחרון ולהשאיר חזרות מופרדות ב-blank
  • משתמשים במנוע אחד לכל פרופיל שפה ומעבירים רשימות עמודים מפורשות עבור מסמכים מעורבי-כתב
  • RightToLeft משנה רק את סדר התיבות; הטקסט שזוהה נשאר בסדר הלוגי של Unicode
  • מתקינים מודלים עם Install-RapidOCRModels.ps1 כדי שקיבועי ה-SHA256 יחזיקו את זיווג המודל והמילון; מגדירים UseAngleClassifier := False אם התקנתם עם -SkipClassifier
  • מרימים את MaxPixels מעל ברירת המחדל של 16,777,216 לפני הרצת עמודי A3 ומעלה ב-300 DPI

פרופילי השפה של RapidOCR, מתאם ה-DLL המקורי וצינור שכבת הטקסט של ה-OCR הם חלק מ-HotPDF Delphi PDF Component עבור Delphi, C++Builder ו-FPC/Lazarus ל-Windows, החל מ-v2.775.0 עבור הפרופילים הרב-לשוניים