רשימת המילים בעברית בפחות מ-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)

הקוד המלא

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

טריקים שכל מפתח Python חייב להכיר – חלק ב'

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

אינדקס שלילי

שפת פייתון מאפשר גישה לאיבר במיקום ה-x מסוף הרשימה ע"י שימוש באינדקס שלילי. לדוגמה:

my_list[-1]

יחזיר את האיבר האחרון ו-

my_list[-3]

יחזיר את האיבר השלישי מהסוף.

defaultdict ו-Counter

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

from collections import defaultdict
groups = defaultdict(list)
groups["fruits"].append("banana") // groups["fruits"] = ["banana"]
print groups["vegetables"] // []

מבנה נתונים דומה הוא Counter, המזכיר את defaultdict(int) אך מספק מספר פונקציות שימושיות עבור מניה של אובייקטים:

from collections import Counter
counter = Counter()
for char in "banana":
  counter[char] += 1
print counter.most_common() // [('a', 3), ('n', 2), ('b', 1)]

Interactive Debugging

אם אתם כמוני – משתמשים בעורך טקסט כדי לערוך קוד פייתון (ולא ב-IDE), ומריצים את התוכנית באמצעות ה-command line, בטח יצא לכם להוסיף באופן זמני פקודות print כדי "לדבג" את הקוד ולהדפיס מידע שימושי. במקרים רבים זה עובד, אבל הבעיה עם השיטה הזאת היא שאם רוצים להדפיס מידע נוסף, יש צורך לערוך את הקוד ולהריץ מחדש את התוכנית.

שיטה נוספת ולעתים נוחה יותר לביצוע דיבאג היא שימוש בדיבאגר המובנה של python ששמו הוא, באופן לא מפתיע, Python Debugger, ובקיצור PDB. באמצעות הוספת שורה אחת לקוד שלכם, תוכלו לעצור את הקוד בזמן ריצה, ולקבל python shell בתור ה-context הנוכחי של התוכנית שלכם, ממנו תוכלו לגשת למשתנים ולהריץ כל קוד Python שבא לכם:

import pdb; pdb.set_trace()

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

הפונקציה partial

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

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

def round3(n):
    return round(n, 3)

>> round3(3.141592653)
3.142

הפונקציה partial מאפשרת לנו להגיע לאותה תוצאה בשורה אחת:

round3 = partial(round, ndigits = 3)

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

המודול itertools

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

  • הפונקציה chain משרשרת מספר איטרטורים לאיטרטור אחד, שכאשר רצים עליו, פשוט רץ על כל האיטרטורים אחד אחרי השני:
chain('ABC', xrange(3)) // A B C 0 1 2
  • הפונקציה product מחזירה מכפלה קרטזית של מספר איטרטורים. למשל עבור שני איטרטורים היא תחזיר את אוסף כל הזוגות המורכבים מאיבר מהאיטרטור הראשון ואיבר מהאיטרטור השני, בדומה לשתי לולאות מקוננות:
product("AB", "12") // ('A', '1'), ('A', '2'), ('B', '1'), ('B', '2')
  • הפונקציה izip דומה לפונקציה המובנית zip (אין קשר לדחיסת נתונים, אלא ל"ריץ' רץ'"), אבל מחזירה איטרטור במקום רשימה. שימושית כאשר רוצים לרוץ על שני איטרטורים או יותר המכילים מספר רב של ערכים, בלי לטעון את כל המידע לזיכרון בבת אחת.
  • פונקציות מעניינות נוספות: combinations, permutations, groupby, cycle.

Pylint

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

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

> pip install pylint
> pylint --reports=n myapp.py
F:  5, 1: Unable to import 'non.existing' (import-error)

שימו לב ש-Pylint בהגדרות ברירת המחדל יכול להיות קצת מעיק, ולכן אני ממליץ ליצור קובץ pylint.rc בו תגדירו למשל אילו הודעות לא מעניינות אתכם. בנוסף Pylint יכול לרוץ על מודול שלם ואין צורך להריץ אותו על כל אחד מהקבצים בנפרד.

המודול logging

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

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

import logging
...
logging.warning("Invalid parameter %s", param)

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

import logging.config
logging.config.dictConfig({
  'version': 1,
  'formatters': { 
    'standard': { 
      'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
    },
  },
 'handlers': { 
    'file': { 
      'formatter': 'standard',
      'class': 'logging.FileHandler',
      'filename': 'myapp.log',
    },
  },
  'loggers': { 
    '': { 
      'handlers': ['file'],
      'level': 'INFO',
      'propagate': True,
    },
  },
})

מאבטחים את הבית בפחות מ-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!

למה אסור לכם לבצע import ישירות לפונקציות או אובייקטים

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

import module
module.func('yummy')

השיטה השניה היא ביצוע import ישירות לאובייקט או פונקצייה אותם אנו צריכים:

from module import func
func('meh')

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

כתיבת קוד בדיק

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

from time import sleep

def process_or_wait(value):
    try:
        # process value
        return True
    except:
        sleep(10)
        return False

כעת נכתוב בדיקה אוטומטית עבור קוד זה. בדיקה עבור המקרה בו החישוב נכשל יכולה להיראות כך:

import my_module
import unittest

class Test1(unittest.TestCase):
    def test_process_or_wait__failure(self):
        ret_val = my_module.process_or_wait('invalid')
        self.assertFalse(ret_val)

הבדיקה אכן עובדת ומוודאת שעבור ערך לא תקין, הפונקציה שלנו מחזירה False. אז מה בכל זאת הבעיה? הבעיה היא שבמקרה של כישלון, הפונקציה process_or_wait מחכה 10 שניות, כלומר הבדיקה תימשך 10 שניות!

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

import my_module
import unittest
from mock import patch

class Test1(unittest.TestCase):
    @patch('time.sleep', side_effect = lambda _ : None)
    def test_process_or_wait_failure(self, sleep_mock):
        ret_val = my_module.process_or_wait('invalid')
        self.assertFalse(ret_val)
        sleep_mock.assert_called_with(10)

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

המשמעות של import

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

from time import sleep

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

from time import sleep as zzz
print zzz # <built-in function sleep>

עכשיו נבין מה קרה בקוד הבדיקה. הפונקציה patch אכן החליפה את הפונקציה sleep במודול time המקורי, אבל היא לא החליפה את העותק המקומי של הפונקציה במודול המייבא mymodule. זאת הסיבה שקוד הבדיקה לא עבד.

אך אם היינו כותבים את הקוד הנבדק באופן הבא, הבדיקה הייתה עובדת:

import time

def process_or_wait(value):
    try:
        # process value
        return True
    except:
        time.sleep(10)
        return False

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

המסקנה היא שכדי לוודא שהקוד שלנו יהיה בדיק, אסור (אוקיי, לא מומלץ) לבצע import לפונקצייה ישירות.

וכמובן שמסקנה זאת תקפה לגבי כל* הפונקציות והאובייקטים.

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


גילוי נאות: למעשה, יכלנו להסתדר גם עם הקוד המקורי ע"י כתיבת קוד הבדיקה כך שיחליף את העותק המקומי של הפונקציה sleep:

@patch('mymodule.sleep', side_effect = lambda _ : None)

אמנם זהו קוד תקין לחלוטין, אך לא מומלץ מכיוון שהוא מניח דברים לגבי המימוש הפנימי של mymodule (במקרה שלנו – שהוא מבצע import לפונקציה sleep ישירות), ולא מתייחס אליו בתור קופסה שחורה. הנחות מסוג זה הופכות את קוד הבדיקה לשביר, מכיוון ששינויים באופן המימוש של המודול הנבדק, אפילו אם לא השפיעו על התנהגותו, עלולים לגרום לקוד הבדיקה לא לעבוד. מסיבה זאת אני מעדיף שלא להשתמש ב-patch באופן זה.

12 טריקים שכל מפתח python חייב להכיר

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

Dict Comprehension

אחת מפניני התחביר המוכרים ביותר של python היא list comprehension:

>>> [x ** 2 for x in range(10) if x != 4]
[0, 1, 4, 9, 25, 36, 49, 64, 81]

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

>>> {x: x ** 2 for x in range(5) if x != 4}
{0: 0, 1: 1, 2: 4, 3: 9}

Argument Unpacking

בדומה ל-params בשפת C#, גם פייתון תומכת בהמרת הפרמטרים של פונקצייה למערך, ואת הפרמטרים השמיים (keyword params) למילון. זה נראה כך:

def func(regular_arg, *args, **kwargs):
    print regular_arg, args, kwargs

כשנקרא לפונקציה, args יהיה מערך של כל הפרמטרים הרגילים, פרט לראשון שמועבר כ-regular arg, ובנוסף kwargs יהיה מילון שיכיל את כל הפרמטרים שהועברו לפי שם.

>>> func(1, 2, 3, a=1, b=2)
1 (2, 3) {'a': 1, 'b': 2}

הפונקציה enumerate

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

i = 0
for x in my_list:
    print i, some_processing(x)
    i += 1

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

for i, x in enumerate(my_list):
    print i, some_processing(x)

IPython

אמנם לא מדובר בחלק מהשפה עצמה, אך חבילת IPython מציעה shell נוח הרבה יותר מה-interactive interpreter של פייתון. בין תכונותיו הרבות:

  • Ctrl-C ו-Ctrl-V עובדים
  • השלמה אוטומטית באמצעות מקש TAB
  • צביעת הפלט
  • זיכרון של פקודות מריצות קודמות באמצעות מקש חץ למעלה
  • אפשרות לביצוע embed בתוכניות שלנו ליצירת shell נוח

