מצלמה ומודלי שפה מקומיים
כל חיישן שקראנו עד כה נתן לנו מספר אחד בכל פעם. מצלמה נותנת מאות אלפי מספרים בתמונה אחת, ומודל שפה נותן משפטים שנשמעים בטוחים גם כשהם שגויים. בפרק האחרון הזה נפעיל את שני הכלים, נלמד מתי לא להאמין להם, ומה אנחנו חייבים למי שמופיע בתמונה.
- נחשב את גודל הפריים הגולמי, ונבין למה הלוח שולח תמונות JPEG.
- נבנה שרת שידור MJPEG, ונחבר אליו מצלמה על ESP32 בקושחה ייעודית.
- נצלם תמונה ב-Raspberry Pi 4/5 בעזרת Picamera2, ונקרא שידור מרוחק בעזרת OpenCV.
- נשאל מודל שפה מקומי דרך Ollama מן הלוח, ונבדוק את תשובתו לפני שהתוכנית מבצעת משהו.
- נתעד את השימוש בבינה מלאכותית, ונכבד את הפרטיות וההסכמה של המצולמים.
24.1 התמונה היא הרבה מספרים (An Image is Many Numbers)
תמונה היא רשת של נקודות הנקראות פיקסלים, ופיקסל צבעוני הוא שלושה מספרים: אדום, ירוק וכחול, ובדרך כלל בית אחד לכל אחד מהם. כל תמונה בודדת מתוך רצף תמונות נקראת פריים.
| סמל | פירוש | יחידה |
|---|---|---|
| W, H | רוחב הפריים וגובהו בפיקסלים | — |
| c | מספר הבתים לכל פיקסל: 3 בצבע ו-1 באפור | — |
| B | גודל הפריים בבתים, לפני כל דחיסה | — |
המספר הזה גדול מכל הזיכרון הפנימי בשבב ESP32 המקורי, כ-520 kB לפי היצרן. לכן הלוח אינו מחזיק את הפריים הגולמי: הוא מבקש מן המצלמה תמונה דחוסה בפורמט JPEG, ומסתייע בזיכרון חיצוני PSRAM אם יש. ל-Raspberry Pi 4/5, לעומת זאת, יש די זיכרון לפריים הגולמי, ולכן את התמונות מנתחים בו.
24.2 מצלמה על ESP32 ושידור MJPEG (ESP32 Camera and MJPEG Streaming)
הקושחה הרשמית של מיקרו-פייתון אינה כוללת מודול בשם camera, ולכן הסעיף הזה נוגע רק ל-ESP32 עם מצלמה. דרושה קושחה ייעודית שנבנית יחד עם מנהל ההתקן של המצלמה. כאן אנחנו עוקבים אחרי התיעוד של פרויקט פתוח בשם micropython-camera-driver, שתומך בחיישן OV2640 ובלוחות כמו ESP32-CAM.
התוכנית הבאה נכתבה לפי התיעוד הזה, ולא הורצה בבניית הספר, כי ללוח המדומה אין מצלמה:
import camera
camera.init(0, format=camera.JPEG, fb_location=camera.PSRAM)
jpg = camera.capture()
print("bytes:", len(jpg))
with open("photo.jpg", "wb") as f:
f.write(jpg)camera אינו חלק ממיקרו-פייתון הרשמית, ולכן השמות עשויים להיות שונים בין קושחה לקושחה, ומספרי ההדקים של המצלמה משתנים בין לוחות ומועברים ל-init. נעיין בתיעוד של הקושחה שהתקנו בפועל, ונוודא שהיא מתאימה ללוח שלנו ושהיא דורשת PSRAM. קושחה ייעודית עלולה גם לפגר אחרי הגרסה הרשמית האחרונה ולחסור תכונות: למשל, לפי התיעוד שלה היא נבנית בלי BLE.איך התמונות מגיעות לדפדפן? הדרך הפשוטה ביותר היא MJPEG: השרת אינו מסיים את התשובה, אלא שולח תמונות JPEG זו אחר זו בתוך תשובת HTTP אחת, ומפריד ביניהן במפריד שאנחנו בוחרים. הדפדפן מחליף כל תמונה בזו שאחריה. זהו שידור זרם. את מבנה השרת כולו אפשר לנסות בלי מצלמה, אם מציבים מחרוזות קצרות במקום התמונות:
import socket
import wifi
HEAD = (b"HTTP/1.0 200 OK\r\n"
b"Content-Type: multipart/x-mixed-replace;"
b" boundary=frame\r\n\r\n")
FRAMES = (b"<jpeg-1>", b"<jpeg-2>")
def send_frame(conn, jpg):
conn.sendall(b"--frame\r\nContent-Type: image/jpeg\r\n")
conn.sendall(b"Content-Length: %d\r\n\r\n" % len(jpg))
conn.sendall(jpg)
conn.sendall(b"\r\n")
wlan = wifi.connect()
print("open http://%s/" % wlan.ifconfig()[0])
addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1]
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(addr)
server.listen(1)
while True:
conn, peer = server.accept()
conn.recv(1024)
conn.sendall(HEAD)
for jpg in FRAMES:
send_frame(conn, jpg)
conn.close()open http://192.168.1.50/ [http_sent] HTTP/1.0 200 OK Content-Type: multipart/x-mixed-replace; boundary=frame --frame Content-Type: image/jpeg Content-Length: 8 <jpeg-1> --frame Content-Type: image/jpeg Content-Length: 8 <jpeg-2> ...
כשיש קושחה ייעודית, מחליפים את לולאת for בפונקציה שמצלמת ברצף עד שהדפדפן סוגר את החיבור. כדי להציג את השידור בדף אינטרנט מספיק התג <img src="/">.
for בשרת הקודם. לא הורץ בבניית הספר, כי הוא דורש מצלמה.def stream(conn):
conn.sendall(HEAD)
try:
while True:
send_frame(conn, camera.capture())
except OSError:
conn.close()24.3 מצלמה ב-Raspberry Pi: Picamera2 (Picamera2)
ב-Raspberry Pi 4/5 אין צורך בקושחה ייעודית. Picamera2 היא הספרייה הרשמית בפייתון, והיא פועלת מעל שכבת libcamera במערכת. מחברים את מודול המצלמה בכבל הסרט שלו לשקע המצלמה כשהמתח מנותק, ושמים לב שהשקע ב-Pi 5 קטן יותר ודורש כבל מתאים. הספרייה מותקנת מראש בגרסאות המערכת עם שולחן עבודה, ואינה מותקנת בגרסאות Lite:
sudo apt install python3-picamera2 --no-install-recommends rpicam-hello --list-cameras
import time
from picamera2 import Picamera2
picam2 = Picamera2()
config = picam2.create_still_configuration(
main={"size": (1280, 720), "format": "RGB888"})
picam2.configure(config)
picam2.start()
time.sleep(1)
picam2.capture_file("photo.jpg")
array = picam2.capture_array("main")
print(array.shape)
picam2.stop()| פקודה | מה היא עושה |
|---|---|
| create_still_configuration(main=...) | הגדרת צילום סטילס: המידות וסדר הבתים. |
| picam2.start() | הפעלת המצלמה. ממתינים רגע עד שמצב התאורה מתייצב. |
| capture_file("photo.jpg") | שמירת התמונה בקובץ. הסיומת קובעת את הפורמט. |
| capture_array("main") | התמונה כמערך NumPy לניתוח. |
RGB888 מסדר את הבתים כחול, ירוק, אדום, וזה הסדר ש-OpenCV מצפה לו. את הספרייה אין להתקין בפקודה pip; המדריך ממליץ על apt כדי שתתאים ל-libcamera. מי שעובד בסביבה וירטואלית יוצר אותה עם --system-site-packages.כדי לשדר מה-Pi עצמו אין צורך לכתוב שרת מאפס: בדוגמאות הספרייה הרשמית יש קובץ mjpeg_server.py שמשתמש במקודד JpegEncoder כדי לבנות את אותו שידור.
24.4 ניתוח שידור מרוחק (Analysing a Remote Stream)
כשהלוח אינו מסוגל לנתח, ה-Pi מנתח במקומו: הוא קורא את השידור של הלוח ופועל על כל פריים. הניתוח השימושי הפשוט ביותר הוא זיהוי תנועה, והוא אינו דורש למידת מכונה: משווים כל פיקסל לפיקסל המקביל לו בפריים הקודם.
| סמל | פירוש | יחידה |
|---|---|---|
| S | סכום ההפרשים המוחלטים בין כל שני פיקסלים מקבילים | — |
| N | מספר הפיקסלים | — |
| D | ההפרש הממוצע לפיקסל | — |
מכיוון שלמצלמה יש רעש, אף פריים אינו זהה לקודם בדיוק. לכן משווים את D לסף, כמו שעשינו במדידת הפוטנציומטר. נבדוק זאת על שני פריימים קטנים שכתובים בתוכנית עצמה, כך שהיא רצה בשני הלוחות בלי מצלמה:
THRESHOLD = 5.0
REF = ((10, 10, 10), (10, 10, 10), (10, 10, 10))
NOISY = ((11, 10, 9), (10, 12, 10), (10, 9, 11))
MOVED = ((10, 10, 10), (10, 200, 10), (10, 10, 10))
def score(a, b):
total = 0
count = 0
for row_a, row_b in zip(a, b):
for p, q in zip(row_a, row_b):
total += abs(p - q)
count += 1
return total / count
for name, frame in (("noisy", NOISY), ("moved", MOVED)):
d = score(REF, frame)
print("%s: D=%.2f motion=%s" % (name, d, d > THRESHOLD))noisy: D=0.67 motion=False moved: D=21.11 motion=True
התוכנית הבאה עושה את אותו הדבר בעזרת OpenCV על פריימים אמיתיים, וקוראת את השידור של הלוח מסעיף 24.2. מתקינים אותה בפקודה sudo apt install python3-opencv; קריאה מכתובת HTTP דורשת גרסה שנבנתה עם תמיכה בניגון שידורים. התוכנית לא הורצה בבניית הספר, כי היא דורשת שידור אמיתי:
import cv2
URL = "http://192.168.1.50/"
THRESHOLD = 5.0
cap = cv2.VideoCapture(URL)
previous = None
while True:
ok, frame = cap.read()
if not ok:
print("no frame")
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if previous is not None:
d = float(cv2.absdiff(gray, previous).mean())
if d > THRESHOLD:
print("motion: D=%.2f" % d)
previous = gray
cap.release()24.5 זיהוי תמונה ו-TinyML (Image Recognition and TinyML)
ההפרש בין שני פריימים אומר שמשהו השתנה, ואינו אומר מה. זיהוי תמונה דורש מודל: קובץ של מספרים רבים שכוונו באימון על דוגמאות, וזוהי למידת מכונה. הרצת המודל המאומן על קלט חדש נקראת הסקה.
הרעיון הוא שהאימון נשאר במחשב, ורק ההסקה יורדת לשבב. איננו מתכנתים זאת בספר הזה, כי מיקרו-פייתון הרשמית אינה כוללת מודול מוכן לכך, והפרטים רחבים מפרק אחד. הטבלה מסכמת את הבחירה בתיאור מילולי. אין בה מספרי דיוק או זמן, כי הם תלויים במודל ובחומרה, ומה שנכון היום משתנה מחר, ולכן נמדוד אותם בעצמנו בפרויקט שלנו.
| היכן המודל רץ | למה הוא מתאים | המחיר |
|---|---|---|
| על הבקר (TinyML) | שאלה צרה מאוד ותשובה מיידית בלי רשת | מודל קטן ומוגבל |
| על ה-Pi | ניתוח תמונות ומודל שפה קטן בתוך הבית | איטי יותר ממחשב חזק, וצורך הרבה זיכרון |
| על מחשב ברשת | מודלים גדולים יותר, בלי להוציא נתונים מהרשת | מכשיר נוסף שפועל כל הזמן |
| שירות ענן | המודלים הגדולים ביותר בלי חומרה | הנתונים עוזבים את הבית, ונדרש אינטרנט |
24.6 מודל שפה מקומי דרך Ollama (A Local Language Model with Ollama)
Ollama היא תוכנה שמריצה מודלי שפה במחשב שלנו ופותחת להם ממשק HTTP. כלומר, הלוח שלנו משוחח איתה בפקודות שאנחנו מכירים מפרק הבקשות, בלי ספרייה חדשה. המודל עצמו אינו רץ על הלוח לעולם, אלא על Raspberry Pi 4/5 או על מחשב. מתקינים את Ollama לפי ההוראות הרשמיות באתר שלה, ואז מורידים מודל:
model-name שמים מודל קטן מספריית האתר. השמות משתנים בקביעות, ולכן איננו קובעים אחד. לא הורץ בבניית הספר.ollama pull model-name ollama list
- לפי התיעוד בזמן כתיבת הפרק: השרת מאזין ל-
127.0.0.1:11434, כלומר במחשב עצמו בלבד. - כדי שהלוח יגיע אליו, מגדירים את משתנה הסביבה
OLLAMA_HOST=0.0.0.0:11434. אז כל מי שברשת יכול להשתמש בו, ולכן עושים זאת רק ברשת שאנחנו סומכים עליה. - את התשובה מבקשים מ-
POST /api/generate, ומוצאים אותה בשדהresponse. - השידור מופעל כברירת מחדל, ולכן שולחים
"stream": Falseכדי שהתשובה תגיע בחתיכה אחת בפורמט JSON.
SIMULATED. מה שקרה בפועל הוא התוכנית עצמה: הבקשה ששלחה וקריאת התשובה. תשובה של מודל אמיתי תהיה שונה.import requests
import wifi
URL = "http://192.168.1.20:11434/api/generate"
MODEL = "model-name"
body = {"model": MODEL,
"prompt": "Explain PWM in one short sentence.",
"stream": False}
wifi.connect()
r = requests.post(URL, json=body, timeout=60)
try:
if r.status_code == 200:
print(r.json()["response"])
else:
print("server said", r.status_code)
finally:
r.close()SIMULATED ANSWER: written by the book, no model ran.
מה אם נרצה שהמודל יחליט משהו במעגל? כאן הכלל החשוב ביותר בפרק: תשובת המודל היא קלט לא מהימן, כמו כל קלט מחוץ לתוכנית. היא אינה מגיעה למפעיל לפני בדיקה. מבקשים ממנו פורמט JSON בעזרת השדה format, מקבלים רק ערכים מרשימה צרה, וכל מה שמחוץ לה נדחה והמצב נשאר כמות שהוא:
note שבה אומר זאת. שום מודל לא רץ כאן.import json
import requests
import wifi
URL = "http://192.168.1.20:11434/api/generate"
MODEL = "model-name"
ALLOWED = ("on", "off")
PROMPT = ('Temperature is %.1f C. Reply only with JSON: '
'{"fan":"on"} or {"fan":"off"}.')
def ask(prompt):
body = {"model": MODEL, "prompt": prompt,
"format": "json", "stream": False}
r = requests.post(URL, json=body, timeout=60)
try:
if r.status_code != 200:
return None
return r.json()["response"]
finally:
r.close()
def fan_command(text):
try:
cmd = json.loads(text)["fan"]
except (ValueError, KeyError, TypeError):
return None
return cmd if cmd in ALLOWED else None
wifi.connect()
answer = ask(PROMPT % 31.5)
print("model said:", answer)
cmd = fan_command(answer) if answer else None
if cmd is None:
print("rejected: fan unchanged")
else:
print("fan ->", cmd)model said: {"fan":"on","note":"simulated"}
fan -> onכעת מחליפים את הטקסט שהשרת המדומה מחזיר, ומשאירים את התוכנית כמות שהיא. כל התשובות בטבלה הזאת נכתבו בספר כדי לבדוק את התוכנית:
| התשובה המדומה | השורה האחרונה בפלט |
|---|---|
{"fan":"on","note":"simulated"} | fan -> on |
{"fan":"max","note":"simulated"} | rejected: fan unchanged |
SIMULATED: I think the fan should run. | rejected: fan unchanged |
OLLAMA_NO_CLOUD=1.24.7 חשיבה ביקורתית ותיעוד שימוש ב-AI (Critical Thinking and Documenting AI Use)
מודל שפה אינו מחפש אמת אלא מייצר טקסט סביר. לכן הוא עלול להמציא בביטחון מלא פונקציה שאינה קיימת, מספר של הדק או מקור שלא היה ולא נברא. זה נקרא הזיה. הסכנה גדולה יותר בחשמל, כי חיבור שגוי עלול להרוס את הלוח או לפגוע במי שנוגע בו.
- פקודה וספרייה: מחפשים אותן בתיעוד הרשמי של הלוח שלנו ושל הגרסה שלנו.
- קוד: קוראים אותו שורה אחר שורה, מריצים אותו, ומסבירים אותו במילים שלנו. מה שאיננו מצליחים להסביר, איננו מגישים.
- מספר: מחשבים אותו מחדש בעצמנו, או מודדים.
- חיבור ומתח: לא מחברים דבר רק כי מודל אמר זאת. בודקים בגיליון הנתונים של הרכיב.
- חזרה: אותה שאלה עשויה לתת שתי תשובות שונות, ולכן הסכמה בין שתי תשובות אינה הוכחה.
התיעוד הוא חלק מהיושר ומההנדסה גם יחד: מי שיקרא את הפרויקט שלנו בעוד שנה צריך לדעת מה כתבנו בעצמנו ומה בא מכלי. שומרים יומן קצר בקובץ בתוך הפרויקט:
| סעיף ביומן | מה כותבים |
|---|---|
| הכלי והמודל | השם והגרסה כפי שהם מופיעים בהפעלה, והתאריך. |
| הפרומפט | מה בדיוק שאלנו, או תקציר נאמן שלו. |
| מה לקחנו | הקבצים והשורות שבאו מן התשובה, ומה שינינו בהם. |
| איך וידאנו | התיעוד שעיינו בו, והניסוי שערכנו ותוצאתו. |
| מה דחינו | הטעות שמצאנו בתשובה. זה הסעיף היקר ביותר. |
לפני שמשתמשים בכלי כלשהו בעבודה שמוערכת, קוראים את הכללים של בית הספר שלנו או של הגוף שמגישים לו, ופועלים לפיהם.
machine שלא ראינו קודם. ציינו שתי דרכים לוודא אותה לפני השימוש.24.8 מצלמה ופרטיות (Cameras and Privacy)
המעגל היחיד בספר הזה שעלול לפגוע במי שאינו קשור לפרויקט הוא המצלמה. תמונה חושפת מי היה במקום, מתי ועם מי. פרטיות היא אילוץ תכנון, כמו מגבלת המתח על הדק, וחושבים עליה לפני כתיבת התוכנית.
- לא מצלמים היכן שאנשים מצפים לפרטיות, ולא מכוונים את המצלמה אל ביתו של השכן או אל הדרך הציבורית.
- מכריזים עליה: משאירים את המצלמה גלויה, תולים שלט, ומדליקים נורית כל עוד השידור פועל.
- המינימום הנדרש: הרזולוציה הנמוכה ביותר שמספיקה למשימה, בלי שמירה שלא לצורך, ובמשך זמן מוגדר ואז מחיקה. אם מספיק שהתוכנית תגיד "יש תנועה", היא לא שולחת את התמונה.
- ניתוח בתוך הבית: ניתוח ב-Pi משאיר את התמונות ברשת המקומית. אבל "מקומי" אינו אומר "בטוח": יומנים וקבצים עלולים להישאר בדיסק.
- גישה: השרת שלנו בלי סיסמה ובלי הצפנה, ולכן הוא נשאר ברשת מהימנה, ואיננו מפנים אליו פורט מהנתב.
- פנים: זיהוי אנשים בשמם מסוכן הרבה יותר מזיהוי תנועה. אין לבנות אותו בפרויקט לימודי.
רעיונות עיקריים בפרק:
- פריים גולמי גדול מדי לבקר, ולכן הלוח משדר תמונות JPEG, וה-Pi מנתח.
- המודול
cameraאינו בקושחה הרשמית, והפקודות שלו שונות מקושחה לקושחה. - MJPEG הוא תמונות עוקבות בתוך תשובת HTTP אחת, ומדד ההפרש יחד עם סף מגלה שינוי בלי למידת מכונה.
- תשובת המודל היא קלט לא מהימן: היא עוברת בדיקה לפני שהיא מגיעה למפעיל.
- מוודאים כל מה שהמודל מייצר, ומתעדים מה לקחנו ממנו.
- כל מי שמופיע בתמונה יודע ומסכים, ואוספים את המינימום הנדרש.
סיכום הכללים בפרק:
| הכלל | הנוסחה |
|---|---|
| גודל פריים גולמי | B = W · H · c |
| מדד ההפרש | D = SN |
פקודות הפרק:
| פקודה | מה היא עושה |
|---|---|
| camera.init(0, format=camera.JPEG) | אתחול המצלמה בקושחה ייעודית. |
| camera.capture() | תמונת JPEG בצורת בתים, בקושחה ייעודית. |
| multipart/x-mixed-replace | סוג התוכן בשידור MJPEG. |
| picam2.capture_file(name) | צילום תמונה לקובץ ב-Pi. |
| picam2.capture_array("main") | צילום תמונה למערך ב-Pi. |
| cv2.VideoCapture(url) | פתיחת שידור מרוחק בעזרת OpenCV. |
| cv2.absdiff(a, b) | ההפרש המוחלט בין שני פריימים. |
| requests.post(url, json=body) | בקשה לשרת Ollama. |
| r.json()["response"] | טקסט התשובה מ-Ollama. |
מילון מונחים:
| עברית | English | ערבית |
|---|---|---|
| פיקסל | Pixel | البكسل |
| פריים | Frame | الإطار |
| קושחה | Firmware | البرنامج الثابت |
| שידור זרם | Streaming | البثّ |
| זיהוי תמונה | Image Recognition | التعرّف على الصور |
| למידת מכונה | Machine Learning | التعلّم الآلي |
| מודל | Model | النموذج |
| הסקה | Inference | الاستدلال |
| מודל שפה | Language Model | نموذج اللغة |
| פרומפט | Prompt | نصّ الطلب |
| הזיה | Hallucination | الهلوسة |
| פרטיות | Privacy | الخصوصية |
| הסכמה | Consent | الموافقة |
"max". רק התוכנית יודעת מה בטוח למעגל.dir(machine). אך קיומה אינה מספיקה: קוראים גם מה היא עושה.שאלות לתרגול:
cam_esp32.py אינה פועלת על הקושחה הרשמית של מיקרו-פייתון?camera, ולכן היא נעצרת בשורת הייבוא."stream": False?response. בלי זה התשובה מגיעה בחלקים עוקבים, וכל חלק הוא JSON עצמאי, ולכן אי אפשר לקרוא את התשובה כולה בבת אחת.frame_diff.py: מהו הערך הקטן ביותר של הפיקסל האמצעי בפריים MOVED שעושה את D גדול מ-5? הערכים האחרים אינם משתנים, והפיקסל הוא מספר שלם.ask מסתיימת בשגיאת OSError אם השרת כבוי. כתבו פונקציה שעוטפת אותה ומחזירה None במקרה הזה.def safe_ask(prompt):
try:
return ask(prompt)
except OSError:
print("server unreachable")
return Noneexec() בלוח, "כדי שהמודל יכתוב את התוכנית בעצמו". הסבירו את הסכנה והציעו חלופה.fan_command.(א) מה רץ על הלוח ומה על ה-Pi?
(ב) היכן נכנסת חובת ההסכמה?
(ג) מה אנחנו מודדים בעצמנו לפני שמבטיחים זמן תגובה כלשהו?
מהנורית הראשונה עד כאן
התחלנו את הספר בנורית מהבהבת ובשורה אחת שכותבת 1 על הדק. אחר כך קראנו כפתור ומתח, ולמדנו שהזמן וההפסקות הם חלק מן התוכנית. שוחחנו עם שבבים דרך אפיקים וכתבנו ספריות וקבצים. משם יצאנו אל הרשת, בנינו מערכת בקרה ופרויקט שלם, הפכנו את ה-Pi לשרת שלו, וסיימנו בתמונה ובמודל שפה. הכלים גדלו מאוד, אבל ההרגל נשאר כפי שהיה מאז השורה הראשונה: יודעים מאין בא כל מספר, ולא מאמינים למה שלא בדקנו. את ההרגל הזה ניקח איתנו לכל לוח ולכל כלי שיבואו אחרי אלה.