תוכן עניינים:

מיון חרוזים רובוטיים: 3 שלבים (עם תמונות)
מיון חרוזים רובוטיים: 3 שלבים (עם תמונות)

וִידֵאוֹ: מיון חרוזים רובוטיים: 3 שלבים (עם תמונות)

וִידֵאוֹ: מיון חרוזים רובוטיים: 3 שלבים (עם תמונות)
וִידֵאוֹ: ✔ האם אתה מטומטם? במבחן הזה תגלה ! ( רק 12% מצליחים ) 2024, יולי
Anonim
Image
Image
מיון חרוזים רובוטי
מיון חרוזים רובוטי
מיון חרוזים רובוטי
מיון חרוזים רובוטי
מיון חרוזים רובוטי
מיון חרוזים רובוטי

בפרויקט זה נבנה רובוט למיון חרוזי פרלר לפי צבע.

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

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

אני עובד עבור Phidgets Inc ולכן השתמשתי בעיקר ב- Phidgets לפרויקט זה - אך ניתן היה לעשות זאת באמצעות כל חומרה מתאימה.

שלב 1: חומרה

הנה מה שבעבר בניתי את זה. בניתי אותו 100% עם חלקים מ- phidgets.com, ודברים שהיו לי מונחים בבית.

לוחות פידג'טים, מנועים, חומרה

  • HUB0000 - פידג'ט רכזת VINT
  • 1108 - חיישן מגנטי
  • 2x STC1001 - פידג'ר סטפר 2.5A
  • 2x 3324 - 42STH38 NEMA -17 מדרג ללא הילוכים
  • 3x 3002 - כבל פידג'ט 60 ס"מ
  • 3403 - רכזת 4 יציאות USB2.0
  • 3031 - זנב נקבה 5.5x2.1 מ"מ
  • 3029 - 2 חוטים 100 'כבל מעוות
  • 3604 - 10 מ"מ LED לבן (שקית של 10)
  • 3402 - מצלמת רשת USB

חלקים אחרים

  • ספק כוח 24VDC 2.0A
  • גרוטאות עץ ומתכת מהמוסך
  • קשרי רוכסן
  • מיכל פלסטיק עם החלק התחתון חתוך

שלב 2: עיצוב הרובוט

עיצוב הרובוט
עיצוב הרובוט
עיצוב הרובוט
עיצוב הרובוט
עיצוב הרובוט
עיצוב הרובוט

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

איסוף חרוזים

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

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

אחסון חרוזים

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

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

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

מַצלֵמָה

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

איתור מיקום

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

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

סיים את הרובוט

בשלב זה הכל נבדק ונבדק. הגיע הזמן להרכיב הכל יפה ולעבור לתוכנת כתיבה.

2 מנועי הצעד מונעים על ידי בקרי צעד STC1001. רכזת HUB000 - USB VINT משמשת להפעלת בקרי הצעדים, כמו גם קריאת החיישן המגנטי והנעת ה- LED. מצלמת הרשת ו- HUB0000 מחוברים שניהם לרכזת USB קטנה. זנב 3031 וכמה חוט משמשים יחד עם ספק כוח של 24V להנעת המנועים.

שלב 3: כתוב קוד

Image
Image

C# ו- Visual Studio 2015 משמשים לפרויקט זה. הורד את המקור בראש דף זה ועקוב אחריו - הסעיפים העיקריים מפורטים להלן

אִתחוּל

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

טופס ריק פרטי Form1_Load (שולח אובייקט, EventArgs e) {

/ * אתחל ופתח פידג'טים */

top. HubPort = 0; top. Attach += Top_Attach; top. Detach += Top_Detach; top. PositionChange += Top_PositionChange; top. Open ();

bottom. HubPort = 1;

bottom. Attach += Bottom_Attach; bottom. Detach += Bottom_Detach; bottom. PositionChange += Bottom_PositionChange; bottom. Open ();

magSensor. HubPort = 2;

magSensor. IsHubPortDevice = true; magSensor. Attach += MagSensor_Attach; magSensor. Detach += MagSensor_Detach; magSensor. SensorChange += MagSensor_SensorChange; magSensor. Open ();

led. HubPort = 5;

led. IsHubPortDevice = true; led. Channel = 0; led. Attach += Led_Attach; led. Detach += Led_Detach; led. Open (); }

חלל פרטי Led_Attach (שולח אובייקט, Phidget22. Events. AttachEventArgs e) {

ledAttachedChk. Checked = true; led. State = true; ledChk. Checked = true; }

void פרטי MagSensor_Attach (שולח אובייקט, Phidget22. Events. AttachEventArgs e) {

magSensorAttachedChk. Checked = true; magSensor. SensorType = VoltageRatioSensorType. PN_1108; magSensor. DataInterval = 16; }

חלל פרטי Bottom_Attach (שולח אובייקט, Phidget22. Events. AttachEventArgs e) {

bottomAttachedChk. Checked = true; bottom. CurrentLimit = bottomCurrentLimit; bottom. Engaged = true; bottom. VelocityLimit = bottomVelocityLimit; bottom. Acceleration = bottomAccel; bottom. DataInterval = 100; }

חלל פרטי Top_Attach (שולח אובייקט, Phidget22. Events. AttachEventArgs e) {

topAttachedChk. Checked = true; top. CurrentLimit = topCurrentLimit; top. Engaged = true; top. RescaleFactor = -1; top. VelocityLimit = -topVelocityLimit; top. Acceleration = -topAccel; top. DataInterval = 100; }

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

מיקום מוטורי

קוד הטיפול במנועים מורכב מפונקציות נוחות להעברת המנועים. המנועים בהם השתמשתי הם 3, 200 1/16 צעדים לכל מהפכה, אז יצרתי קבוע בשביל זה.

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

חלל פרטי nextMagnet (המתנה בוליאנית = שקר) {

posn double = top. Position % stepsPerRev;

top. TargetPosition += (stepsPerRev - posn);

אם (חכה)

while (top. IsMoving) Thread. Sleep (50); }

חלל פרטי nextCamera (המתנה בוליאנית = שקר) {

posn double = top. Position % stepsPerRev; if (posn <Properties. Settings. Default.cameraOffset) top. TargetPosition += (Properties. Settings. Default.cameraOffset - posn); else top. TargetPosition + = ((Properties. Settings. Default.cameraOffset - posn) + stepsPerRev);

אם (חכה)

while (top. IsMoving) Thread. Sleep (50); }

חלל פרטי nextHole (המתנה בוליאנית = שקר) {

posn כפול = top. Position % stepsPerRev; if (posn <Properties. Settings. Default.holeOffset) top. TargetPosition += (Properties. Settings. Default.holeOffset - posn); else top. TargetPosition + = ((Properties. Settings. Default.holeOffset - posn) + stepsPerRev);

אם (חכה)

while (top. IsMoving) Thread. Sleep (50); }

לפני תחילת ריצה, הצלחת העליונה מיושרת באמצעות החיישן המגנטי. ניתן לקרוא את הפונקציה alignMotor בכל עת כדי ליישר את הצלחת העליונה. פונקציה זו מגבירה במהירות את הלוח למהפכה מלאה אחת עד שהיא רואה נתוני מגנטים מעל סף. לאחר מכן הוא מגבה מעט ומתקדם שוב לאט לאט, ולוכד נתוני חיישנים תוך כדי תנועה. לבסוף, הוא מגדיר את המיקום למיקום נתוני המגנטים המרבי ומאפס את קיזוז המיקום ל- 0. לפיכך, מיקום המגנט המרבי תמיד צריך להיות ב (top. Position % stepsPerRev)

Thread alignMotorThread; בוליאני מסור מגנט; magSensorMax כפול = 0; private void alignMotor () {

// מצאו את המגנט

top. DataInterval = top. MinDataInterval;

sawMagnet = false;

magSensor. SensorChange += magSensorStopMotor; top. VelocityLimit = -1000;

int tryCount = 0;

נסה שוב:

top. TargetPosition += stepsPerRev;

while (top. IsMoving &&! sawMagnet) Thread. Sleep (25);

אם (! sawMagnet) {

if (tryCount> 3) {Console. WriteLine ("יישור נכשל"); top. Engaged = false; bottom. Engaged = false; runtest = false; לַחֲזוֹר; }

tryCount ++;

Console. WriteLine ("האם אנחנו תקועים? מנסים גיבוי …"); top. TargetPosition -= 600; while (top. IsMoving) Thread. Sleep (100);

תלך שוב;

}

top. VelocityLimit = -100;

magData = רשימה חדשה> (); magSensor. SensorChange += magSensorCollectPositionData; top. TargetPosition += 300; while (top. IsMoving) Thread. Sleep (100);

magSensor. SensorChange -= magSensorCollectPositionData;

top. VelocityLimit = -topVelocityLimit;

KeyValuePair max = magData [0];

foreach (זוג KeyValuePair ב- magData) אם (pair. Value> max. Value) max = pair;

top. AddPositionOffset (-max. Key);

magSensorMax = max. Value;

top. TargetPosition = 0;

while (top. IsMoving) Thread. Sleep (100);

Console. WriteLine ("יישור הצליח");

}

רשימה> magData;

private void magSensorCollectPositionData (שולח אובייקט, Phidget22. Events. VoltageRatioInputSensorChangeEventArgs e) {magData. Add (new KeyValuePair (top. Position, e. SensorValue)); }

private void magSensorStopMotor (שולח אובייקט, Phidget22. Events. VoltageRatioInputSensorChangeEventArgs e) {

if (top. IsMoving && e. SensorValue> 5) {top. TargetPosition = top. Position - 300; magSensor. SensorChange -= magSensorStopMotor; sawMagnet = true; }}

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

private int BottomPosition {get {int posn = (int) bottom. Position % stepsPerRev; if (posn <0) posn += stepsPerRev;

return (int) Math. Round (((posn * beadCompartment) / (double) stepsPerRev));

} }

private void SetBottomPosition (int posn, bool wait = false) {

posn = posn % beadCompartment; targetPosn כפול = (posn * stepsPerRev) / beadCompartment;

double currentPosn = bottom. Position % stepsPerRev;

posnDiff כפול = targetPosn - currentPosn;

// שמור על זה כשלבים מלאים

posnDiff = ((int) (posnDiff / 16)) * 16;

if (posnDiff <= 1600) bottom. TargetPosition += posnDiff; else bottom. TargetPosition - = (stepsPerRev - posnDiff);

אם (חכה)

while (bottom. IsMoving) Thread. Sleep (50); }

מַצלֵמָה

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

bool runVideo = true; bool videoRunning = false; לכידת VideoCapture; Thread cvThread; צבע זוהה צבע; זיהוי בוליאני = שקר; int detectCnt = 0;

private void cvThreadFunction () {

videoRunning = false;

ללכוד = VideoCapture חדש (נבחר מצלמה);

באמצעות (חלון חלון = חלון חדש ("לכידה")) {

תמונת מחצלת = מחצלת חדשה (); מחצלת image2 = מחצלת חדשה (); while (runVideo) {capture. Read (image); אם (image. Empty ()) נשבר;

אם (מזהה)

detectCnt ++; else detectCnt = 0;

if (זיהוי || circleDetectChecked || showDetectionImgChecked) {

Cv2. CvtColor (image, image2, ColorConversionCodes. BGR2GRAY); שטיחי מחצלות = image2. Threshold ((כפול) Properties. Settings. Default.videoThresh, 255, ThresholdTypes. Binary); thrests = thress. GaussianBlur (OpenCvSharp. Size חדש (9, 9), 10);

אם (showDetectionImgChecked)

image = thrests;

if (מזהה || circleDetectChecked) {

חוג CircleSegment = thresh. HoughCircles (HoughMethods. Gradient, 2, /*thres. Rows/4*/ 20, 200, 100, 20, 65); if (bead. Length> = 1) {image. Circle (חרוז [0]. מרכז, 3, סולם חדש (0, 100, 0), -1); image. Circle (חרוז [0]. מרכז, (int) חרוז [0]. רדיוס, סקלר חדש (0, 0, 255), 3); if (חרוז [0]. רדיוס> = 55) {Properties. Settings. Default.x = (עשרוני) חרוז [0]. Center. X + (עשרוני) (חרוז [0]. Radius / 2); Properties. Settings. Default.y = (עשרוני) חרוז [0]. Center. Y - (עשרוני) (חרוז [0]. Radius / 2); } אחרת {Properties. Settings. Default.x = (עשרוני) חרוז [0]. Center. X + (עשרוני) (חרוז [0]. Radius); Properties. Settings. Default.y = (עשרוני) חרוז [0]. Center. Y - (עשרוני) (חרוז [0]. רדיוס); } Properties. Settings. Default.size = 15; Properties. Settings. Default.height = 15; } אחר {

CircleSegment עיגולים = thresh. HoughCircles (HoughMethods. Gradient, 2, /*thres. Rows/4*/ 5, 200, 100, 60, 180);

אם (עיגולים.אורך> 1) {רשימה xs = עיגולים. בחר (c => c. Center. X). ToList (); xs. Sort (); רשימת ys = עיגולים. בחר (c => c. Center. Y). ToList (); ys. Sort ();

int medianX = (int) xs [xs. Count / 2];

int medianY = (int) ys [ys. Count / 2];

if (medianX> image. Width - 15)

medianX = image. Width - 15; אם (medianY> image. Height - 15) medianY = image. Height - 15;

image. Circle (medianX, medianY, 100, סולם חדש (0, 0, 150), 3);

אם (מזהה) {

Properties. Settings. Default.x = medianX - 7; Properties. Settings. Default.y = חציון Y - 7; Properties. Settings. Default.size = 15; Properties. Settings. Default.height = 15; }}}}}

Rect r = new Rect ((int) Properties. Settings. Default.x, (int) Properties. Settings. Default.y, (int) Properties. Settings. Default.size, (int) Properties. Settings. Default.height);

Mat beadSample = מחצלת חדשה (תמונה, r);

Scalar avgColor = Cv2. Mean (beadSample); detectedColor = Color. FromArgb ((int) avgColor [2], (int) avgColor [1], (int) avgColor [0]);

image. Rectangle (r, סולם חדש (0, 150, 0));

חלון. ShowImage (תמונה);

Cv2. WaitKey (1); videoRunning = true; }

videoRunning = false;

} }

private void cameraStartBtn_Click (שולח אובייקט, EventArgs e) {

if (cameraStartBtn. Text == "התחל") {

cvThread = thread חדש (ThreadStart חדש (cvThreadFunction)); runVideo = true; cvThread. Start (); cameraStartBtn. Text = "עצור"; while (! videoRunning) Thread. Sleep (100);

updateColorTimer. Start ();

} אחר {

runVideo = false; cvThread. Join (); cameraStartBtn. Text = "התחל"; }}

צֶבַע

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

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

ישנם אלגוריתם מסובך לחישוב הפרש הצבעים. אנו משתמשים ב- CIE2000, שמפיק מספר ליד 1 אם לא ניתן להבחין בין 2 צבעים לאדם. אנו משתמשים בספריית ColorMine C# לביצוע חישובים מסובכים אלה. נמצא כי ערך DeltaE של 5 מציע פשרה טובה בין חיובי שווא לשלילי שווא.

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

רשימה

צבעים = רשימה חדשה (); רשימה colorPanels = רשימה חדשה (); רשימה colorTxts = רשימה חדשה (); רשימה colorCnts = רשימה חדשה ();

const int numColorSpots = 18;

const int unknownColorIndex = 18; int findColorPosition (צבע ג) {

Console. WriteLine ("מוצא צבע …");

var cRGB = Rgb חדש ();

cRGB. R = c. R; cRGB. G = c. G; cRGB. B = c. B;

int bestMatch = -1;

התאמה כפולה Delta = 100;

for (int i = 0; i <colors. Count; i ++) {

var RGB = Rgb חדש ();

RGB. R = צבעים . R; RGB. G = צבעים . G; RGB. B = צבעים . B;

דלתא כפולה = cRGB. Compare (RGB, חדש CieDe2000Comparison ());

// דלתא כפולה = deltaE (c, צבעים ); Console. WriteLine ("DeltaE (" + i. ToString () + "):" + delta. ToString ()); אם (delta <matchDelta) {matchDelta = delta; bestMatch = i; }}

if (matchDelta <5) {Console. WriteLine ("נמצא! (Posn:" + bestMatch + "Delta:" + matchDelta + ")"); החזר bestMatch; }

if (colors. Count <numColorSpots) {Console. WriteLine ("צבע חדש!"); colors. Add (c); this. BeginInvoke (פעולה חדשה (setBackColor), אובייקט חדש {colors. Count - 1}); writeOutColors (); החזרה (צבעים.ספירה - 1); } אחר {Console. WriteLine ("צבע לא ידוע!"); החזר unknownColorIndex; }}

מיון ההיגיון

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

Thread colourTestThread; Runtest בוליאני = false; void colourTest () {

אם (! top. Engaged)

top. Engaged = true;

אם (! bottom. Engaged)

bottom. Engaged = true;

while (runest) {

nextMagnet (נכון);

חוט. שינה (100); נסה {if (magSensor. SensorValue <(magSensorMax - 4)) alignMotor (); } לתפוס {alignMotor (); }

nextCamera (נכון);

גילוי = נכון;

while (detectCnt <5) Thread. Sleep (25); Console. WriteLine ("Detect Count:" + detectCnt); גילוי = שקר;

צבע c = detectedColor;

this. BeginInvoke (פעולה חדשה (setColorDet), אובייקט חדש {c}); int i = findColorPosition (ג);

SetBottomPosition (i, true);

חור הבא (נכון); colorCnts ++; this. BeginInvoke (פעולה חדשה (setColorTxt), אובייקט חדש {i}); חוט. שינה (250);

אם (colorCnts [unknownColorIndex]> 500) {

top. Engaged = false; bottom. Engaged = false; runtest = false; this. BeginInvoke (פעולה חדשה (setGoGreen), null); לַחֲזוֹר; }}}

color private voTestBtn_Click (שולח אובייקט, EventArgs e) {

if (colourTestThread == null ||! colourTestThread. IsAlive) {colourTestThread = new Thread (new ThreadStart (colourTest)); runtest = true; colourTestThread. Start (); colourTestBtn. Text = "STOP"; colourTestBtn. BackColor = Color. Red; } אחר {runtest = false; colourTestBtn. Text = "GO"; colourTestBtn. BackColor = Color. Green; }}

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

תחרות אופטיקה
תחרות אופטיקה

פרס שני בתחרות האופטיקה

מוּמלָץ: