יצירת תמונת אימוג'י

יצירת תמונה מאימוג'י בפחות מ-50 שורות קוד

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

מסיבה זאת החלטתי להכריז על סדרת הפוסטים "50 שורות של קוד" (שם לא סופי, אשמח להצעות), כאשר זהו הפוסט הראשון בסדרה. במסגרת פוסטים אלו, אציג ואסביר לפרטים קטעי קוד בעלי 50 שורות או פחות, המהווים תוכנית שלמה. או במילים אחרות – קוד שעושה משהו מעניין והוא לא חלק ממשהו אחר. בהסברים אשתדל להתייחס לשתי השאלות: מה הקוד עושה, ולמה דווקא ככה?

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

emoticon

התוכנית תייצר את התמונה הבאה (לחצו להגדלה):

emoticon.jpg.out

החומרים הדרושים

את הקוד נכתוב בשפת python, ונשתמש בספריות pillow (עיבוד תמונה) ו-numpy (מתמטיקה), וכמו כן בספרייה המובנית sys:

from PIL import Image
import numpy as np
import sys

בנוסף, הקוד שלנו ישתמש בתמונה כלשהי המכילה אוסף של אייקונים, או emoji, שכולם בעלי גובה ורוחב זהים. אין חשיבות לתמונה שנבחר, כל עוד האייקונים ריבועיים (גובה == רוחב) ואנחנו יודעים את הגודל שלהם מראש. לצורך ההדגמה, השתמשתי בתמונה הזאת (גובה = רוחב = 20).

emoticons3

שלב ראשון – טעינת האייקונים

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

ICON_SIZE = 20

ולאחר מכן נטען את קובץ התמונה ע"י שימוש בפונקציה Image.open של ספריית pillow.

emo_image = Image.open("emoticons.png")

לפני שנוכל לפרק את התמונה לאייקונים, אנחנו צריכים לברר כמה אייקונים יש בכל שורה ובכל עמודה, נעשה זאת ע"י שימוש במאפיין size של התמונה, שהוא tuple המכיל את רוחב התמונה וגובה התמונה, ונחלק אותו ב-ICON_SIZE (לדוגמה אם בשורה יש 80 פיקסלים ורוחב כל אייקון הוא 20 פיקסלים, אז מספר האייקונים הוא 4):

x_size, y_size = icons_image.size
x_icons = x_size / ICON_SIZE
y_icons = y_size / ICON_SIZE

כעת נפרק את התמונה לתמונות קטנות של האייקונים עצמם. נעשה זאת ע"י שתי לולאות מקוננות, אחת על השורות ואחת על העמודות:

icons = []
for row in xrange(y_icons):
    for col in xrange(x_icons):
        icons.append(crop_icon(icons_image, row, col))

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

icons = [
    crop_icon(icons_image, row, col)
    for col in range(x_icons) for row in range(y_icons)]

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

def crop_icon(img, row, col):
    return img.crop((
        col * ICON_SIZE,
        row * ICON_SIZE,
        (col + 1) * ICON_SIZE,
        (row + 1) * ICON_SIZE))

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

emoji1
שימו לב שמתחילים לספור מ-0

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

שלב שני – טעינת התמונה המקורית

לאחר שבנינו מערך של אייקונים, נטען בצורה דומה את התמונה המקורית אותה רוצים להמיר:

img_filename = sys.argv[1]
img = Image.open(img_filename).convert('RGBA')
x_size, y_size = img.size

בדוגמה זו אנו מקבלים את שם התמונה משורת הפקודה ע"י שימוש ב-sys.argv, כמובן שניתן גם להשתמש בשם קבוע:

img_filename = "my_image.png"

שימו לב שלאחר הקריאה ל-open, אנו מבצעים קריאה ל-convert עם הפרמטר "RGBA". קריאה זו נועדה כדי להמיר את הייצוג של התמונה לפלטת צבעים המכילה גם את פרמטר השקיפות, אלפא (Alpha). בהמשך נבין מדוע.

מכיוון שאנו מייצרים את התמונה החדשה מאייקונים בלבד, הגודל שלה חייב להיות כפולה של גודל האייקונים (לא נרצה להשתמש בחלק של אייקון), ולכן נעגל את גודל התמונה כלפי מטה לכפולה הקרובה ביותר לגודל האייקונים (ללא שינוי התמונה המקורית):

x_size -= (x_size % ICON_SIZE)
y_size -= (y_size % ICON_SIZE)

שורות הקוד הללו מחסרות מגודל התמונה את השארית שנותרת לאחר החלוקה בגודל האייקון. השארית מחושבת ע"י האופרטור מודולו %. לדוגמה, אם רוחב התמונה המקורית הוא 425 פיקסלים, הרוחב החדש יהיה 420 פיקסלים, שהוא כפולה של 20 (גודל האייקון).

שלב שלישי – מציאת האימוג'י המתאים ביותר

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

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

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

  1. מרחק מבוסס צבעים
  2. מרחק מבוסס טקסטורה
  3. מציאת וספירה של נקודות דומות (descriptors) בשתי התמונות
  4. מרחק אוקלידי פשוט (לתמונות חד מימדיות, למשל בגווני אפור בלבד)
  5. מרחק ספציפי לתחום מסויים (למשל פרצופים)
  6. שילוב של השיטות הנ"ל

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

על היסטוגרמות צבעים

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

אדום - 10 פיקסלים
כחול - 20 פיקסלים
ירוק - 7  פיקסלים

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

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

ב-python, כדי לחשב היסטוגרמה תלת-מימדית של הצבעים בתמונה נמיר אותה תחילה למערך ע"י הפונקציה getdata, ולאחר מכן נשתמש בפונקציה histogramdd של numpy:

def img_hist(img):
    arr = np.array(img.getdata(), np.uint8)
    return np.histogramdd(arr[:,:-1], bins = 6, range = [[0, 256]] * 3, weights = arr[:,3])[0]

הפרמטרים לפונקציה histogramdd הם (לפי הסדר):

  1. מערך דו מימדי עליו נרצה לחשב היסטוגרמה. המערך צריך להיות בגודל N*D כאשר N הוא מספר הנקודות בתמונה, ו-D הוא מספר המימדים (במקרה שלנו – 3). שימו לב שאנו מבצעים חיתוך של המערך, זאת כי המערך שהתקבל מ-getdata הוא בגודל N*4 (אדום, כחול, ירוק, ואלפא – פרמטר ה"שקיפות"). את אלפא אנחנו לא רוצים בהיסטוגרמה, כי הוא לא בדיוק חלק מהצבע, אבל נשתמש בו בהמשך.
  2. bins = 6 הוא מספר התאים שיהיו בהיסטוגרמה, לכל מימד. כלומר בהיסטוגרמה שלנו יהיו 6 בשלישית תאים, שהם 216 תאים המייצגים 216 טווחי צבעים. את הפרמטר בחרתי בצורה מושכלת – ערך נמוך מדי יתן תוצאות באיכות נמוכה, מכיוון שהוא יסווג צבעים שונים יחד. ערך גבוה מדי יצריך זמן ריצה גבוה יותר, ולא ישפר משמעותית את התוצאה. ניתן לשחק עם הפרמטר הזה ולראות מה קורה.
  3. range – טווח הצבעים המלא של התוצאה. חובה להגדיר פרמטר זה כדי להבטיח שכל ההיסטוגרמות תהיינה על אותם טווחים, אחרת לא תהיה משמעות להשוואה ביניהן.
  4. weights – זוכרים את הפרמטר אלפא? זהו בית אחד לכל פיקסל הקובע את רמת השקיפות שלו. פיקסל בעל פרמטר אלפא של 0 יהיה שקוף לגמרי ולמעשה אין משמעות לצבע שלו. מנגד, פיקסל עם אלפא 255 יהיה אטום לגמרי. כדי שלא נתחשב בפיקסלים שקופים בהיסטוגרמה שלנו, נעביר את מערך האלפא בפרמטר weights, הקובע את "המשקל" של כל פיקסל בהיסטוגרמה. כך ככל שפיקסל שקוף יותר, הוא ישפיע פחות על התוצאה הסופית, ופיקסלים שקופים לגמרי לא ייספרו כלל.

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

מרחק בין היסטוגרמות

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

def hist_distance(hist1, hist2):
    return np.linalg.norm(hist1 - hist2)

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

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

כעת כשאנחנו יודעים איך למדוד את המרחק בין חלק תמונה (region) לאייקון, כל שנותר הוא לחפש את האייקון בעל המרחק הקטן ביותר:

icon = min(icons, key = lambda icon: img_distance(icon, region))

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

icon_hists = map(img_hist, icons)

ופעם אחת עבור כל חלק בתמונה:

region_hist = img_hist(region)

וכעת נשתמש בפונקציה hist_distance שהגדרנו למעלה למציאת האייקון הקרוב ביותר:

icon = min(
    enumerate(icons),
    key = lambda icon: hist_distance(icon_hists[icon[0]], region_hist))[1]

שלב רביעי ואחרון – הרכבת התמונה המלאה

נייצר תחילה תמונה חדשה בגודל המתאים, ועם רקע לבן (אפשר לבחור כל רקע שרוצים):

new_img = Image.new("RGB", (x_size, y_size), "white")

וכעת עבור כל חלק בתמונה המקורית, נמצא את האייקון המתאים ונדביק אותו במקום המתאים בתמונה החדשה:

new_img = Image.new("RGB", (x_size, y_size), "white")
for row in range(y_size / ICON_SIZE):
    for col in range(x_size / ICON_SIZE):
        region_hist = img_hist(crop_icon(img, row, col))
        icon = min(
            enumerate(icons),
            key = lambda icon: hist_distance(icon_hists[icon[0]], region_hist))[1]
     new_img.paste(icon, (col * ICON_SIZE, row * ICON_SIZE))

שימו לב לשימוש בפונקציה paste של התמונה, המקבלת חלק תמונה ומיקום, ו"מדביקה" את חלק התמונה במיקום המתאים בתמונה החדשה. כדי לשמר את הרקע הלבן בתמונה החדשה, ניתן לשנות את השורה האחרונה באופן הבא:

new_img.paste(icon, (col * ICON_SIZE, row * ICON_SIZE),
              mask = icon.split()[3])

כעת כל שנשאר הוא לשמור את התמונה החדשה:

new_img.save(img_filename + '.out.png')

וניתן אף להציג אותה:

new_img.show()

I, Code emoji

מה הלאה?

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

  1. שימוש בפונקציית מרחק שונה למציאת אייקונים
  2. שימוש באייקונים בגדלים שונים / לא ריבועיים
  3. הוספת progress bar למעקב אחרי התקדמות הקוד
  4. שיפור זמן הריצה של הקוד, למשל ע"י ביצוע cache להיסטוגרמות של חלקי תמונה זהים
  5. קבלת כל הפרמטרים משורת הפקודה, או לחלופין:
  6. עטיפת הקוד באתר אינטרנט פשוט כדי שיהיה נוח לשימוש

הקוד המלא

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

6 תגובות בנושא “יצירת תמונה מאימוג'י בפחות מ-50 שורות קוד”

  1. "בדיקת שפיות" אפשרית על קביעת המרחק המינימלי:
    האם הפעלה על emoticons.png בעצמה תייצר תמונה זהה?

    שווה בדיקה D:

  2. ממש יפה :)

    תיקון קטן לגבי הlist comprehension:
    2 הלולאות לא זהות (אמנם זה לא משפיע על המשך הקוד).
    הקוד הראשון עובר עבור כל שורה על כל עמודה, והשני עבור כל עמודה על כל שורה, מה שמשנה את סדר המערך.

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

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

כתיבת תגובה

האימייל לא יוצג באתר. (*) שדות חובה מסומנים