Mục lục:

Phân loại hạt robot: 3 bước (có hình ảnh)
Phân loại hạt robot: 3 bước (có hình ảnh)

Video: Phân loại hạt robot: 3 bước (có hình ảnh)

Video: Phân loại hạt robot: 3 bước (có hình ảnh)
Video: Robot biến hình siêu ngầu #shorts #short #youtube 2024, Tháng bảy
Anonim
Image
Image
Phân loại hạt bằng rô bốt
Phân loại hạt bằng rô bốt
Phân loại hạt bằng rô bốt
Phân loại hạt bằng rô bốt
Phân loại hạt bằng rô bốt
Phân loại hạt bằng rô bốt

Trong dự án này, chúng tôi sẽ chế tạo một robot để phân loại các hạt Perler theo màu sắc.

Tôi luôn muốn chế tạo một robot phân loại màu sắc, vì vậy khi con gái tôi quan tâm đến chế tác hạt Perler, tôi đã xem đây là một cơ hội hoàn hảo.

Hạt Perler được sử dụng để tạo ra các dự án nghệ thuật hợp nhất bằng cách đặt nhiều hạt lên một tấm ván, sau đó nấu chảy chúng với nhau bằng bàn là. Bạn thường mua những hạt này trong gói màu hỗn hợp 22.000 hạt khổng lồ và dành nhiều thời gian để tìm kiếm màu bạn muốn, vì vậy tôi nghĩ việc phân loại chúng sẽ làm tăng hiệu quả nghệ thuật.

Tôi làm việc cho Phidgets Inc. vì vậy, tôi hầu hết sử dụng Phidgets cho dự án này - nhưng điều này có thể được thực hiện bằng bất kỳ phần cứng phù hợp nào.

Bước 1: Phần cứng

Đây là những gì tôi đã sử dụng để xây dựng cái này. Tôi đã xây dựng nó 100% bằng các bộ phận từ phidgets.com và những thứ tôi có ở khắp nơi trong nhà.

Phidgets Bo mạch, Động cơ, Phần cứng

  • HUB0000 - VINT Hub Phidget
  • 1108 - Cảm biến từ tính
  • 2x STC1001 - Phidget bước 2.5A
  • 2x 3324 - 42STH38 NEMA-17 Bậc thang không bánh răng lưỡng cực
  • 3x 3002 - Cáp Phidget 60cm
  • 3403 - Trung tâm 4 cổng USB2.0
  • 3031 - Bím tóc cái 5,5x2,1mm
  • 3029 - 2 dây 100 'cáp xoắn
  • Đèn LED trắng 3604 - 10mm (Túi 10 chiếc)
  • 3402 - Webcam USB

Những khu vực khác

  • Nguồn điện 24VDC 2.0A
  • Phế liệu gỗ và kim loại từ nhà để xe
  • Quan hệ zip
  • Hộp nhựa bị cắt đáy

Bước 2: Thiết kế Robot

Thiết kế Robot
Thiết kế Robot
Thiết kế Robot
Thiết kế Robot
Thiết kế Robot
Thiết kế Robot

Chúng ta cần thiết kế thứ gì đó có thể lấy một hạt duy nhất từ phễu đầu vào, đặt nó dưới webcam, sau đó chuyển nó vào thùng thích hợp.

Nhặt hạt

Tôi quyết định làm phần 1 với 2 miếng ván ép tròn, mỗi miếng có một lỗ được khoan ở cùng một vị trí. Miếng dưới cùng được cố định và miếng trên cùng được gắn với một động cơ bước, có thể xoay nó bên dưới một cái phễu chứa đầy hạt. Khi lỗ di chuyển dưới phễu, nó sẽ nhặt một hạt duy nhất. Sau đó, tôi có thể xoay nó dưới webcam, và sau đó xoay tiếp cho đến khi nó khớp với lỗ ở phần dưới cùng, lúc đó nó sẽ lọt qua.

Trong hình này, tôi đang kiểm tra xem hệ thống có thể hoạt động hay không. Mọi thứ đều được cố định ngoại trừ miếng ván ép tròn trên cùng, được gắn vào một động cơ bước nằm ngoài tầm nhìn bên dưới. Webcam vẫn chưa được gắn. Tôi chỉ đang sử dụng Bảng điều khiển Phidget để chuyển sang động cơ tại thời điểm này.

Lưu trữ hạt

