רשימת המילים בעברית בפחות מ-50 שורות קוד

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

שניה לפני שהתייאשתי נזכרתי שאני מתכנת והחלטתי לנצל את ההזדמנות כדי לייצר רשימה כזאת בעצמי, ואתם הרווחתם עוד פוסט :)

איך נייצר את הרשימה?

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

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

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

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

כדי לקרוא קוד HTML נשתמש בספרייה BeautifulSoup, ובספריית re הסטנדרטית של python. המחלקה Counter מהמודול collections תשמש לשמירת מיפוי בין כל מילה למספר ההופעות שלה, והספרייה progressbar2 תשמש ליצירת מד התקדמות.

from bs4 import BeautifulSoup
from collections import Counter
from progressbar import ProgressBar
import re, os

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

שלב ראשון – יצירת רשימה של שמות הקבצים

נתחיל בחילוץ (unzipping) הקובץ שהורדנו, המכיל את כל הערכים בויקיפדיה, ע"י שימוש בתוכנה 7Zip. התוצאה היא עץ תיקיות ענקי המחולק לתיקיות לפי האות הראשונה של שם הערך, לאחר מכן האות השנייה וכו'. נרצה לעבור על כל הקבצים בעץ, ולשמור את הנתיבים שלהם ברשימה אחת ארוכה.

נעשה זאת ע"י שימוש בפונקציה walk של מודול os, המקבל תיקייה, ומחזירה איטרטור (iterator) על כל הקבצים והתיקיות בה בצורה רקורסיבית:

all_files = []
for root, folders, filenames in os.walk(u'wikipedia-he-html'):
  for filename in filenames:
    all_files.append(os.path.join(root, filename))

שימו לב שאנו מעבירים לפונקציה את שם התיקייה בתור מחרוזת unicode (מתחילה באות u). דבר זה נדרש כדי שהפונקציה תתמוך בנתיבים המכילים עברית.

כל ערך החוזר מהאיטרטור הוא שלשה המייצגת תיקייה, שמות כל תתי התיקיות בה, ושמות כל הקבצים בה. נרוץ בלולאה על שמות הקבצים ונוסיף אותם לרשימה, לא לפני שנחבר אותם לנתיב המלא של הקובץ (המורכב מנתיב התיקייה + שם הקובץ), ע"י שימוש בפונקציה join במודול os.path.

נסו להדפיס את אורך הרשימה all_files כדי לוודא שהיא אכן ארוכה מאוד.

שלב שני – קריאת תוכן הקבצים

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

with ProgressBar(max_value = len(all_files)) as progress:
  for i, file_path in enumerate(all_files):
    progress.update(i)
    html = open(file_path, "rb").read().decode('utf8')

שימו לב שקריאת הקובץ מורכבת משלושה שלבים:

  1. פתיחת הקובץ לקריאה באמצעות הפונקציה open. בחלק ממערכות ההפעלה (בעיקר Windows) חשוב להעביר את הפרמטר "rb" (שמשמעותו read binary) כדי שהקובץ ייקרא בדיוק כמו שהוא.
  2. קריאת תוכן הקובץ לתוך מחרוזת ע"י הפונקציה read.
  3. פתיחת הקידוד של הקובץ, כלומר המרת תוכנו מפורמט בינארי בקידוד UTF-8 למחרוזת מסוג unicode, ע"י הפונקציה decode.

אם שאלתם את עצמכם איך אנחנו יודעים שקידוד הקובץ הוא UTF-8, התשובות הן: (א) זהו הקידוד הנפוץ ביותר לדפי HTML; (ב) כמעט כל עורך טקסט מתקדם יזהה זאת עבורכם כשתפתחו באמצעותו את אחד הקבצים; (ג) זה כתוב בתוך הקבצים: charset=UTF-8.

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

all_files = all_files[:1000]

כעת נסו להריץ את הקוד, סביר להניח שקיבלתם את השגיאה הבאה:

UnicodeDecodeError: 'utf8' codec can't decode byte 0x89 in position 0: invalid start byte

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

try:
  html = open(file_path, "rb").read().decode('utf8')
except UnicodeDecodeError:
  continue

לאחר סיום שלב זה יש לנו, עבור כל קובץ, מחרוזת עם התוכן שלו, שהוא קוד HTML.

שלב שלישי – מציאת כל הטקסט בעמוד

מכיוון שכל הקבצים הגיעו ממקור יחיד – המבנה שלהם דומה, ובפרט כל הטקסט שאנו מחפשים מופיע בתוך תגיות HTML מסוג <p> (פסקה). נרצה לעבור על התוכן של כל אחת מהתגיות הללו.

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

soup = BeautifulSoup(html, 'html.parser')
for p in soup.find_all('p'):
  # do something with p

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

דרך חלופית ומהירה הרבה יותר היא מציאת כל התגיות <p> בקובץ ע"י שימוש בביטויים רגולרים (regular expressions), ופענוח התוכן שלהן בלבד:

PARAGRAPHS_PATTERN = re.compile(r"<p>(.*?)</p>")

for p_html in PARAGRAPHS_PATTERN.findall(html):
  p = BeautifulSoup(p_html, 'html.parser')

נתעכב על הביטוי הרגולרי: הוא מתחיל בתגית הפותחת <p> ומסתיים בתגית הסוגרת </p>. תוכן התגית מותאם (matched) ע"י הביטוי הנפוץ נקודה-כוכבית, והוא בתוך סוגריים מכיוון שרק בו אנו מעוניינים, ללא תגיות הפסקה. אך מה פשר סימן השאלה?

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

<p>hello from icode.co.il</p>
<p>don't forget to subscribe</p>

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

<p>hello from icode</p>
<p>don't forget to subscribe</p>

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

<p>hello from icode</p>
<p>don't forget to subscribe</p>

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

  1. פעמים רבות התגית p תכיל תכונות (attributes) שונות, למשל <p class="blue">, ולכן לא תיתפס ע"י הביטוי הרגולרי. בויקיפדיה תגיות הפסקה לא מכילות תכונות.
  2. במידה ותגית p נמצאת בתוך תגית p אחרת, הביטוי הרגולרי יחזיר תוצאה שגויה. אמנם הדבר לא חוקי בקוד HTML, אך אתרים רבים עדיין מבצעים את הטעות הזאת, והדפדפנים סלחנים כלפיה. למזלנו בויקיפדיה קוד ה-HTML הוא תקני (ואף מיוצר אוטומטית), ולכן הבעיה הזאת לא קיימת.

בשלב זה המשתנה p מכיל אובייקט מטיפוס Tag (של BeautifulSoup), המכיל ייצוג כלשהו של תוכן הפסקה. אך זה עדיין לא מספיק, מכיוון שהייצוג הזה עלול להכיל בעצמו קוד HTML:

<p>hello from <a href="/">icode</a><p>

למזלנו, BeautifulSoup מספקת פונקציה פשוטה להמרת קוד HTML לטקסט:

p_text = p.get_text() # => "hello from icode"

שלב רביעי ואחרון – חלוקה למילים וספירתן

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

words = p_text.split()

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

CHARS_PATTERN = re.compile(ur"""[^אבגדהוזחטיכלמנסעפצקרשתןףץםך'\- "]""")
p_text = CHARS_PATTERN.sub('', p_text)

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

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

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

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

  • קובץ הקוד שלנו צריך להיות שמור בקידוד UTF-8. רוב עורכי הטקסט תומכים בשמירה בקידוד זה.
  • מחרוזות המכילות עברית חייבות להתחיל בתו u (מסמל unicode) לפני המחרוזת.
  • יש להוסיף בתחילת הקובץ את ההערה הבאה:
# -*- coding: utf-8 -*-

כעט נשתמש במבנה הנתונים Counter כדי לספור כמה מופעים יש מכל מילה:

freq = Counter()

for word in words:
  word = word.strip('="')
  if len(word) > 1:
    freq[word] += 1

בקטע קוד זה אנו מבצעים 3 פעולות עבור כל מילה:

  1. משתמשים בפונקציה strip כדי להסיר מקפים וגרשיים המופיעים בתחילתה או בסופה (למשל המילה "ב-" תהפוך למילה "ב").
  2. מוודאים שהמילה היא באורך שני תווים לפחות, כדי לדלג על אותיות קישור המופרדות במקף.
  3. מגדילים באחד את מספר ההופעות ב-Counter שלנו (במידה ואין עדיין הופעות, Counter דואג לאתחל אוטומטית את מספר ההופעות ל-0).

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

open('words.txt', "wb").write(
  u"\n".join("%s, %s" % x for x in freq.most_common()).encode('utf8'))

חשוב לזכור לקודד כ-UTF-8 (או קידוד אחר התומך בעברית) טרם השמירה.

קובץ המילים אמור להיראות כך:

של, 523462
את, 362830
על, 280208
הוא, 155791
לא, 122371
...
פילי, 44
הלשכות, 44
שליחו, 43
בבואה, 43
...

העשרה: שיפור מהירות הריצה

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

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

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

נסו לעבור על הקוד המלא ולמצוא אותן.

מה הלאה?

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

  1. זיהוי והסרת אותיות קישור מהמילים. למשל המילה "במכונית" תיספר כמו המילה "מכונית"
  2. הסרת מילים שתדירותן נמוכה מדי
  3. שימוש במקור טקסט אחר
  4. השוואה בין מקורות טקסט שונים ליצירת רשימת מילים שאינה מוטה למקור מסויים
  5. חיפוש ביטויים נפוצים במקום מילים נפוצות
  6. תמיכה בשפות נוספות
  7. שימוש ב-dump עדכני יותר של ויקיפדיה (קיים בפורמטים שאינם HTML)

הקוד המלא

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

מאבטחים את הבית בפחות מ-80 שורות קוד

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

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

אבל איזה מזל שאנחנו המתכנתים יכולים לעשות דברים בעצמנו! אז ניצלתי את ההזדמנות כדי לכתוב עוד פוסט לסדרה, הפעם של תוכנת אבטחה פשוטה המבצעת:

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

נשמע הרבה ל-80 שורות קוד? גם אני חשבתי כך, אבל איזה מזל שיש את python.

אז בואו נעשה את זה!

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

את הקוד נכתוב, כאמור, בשפת python (גרסה 2.7) ונשתמש בספריות הבאות:

  • OpenCV – ספריית ראייה ממוחשבת (ולכידת וידאו).
  • scapy – ספריית רשת. נשתמש בה לבדיקה האם מכשיר מחובר לרשת הביתית.
  • pushbullet – לשליחת התרעות באמצעות השירות Pushbullet (ראו בהמשך).
  • numpy – ספריית מתמטיקה.
  • הספריות המובנות multiprocessing ו-datetime.

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

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

שלב ראשון – צפיה במצלמה

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

cap = cv2.VideoCapture(0)
frame_size = (int(cap.get(3)), int(cap.get(4)))

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

כעת נרוץ בלולאה ובכל איטרציה נבקש תמונה מהמצלמה ע"י הפונקציה read של VideoCapture:

while cap.isOpened():
    success, frame = cap.read()
    assert success, "failed reading frame"
    now = datetime.datetime.now()

שימו לב שפונקציית read מחזירה שני ערכים: הצלחה/כשלון ואת התמונה עצמה. כמו כן שימו לב לשורת ה-assert, שנועדה לוודא שהצלחנו לקבל תמונה. אם לא הצלחנו, יזרק exception והתוכנית תצא. השורה האחרונה שומרת את הזמן הנוכחי במשתנה now, לו נזדקק בהמשך.

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

while cap.isOpened():
    ...

    cv2.imshow('frame', frame)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

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

לכידת תמונה ממצלמת הרשת
לכידת תמונה ממצלמת הרשת

שלב שני – זיהוי תנועה

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

  1. טשטוש התמונה והמרתה לגווני אפור, זאת כדי להפחית שינויים מזעריים בין התמונות הנגרמים ע"י המצלמה.
  2. ביצוע diff (חיסור) בין הפריים הנוכחי לפריים הקודם ויצירת תמונה המורכבת מההבדלים ביניהם. הבדלים בין התמונות יהיו קיימים רק אם התרחשה תנועה כלשהי. אם לא היתה תנועה, הפריימים יהיו (כמעט) זהים.
  3. סינון כל ההבדלים מתחת לסף מסויים, כדי להסיר "רעשים" מזעריים הנגרמים ע"י המצלמה עצמה גם אם אין תנועה.
  4. אם התמונה הסופית מכילה פיקסלים שאינם שחורים, נניח שהייתה תנועה ונפעל בהתאם.

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

def have_motion(frame1, frame2):
    delta = cv2.absdiff(frame1, frame2)
    thresh = cv2.threshold(delta, 25, 255, cv2.THRESH_BINARY)[1]
    return numpy.sum(thresh) > 0

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

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

כעת כל שנותר הוא להשתמש בפונקצייה have_motion בלולאה הראשית:

frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
frame_gray = cv2.GaussianBlur(frame_gray, (21, 21), 0)

if have_motion(prev_frame, frame_gray):
    last_motion = now
    # TODO: start recording to a video file

prev_frame = frame_gray

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

שלב שלישי – שמירה לקובץ וידאו

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

motion_filename = now.strftime("%Y_%m_%d_%H_%M_%S_MOTION.avi")
motion_file = cv2.VideoWriter(motion_filename, fourcc, 20.0, frame_size)

אתחול האובייקט מתבצע עם הפרמרטים הבאים:

      1. שם קובץ הפלט, אותו אנו בונים בשורות הראשונה והשנייה לפי הזמן הנוכחי (מהמשתנה now).
      2. אובייקט מסוג CV_FOURCC, המציין את הקידוד בו נרצה לשמור את הקובץ. חשוב לבחור את הקידוד כך שיתמך גם ע"י מערכת ההפעלה שלנו, וגם ע"י המכשיר הנייד אליו שולחים את הקובץ. עבורי הקידוד XVID עובד מצויין, אך יתכן שתזדקקו לקצת ניסוי וטעייה עם רשימת הקידודים הנמצאת באתר fourcc.org.
        fourcc = cv2.cv.CV_FOURCC(*"XVID")
      3. מספר פריימים לשניה.
      4. tuple המכיל את רוחב וגובה הפריים, אותו יצרנו מוקדם יותר.

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

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

if motion_file is not None:
    motion_file.write(frame)

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

if motion_file is not None:
    motion_file.write(frame)
    if now - last_motion > MOTION_RECORD_TIME:
        motion_file.release()
        motion_file = None
        # TODO: send video file

כאשר את הפרמטר MOTION_RECORD_TIME נגדיר בתחילת הקובץ, למשל ל-10 שניות:

MOTION_RECORD_TIME = datetime.timedelta(seconds = 10)

כעת למעשה סיימנו לכתוב תוכנית המזהה ומקליטה תנועה לקובץ וידאו!

שלב רביעי – בדיקה האם אני בבית

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

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

 find mac address <device name>

בנוסף נצטרך למצוא את כתובת ה-IP שלנו וה-subnet mask של הרשת הביתית שלנו, ע"י הרצת הפקודה ipconfig ב-command line של Windows (הוראות עבור מערכות הפעלה אחרות). את שני הנתונים האלו נמיר לטווח כתובות ה-IP של הרשת הביתית (בפורמט CIDR) ע"י שימוש בכלי הבא. זה אולי נשמע קצת מסובך, אבל לרוב כל שנצטרך לעשות הוא להחליף את המספר האחרון בכתובת ה-IP שלנו ב-0 ולהוסיף את המחרוזת /24.

נשמור את שני פריטי המידע הנ"ל במשתנים:

DEVICE_MAC = "3d:f9:c2:d8:0f:d5"
SUBNET = "192.168.1.0/24"

כעת נשתמש בספרייה scapy כדי לבצע סריקת ARP. שאילתת ARP היא הדרך המקובלת להמרת כתובת IP ברשת המקומית לכתובת MAC. אפשר לדמיין שאילתת ARP כצעקה "מיהו בעל כתובת ה-IP הזאת?", אליה עונה בעל הכתובת בלבד: "אני הבעלים של כתובת ה-IP, וה-MAC שלי הוא…".

סריקת ARP שולחת שאילתות ARP עבור כל אחת מכתובות ה-IP האפשריות ברשת ואוספת את התשובות שהתקבלו. אם המכשיר שלנו מחובר לרשת (יש לו כתובת IP ברשת), הוא אמור לענות לפקודת ה-ARP עם כתובת ה-MAC שלו, וכך נדע שהוא מחובר. נכתוב קוד שמבצע את הסריקה הנ"ל, אוסף את התשובות ומחפש בהן את כתובת ה-MAC של המכשיר שלנו:

def is_device_connected(mac_addr):
    answer, _ = scapy.srp(
        scapy.Ether(dst="ff:ff:ff:ff:ff:ff") /
        scapy.ARP(pdst=SUBNET), timeout=2)
    return mac_addr in (rcv.src for _, rcv in answer)

לאחר הרצת הפונקציה scapy.srp, המשתנה answer יכיל את מערך התשובות שהתקבלו לשאילתות ה-ARP. השורה השנייה בודקת האם כתובת ה-MAC שסופקה נמצאת בתוך אחת התשובות.

שלב חמישי ואחרון – שליחת קובץ הוידאו

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

def push_file(filename):
    if is_device_connected(DEVICE_MAC):
        print "Device is connected, not sending"
        return
    print "Sending", filename
    pushbullet = Pushbullet("PUSHBULLET_API_KEY")
    my_device = pushbullet.get_device("My Device")
    file_data = pushbullet.upload_file(open(filename, "rb"), filename)
    pushbullet.push_file(device = my_device, **file_data)

שימו לב להחליף את המחרוזות PUSHBULLET_API_KEY ו-My Device בערכים המתאימים מתוך חשבון ה-Pushbullet שלכם.

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

כדי שדבר כזה לא יקרה (חור אבטחה!) נרצה להריץ את תהליך השליחה במקביל ללולאה הראשית. ניתן לעשות זאת ע"י שימוש ב-threads, אך ב-python מומלץ להשתמש דווקא בתהליך נפרד. נעשה זאת ע"י שימוש במחלקה Process מתוך הספריה המובנית multiprocessing:

if now - last_motion > MOTION_RECORD_TIME:
    ...
    Process(target = push_file, args = (motion_filename, )).start()

סיימנו! וכך נראית ההודעה שמתקבלת במכשיר:

ההודעה שהתקבלה במכשיר הנייד
ההודעה שהתקבלה במכשיר הנייד

מה הלאה?

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

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

הקוד המלא

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

יצירת תמונה מאימוג'י בפחות מ-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!