5.1. אובייקט ה-Image¶
אלגוריתם לעיבוד תמונה סורק את התמונה פיקסל אחר פיקסל. בכל מיקום הוא מבצע משהו פשוט – קורא ערך, משווה אותו אל סף, משלב אותו עם הפיקסל המקביל בתמונה שנייה, וכותב תוצאה בחזרה. כאשר הדבר חוזר על עצמו על פני פריים שלם, אותן החלטות פשוטות לכל פיקסל הן שמהן בנויים זיהוי קצוות, מעקב אחר רכיבים, פענוח QR Code, וכל טכניקה קלאסית אחרת של ראייה ממוחשבת. כדי לבצע את העבודה הזו ביעילות, האלגוריתם חייב לדעת היכן יושב כל פיקסל בזיכרון, מה משמעות הערך של כל פיקסל בפועל, ואיזה חלק של התמונה עליו לבחון. ה-image.Image הוא האובייקט שמארגן את המידע הזה.
חיישני הראייה הסתיימו ברגע ש-csi.CSI.snapshot() חוזר. כל מה שהמנגנון בצד המצלמה עשה כדי לייצר את הפריים שנקלט כבר הסתיים; היישום מחזיק את ה-Image בידיו וצריך לדעת מה לעשות איתו.
5.1.1. החוצץ ומאפייניו¶
בתוך ה-Image נמצא מצביע אל בלוק רציף של בייטים ב-RAM וכותרת קטנה הנושאת שלושה פריטי מטא-נתונים: רוחב התמונה בפיקסלים, גובהה בפיקסלים, ופורמט הפיקסל שבו מצויים הבייטים. הבייטים הם הפיקסלים עצמם, מאוחסנים בסדר שורה-ראשי – תחילה כל הפיקסלים של השורה העליונה, אחר כך כל הפיקסלים של השורה השנייה, וכך הלאה עד לתחתית. המאפיינים מתארים כיצד לקרוא אותם.
רוחב וגובה הם פשוט מספרים שלמים. פורמט הפיקסל הוא המאפיין המעניין יותר, מכיוון שהוא קובע כמה בייטים תופס כל פיקסל ומה הבייטים הללו מקודדים. תמונה בגווני אפור נושאת בייט אחד לכל פיקסל המחזיק ערך בהירות. תמונת RGB565 נושאת שני בייטים לכל פיקסל המחזיקים שדות אדום, ירוק וכחול ארוזים במילה בת 16 סיביות. תמונת Bayer נושאת בייט אחד לכל פיקסל, אך כל פיקסל נדגם דרך אחד משלושה מסנני צבע הנבחרים לפי מיקומו בפסיפס. חיישני הראייה מנו את הקטלוג כולו; מה שחשוב כאן הוא שבדיוק אחד מהפורמטים הללו מוגדר על כל Image, והבחירה מניעה את חשבון הבייטים-לכל-פיקסל ואת משמעותו של כל בייט בודד בחוצץ.
עם מצביע אל החוצץ, הרוחב, הגובה והפורמט, כל מאפיין אחר שאלגוריתם עשוי לרצות נובע כחישוב קצר. הבייט שמתחיל את הפיקסל (x, y) יושב בהיסט (y * width + x) * bytes_per_pixel מתחילת החוצץ. ספירת הבייטים הכוללת היא width * height * bytes_per_pixel. הכתובת של השורה הבאה כלפי מטה היא בדיוק width * bytes_per_pixel בייטים אחרי תחילת השורה הנוכחית. ה-Image חושף את שלושת המאפיינים דרך קריאות מתודה פשוטות – width(), height(), format() – בתוספת ה-size הנגזר דרך size(). מתודות במקומות אחרים במודול משתמשות בערכים הללו כדי לבצע בעצמן את חשבון ההיסטים; קוד יישום נדרש לכך לעיתים רחוקות.
Image הוא מעטפת Python קטנה המצביעה אל בלוק רציף של זיכרון: כותרת הנושאת את הרוחב, הגובה ופורמט הפיקסל, ולאחריה חוצץ הפיקסלים עצמו.¶
5.1.2. מהיכן מגיע החוצץ¶
סיפור ברירת המחדל לאורך הפרק הזה הוא זה שחיישני הראייה כבר כיסו: פריים שנקלט מגיע מ-snapshot, הבייטים יושבים בחוצץ הפריימים של המצלמה, וה-Image המוחזר מצביע אליהם. שלוש דרכים נוספות להשגת אחד עולות באופן קבוע, וכל אחת מהן מרמזת על משהו שונה בנוגע למקום שבו החוצץ מסתיים.
טעינה מקובץ נראית כמו העברת נתיב לבנאי: image.Image("/sdcard/saved.jpg"). המודול קורא את הקובץ אל תוך חוצץ שהוקצה זה עתה ב-heap של Python. קבצי BMP, PGM ו-PPM מפוענחים בדרך פנימה וה-Image המתקבל נושא פורמט פיקסל לא דחוס. קבצי JPEG ו-PNG נשארים דחוסים – ה-Image נושא את הפורמט JPEG או PNG, והחוצץ מחזיק את זרם הבייטים של הקובץ כמעט ללא שינוי. כדי לבצע כל עבודה ברמת הפיקסל על תמונה דחוסה, היישום ממיר אותה תחילה דרך to_rgb565() או to_grayscale(), וההמרה הזו היא המקום שבו ההתפרסות – והניפוח המקביל של ה-heap, שבו JPEG של 30 KB יכול להפוך ל-600 KB של RGB565 – מתרחשת בפועל. טעינה מקובץ שימושית ביותר במהלך הפיתוח, כאשר יש לבדוק אלגוריתם מול פריים ייחוס ידוע המאוחסן לצד הסקריפט.
בניית תמונה מאפס היא מקרה הקנבס: image.Image(320, 240, image.RGB565) מבקשת מהמודול להקצות את מספר הבייטים הזה בפורמט הזה, לאפס את התוכן, ולהחזיר את המעטפת. הפיקסלים עדיין אינם מציינים דבר – כולם אפס – אך התמונה הריקה היא סוס העבודה עבור חופן של דפוסים חוזרים: פריימי ייחוס שמהם מחוסר פריים נוכחי, קנבסים שעליהם מורכבות שכבות על גרפיות, חוצצים בינאריים שמתמלאים ומשמשים כמסכות.
בנייה מתוך ndarray מגשרת בכיוון ההפוך, מכל חישוב מספרי בחזרה אל מודול התמונה. העברת ulab.numpy.ndarray מסוג float32 לבנאי מייצרת Image שממדיו תואמים את ה-ndarray – צורה דו-צירית (h, w) הופכת לתמונה בגווני אפור, צורה תלת-צירית (h, w, 3) הופכת ל-RGB565 – כשערכי הנקודה הצפה משוקללים מ-0.0 – 255.0 לטווח הפיקסלים השלם. מפת חום של רשת נוירונים, מערך מספרי מכל סוג, כל דבר שנוצר על ידי ml או ulab הופך למשהו שצד הציור וההסתכלות של מודול התמונה יכול להשתמש בו.
כל ארבעת המקורות מחזירים את אותו סוג של Image. קוד שמשתמש באובייקט המוחזר לעולם אינו צריך לעקוב מהיכן הוא הגיע.
5.1.3. שני מבטים על הבייטים¶
רוב הזמן קוד היישום מתייחס אל Image כאל אובייקט תמונה מטופס – דבר עם מתודות בעלות שם. החצי השני של הסיפור הוא שאותו אובייקט עצמו מופיע גם, באופן שקוף, כרצף שטוח של בייטים בפני כל API של MicroPython שמקבל ארגומנט bytes. הבייטים אינם עותק של החוצץ; הם מבט ישיר עליו.
ההסדר הזה הוא מה שהופך את דחיפת פריים שנקלט החוצה מהמצלמה לשורה בודדת. גיבוב שלו, שליחתו דרך פורט טורי, העברתו אל socket ברשת – אף אחד מאלה אינו זקוק לשלב נפרד של ”המרת התמונה לבייטים“:
import csi
import hashlib
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)
img = csi0.snapshot()
uart.write(img) # transmits the raw pixel bytes
hashlib.sha256(img) # hashes the same bytes
sock.send(img) # sends them over a socket
מבט-דמוי-הבייטים הוא לקריאה בלבד כברירת מחדל, במכוון. חוצצי תמונה הם גדולים ולעיתים משותפים בין שכבות של מחסנית הדימות, ולכן מתן הכוח ל-buf[0] = 0 מקרי במקום עמוק כלשהו במחסנית הקריאות להשחית אותו בשקט הוא קצה חד מכדי להותירו חשוף. כאשר גישה ברמת בייט לקריאה-כתיבה היא מה שהיישום באמת זקוק לו – כתיבת ערך כיול אל היסט ידוע, למשל – bytearray() מחזיר מבט נפרד, מפורשות לקריאה-כתיבה, על אותו זיכרון, המסמן את הכוונה במקום הקריאה.
5.1.4. היכן שוכן החוצץ¶
חוצצי פיקסלים גדולים דיים שכדי שמקום הימצאם ב-RAM יהיה משמעותי. פריים QQVGA RGB565 הוא 160 × 120 × 2 = 38,400 בייטים; פריים VGA RGB565 הוא 614,400 בייטים; קלט RGB565 של 224 × 224 שמסווג מבוסס-רשת-נוירונים עשוי לצרוך הוא כ-100 KB. ה-heap של Python במצלמות הקטנות ביותר יכול להיות רק כמה עשרות קילובייטים לאחר שזמן הריצה אותחל. החזקת יותר מפריים או שניים של נתוני תמונה ב-heap הייתה דוחקת כל דבר אחר ממנו.
המוצא הוא שחוצצי תמונה ברובם אינם שוכנים ב-heap של Python. הם שוכנים באזור הייעודי של ה-RAM ש-חיישני הראייה הציגו בתור חוצץ הפריימים (frame buffer) – אותו זיכרון שאליו ה-DMA של המצלמה כותב פריימים שנקלטו ושממנו תצוגה המקדימה של ה-IDE קוראת פריימים שהושלמו. רוב הפעולות על Image משנות את המקור שלהן במקום: האלגוריתם קורא פיקסלים, מחליט, כותב ערכים חדשים בחזרה, ולא מוקצית תמונת תוצאה נפרדת. הפעולות שכן מייצרות תוצאה נפרדת – המרות פורמט וחופן אחרות – ניתן לבקש מהן למקם את התוצאה הזו בחוצץ הפריימים דרך ארגומנט מילת המפתח copy_to_fb. copy_to_fb=True עושה שני דברים בו-זמנית: הוא מציב את תמונת התוצאה בחוצץ הפריימים במקום ב-heap (תוך עקיפת הלחץ על ה-heap) והוא הופך את התוצאה לפריים הבא שתצוגה המקדימה של ה-IDE תציג. הוספת copy_to_fb=True אל הצעד הסופי של צינור עיבוד, צפייה בתוצאה מופיעה על המסך, וחזרה משם, היא אחד מניבי הדיבוג השימושיים ביותר בעיבוד תמונה.
עם מעטפת המחזיקה חוצץ מתויג, ארבע דרכים להביא אחת לכלל קיום, שני מבטים על הבייטים שלה, ומתג המחליט היכן ננחתות חדשות, ה-Image כבר אינו תעלומה. השאלות היסודיות הנותרות – כיצד נקרא מיקום פיקסל, מה כל פיקסל מחזיק בפועל, כיצד לתחום פעולה לחלק מתמונה – בנויות על גביו.