Phần tiếp theo là thiết kế hệ thống thùng để đựng từng màu. Tôi quyết định sử dụng một động cơ bước thứ hai bên dưới để hỗ trợ và quay một thùng hàng tròn với các ngăn cách đều nhau. Điều này có thể được sử dụng để xoay ngăn chính xác bên dưới lỗ mà hạt sẽ rơi ra.

Tôi đã xây dựng cái này bằng bìa cứng và băng keo. Điều quan trọng nhất ở đây là tính nhất quán - mỗi ngăn phải có cùng kích thước và toàn bộ vật phải có trọng lượng đồng đều để nó quay mà không bị bỏ qua.

Việc loại bỏ hạt được thực hiện bằng cách đậy chặt nắp để lộ ra một ngăn duy nhất tại một thời điểm, do đó, các hạt có thể được đổ ra ngoài.

Máy ảnh

Webcam được gắn trên tấm trên cùng giữa phễu và vị trí lỗ tấm dưới. Điều này cho phép hệ thống xem xét hạt trước khi thả nó. Một đèn LED được sử dụng để chiếu sáng các hạt dưới máy ảnh và ánh sáng xung quanh bị chặn, nhằm cung cấp một môi trường chiếu sáng nhất quán. Điều này rất quan trọng để phát hiện màu chính xác, vì ánh sáng xung quanh thực sự có thể làm mất màu cảm nhận.

Phát hiện vị trí

Điều quan trọng là hệ thống có thể phát hiện chuyển động quay của bộ tách hạt. Điều này được sử dụng để thiết lập vị trí ban đầu khi khởi động, nhưng cũng để phát hiện xem động cơ bước có bị mất đồng bộ hay không. Trong hệ thống của tôi, một hạt đôi khi sẽ bị kẹt khi được nhặt và hệ thống cần thiết để có thể phát hiện và xử lý tình huống này - bằng cách sao lưu một chút và thử agian.

Có rất nhiều cách để xử lý điều này. Tôi quyết định sử dụng một cảm biến từ tính 1108, với một nam châm được nhúng vào cạnh của tấm trên cùng. Điều này cho phép tôi xác minh vị trí trên mọi vòng quay. Một giải pháp tốt hơn có lẽ sẽ là một bộ mã hóa trên động cơ bước, nhưng tôi có một 1108 nằm xung quanh nên tôi đã sử dụng nó.

Kết thúc Robot

Tại thời điểm này, mọi thứ đã được giải quyết và kiểm tra. Đã đến lúc gắn kết mọi thứ độc đáo và chuyển sang viết phần mềm.

2 động cơ bước đang được điều khiển bởi bộ điều khiển bước STC1001. Một trung tâm HUB000 - USB VINT được sử dụng để chạy bộ điều khiển bước, cũng như đọc cảm biến từ và điều khiển đèn LED. Cả webcam và HUB0000 đều được gắn vào một trung tâm USB nhỏ. Một bím tóc 3031 và một số dây được sử dụng cùng với nguồn điện 24V để cung cấp năng lượng cho động cơ.

Bước 3: Viết mã

Image
Image

C # và Visual Studio 2015 được sử dụng cho dự án này. Tải xuống nguồn ở đầu trang này và làm theo - các phần chính được trình bày bên dưới

Khởi tạo

Đầu tiên, chúng ta phải tạo, mở và khởi tạo các đối tượng Phidget. Điều này được thực hiện trong sự kiện tải biểu mẫu và các trình xử lý đính kèm Phidget.

private void Form1_Load (object sender, EventArgs e) {

/ * Khởi tạo và mở Phidgets * /

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 ();

đã dẫn. HubPort = 5;

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

private void Led_Attach (object sender, Phidget22. Events. AttachEventArgs e) {

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

private void MagSensor_Attach (người gửi đối tượng, Phidget22. Events. AttachEventArgs e) {

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

private void Bottom_Attach (object sender, Phidget22. Events. AttachEventArgs e) {

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

private void Top_Attach (object sender, 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; }

Chúng tôi cũng đọc bất kỳ thông tin màu nào đã lưu trong quá trình khởi tạo, vì vậy có thể tiếp tục chạy trước đó.

Định vị động cơ

Mã xử lý động cơ bao gồm các chức năng thuận tiện cho việc di chuyển động cơ. Các động cơ tôi đã sử dụng là 3, 200 1/16 bước cho mỗi vòng quay, vì vậy tôi đã tạo một hằng số cho điều này.

Đối với động cơ trên cùng, có 3 vị trí chúng tôi muốn có thể gửi tới động cơ: webcam, lỗ và nam châm định vị. Có một chức năng để di chuyển đến từng vị trí sau:

private void nextMagnet (Boolean wait = false) {

double posn = top. Position% stepsPerRev;

top. TargetPosition + = (stepsPerRev - posn);

nếu (chờ đã)

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

private void nextCamera (Boolean wait = false) {

double posn = top. Position% stepsPerRev; if (posn <Properties. Settings. Default.cameraOffset) top. TargetPosition + = (Properties. Settings. Default.cameraOffset - posn); else top. TargetPosition + = ((Thuộc tính. Settings. Default.cameraOffset - posn) + stepsPerRev);

nếu (chờ đã)

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

private void nextHole (Boolean wait = false) {

double posn = top. Position% stepsPerRev; if (posn <Properties. Settings. Default.holeOffset) top. TargetPosition + = (Properties. Settings. Default.holeOffset - posn); else top. TargetPosition + = ((Thuộc tính. Settings. Default.holeOffset - posn) + stepsPerRev);

nếu (chờ đã)

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

Trước khi bắt đầu chạy, tấm trên cùng được căn chỉnh bằng cách sử dụng cảm biến từ tính. Chức năng alignMotor có thể được gọi bất kỳ lúc nào để căn chỉnh tấm trên cùng. Đầu tiên, chức năng này nhanh chóng quay tấm lên đến 1 vòng quay đầy đủ cho đến khi nó thấy dữ liệu nam châm trên ngưỡng. Sau đó, nó sao lưu một chút và di chuyển về phía trước một lần nữa từ từ, ghi lại dữ liệu cảm biến khi nó di chuyển. Cuối cùng, nó đặt vị trí thành vị trí dữ liệu nam châm lớn nhất và đặt lại độ lệch vị trí thành 0. Do đó, vị trí nam châm tối đa phải luôn ở (trên cùng. Vị trí% bướcPerRev)

Thread alignMotorThread; Boolean sawMagnet; double magSensorMax = 0; private void alignMotor () {

// Tìm nam châm

top. DataInterval = top. MinDataInterval;

sawMagnet = false;

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

int tryCount = 0;

thử lại:

top. TargetPosition + = stepsPerRev;

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

if (! sawMagnet) {

if (tryCount> 3) {Console. WriteLine ("Căn chỉnh không thành công"); top. Engaged = false; bottom. Engaged = false; runtest = false; trở lại; }

tryCount ++;

Console. WriteLine ("Chúng tôi có bị mắc kẹt không? Đang thử một bản sao lưu…"); top. TargetPosition - = 600; while (top. IsMoving) Thread. Sleep (100);

goto tryagain;

}

top. VelocityLimit = -100;

magData = new Danh sách> (); magSensor. SensorChange + = magSensorCollectPositionData; top. TargetPosition + = 300; while (top. IsMoving) Thread. Sleep (100);

magSensor. SensorChange - = magSensorCollectPositionData;

top. VelocityLimit = -topVelocityLimit;

KeyValuePair max = magData [0];

foreach (cặp KeyValuePair trong magData) if (pair. Value> max. Value) max = pair;

top. AddPositionOffset (-max. Key);

magSensorMax = max. Value;

top. TargetPosition = 0;

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

Console. WriteLine ("Căn chỉnh thành công");

}

Danh sách> magData;

private void magSensorCollectPositionData (object sender, Phidget22. Events. VoltageRatioInputSensorChangeEventArgs e) {magData. Add (new KeyValuePair (top. Position, e. SensorValue)); }

private void magSensorStopMotor (object sender, Phidget22. Events. VoltageRatioInputSensorChangeEventArgs e) {

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

Cuối cùng, động cơ dưới cùng được điều khiển bằng cách gửi nó đến một trong các vị trí chứa hạt. Đối với dự án này, chúng tôi có 19 vị trí. Thuật toán đang chọn một đường đi ngắn nhất và quay theo chiều kim đồng hồ hoặc ngược chiều kim đồng hồ.

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

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

} }

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

posn = posn% beadComp domains; double targetPosn = (posn * stepsPerRev) / beadComp domains;

double currentPosn = bottom. Position% stepPerRev;

double posnDiff = targetPosn - currentPosn;

// Giữ nó dưới dạng đầy đủ các bước

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

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

nếu (chờ đợi)

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

Máy ảnh

OpenCV được sử dụng để đọc hình ảnh từ webcam. Chuỗi camera được bắt đầu trước khi bắt đầu chuỗi phân loại chính. Chuỗi này liên tục đọc trong hình ảnh, tính toán màu trung bình cho một khu vực cụ thể bằng cách sử dụng Trung bình và cập nhật một biến màu toàn cầu. Sợi này cũng sử dụng HoughCircles để cố gắng phát hiện một hạt hoặc lỗ trên tấm trên cùng, để tinh chỉnh khu vực mà nó đang nhìn để phát hiện màu sắc. Ngưỡng và số HoughCircles được xác định thông qua thử và sai, và phụ thuộc nhiều vào webcam, ánh sáng và khoảng cách.

bool runVideo = true; bool videoRunning = false; Quay videoCapture; Chủ đề cvThread; Màu sắc được phát hiện; Màu sắc; Boolean phát hiện = false; int detectorCnt = 0;

private void cvThreadFunction () {

videoRunning = false;

capture = new VideoCapture (selectCamera);

using (Window window = new Window ("capture")) {

Mat image = new Mat (); Mat image2 = new Mat (); while (runVideo) {capture. Read (hình ảnh); if (image. Empty ()) break;

nếu (phát hiện)

phát hiệnCnt ++; khác phát hiệnCnt = 0;

if (phát hiện || circleDetectChecked || showDetectionImgChecked) {

Cv2. CvtColor (image, image2, ColorConversionCodes. BGR2GRAY); Mat thres = image2. Threshold ((double) Properties. Settings. Default.videoThresh, 255, ThresholdTypes. Binary); thres = thres. GaussianBlur (OpenCvSharp. Size mới (9, 9), 10);

if (showDetectionImgChecked)

hình ảnh = thres;

if (phát hiện || circleDetectChecked) {

CircleSegment bead = thres. HoughCircles (HoughMethods. Gradient, 2, /*thres. Rows/4*/ 20, 200, 100, 20, 65); if (bead. Length> = 1) {image. Circle (bead [0]. Center, 3, new Scalar (0, 100, 0), -1); image. Circle (bead [0]. Center, (int) bead [0]. Radius, new Scalar (0, 0, 255), 3); if (bead [0]. Radius> = 55) {Properties. Settings. Default.x = (decimal) bead [0]. Center. X + (decimal) (bead [0]. Radius / 2); Properties. Settings. Default.y = (decimal) bead [0]. Center. Y - (decimal) (bead [0]. Radius / 2); } else {Properties. Settings. Default.x = (decimal) bead [0]. Center. X + (decimal) (bead [0]. Radius); Properties. Settings. Default.y = (decimal) bead [0]. Center. Y - (decimal) (bead [0]. Radius); } Thuộc tính. Settings. Default.size = 15; Properties. Settings. Default.height = 15; } khác {

CircleSegment circle = thres. HoughCircles (HoughMethods. Gradient, 2, /*thres. Rows/4*/ 5, 200, 100, 60, 180);

if (circle. Length> 1) {List xs = circle. Select (c => c. Center. X). ToList (); xs. Sort (); Liệt kê ys = circle. Select (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; if (medianY> image. Height - 15) medianY = image. Height - 15;

image. Circle (medianX, medianY, 100, new Scalar (0, 0, 150), 3);

if (phát hiện) {

Properties. Settings. Default.x = medianX - 7; Properties. Settings. Default.y = medianY - 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 = new Mat (image, r);

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

image. Rectangle (r, new Scalar (0, 150, 0));

window. ShowImage (hình ảnh);

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

videoRunning = false;

} }

private void cameraStartBtn_Click (object sender, EventArgs e) {

if (cameraStartBtn. Text == "start") {

cvThread = new Thread (new ThreadStart (cvThreadFunction)); runVideo = true; cvThread. Start (); cameraStartBtn. Text = "dừng"; while (! videoRunning) Thread. Sleep (100);

updateColorTimer. Start ();

} khác {

runVideo = false; cvThread. Join (); cameraStartBtn. Text = "start"; }}

Màu sắc

Bây giờ, chúng tôi có thể xác định màu sắc của một hạt và quyết định dựa trên màu đó để thả nó vào thùng chứa nào.

Bước này dựa trên sự so sánh màu sắc. Chúng tôi muốn có thể phân biệt các màu để hạn chế dương tính giả, nhưng cũng cho phép đủ ngưỡng để hạn chế âm tính giả. So sánh màu sắc thực sự phức tạp một cách đáng ngạc nhiên, bởi vì cách máy tính lưu trữ màu sắc dưới dạng RGB và cách con người cảm nhận màu sắc không tương quan tuyến tính. Để làm cho vấn đề tồi tệ hơn, màu sắc của ánh sáng mà một màu đang được xem xét cũng phải được xem xét.

Có các thuật toán phức tạp để tính toán sự khác biệt màu sắc. Chúng tôi sử dụng CIE2000, xuất ra một số gần 1 nếu con người không thể phân biệt được 2 màu. Chúng tôi đang sử dụng thư viện ColorMine C # để thực hiện các phép tính phức tạp này. Giá trị DeltaE là 5 đã được phát hiện là cung cấp một sự thỏa hiệp tốt giữa dương tính giả và âm tính giả.

Vì thường có nhiều màu hơn các thùng chứa, nên vị trí cuối cùng được dành làm thùng catchall. Tôi thường đặt những thứ này sang một bên để chạy qua máy lần thứ hai.

Danh sách

color = new List (); List colorPanels = new List (); Danh sách màuTxts = new List (); Liệt kê colorCnts = new List ();

const int numColorSpots = 18;

const int chưa biếtColorIndex = 18; int findColorPosition (Màu c) {

Console. WriteLine ("Tìm màu…");

var cRGB = new Rgb ();

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

int bestMatch = -1;

trận đấu đôiDelta = 100;

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

var RGB = new Rgb ();

RGB. R = màu . R; RGB. G = màu . G; RGB. B = màu . B;

double delta = cRGB. Compare (RGB, mới CieDe2000Comparison ());

// double delta = deltaE (c, Colors ); Console. WriteLine ("DeltaE (" + i. ToString () + "):" + delta. ToString ()); if (delta <matchDelta) {matchDelta = delta; bestMatch = i; }}

if (matchDelta <5) {Console. WriteLine ("Tìm thấy! (Posn:" + bestMatch + "Delta:" + matchDelta + ")"); trả về bestMatch; }

if (Colors. Count <numColorSpots) {Console. WriteLine ("Màu mới!"); màu sắc. Add (c); this. BeginInvoke (Hành động mới (setBackColor), đối tượng mới {Colors. Count - 1}); writeOutColors (); return (Colors. Count - 1); } else {Console. WriteLine ("Màu không xác định!"); trả về chưa rõColorIndex; }}

Sắp xếp logic

Chức năng phân loại tập hợp tất cả các mảnh lại để thực sự phân loại hạt. Chức năng này chạy trong một chuỗi chuyên dụng; di chuyển đĩa trên cùng, phát hiện màu hạt, đặt nó vào thùng, đảm bảo đĩa trên cùng luôn thẳng hàng, đếm số hạt, v.v. Nó cũng ngừng chạy khi thùng catchall đầy - Nếu không, chúng ta sẽ chỉ có các hạt tràn.

Chủ đề colourTestThread; Boolean runtest = false; void colourTest () {

if (! top. Engaged)

top. Engaged = true;

if (! bottom. Engaged)

bottom. Engaged = true;

trong khi (runtest) {

nextMagnet (true);

Thread. Sleep (100); thử {if (magSensor. SensorValue <(magSensorMax - 4)) alignMotor (); } bắt {alignMotor (); }

nextCamera (true);

phát hiện = true;

while (detector <5) Thread. Sleep (25); Console. WriteLine ("Số lượng phát hiện:" + detector); phát hiện = sai;

Màu sắc c = màu sắc được phát hiện;

this. BeginInvoke (Hành động mới (setColorDet), đối tượng mới {c}); int i = findColorPosition (c);

SetBottomPosition (i, true);

nextHole (true); colorCnts ++; this. BeginInvoke (Hành động mới (setColorTxt), đối tượng mới {i}); Thread. Sleep (250);

if (colorCnts [hiddenColorIndex]> 500) {

top. Engaged = false; bottom. Engaged = false; runtest = false; this. BeginInvoke (Hành động mới (setGoGreen), null); trở lại; }}}

private void colourTestBtn_Click (object sender, EventArgs e) {

if (colourTestThread == null ||! colourTestThread. IsAlive) {colourTestThread = new Thread (new ThreadStart (colourTest)); runtest = true; colourTestThread. Start (); colourTestBtn. Text = "DỪNG"; colourTestBtn. BackColor = Color. Red; } else {runtest = false; colourTestBtn. Text = "ĐI"; colourTestBtn. BackColor = Color. Green; }}

Tại thời điểm này, chúng tôi có một chương trình làm việc. Một số bit mã đã bị bỏ sót trong bài viết, vì vậy hãy xem nguồn để thực sự chạy nó.

Cuộc thi Quang học
Cuộc thi Quang học

Giải nhì cuộc thi Quang học

Đề xuất: