שכבות טקסט OCR, גבולות ברקוד ותיבות הסתרת פנים סוטים בעמודי PDF חתוכים כשפיקסלי bitmap ממופים חזרה דרך ה-MediaBox במקום התיבה שה-renderer באמת רסטר: ה-CropBox כפי שהוא נחתך מול ה-MediaBox (ISO 32000-1 §14.11.2). HotPDF תיקן זאת עבור ApplyLoadedOCRTextLayer ב-v2.770.153, ועבור DecodeLoadedPageBarcodes ו-DetectLoadedRedactionFindings ב-v2.770.154
דוח הבאג שבדרך כלל מגיע נראה כך. ארכיון חוזים סרוק עובר OCR, הפלט ניתן לחיפוש, וההדגשה של פגיעת החיפוש עבור מספר סעיף מוצגת חצי אינץ' מתחת ומשמאל למספר המודפס. רוב הקבצים באצווה תקינים. השבורים כולם הגיעו מתחנת סריקה אחת שכותבת /CropBox כדי לחתוך את שולי הפלטה. הפרט האחד הזה מפריד בין התמונה שמנוע ה-OCR ראה לבין המסגרת שבה שכבת הטקסט הוצבה, ואותו אי-התאמה מזיז גם את גבולות הברקוד ו, חמור מכך, את תיבות הסתרת הפנים
למה שכבת הטקסט של ה-OCR מתרחקת מהמילים הסרוקות?
שכבת הטקסט סוטה כי שני חצאי הצינור חלקו על איזה מלבן ה-bitmap מכסה. ב-v2.766.64 HotPDF שינתה את הרינדור, ייצוא ה-SVG, הצופה וההדפסה כדי לכבד את ה-CropBox: עמוד מוצג דרך ה-CropBox שלו כפי שהוא נחתך מול ה-MediaBox שלו, מה ש-ISO 32000-1 §14.11.2 מורה, ו-GetLoadedPageVisibleBox נוסף כדי להחזיר את אותה תיבה נראית. יכולות הזיהוי המשיכו לבנות את הטרנספורם שלהן מ-device ל-page מתוך GetLoadedPageBox(PageIndex, pbMediaBox, ...). הרסטר כיסה עכשיו את התיבה הנראית, הטרנספורם עדיין הניח את ה-MediaBox, וכל מיקום שזוהה חזר מוסט בפער שבין השניים
החלון הנגוע הוא אפוא מדויק. ApplyLoadedOCRTextLayer הציב טקסט במקום הלא נכון מ-v2.766.64 ועד v2.770.152. DecodeLoadedPageBarcodes לעמוד שלם וה-detection של פנים בתוך DetectLoadedRedactionFindings נשארו שגויים build אחד נוסף, עד v2.770.153. לפני v2.766.64 ה-renderer צייר את ה-MediaBox כולו, כך שהמיפוי והרסטר הסכימו, במחיר זיהוי של תוכן שצופים לעולם לא מציגים. התיקונים שינו שלושה דברים יחד עבור כל יכולת: את הטרנספורם, את הערכת תקציב הפיקסלים, ואת תיבת העמוד שנמסרת למנוע מותאם בתוך רשומת הבקשה
כמה מקרים מעולם לא נפגעו:
- עמודים בלי
/CropBox, או שה-CropBox שלהם שווה ל-MediaBox, ממופים זהים לפני ואחרי התיקון DecodeLoadedPageBarcodesעםHasRegionמוגדר מרנדר בדיוק את האזור שמעבירים וממפה דרך אותו אזור, כך שפענוח אזור-מפורש היה נכון לאורך כל הדרך; הבדיקה שהאזור שוכן בתוך העמוד עדיין משתמשת ב-MediaBox- ממצאי הסתרה מבוססי תבנית (אימיילים, מספרי כרטיסים וכדומה) מגיעים מחילוץ טקסט במרחב המשתמש, לא מרסטר, ולכן רק ממצאי detection הפנים זזו
שלוש מערכות קואורדינטות, ואילו APIs של HotPDF משתמשים בכל אחת
קוד HotPDF שנוגע בזיהוי מתמודד עם שלוש מערכות, ורוב באגי המיפוי מגיעים מערבוב של שתיים מהן
- פיקסלי bitmap: ראשית שמאל-עליונה, Y גדל כלפי מטה, היחידות פיקסלים ב-DPI של הבקשה.
THPDFOCRWord.Left,Top,Rightו-Bottomנמצאים במערכת הזאת, וכן נקודות ה-baseline האופציונליות, התוצאות ש-IHPDFBarcodeDecoderמותאם מחזיר, והתיבות מ-IHPDFFaceDetectorמותאם - מרחב משתמש PDF של עמוד טעון: ראשית שמאל-תחתונה, Y גדל כלפי מעלה, היחידות נקודות, עם
Bottom < Top.GetLoadedPageBoxו-GetLoadedPageVisibleBoxמחזירים Left, Bottom, Right, Top במערכת הזאת, וכך גם שדותPageLeft,PageBottom,PageRightו-PageTopשלTHPDFOCRRequest, הגבולות ב-THPDFDecodedBarcodeוהמלבנים ב-THPDFRedactionFinding - קואורדינטות ציור עמוד של HotPDF: ה-API שמשתמשים בו לבניית עמודים חדשים (פלט טקסט, צורות, ברקודים, קישורים, שדות טפסים) עובד עם ראשית שמאל-עליונה ו-Y גדל כלפי מטה. המערכת הזאת שייכת לייצור מסמכים ואין לה שום קשר ל-APIs של מסמכים טעונים שלמעלה, ולכן לעולם לא מזינים אליה מלבן במרחב משתמש של עמוד טעון ללא שינוי
רשומת המילה של ה-OCR מבוססת בכוונה על פיקסלים: מנוע מדווח על מה שראה בתמונה, ו-ApplyLoadedOCRTextLayer בבעלותו ההמרה. החלוקה הזאת עובדת רק כשההמרה משתמשת בתיבה הנכונה, מה ש-v2.770.153 השיב
הטרנספורם מ-device ל-page שמאחורי OCR, ברקודים ופנים
HotPDF ממפה פיקסלי bitmap אל העמוד עם מטריצה אפינית אחת שנבנית מחמישה קלטים: הסיבוב, הקנה המידה DPI / 72, גובה ה-bitmap, וה-Left, Bottom, Right ו-Top של התיבה המרונדרת. OCR, פענוח ברקודים ו-detection של פנים חולקים רוטינה אחת עבור זה, ולכן קלט תיבה שגוי אחד שבר את שלושתם באותו אופן. עבור עמוד לא מסובב מטריצת ה-page-to-device [A B C D E F] היא:
A = Scaleו-D = -Scale, כאשרScale = DPI / 72; ה-Dהשלילי הופך מרחב משתמש (Y מעלה) אל מרחב bitmap (Y מטה)B = C = 0, כי עמוד לא מסובב אינו מחזיק גזירה או החלפה בין ציריםE = -Left * Scale, שמזיז את הקצה השמאלי של התיבה אל עמודת פיקסל 0F = BitmapHeight + Bottom * Scale, שממפה את הקצה התחתון של התיבה אל y = BitmapHeight, הקצה התחתון של ה-bitmap, כך שהקצה העליון נוחת על שורה 0
פיקסלים חוזרים אל העמוד דרך ההופכית של אותה מטריצה. Request.PageRotation נושא את ה-/Rotate של העמוד מנורמל ל-0, 90, 180 או 270 (כל ערך שאינו כפולה של 90 מטופל כ-0), וה-renderer מסובב את העמוד עם כיוון השעון כפי ש-ISO 32000-1 §7.7.3.3 דורש. תחת סיבוב הצירים מתחלפים וזוג קצוות תיבה אחר מקובע אל ראשית ה-bitmap. כתובים כנוסחאות הופכיות, עם S = DPI / 72, x ו-y בפיקסלים ו-H גובה ה-bitmap:
| /Rotate | X של העמוד | Y של העמוד | קצוות התיבה שהמיפוי נשען עליהם |
|---|---|---|---|
| 0 | Left + x / S | Bottom + (H - y) / S | Left, Bottom |
| 90 | Left + y / S | Bottom + x / S | Left, Bottom |
| 180 | Right - x / S | Bottom + y / S | Right, Bottom |
| 270 | Right - y / S | Top - x / S | Right, Top |
העמודה האחרונה מסבירה למה הבאג נראה אקראי בייצור. CropBox שחותך רק את ראש העמוד משאיר את Left ו-Bottom ללא מגע, ולכן עמודים זקופים יצאו מושלמים ורק עמודים שנושאים /Rotate 270 סטו. הסיבוב גם מחליף את ממדי ה-bitmap: ב-90 וב-270 ה-bitmap הוא ברוחב (Top - Bottom) * S פיקסלים ובגובה (Right - Left) * S פיקסלים
מה משתבש עם MediaBox [0 0 612 792] ו-CropBox [36 36 576 756]?
עם חיתוך של חצי אינץ' מכל צד, שכבת הטקסט של עמוד לא מסובב נוחתת בדיוק 36 נקודות משמאל ו-36 נקודות מתחת למילים הסרוקות כשה-MediaBox בשימוש. קחו עמוד US Letter שה-CropBox שלו חותך 36 נקודות (0.5 אינץ') מכל קצה. התיבה הנראית היא 540 על 720 נקודות, ולכן ברזולוציית ה-OCR המוגדרת של 300 DPI הקנה הוא 300 / 72 ≈ 4.1667 וה-bitmap הוא 2250 על 3000 פיקסלים
נניח שהמנוע מדווח על מילה עם תיבת פיקסלים Left 450, Top 600, Right 900, Bottom 660 וללא בסיס. HotPDF אז מציב את הבסיס 20 אחוזים מגובה המילה מעל הקצה התחתון, בשורת פיקסלים 648, וממפה את נקודת ההתחלה (450, 648):
- דרך התיבה הנראית: x = 36 + 450 / 4.1667 = 144.0 ו-y = 36 + (3000 - 648) / 4.1667 = 600.48, מה שהוא המקום שבו המילה מודפסת
- דרך ה-MediaBox: x = 0 + 108.0 = 108.0 ו-y = 0 + 564.48 = 564.48, הזזה אחידה של (-36, -36) נקודות
מסובבים את אותו עמוד וכיוון השגיאה משתנה, כי קצוות אחרים מעורבים. ב-/Rotate 180 האיבר של ה-X משתמש ב-Right, ו-612 במקום 576 דוחף את השכבה 36 נקודות ימינה בזמן ש-Bottom עדיין גורר אותה 36 נקודות מטה. ב-/Rotate 270 גם Right וגם Top גדולים מדי, ולכן השכבה זזה 36 נקודות ימינה ו-36 נקודות מעלה. מסמך עם אוריינטציות מעורבות יכול להציג את הסטייה בשלושה כיוונים, טביעת אצבע אמינה לבאג הזה. קוד שנכתב ביד שגוזר את הקנה מהתיבה, כמו Bitmap.Width / (Right - Left), גם מותח כל קואורדינטה ב-612 / 540, קרוב ל-13 אחוזים, מעל ההזזה
אילו מהמסמכים שלכם נגועים?
מסמך PDF חשוף כשלפחות עמוד אחד יש תיבה נראית ששונה מה-MediaBox שלו, ו-HotPDF יכול לומר לכם את זה בכמה שורות. משווים את GetLoadedPageBox עם pbMediaBox מול GetLoadedPageVisibleBox עבור כל עמוד, ומדפיסים GetLoadedPageRotation לצד, כדי שתוכלו לחזות את כיוון הסטייה מהטבלה שלמעלה. THPDFPageBoundary מציע גם את pbCropBox, pbBleedBox, pbTrimBox ו-pbArtBox, אבל GetLoadedPageBox(pbCropBox) נופל בחזרה אל ה-MediaBox כשאין crop box ואינו חותך, ולכן התיבה הנראית היא הדבר הנכון להשוות מולו
uses
System.SysUtils, HPDFDoc;
procedure ReportCroppedPages(const FileName: string);
var
Pdf: THotPDF;
I: Integer;
ML, MB, MR, MT, VL, VB, VR, VT, Tmp: Single;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile(FileName) < 1 then
raise Exception.Create('Cannot load ' + FileName);
for I := 0 to Pdf.LoadedPageCount - 1 do
begin
if not Pdf.GetLoadedPageBox(I, pbMediaBox, ML, MB, MR, MT) then
Continue;
// ייתכן שהמערך השמור מפרט את פינותיו בכל סדר
if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
// כבר מנורמל ונחתך מול ה-MediaBox
if not Pdf.GetLoadedPageVisibleBox(I, VL, VB, VR, VT) then
Continue;
if (Abs(VL - ML) > 0.01) or (Abs(VB - MB) > 0.01) or
(Abs(VR - MR) > 0.01) or (Abs(VT - MT) > 0.01) then
Writeln(Format('Page %d MediaBox [%g %g %g %g] visible [%g %g %g %g] /Rotate %d',
[I + 1, ML, MB, MR, MT, VL, VB, VR, VT,
Pdf.GetLoadedPageRotation(I)]));
end;
finally
Pdf.Free;
end;
end;
שני פרטים של GetLoadedPageVisibleBox משנים עבור סקריפטים כמו זה. הפונקציה משאירה את פרמטרי ה-out שלה ללא מגע כשהיא נכשלת, ולכן הגדרה מראש של גודל עמוד ברירת מחדל לפני הקריאה היא תבנית בטוחה. וכש-CropBox פגום לא חותך כלל את ה-MediaBox, הפונקציה מחזירה את ה-MediaBox ולא מלבן ריק. אם הדוח מפרט עמודים וה-build המופץ שלכם ישן מ-v2.770.153 עבור OCR, או מ-v2.770.154 עבור ברקודים ופנים, מריצים זיהוי מחדש על אותם עמודים אחרי השדרוג. שכבת OCR שנקבעה על ידי build נגוע נשארת בקובץ השמור, ואפשרות SkipPagesWithText כברירת המחדל תדלג על אותם עמודים במעבר שני אלא אם תכבו אותה או תסירו קודם את השכבה הישנה
איך IHPDFOCREngine מותאם אמור למפות פיקסלים חזרה אל מרחב PDF?
IHPDFOCREngine מותאם אמור להחזיר תיבות מילים בפיקסלי bitmap ולתת ל-HotPDF לעשות את המיפוי; ממירים אל מרחב משתמש רק עבור ההחלטות שלכם, ואז משתמשים בתיבה מתוך הבקשה, לעולם לא ב-MediaBox. מאז v2.770.153 ה-PageLeft, PageBottom, PageRight ו-PageTop של הבקשה מתארים את התיבה הנראית המרונדרת, כך שהם תואמים את Request.Bitmap במדויק. ה-helper להלן הוא ההופכי של הטרנספורם של הספרייה, כולל שימושו בגובה ה-bitmap האמיתי עבור עמודים זקופים, ולכן הוא מסכים עם HotPDF עד הפיקסל
uses
System.SysUtils, System.Math, Vcl.Graphics, HPDFDoc;
// פיקסל bitmap (ראשית שמאל-עליונה, Y מטה) אל מרחב משתמש PDF
// (ראשית שמאל-תחתונה, Y מעלה), דרך התיבה שממנה ה-bitmap רונדר
procedure HotPixelToPage(Rotation, DPI, BitmapHeight: Integer;
Left, Bottom, Right, Top: Single; X, Y: Double;
out PageX, PageY: Double);
var
S: Double;
begin
S := DPI / 72.0;
case Rotation of
90: begin PageX := Left + Y / S; PageY := Bottom + X / S; end;
180: begin PageX := Right - X / S; PageY := Bottom + Y / S; end;
270: begin PageX := Right - Y / S; PageY := Top - X / S; end;
else
PageX := Left + X / S;
PageY := Bottom + (BitmapHeight - Y) / S;
end;
end;
סיבה ריאלית להזדקק למרחב משתמש בתוך מנוע היא כלל אזור: חשבוניות שאת הכותרת המודפסת שלהן אף פעם לא רוצים ניתנת לחיפוש, או אזור חותמת שמבלבל את המזהה. המנוע להלן, שנכתב עם TInterfacedObject כך שמונה ההפניות מטפל במחזור חייו, מסנן מילים לפי המקום שבו המרכזים שלהן נופלים על העמוד, ואז מחזיר את השורדים ללא מגע בקואורדינטות פיקסלים. RunRecognizer עומד במקום קריאת המזהה שלכם
type
TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
private
FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single; // מרחב משתמש
function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
out Words: THPDFOCRWords): boolean; // המזהה שלכם, תיבות פיקסלים
public
constructor Create(SkipLeft, SkipBottom, SkipRight, SkipTop: Single);
function GetName: AnsiString;
function Recognize(const Request: THPDFOCRRequest;
out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
end;
function TZoneFilterOCREngine.Recognize(const Request: THPDFOCRRequest;
out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
var
Raw: THPDFOCRWords;
I, Count: Integer;
CX, CY: Double;
begin
Diagnostic := '';
SetLength(Words, 0);
if not RunRecognizer(Request.Bitmap, Request.MaxWords, Raw) then
begin
Diagnostic := 'recognizer failed';
Exit(False);
end;
SetLength(Words, Length(Raw));
Count := 0;
for I := 0 to High(Raw) do
begin
HotPixelToPage(Request.PageRotation, Request.DPI,
Request.Bitmap.Height, Request.PageLeft, Request.PageBottom,
Request.PageRight, Request.PageTop,
(Raw[I].Left + Raw[I].Right) / 2, (Raw[I].Top + Raw[I].Bottom) / 2,
CX, CY);
if (CX >= FSkipLeft) and (CX <= FSkipRight) and
(CY >= FSkipBottom) and (CY <= FSkipTop) then
Continue;
Words[Count] := Raw[I]; // עדיין פיקסלים: HotPDF ממפה אותם בעצמו
Inc(Count);
end;
SetLength(Words, Count);
Result := True;
end;
מוסרים את המנוע אל ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info) כמו עם כל מנוע אחר. הספרייה מאמתת את מה שחוזר לפני שהיא סומכת עליו: מילה מושמטת ונספרת ב-Info.DroppedWordCount כשהתיבה שלה יוצאת מה-bitmap, כש-Right <= Left או Bottom <= Top, או כש-Confidence מחוץ ל-0..1 או מתחת ל-MinimumConfidence. החזרת יותר מילים מ-MaxWordsPerPage, או דחיפת המניין הרץ מעבר ל-MaxTotalWords, מכשילה את הקריאה כולה עם שגיאת תקציב, ולכן נאמנים ל-Request.MaxWords בתוך המנוע. לא ממירים תיבות מילים אל מרחב משתמש לפני ההחזרה; HotPDF היה מתייחס לערכי הנקודות בתור פיקסלים והשכבה הייתה קורסת אל ראשית ה-bitmap
מיפוי הפלט של ה-detector שלכם
אותו helper משרת צינור ביתי שנבנה על RenderLoadedPageToBitmap, שמרנדר את התיבה הנראית ומיישם את /Rotate בדיוק כפי שיכולות הזיהוי עושות. קוראים את התיבה עם GetLoadedPageVisibleBox, מנרמלים את הסיבוב באותו אופן שבו HotPDF עושה זאת, וממפים שתי פינות נגדיות של כל תיבת פיקסלים. ציר ה-Y מתהפך, וב-90 וב-270 מעלות הצירים מתחלפים, כך שהפינות הממופות יוצאות ללא סדר קבוע; לוקחים את המינימום והמקסימום של הנקודות הממופות, מה שהוא גם האופן שבו HotPDF בונה את גבולות הברקוד
const
DPI = 200;
var
Pdf: THotPDF;
Bmp: TBitmap;
VL, VB, VR, VT: Single;
Rotation: Integer;
PxL, PxT, PxR, PxB, X1, Y1, X2, Y2: Double;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('scanned-ids.pdf');
if not Pdf.GetLoadedPageVisibleBox(0, VL, VB, VR, VT) then Exit;
Rotation := Pdf.GetLoadedPageRotation(0) mod 360;
if Rotation < 0 then Inc(Rotation, 360);
if (Rotation <> 90) and (Rotation <> 180) and (Rotation <> 270) then
Rotation := 0;
Bmp := Pdf.RenderLoadedPageToBitmap(0, DPI);
if Bmp = nil then Exit;
try
MyDetector(Bmp, PxL, PxT, PxR, PxB); // הקוד שלכם, תיבת פיקסלים
HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
PxL, PxT, X1, Y1);
HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
PxR, PxB, X2, Y2);
Writeln(Format('User-space box [%.2f %.2f %.2f %.2f]',
[Min(X1, X2), Min(Y1, Y2), Max(X1, X2), Max(Y1, Y2)]));
finally
Bmp.Free;
end;
finally
Pdf.Free;
end;
end;
התנהגות הסיבוב מכוסה לעומק רב יותר ב-שיטוח סיבוב עמוד בלי לשבור את תיבות העמוד, וצינור פענוח הברקודים שצורך את אותו טרנספורם ב-פענוח קודי QR מסובבים מעמודי PDF. אם המנוע שלכם עוטף מזהה חיצוני, מתאם ה-Tesseract OCR עבור PDF ניתן לחיפוש מציג את צד בידוד ה-process והביטול של אותו ממשק
עזר זריז: מיפוי קואורדינטות ששומר על ה-CropBox
- ה-renderer מרסטר את התיבה הנראית, ה-CropBox כפי שהוא נחתך מול ה-MediaBox (ISO 32000-1 §14.11.2); כל מיפוי מפיקסל לעמוד חייב להשתמש באותה תיבה, נקראת עם
GetLoadedPageVisibleBox - HotPDF v2.770.153 תיקן את
ApplyLoadedOCRTextLayer; v2.770.154 תיקן אתDecodeLoadedPageBarcodesלעמוד שלם וממצאי פנים מתוךDetectLoadedRedactionFindings; builds מ-v2.766.64 ועד אותן גרסאות נגועים - תיבות
THPDFOCRWordהן פיקסלי bitmap עם ראשית שמאל-עליונה;GetLoadedPageBoxו-GetLoadedPageVisibleBoxמחזירים מרחב משתמש PDF עם ראשית שמאל-תחתונה ו-Bottom < Top - הקנה הוא
DPI / 72; גוזרים אותו מה-DPI, לעולם לא מתיבת עמוד שמחולקת את רוחב ה-bitmap - /Rotate מכריע אילו קצוות חשובים: Left ו-Bottom ב-0 וב-90, Right ו-Bottom ב-180, Right ו-Top ב-270
- מחזירים מילי OCR בפיקסלים ונותנים ל-HotPDF למפות אותם; ממירים רק עבור לוגיקת הסינון שלכם
- מריצים OCR מחדש על עמודים חתוכים שעובדו על ידי build נגוע, וזוכרים ש-
SkipPagesWithTextמדלג על עמודים שכבר נושאים את השכבה הישנה
יכולות הזיהוי, שאילתות תיבות העמוד ורינדור מסמכים טעונים שבשימוש כאן מגיעים כולם ברכיב HotPDF עבור Delphi ו-C++Builder; רישוי, הורדות ניסיון ורשימת היכולות המלאה נמצאים ב-עמוד HotPDF Delphi PDF component