למה נכנסנו לזה בכלל

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

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

למה OCR בעברית קשה יותר מאנגלית

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

  • כיווניות. עברית נכתבת מימין לשמאל, אבל מספרים בתוך המשפט נכתבים משמאל לימין. תעודת משלוח מלאה במספרים. המנוע צריך לזהות נכון היכן נגמר בלוק עברי ומתחיל בלוק של ספרות, אחרת השורה מתהפכת ומק״ט 4471 הופך ל-1744.
  • היעדר ניקוד. בלי ניקוד הרבה מילים נראות זהות, והמנוע לא יכול להישען על הקשר תנועתי כמו באנגלית. מצד שני, אם במקרה יש ניקוד סורק אחד, המנוע לפעמים מפרש את הנקודות כרעש או כפיסוק.
  • אותיות סופיות. ם מול מ, ן מול נ, ץ מול צ, ף מול פ, ך מול כ. חמישה זוגות שנבדלים לפעמים בכמה פיקסלים של זנב. בסריקה גרועה זה מקור עצום לטעויות, במיוחד בסופי מילים בתיאורי מוצר.
  • טקסט מעורב. עברית, אנגלית ומספרים באותה שורה. מנוע שמזהה את השפה ברמת השורה ולא ברמת המילה נשבר כאן לגמרי.
  • פונטים לא סטנדרטיים. תעודות מודפסות בפונטים צרים או מרובעים שהמנוע כמעט לא ראה באימון. אות ד ואות ר נראות כמעט זהות בכמה מהפונטים האלה.
  • איכות פיזית. חותמות, קימוטים, צילום בזווית, הצללה. כל אחד מאלה לבד מוריד דיוק, וביחד הם מכפילים את הנזק.

השורה התחתונה: OCR בעברית קשה כי הצירוף של כיווניות, אותיות דומות וטקסט מעורב יוצר הרבה יותר נקודות כשל מאשר טקסט אנגלי נקי, וכל אחת מהן מתגברת כשהתמונה עצמה באיכות בינונית. מנוע גנרי לא יודע כלום על הדומיין שלכם, ולכן אין לו שום דרך לתקן את עצמו.

הטעות הראשונה: מנוע גנרי, אפס הכנה

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

הטעות השנייה שנבעה מהראשונה הייתה שהאמנו ל-confidence score של המנוע. המנוע מחזיר לכל מילה ציון ביטחון, ואנחנו סיננו לפיו: מעל סף מסוים מקבלים, מתחת דוחים לבדיקה ידנית. הבעיה היא שהציון הזה מכויל על הדאטה של המנוע, לא על שלנו. ראינו מק״טים שגויים לחלוטין שקיבלו ציון ביטחון גבוה, כי המנוע היה בטוח שהוא קרא ספרה, פשוט את הספרה הלא נכונה. ציון ביטחון גבוה על טקסט שגוי הוא הדבר המסוכן ביותר במערכת כזאת, כי הוא עוקף בדיוק את השכבה שאמורה לתפוס טעויות.

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

Pre-processing: רוב הקרב מוכרע כאן

השיפור הראשון והגדול ביותר הגיע לפני שהתמונה בכלל מגיעה למנוע. הבנו שאם ננקה את הקלט, המנוע הגנרי פתאום נהיה הרבה יותר טוב, בלי לגעת בו בכלל. בנינו שלב pre-processing שכל תמונה עוברת דרכו.

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

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

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

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

Layout לפני טקסט: איפה בכלל השדות

אחרי הניקוי, לפני שאנחנו קוראים אות אחת, אנחנו קודם מבינים את מבנה העמוד. זיהוי layout הוא שלב נפרד לגמרי מזיהוי טקסט, וזה אחד הדברים שאיחרנו להפנים. במקום לשלוח את כל העמוד למנוע, אנחנו מזהים קודם את האזורים: איפה הכותרת עם פרטי הספק, איפה הטבלה של השורות, איפה הבלוק של החתימה והחותמת למטה.

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

בונוס שלא ציפינו לו: כשמנוע קורא תא בודד שמכיל רק מספר, אפשר להגיד לו לצפות רק לספרות. רוב המנועים מאפשרים להגביל את סט התווים לכל קריאה. תא של כמות מקבל whitelist של ספרות בלבד, תא של מק״ט מקבל ספרות ואולי מקף, ותא של תיאור מקבל אלפבית עברי. ההגבלה הזאת לבדה חיסלה שכבה שלמה של טעויות, כמו האות O שנקראת במקום הספרה 0, או ב עברית שנקראת במקום מספר.

ההקשר המבני: תעודת משלוח היא לא טקסט חופשי

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

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

Post-processing ומילון דומיין

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

הדוגמה החזקה ביותר היא התאמת מק״ט. נניח שהמנוע קרא 4471, אבל בקטלוג של הלקוח אין מק״ט כזה. במקום לפסול, אנחנו מחפשים את המק״ט הקרוב ביותר בקטלוג לפי מרחק עריכה, מרחק לוינשטיין, עם משקלים מותאמים. נתנו משקל נמוך יותר להחלפות בין תווים שדומים חזותית: 0 ו-8, 1 ו-7, 4 ו-1, ובעברית ד ו-ר, ה ו-ח, ו-ם ו-ס. אם המק״ט הקרוב ביותר בקטלוג הוא 4477, וההבדל הוא רק בספרה שדומה חזותית לזו שנקראה, אנחנו מתקנים בביטחון גבוה. אם יש שני מועמדים באותו מרחק, מסמנים לבדיקה ידנית.

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

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

Fallback, validation, ומה שהמספרים לא סיפרו

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

מעל הכול יש לולאת validation מול הדאטה הקיים. כל שורה שהמערכת מחלצת נבדקת מול הקטלוג, מול היסטוריית ההזמנות של אותו ספק, ומול טווחי הכמויות הרגילים. ספק שתמיד שולח את אותם עשרים מק״טים הוא מתנה: אם השורה טוענת למק״ט שהספק הזה מעולם לא שלח, זה חשוד גם אם ה-OCR בטוח. ההיסטוריה הפכה למקור אמת נוסף.

לגבי ה-94%: חשוב להיות מדויקים לגבי מה הוא אומר. זה דיוק ברמת השדה על סט הבדיקה שלנו, לא הבטחה שכל תעודה יוצאת מושלמת. תעודה עם עשר שורות ושלושה שדות בכל שורה היא שלושים שדות, ו-94% אומר שבממוצע כמעט שני שדות בתעודה כזאת עדיין דורשים תשומת לב. לכן ה-UI לא מנסה להסתיר את זה. הוא מציג ללקוח בדיוק את השדות שהמערכת לא בטוחה בהם, מודגשים, לאישור מהיר. עדיף לבקש מהמשתמש לאשר חמישה שדות מסומנים מאשר להכניס בשקט שדה אחד שגוי שיתגלה רק כשהמלאי לא מסתדר בסוף החודש.

מה היינו אומרים למי שמתחיל עכשיו

אם היינו מתחילים היום מאפס, לא היינו מבזבזים את החודש הראשון בחיפוש אחרי המנוע המושלם. המנוע הגנרי שנתן לנו 40% הוא בערך אותו מנוע שמשרת אותנו ב-94% היום. מה שהשתנה זה כל מה שעוטף אותו: הכנת התמונה, הבנת המבנה, השימוש בקטלוג ובהיסטוריה כמקורות אמת, והכבוד לעובדה שביטחון של מנוע הוא לא ביטחון שלנו.

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