Spisu treści:

Robotyczne sortowanie kulek: 3 kroki (ze zdjęciami)
Robotyczne sortowanie kulek: 3 kroki (ze zdjęciami)

Wideo: Robotyczne sortowanie kulek: 3 kroki (ze zdjęciami)

Wideo: Robotyczne sortowanie kulek: 3 kroki (ze zdjęciami)
Wideo: Robotyka - Sortowanie kulek 2 2024, Listopad
Anonim
Image
Image
Automatyczne sortowanie kulek
Automatyczne sortowanie kulek
Automatyczne sortowanie kulek
Automatyczne sortowanie kulek
Automatyczne sortowanie kulek
Automatyczne sortowanie kulek

W tym projekcie zbudujemy robota sortującego koraliki Perler według kolorów.

Zawsze chciałem zbudować robota sortującego kolory, więc kiedy moja córka zainteresowała się tworzeniem koralików Perler, uznałem to za doskonałą okazję.

Koraliki Perlera służą do tworzenia połączonych projektów artystycznych, umieszczając wiele koralików na tablicy perforowanej, a następnie stapiając je razem z żelazkiem. Zazwyczaj kupujesz te koraliki w gigantycznych 22 000 paczkach z mieszanymi kolorami i spędzasz dużo czasu na szukaniu koloru, który chcesz, więc pomyślałem, że sortowanie ich zwiększy wydajność sztuki.

Pracuję dla Phidgets Inc., więc do tego projektu używałem głównie Phidgets - ale można to zrobić za pomocą dowolnego odpowiedniego sprzętu.

Krok 1: Sprzęt

Oto czego użyłem do zbudowania tego. Zbudowałem go w 100% z części z phidgets.com i rzeczy, które leżały w domu.

Tablice Phidgets, silniki, sprzęt

  • HUB0000 - VINT Hub Phidget
  • 1108 - Czujnik magnetyczny
  • 2x STC1001 - 2.5A krokowy Phidget
  • 2x 3324 - 42STH38 NEMA-17 Bipolarny bezprzekładniowy stepper
  • 3x 3002 - Kabel Phidget 60cm
  • 3403 - 4-portowy koncentrator USB 2.0
  • 3031 - Pigtail żeński 5,5x2,1mm
  • 3029 - 2-żyłowy skręcony kabel 100'
  • 3604 - 10mm biała dioda LED (opakowanie 10 sztuk)
  • 3402 - Kamera internetowa USB

Inne części

  • Zasilanie 24VDC 2.0A
  • Złom drewna i metalu z garażu
  • Opaski na suwak
  • Plastikowy pojemnik z odciętym dnem

Krok 2: Zaprojektuj robota

Zaprojektuj robota
Zaprojektuj robota
Zaprojektuj robota
Zaprojektuj robota
Zaprojektuj robota
Zaprojektuj robota

Musimy zaprojektować coś, co będzie mogło pobrać pojedynczą kulkę z zasobnika wejściowego, umieścić ją pod kamerą internetową, a następnie przenieść do odpowiedniego pojemnika.

Odbiór koralików

Postanowiłem zrobić pierwszą część z 2 kawałkami okrągłej sklejki, każdy z otworem wywierconym w tym samym miejscu. Dolny element jest nieruchomy, a górny jest przymocowany do silnika krokowego, który może obracać go pod lejem wypełnionym koralikami. Kiedy otwór przesuwa się pod lejem, zbiera pojedynczy koralik. Następnie mogę go obracać pod kamerą internetową, a następnie obracać dalej, aż dopasuje się do otworu w dolnej części, w którym to momencie wypadnie.

Na tym zdjęciu testuję, czy system może działać. Wszystko jest naprawione, z wyjątkiem górnego okrągłego kawałka sklejki, który jest przymocowany do silnika krokowego niewidocznego pod spodem. Kamera internetowa nie została jeszcze zamontowana. W tym momencie po prostu używam panelu sterowania Phidget, aby włączyć silnik.

Przechowywanie koralików

Kolejna część to zaprojektowanie systemu pojemników na każdy kolor. Postanowiłem użyć drugiego silnika krokowego poniżej, aby podeprzeć i obrócić okrągły pojemnik z równomiernie rozmieszczonymi przegródkami. Można to wykorzystać do obrócenia odpowiedniej przegródki pod otworem, z którego wypadnie koralik.

Zbudowałem to za pomocą kartonu i taśmy klejącej. Najważniejsza jest tutaj konsystencja – każda komora powinna mieć ten sam rozmiar, a całość powinna być równomiernie dociążona, aby kręciła się bez przeskakiwania.

