מאמר טכני

פענוח קודי QR מסובבים בעמודי PDF עם HotPDF

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

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

מדוע סיבוב מסכת הסריקה לעולם לא מתקן QR מסובב?

כי פריסת דפוסי ה-finder של QR אסימטרית בכוונה, וסיבוב של תמונה שלמה משמר את האסימטריה הזאת במקום להסיר אותה. QR Code מציב שלושה ריבועי finder בפינות שמאל-עליון, ימין-עליון ושמאל-תחתון, ומשאיר את הפינה השמאל-תחתונה ריקה (ISO/IEC 18004:2015 §6.3.3). אותה פינה חסרה היא רמז הכיוון. מסובבים את ה-bitmap של העמוד בתשעים מעלות והחור פשוט עובר לפינה אחרת. אין סיבוב לא-טריוויאלי של המישור שמחזיר פריסה של שלוש פינות אל עצמה, ולכן מפענח שמקבל רק את הסידור הקנוני ידחה כל ניסיון בתורו

זה חשוב כי התיקון המובן מאליו הוא התיקון הלא נכון. האינסטינקט הטבעי הוא לתלות את הניסיון החוזר בחוץ: לעבד את העמוד, למסור את המסכה למפענח, ואם זה נכשל, לסובב את המסכה ולנסות שוב ב-90, ב-180 וב-270 מעלות. עבור Code 39 המדיניות הזאת בדיוק נכונה, כי לסימבולוגיה ליניארית יש דפוסי התחלה וסיום שהסורק מוצא ברגע שהפסים רצים אופקית. עבור QR זה ארבעה כישלונות מובטחים ואחר כך דיווח שלא נמצא דבר

קבוצת D4, מיושמת על מטריצת ה-modules

המקום הנכון לנרמול הוא אחרי הדגימה, על רשת ה-modules הבוליאנית ולא על מסכת הפיקסלים. ברגע שהמפענח הצליח לפתור את הסמל למטריצה של n על n של modules כהים ובהירים, הוא יכול למנות את הקבוצה הדיאדרלית של הריבוע: ארבעה סיבובים כפול שתי השתקפויות, שמונה כיווני מועמדים בסך הכל. עבור כל מועמד הוא בודק את משולש ה-finder, והמועמד הראשון ששלושת ה-finder שלו נוחתים בעמדות שמאל-עליון, ימין-עליון ושמאל-תחתון הוא הכיוון האמיתי. משם ה-pipeline הקיים רץ ללא שינוי, כי סיביות מידע הפורמט, מקומם של הנתונים בזיגזג ותיקון Reed-Solomon מניחים כולם מטריצה קנונית ועכשיו מקבלים כזאת

ארבעה עיבודים של אותה מטריצת modules של QR ב-HotPDF תחת סיבובי קבוצת D4 ב-0, 90, 180 ו-270 מעלות, שמראים את שלושת דפוסי ה-finder נודדים בין פינות בזמן שהפינה הריקה נעה איתם, כך שרק הכיוון הקנוני מציג finder בשמאל-עליון, ימין-עליון ושמאל-תחתון בפני המפענח
סיבוב מסכת הפיקסלים לא יכול להסיר את אסימטריית ה-finder של QR, ולכן HotPDF מונה את כיווני ה-D4 על מטריצת ה-modules הנדגמת ושומר את המועמד הראשון שה-finder שלו נוחתים שמאל-עליון, ימין-עליון ושמאל-תחתון

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

זיהוי הגרסה הוא חיפוש התחלקות, לא חילוק

את מספר ה-modules אי אפשר לגזור מחלוקת רוחב הדגימה בגודל module מונח, וטעות כאן היא מקור עדין לכישלונות פענוח בעיבודים ברזולוציה גבוהה. סמל QR של גרסה v הוא בן 4v + 17 modules, כך שגרסה 1 היא 21 modules וגרסה 40 היא 177. מסכה שרוחבה 126 פיקסלים עקבית באותה מידה עם גרסה 1 בשישה פיקסלים ל-module ועם כמה גרסאות גבוהות יותר בגדלי module קטנים. חילוק ליניארי בוחר אחת מהן ובדרך כלל טועה

מה שעובד הוא חיפוש התחלקות על פני הגרסאות המועמדות. הולכים מגרסה 40 ומטה עד גרסה 1, שומרים על המועמדים שמספר ה-modules שלהם מחלק את רוחב הדגימה באופן שלם ומשאיר לפחות שלושה פיקסלים לכל module, ולוקחים את הגרסה הקטנה ביותר ששרדה. רצפת שלושת הפיקסלים היא מה שעוצר את החיפוש מלקבל קריאה מטורפת בצפיפות של סמל גס, וכלל הגרסה-הקטנה מפזר את העמימות הנותרת לטובת הקריאה שסורק באמת היה מייצר

הליכת זיהוי הגרסה של HotPDF עבור סמל QR על מסכה נדגמת של 126 פיקסלים, בוחנת כל מספר modules מועמד 4v בתוספת 17 מגרסה 40 ומטה עד גרסה 1 להתחלקות שלמה ולרצפת module של שלושה פיקסלים לפני שהגרסה הקטנה ביותר ששרדה מנצחת
מספר modules של QR מגיע מחיפוש התחלקות על פני גרסאות מועמדות, ולא מחלוקת רוחב המסכה בגודל module מונח, והגרסה הקטנה ביותר ששרדה מפזרת את העמימות
var
  Pdf: THotPDF;
  Options: THPDFBarcodeDecodeOptions;
  Codes: THPDFDecodedBarcodes;
  Info: THPDFBarcodeDecodeInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('delivery-notes.pdf');
    Options := THPDFBarcodeDecodeOptions.Default;
    Options.DPI := 300;
    Options.RotationPolicy := bdrpFallback;
    Options.MinimumConfidence := 0.5;
    Options.MaxResults := 16;
    if Pdf.DecodeLoadedPageBarcodes(0, Options, Codes, Info) then
      for I := 0 to High(Codes) do
        if Codes[I].Symbology = bsyQRCode then
          Writeln(Codes[I].Text, '  at ',
            Format('%.0f', [Codes[I].OrientationDegrees]), ' degrees');
  finally
    Pdf.Free;
  end;
end;

THPDFBarcodeDecodeOptions.Default מחזיר רשומה מאוכלסת ולא מאופסת, וזה משנה כי DPI של אפס או תקרת תוצאות של אפס הם דרך סבירה-מראה לקבל כלום. RotationPolicy שולט רק בניסיון החוזר החיצוני: bdrpNone מעבד פעם אחת, bdrpFallback מנסה שוב את שאר הכיוונים אחרי מעבר ראשון שנכשל, ו-bdrpAll מעבד כל כיוון ללא תנאי. מאחר שנרמול ה-QR מתרחש בתוך המפענח, עמודי QR נפתרים בניסיון הראשון תחת כל אחת משלוש המדיניות. המדיניות נוכחת בשביל הסימבולוגיות הליניאריות שבאמת זקוקות לה

איך מוכיחים שטרנספורמציה של bitmap לא ממציאה פיקסלים?

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

הגורם היה מונדיאלי ושווה לשאת משם ככלל. מערך דינמי שמוקצה עם SetLength אינו מובטח להגיע מאופס כשהוא תוצאת פונקציה שנוסעת בנתיב שהרנטיים לא מנקה, ותאים שהסיבוב מעולם לא כתב נושאים אז את הבייטים שהיו שם קודם. חלק מהבייטים הישנים האלה לא-אפסיים, ולא-אפס פירושו דיו. התיקון שורה אחת, FillChar(Result[0], N, 0) לפני שלולאת התמורה רצה, והמשמעת שהיא כופה רחבה יותר: כל פונקציה שמחזירה מסכה או חוצץ bitmap צריכה לנקות את הפלט שלה במפורש במקום לסמוך על סמנטיקת ההקצאה

מה שגרם לפגם לשרוד שלוש גרסאות מעניין מהפגם עצמו. מרגע ש-QR העביר את טיפול הכיוון לתוך המפענח, QR הפסיק להפעיל בכלל את סיבוב המסכה החיצוני, והצרכן היחיד שנותר של אותו נתיב קוד היה Code 39. תשתית משותפת מסתירה באגים כאלה כל הזמן: כיסוי מתכונה אחת גורם לנתיב להיראות נבדק בזמן שהתכונה שבאמת תלויה בו אינה מחזיקה בדיקה משלה. כל נתיב שתכונה חדשה מפסיקה להשתמש בו זקוק לבדיקה שעדיין משתמשת בו

קריאת התוצאות חזרה בקואורדינטות עמוד

כל ערך גיאומטרי שהמפענח מייצר מבוטא במסגרת הקואורדינטות של bitmap הניסיון, והקורא לפונקציה צריך אותו במרחב המשתמש של PDF. ההמרה רצה בשני שלבים: לבטל את רבע הסיבוב שהניסיון החוזר החיל, ואז לבטל את הטרנספורמציה של העיבוד שמיפתה את מרחב המשתמש אל ה-bitmap. מה שמגיע ב-THPDFDecodedBarcode הוא תיבת חוסך מיושרת צירים במרחב המשתמש, עם Left, Bottom, Right ו-Top לפי מוסכמת ה-PDF ש-Y גדל כלפי מעלה, בתוספת OrientationDegrees נגד כיוון השעון

pipeline הברקודים של HotPDF מ-bitmap של עמוד מעובד דרך דגימה אל מטריצת modules בוליאנית, נרמול D4, זיהוי גרסה בהתחלקות ופענוח Reed-Solomon, ואז ההמרה הדו-שלבית של הקואורדינטות שמבטלת את רבע הסיבוב של הניסיון החוזר ואת הטרנספורמציה של העיבוד לפני ש-THPDFDecodedBarcode מפרסם Left, Bottom, Right, Top ו-OrientationDegrees במרחב המשתמש
נרמול QR בתוך המפענח מאפשר לעמודים להיפתר בניסיון הראשון, בזמן שההמרה הדו-שלבית של הקואורדינטות הופכת תוצאות bitmap-ניסיון לתיבות מיושרות צירים במרחב המשתמש

מקבלים את כיוון ההמרה השנייה לא נכון והסימפטום מעולה-מרושע: הטקסט מפוענח מושלם, אבל התיבה שמציירים עבור שכבת סימון לסקירה נוחתת על תמונת המראה של המקום הנכון. כל מי שבונה ממשק סקירה מעל המפענח צריך לטעון מול fixture מוכר, עם סמל שהוצב בכוונה ליד אחת מפינות העמוד כך שציר Y הפוך נראה במבט אחד. אותו היגיון חל על כל קואורדינטה שחוצה את גבול העיבוד, וזו הסיבה שעיבוד עמוד PDF ל-bitmap ב-Delphi שווה הבנה לפני שבונים מעל המפענח

מה המפענח המובנה כן ולא עושה

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

// מחליפים במנוע משלך: מממשים IHPDFBarcodeDecoder ומעבירים אותו
// ל-overload מודע-מפענח. HotPDF עדיין אחראי על עיבוד העמודים,
// התקציבים, מיפוי הקואורדינטות והדה-דופליקציה
if not Pdf.DecodeLoadedPageBarcodes(PageIndex, MyDecoder, Options,
     Codes, Info) then
  case Info.Status of
    bdsBudgetExceeded:
      Log('raise MaxPixels or lower DPI: ' + string(Info.Diagnostic));
    bdsRenderError:
      Log('page did not render: ' + string(Info.Diagnostic));
    bdsDecoderError:
      Log(string(Info.DecoderName) + ' failed: ' + string(Info.Diagnostic));
  end;

THPDFBarcodeDecodeInfo הוא המקום שבו pipeline של פרודקשן מרוויח את לחמו. RotationAttemptCount ו-DecoderCallCount אומרים לך אם הניסיון החוזר החיצוני רץ בכלל, ReceivedResultCount מול AcceptedResultCount מפריד מפענח שלא מצא דבר מסף ביטחון שדחה הכל שמצא, ו-RenderedPixels עם PeakWorkingBytes הוא מה שמגרפים כשעבודת אצווה מתחילה להתקשות. קבוצת תוצאות ריקה בתוספת bdsSucceeded אומרת שלעמוד באמת אין סמל קריא, וזו עובדה תפעולית אחרת מ-bdsBudgetExceeded

שדות התקציב דורשים החלטה מכוונת ולא ברירת מחדל. MaxPixels ו-MaxWorkingBytes קיימים כי DPI מכפיל בריבוע: מעבר מ-300 ל-600 DPI על עמוד A4 מכפיל בארבעה הן את עלות העיבוד והן את הקצאת השיא, וקלט לא מהימן שמכריז על תיבת עמוד עצומה יכול להפוך עבודת סריקה לאירוע של אין-זיכרון. קובעים את התקרות למה שהמסמך הלגיטימי הגרוע שלך צריך, ואז משאירים ל-bdsBudgetExceeded לנתב חריגים לנתיב איטי ומבודד

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

סובלנות הסיבוב היא אחת התכונות הבלתי נראות כשהן עובדות ומעציבות כשהן לא, והלקח ההנדסי מתכלל מעבר ל-QR: מנרמלים כמה שיותר קרוב לייצוג הסמנטי, ולא בשכבת הפיקסלים שבה הנתונים עדיין נושאים כל תאונה של איך נתפסו. HotPDF משלח זאת כחלק מרכיב ה-PDF של HotPDF ל-Delphi, לצד חלקי העיבוד, ה-OCR וניתוח העמודים ש-pipeline של קליטה בדרך כלל צריך