ניתן להתקין IPython באמצעות pip:

pip install ipython

If טרינארי

במקום לכתוב:

if something:
    x = "yes"
else:
    x = "no"

ניתן לכתוב פשוט:

x = "yes" if something else "no"

הפונקציה get של מילון

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

x = "default"
if key in my_dict:
    x = my_dict[key]

בדיוק לשם כך קיימת הפונקציה get של מילון:

x = my_dict.get(key, "default")

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

New Style Formatting

כמעט כולם מכירים את האופרטור % המבצע string formatting בדומה לפונקציה printf בשפת c:

"My %s is almost %u years old" % ("grandma", 22)

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

"My {relative} is almost {years} years old".format(
    relative = "son", years = 120)

The Zen of Python

מתוך ה-interpreter הריצו את השורה הבאה:

import this

גנרטורים

התחביר של גנרטורים (generators) דומה לזה של list comprehension, אך עם סוגריים רגילים במקום סוגריים מרובעים:

(x ** 2 for x in range(3))

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

>>> generator = (x ** 2 for x in range(3))
>>> generator
<generator object <genexpr> at 0x0000000002B0CF78>
>>> generator.next()
0
>>> generator.next()
1
>>> for x in generator: print x
...
4
>>> generator.next()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration

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

List Comprehension מקונן

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

[(i,j) for i in range(3) for j in range(i)]

המשתנה _ ב-interpreter

אם הרצנו ביטוי ב-interpreter, ושכחנו לשים את התוצאה במשתנה, ניתן לגשת לתוצאה האחרונה במשתנה _ (קו תחתון):

>>> expensive_processing("bla")
'ffa6706ff2127a749973072756f83c532e43ed02'
>>> _
'ffa6706ff2127a749973072756f83c532e43ed02'

אופרטורי השוואה משורשרים

בפייתון התחביר הבא הוא חוקי:

>>> 10 < x < 11
False
>>> 10 < x < y < 11
False
>>> 1 >= x < 2
True

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

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

>>> def func(x=[]):
...     x.append("wa")
...     print x
... 
>>> foo()
["wa"]
>>> foo()
["wa", "wa"]
>>> foo()
["wa", "wa", "wa"]

שימוש ב-Hypothesis למציאה אוטומטית של מקרי קצה בבדיקות יחידה

כאשר כותבים בדיקות יחידה (unit tests) עבור הקוד שלנו, רוב הבדיקות בנויות כך:

  1. הגדרת המידע עליו ירוץ הקוד הנבדק.
  2. הרצת הקוד הנבדק על המידע.
  3. וידוא נכונות הפלט ע"י assertions כלשהם.

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

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

  1. הגדרת מרחב הקלטים האפשריים עליו ירוץ הקוד הנבדק.
  2. הרצת הקוד על כל אחד מהקלטים האלו בצורה אוטומטית.
  3. וידוא נכונות הפלט ע"י assertions כלשהם.

ספריית Hypothesis מאפשרת לנו לעשות בדיוק את זה!

לדוגמה, נניח שאנחנו רוצים לבדוק פונקציית encode ו-decode כלשהן שכתבנו:

def my_encode(value):
    """ return an encoded value """

def my_decode(decoded_value):
    """ return a decoded value """

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

import unittest
from hypothesis import given
from hypothesis.strategies import text

class TestEncoding(unittest.TestCase):
    @given(text())
    def test_decode_inverts_encode(self, value):
        self.assertEqual(decode(encode(value)), value)

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

כדי להפוך את הבדיקות שלנו לאיתנות יותר, הספרייה מספקת כמה שירותים נוספים:

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

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

איך בוחרים web framework בפייתון

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

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

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

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

הכל שאלה של עלות ותועלת

ניתן לסכם את כל הנושא של בחירת תשתית בהשוואה בין עלות ותועלת.

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

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

  • עקומת הלמידה של החבילה החדשה; האם התנסו בה בעבר? האם התיעוד שלה מקיף ואיכותי?
  • הקושי של התחלת פרוייקט חדש.
  • כמה קוד נצטרך לכתוב כדי לממש את הפרוייקט שלנו? או במילים אחרות – אילו שירותים מספקת התשתית שיחסכו לנו קוד וזמן? שימו לב שחלק מהחבילות מכילות שירותים כמו ניהול בסיס נתונים, cache, ניהול משתמשים ו-sessions, וכדומה, ועבור חבילות אחרות השירותים האלו קיימים כתוספים (פלאגינים).
  • כמה קשה יהיה לתחזק את הקוד לאורך זמן?
  • באיזה שרת נשתמש ב-production? האם קיימת אינטגרציה בין תשתית ה-web לשרת?

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