Usuwanie perełek odbywa się za pomocą szczelnie dopasowanej pokrywki, która odsłania pojedynczą komorę na raz, dzięki czemu perełki można wylać.

Kamera

Kamera internetowa jest zamontowana nad górną płytą między lejem a dolnym otworem w płycie. Pozwala to systemowi przyjrzeć się koralikowi przed jego upuszczeniem. Dioda LED służy do oświetlania koralików pod kamerą, a światło otoczenia jest blokowane, aby zapewnić spójne oświetlenie. Jest to bardzo ważne dla dokładnego wykrywania kolorów, ponieważ oświetlenie otoczenia może naprawdę zepsuć postrzegany kolor.

Wykrywanie lokalizacji

Ważne jest, aby system był w stanie wykryć obrót separatora perełek. Służy do ustawiania pozycji początkowej podczas uruchamiania, ale także do wykrywania, czy silnik krokowy nie jest zsynchronizowany. W moim systemie koralik czasami zacina się podczas podnoszenia, a system musiał być w stanie wykryć i poradzić sobie z tą sytuacją - przez cofnięcie się i ponowne wypróbowanie.

Jest wiele sposobów na poradzenie sobie z tym. Zdecydowałem się zastosować czujnik magnetyczny 1108, z magnesem osadzonym w krawędzi górnej płyty. Pozwala mi to na weryfikację pozycji na każdym obrocie. Lepszym rozwiązaniem byłby prawdopodobnie enkoder na silniku krokowym, ale miałem koło siebie 1108, więc go użyłem.

Zakończ robota

W tym momencie wszystko zostało opracowane i przetestowane. Czas wszystko ładnie zamontować i przejść do pisania oprogramowania.

Dwa silniki krokowe są napędzane przez sterowniki krokowe STC1001. HUB000 - koncentrator USB VINT służy do uruchamiania sterowników krokowych, a także odczytu czujnika magnetycznego i sterowania diodą LED. Kamera internetowa i HUB0000 są podłączone do małego koncentratora USB. Warkocz 3031 i trochę drutu są używane wraz z zasilaczem 24 V do zasilania silników.

Krok 3: Napisz kod

Image
Image

W tym projekcie są używane C# i Visual Studio 2015. Pobierz źródło na górze tej strony i postępuj zgodnie z instrukcjami – główne sekcje są opisane poniżej

Inicjalizacja

Najpierw musimy stworzyć, otworzyć i zainicjować obiekty Phidget. Odbywa się to w zdarzeniu ładowania formularza i obsłudze dołączania Phidget.

private void Form1_Load(object sender, EventArgs e) {

/* Zainicjuj i otwórz Phidgets */

górny. HubPort = 0; top. Attach += Top_Attach; top. Detach += Top_Detach; top. PositionChange += Top_PositionChange; top. Otwórz();

dolny. HubPort = 1;

bottom. Attach += Bottom_Attach; bottom. Detach += Bottom_Detach; bottom. PositionChange += Bottom_PositionChange; dół. Otwórz();

magSensor. HubPort = 2;

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

led. HubPort = 5;

led. IsHubPortDevice = prawda; led. Kanał = 0; led. Attach += Led_Attach; led. Odłącz += Led_Odłącz; led. Otwórz(); }

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

ledAttachedChk. Checked = prawda; led. Stan = prawda; ledChk. Sprawdzone = prawda; }

private void MagSensor_Attach(nadawca obiektu, Phidget22. Events. AttachEventArgs e) {

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

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

bottomAttachedChk. Checked = prawda; bottom. CurrentLimit = bottomCurrentLimit; bottom. Zaangażowany = prawda; bottom. VelocityLimit = bottomVelocityLimit; bottom. Przyspieszenie = bottomAccel; bottom. DataInterval = 100; }

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

topAttachedChk. Checked = prawda; górny. Ograniczenie prądu = górny limit prądu; top. Zaangażowany = prawda; top. RescaleFactor = -1; top. VelocityLimit = -topVelocityLimit; top. Przyspieszenie = -topAccel; top. Przedział danych = 100; }

Podczas inicjalizacji odczytujemy również wszelkie zapisane informacje o kolorze, aby można było kontynuować poprzednie uruchomienie.

Pozycjonowanie silnika

Kod obsługi silnika składa się z wygodnych funkcji poruszania silnikami. Silniki, których użyłem, mają 3 200 1/16 kroków na obrót, więc stworzyłem do tego stałą.

W przypadku górnego silnika istnieją 3 pozycje, które chcemy wysłać do silnika: kamera internetowa, otwór i magnes pozycjonujący. Istnieje funkcja podróżowania do każdej z tych pozycji:

private void nextMagnet(Boolean wait = false) {

double pozn = top. Position % stepsPerRev;

top. TargetPosition += (krokiPerRev - posn);

jeśli (czekaj)

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

private void nextCamera(Boolean wait = false) {

double pozn = 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);

jeśli (czekaj)

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

private void nextHole(Boolean wait = false) {

double pozn = 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);

jeśli (czekaj)

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

Przed rozpoczęciem cyklu górna płyta jest wyrównywana za pomocą czujnika magnetycznego. Funkcję alignMotor można wywołać w dowolnym momencie, aby wyrównać górną płytę. Ta funkcja najpierw szybko obraca płytkę o 1 pełny obrót, aż zobaczy dane magnesu powyżej progu. Następnie cofa się nieco i powoli przesuwa się do przodu, przechwytując dane z czujnika w miarę postępu. Na koniec ustawia pozycję na maksymalną lokalizację danych magnesu i resetuje przesunięcie pozycji do 0. W związku z tym maksymalna pozycja magnesu powinna zawsze wynosić (top. Position % stepsPerRev)

Wyrównanie gwintuMotorGwint;Piła logicznaMagnes; podwójny magSensorMax = 0; private void alignMotor() {

//Znajdź magnes

top. DataInterval = top. MinDataInterval;

SawMagnes = fałsz;

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

int liczba tryCount = 0;

Spróbuj ponownie:

top. TargetPosition += stepsPerRev;

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

jeśli (!piłaMagnes) {

if (tryCount > 3) { Console. WriteLine("Wyrównanie nie powiodło się"); top. Zaangażowany = fałsz; bottom. Zaangażowany = fałsz; runtest = fałsz; powrót; }

tryCount++;

Console. WriteLine("Utknęliśmy? Próbuję wykonać kopię zapasową…"); góra. Pozycja docelowa -= 600; while (top. IsMoving) Thread. Sleep(100);

spróbuj ponownie;

}

top. VelocityLimit = -100;

magData = nowa lista>(); magSensor. SensorChange += magSensorCollectPositionData; górna. Pozycja docelowa += 300; while (top. IsMoving) Thread. Sleep(100);

magSensor. SensorChange -= magSensorCollectPositionData;

top. VelocityLimit = -topVelocityLimit;

Maks. paraWartościKlucz = magData[0];

foreach (para KeyValuePair w magData) if (pair. Value > max. Value) max = para;

top. AddPositionOffset(-max. Key);

magSensorMax = max. Wartość;

góra. Pozycja docelowa = 0;

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

Console. WriteLine("Wyrównanie powiodło się");

}

Lista> magDane;

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

private void magSensorStopMotor(nadawca obiektu, Phidget22. Events. VoltageRatioInputSensorChangeEventArgs e) {

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

Wreszcie, dolny silnik jest sterowany poprzez wysłanie go do jednej z pozycji pojemnika na kulki. Na ten projekt mamy 19 stanowisk. Algorytm wybiera najkrótszą ścieżkę i obraca się zgodnie z ruchem wskazówek zegara lub przeciwnie do ruchu wskazówek zegara.

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

return (int)Math. Round(((poz * beadCompartments) / (double)stepsPerRev));

} }

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

pozn = pozn % kulekPrzedziały; double targetPosn = (posn * stepsPerRev) / beadCompartments;

double currentPosn = bottom. Position % stepsPerRev;

double poznDiff = targetPosn - aktualnyPosn;

// Zachowaj to jako pełne kroki

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

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

jeśli (czekaj)

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

Kamera

OpenCV służy do odczytywania obrazów z kamery internetowej. Wątek aparatu jest uruchamiany przed rozpoczęciem głównego wątku sortowania. Ten wątek stale odczytuje obrazy, oblicza średni kolor dla określonego regionu za pomocą średniej i aktualizuje globalną zmienną koloru. Wątek wykorzystuje również HoughCircles, aby spróbować wykryć koralik lub dziurę w górnej płytce, aby udoskonalić obszar, na który patrzy w celu wykrycia koloru. Wartości progowe i wartości HoughCircles zostały określone metodą prób i błędów i zależą w dużej mierze od kamery internetowej, oświetlenia i odstępów.

bool runVideo = true;bool videoRunning = false; przechwytywanie wideo; Wątek cvThread; Wykryty kolorKolor; Wykrywanie wartości logicznych = fałsz; int detekcjaCnt = 0;