חלק נוסף מהעלות נגזר גם מגודל קהילת המפתחים שמשתמשים בה, שואלים ועונים על שאלות ומפתחים עבורה תוספים והרחבות. למשל נכון לכתיבת שורות אלו לחבילת flask יש כ-10,000 שאלות ב-Stack Overflow, ולעומתה על CherryPy נשאלו 960 בלבד.

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

  • מנוע templates
  • ORM – מידול וגישה לבסיסי נתונים (SQL או NoSQL)
  • ניהול משתמשים והרשאות
  • מנוע cache
  • תמיכה במספר שפות
  • Authentication
  • Multitheading
  • תמיכה בקבצים סטאטיים.
  • DB Migrations
  • ניהול קבצים סטאטיים
  • כלי בדיקה

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

לפרוייקטים קטנים – Bottle

הרבה פעמים אני מוצא את עצמי צריך לכתוב אפליקציה מבוססת javascript הדורשת מעט מאוד צד שרת, או אפילו סתם קוד python שהפלט שלו הוא טקסט בעברית שלא ניתן להציג על command line בצורה נוחה. במקרים כאלו אני רוצה להרים שרת web בפחות מדקה, ואין כמו bottle עבור משימה זאת.

חבילת Bottle היא מיקרו תשתית (micro-framework), המכילה למעשה קובץ אחד בלבד – bottle.py, שבו ממומשת תשתית web שלמה ואפילו שרת לבדיקה. כדי לכתוב תוכנית בסיסית בעזרת bottle, לא נדרשות יותר מחמש שורות הקוד הבאות:

from bottle import route, run, template

@route('/hello/<name>')
def index(name):
    return template('<b>Hello {{name}}</b>!', name=name)

run(host='localhost', port=8080)

למרות היותה מיקרו-תשתית, bottle תומכת בכל מה שצריך כדי לבנות פרוייקטים קטנים: מנוע routing, מנגנון templating, גישה לאובייקטי request ו-response ותמיכה בהעלאת קבצים. בנוסף מכילה bottle לא מעט adapters שיעזרו להתממשק עם חבילות מפורסמות כמו Jinja2, ומעל 15 שרתי http. להלן דוגמה איך בתוספת של שורת קוד אחת בלבד ניתן להשתמש בתבניות של Jinja2:

from bottle import jinja2_view, route
 
 @route('/', name='home')
 @jinja2_view('home.html', template_lookup=['templates'])
 def home():
     return {'title': 'Hello world'}

עבור bottle נכתב גם אוסף קטן ויעיל של פלאגינים המתממשקים לשירותים כמו memcached, mongodb, SQLAlchemy, SQLite ועוד. כל אלו הופכים את bottle למתאימה מאוד עבור פרויקטים בסיסיים הדורשים מספר מצומצם של שירותים חיצוניים.

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

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

חלופה נפוצה וטובה לא פחות ל-bottle היא Flask, שגם היא micro framework, והקהילה שלה גדולה יותר. הנה השוואה של עוד כמה מיקרו תשתיות.

לפרוייקטים בינוניים וגדולים – Django – המלכה הבלתי מעורערת

Django היא ללא ספק גולת הכתר של חבילות תשתיות ה-web הפייתוניות. עם היסטוריה של 12 שנים, מעל 100k (!) שאלות ב-SO וקהילת מפתחים ותורמים ענקית ופעילה, תוכלו למצוא מענה מיידי כמעט לכל שאלה. מערכת ה-ORM של Django ידועה כמקיפה ביותר, תומכת במספר נאה של בסיסי נתונים מבוססי SQL, ומשמשת תמיד כדוגמה לשימוש מופתי ב-Metaclasses.

בדיוק כמו ההוא מהסרט, Django משוחררת מכל מעצורים ומאפשרת לפתח כמעט כל סוג של פרוייקט רשת, גם בקנה מידה ענקי. הגישה של Django היא "הסוללות כלולות", כלומר מפתחיה משתדלים להכניס לתוכה את כל מה שצריך בשביל רוב הפרוייקטים: ORM מפואר, cache, שפת templates שלמה, מערכת ניהול DB, ניהול sessions, ניהול משתמשים ואותנטיקציה, יכולות אבטחה, תמיכה במספר אתרים במקביל, תמיכה בקבצים סטאטיים, תמיכה במספר שפות, ועוד. ואם בכל זאת מצאתם משהו שלא כלול, בוודאי תשמחו לדעת שבמשך השנים פותחו ל-Django מאות, אם לא אלפי "אפליקציות" הרחבה, ביניהן NoSQL DB Backends, תשתיות REST API, חבילות אותנטיקציה מסוגים שונים, חבילות אבטחה, ואפילו מערכות CMS ובלוגים שלמות.

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

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