private void cvThreadFunction() {

videoRunning = fałsz;

przechwytywanie = new VideoCapture(selectedCamera);

using (Okno okna = nowe okno("przechwytywanie")) {

Obraz maty = nowy Mat(); Mat image2 = nowy Mat(); while (runVideo) { capture. Read(image); jeśli (image. Empty()) przerwa;

jeśli (wykrywanie)

wykryjCnt++; w przeciwnym razie wykryjCnt = 0;

if (wykrywanie || circleDetectChecked || showDetectionImgChecked) {

Cv2. CvtColor(obraz, obraz2, kody konwersji kolorów. BGR2GRAY); Mat thres = image2. Threshold((podwójne)Properties. Settings. Default.videoThresh, 255, ThresholdTypes. Binary); thres = thres. GaussianBlur(nowy OpenCvSharp. Size(9, 9), 10);

if (showDetectionImgChecked)

obraz = trójka;

if (wykrywanie || circleDetectChecked) {

CircleSegment koralik = 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 (zgrubienie[0]. Promień >= 55) { Właściwości. Ustawienia. Default.x = (zgrubienie dziesiętne) Zgrubienie[0]. Center. X + (Zgrubienie dziesiętne)(Zgrubienie[0]. Promień / 2); Properties. Settings. Default.y = (dziesiętny) koralik[0]. Center. Y - (dziesiętny)(perełka[0]. Promień / 2); } else { Properties. Settings. Default.x = (dziesiętny) koralik[0]. Center. X + (dziesiętny)(bead[0]. Promień); Properties. Settings. Default.y = (dziesiętny) koralik[0]. Center. Y - (dziesiętny)(perełka[0]. Promień); } Właściwości. Ustawienia. Domyślny.rozmiar = 15; Właściwości. Ustawienia. Domyślna.wysokość = 15; } w przeciwnym razie {

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

if (circles. Length > 1) { Lista xs = circles. Select(c => c. Center. X). ToList(); xs. Sortuj(); Lista ys = okręgi. Wybierz(c => c. Środek. Y). ToList(); tak. Sortuj();

int medianaX = (int)xs[xs. Liczba / 2];

int mediana Y = (int)ys[ys. Liczba / 2];

if (medianaX > obraz. Szerokość - 15)

medianaX = obraz. Szerokość - 15; if (medianaY > obraz. Wysokość - 15) medianaY = obraz. Wysokość - 15;

image. Circle(medianaX, medianaY, 100, new Scalar(0, 0, 150), 3);

jeśli (wykrywanie) {

Właściwości. Ustawienia. Domyślne.x = medianaX - 7; Properties. Settings. Default.y = medianaY - 7; Właściwości. Ustawienia. Domyślny.rozmiar = 15; Właściwości. Ustawienia. Domyślna.wysokość = 15; } } } } }

Rect r = nowy 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);

Skalarny śr.kolor = Cv2. Średnia(próbka koralików); wykrytyKolor = Color. FromArgb((int)avgColor[2], (int)avgColor[1], (int)avgColor[0]);

image. Rectangle(r, nowy Skalar (0, 150, 0));

okno. ShowImage(obraz);

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

videoRunning = fałsz;

} }

private void cameraStartBtn_Click(object sender, EventArgs e) {

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

cvThread = new Thread(new ThreadStart(cvThreadFunction)); runVideo = prawda; cvWątek. Start(); cameraStartBtn. Text = "stop"; while (!videoRunning) Thread. Sleep(100);

aktualizacjaZegarKoloru. Start();

} w przeciwnym razie {

runVideo = fałsz; cvWątek. Dołącz(); cameraStartBtn. Text = "start"; } }

Kolor

Teraz jesteśmy w stanie określić kolor koralika i na podstawie tego koloru zdecydować, do którego pojemnika go wrzucić.

Ten krok polega na porównaniu kolorów. Chcemy być w stanie odróżnić kolory, aby ograniczyć fałszywie pozytywne, ale także zapewnić wystarczający próg, aby ograniczyć fałszywe negatywy. Porównywanie kolorów jest w rzeczywistości zaskakująco złożone, ponieważ sposób, w jaki komputery przechowują kolory jako RGB, oraz sposób, w jaki ludzie postrzegają kolory, nie są ze sobą liniowo skorelowane. Co gorsza, należy również wziąć pod uwagę kolor światła, pod którym kolor jest oglądany.

Istnieje skomplikowany algorytm obliczania różnicy kolorów. Używamy CIE2000, który daje liczbę bliską 1, jeśli 2 kolory byłyby nie do odróżnienia dla człowieka. Do wykonywania tych skomplikowanych obliczeń używamy biblioteki ColorMine C#. Stwierdzono, że wartość DeltaE wynosząca 5 zapewnia dobry kompromis między fałszywie dodatnim a fałszywie ujemnym.

Ponieważ często jest więcej kolorów niż pojemników, ostatnia pozycja jest zarezerwowana jako kosz zbiorczy. Zazwyczaj odkładam je na bok, aby uruchomić maszynę przy drugim przejściu.

Lista

kolory = nowa lista ();Lista colorPanels = nowa lista (); Lista colorsTxts = new List(); Lista colorCnts = new List();

const int numColorSpots = 18;

const int nieznanyIndeksKoloru = 18; int findColorPosition(Kolor c) {

Console. WriteLine("Znajduję kolor…");

var cRGB = nowy Rgb();

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

int najlepsze dopasowanie = -1;

podwójne dopasowanieDelta = 100;

for (int i = 0; i < kolory. Liczba; i++) {

zmienna RGB = nowy Rgb();

RGB. R = kolory. R; RGB. G = kolory. G; RGB. B = kolory. B;

podwójna delta = cRGB. Compare(RGB, nowy CieDe2000Comparison());

//podwójna delta = deltaE(c, kolory); Console. WriteLine("DeltaE (" + i. ToString() + "): " + delta. ToString()); if (delta < matchDelta) { matchDelta = delta; najlepsze dopasowanie = ja; } }

if (matchDelta < 5) { Console. WriteLine("Znaleziono! (Posn: " + bestMatch + " Delta: " + matchDelta + ")"); zwróć najlepsze dopasowanie; }

if (colors. Count < numColorSpots) { Console. WriteLine("Nowy kolor!"); kolory. Dodaj(c); this. BeginInvoke(new Action(setBackColor), new object { colors. Count - 1 }); writeOutColors(); powrót (kolory. Liczba - 1); } else { Console. WriteLine("Nieznany kolor!"); zwróć nieznanyColorIndex; } }

Logika sortowania

Funkcja sortowania łączy wszystkie elementy, aby faktycznie posortować koraliki. Ta funkcja działa w dedykowanym wątku; przesuwanie górnej płytki, wykrywanie koloru koralików, umieszczanie ich w koszu, upewnianie się, że górna płytka jest wyrównana, liczenie koralików itp. Przestaje również działać, gdy pojemnik na wyłapywanie się zapełni - w przeciwnym razie po prostu skończymy z przepełnionymi koralikami.

Kolor niciTestThread;Boolean runtest = false; nieważny test koloru() {

jeśli (!top. Zaangażowany)

top. Zaangażowany = prawda;

jeśli (!bottom. Zaangażowany)

bottom. Zaangażowany = prawda;

podczas (test rundy) {

następny magnes (prawda);

Wątek. Uśpienie(100); try { if (magSensor. SensorValue < (magSensorMax - 4)) alignMotor(); } złapać { alignMotor(); }

następnaKamera(prawda);

wykrywanie = prawda;

while (detectCnt < 5) Thread. Sleep(25); Console. WriteLine("Liczba wykryć: " + detectCnt); wykrywanie = fałsz;

Kolor c = wykrytyKolor;

this. BeginInvoke(nowa akcja (setColorDet), nowy obiekt { c }); int i = znajdźPozycjęKoloru(c);

SetBottomPosition(i, prawda);

następnyOtwór(prawda); kolorCnts++; this. BeginInvoke(new Action(setColorTxt), nowy obiekt { i }); Wątek. Uśpienie(250);

if (colorCnts[unknownColorIndex] > 500) {

top. Zaangażowany = fałsz; bottom. Zaangażowany = fałsz; runtest = fałsz; this. BeginInvoke(nowa Akcja(setGoGreen), null); powrót; } } }

private void colourTestBtn_Click(object sender, EventArgs e) {

if (colourTestThread == null || !colourTestThread. IsAlive) { colorTestThread = new Thread(new ThreadStart(colourTest)); runtest = prawda; kolorTestWątek. Start(); colorTestBtn. Text = "STOP"; colorTestBtn. BackColor = Kolor. Czerwony; } else { runtest = false; colorTestBtn. Text = "GO"; colourTestBtn. BackColor = Kolor. Zielony; } }

W tym momencie mamy działający program. Niektóre fragmenty kodu zostały pominięte w artykule, więc spójrz na źródło, aby go uruchomić.

Konkurs Optyki
Konkurs Optyki

II nagroda w konkursie optyki

Zalecana: