Benutzer-Werkzeuge

Webseiten-Werkzeuge


Seitenleiste

projektewise1718:catcherpublic:start

Themenbeschreibung und Überblick:

Der Roboter CATcher ist ein Roboter, der einer Katze nachempfunden ist. Der Name CATcher setzt sich aus den englischen Worten „cat“ (Katze) und „to catch“ (fangen) zusammen. Der Roboter soll wie eine spielfreudige Katze einem Objekt hinterherjagen und es „fangen“.

Der CATcher besteht im Wesentlichen aus einer Kamera, die für die Objekterkennung zuständig ist und zwei Stepper Motoren, die den Roboter zu dem eingestellten Ziel befördern. Bei dem Ziel handelt es sich um einen farbigen Becher, welcher in einer Menge anderer farbiger Becher gefunden werden soll. In der Endposition soll sich der gewünschte farbige Becher in der Mitte des Sichtbereiches vom CATcher befinden. Die Grundlage des Konstruktion bildet eine Holzplattform, auf welcher sich auch das Herzstück des Roboters befindet - der Raspberry Pi. Bei dem Raspberry Pi handelt es sich um einen kleinen, aber dennoch völlig funktionstüchtigen Computer. Auf unserem Raspberry Pi ist das Linux Betriebssystem Raspbian installiert. Die Bilder der Kamera (Sony PS3 Eye Kamera) werden mithilfe der freien Programmbibliothek OpenCV verarbeitet, welche wir auf dem Raspberry Pi installiert haben.

Das Gesamtsystem CATcher ist in etwa 25 cm breit und 35 cm lang.

Konstruktion:

Der Aufbau des Roboters lässt sich im Wesentlichen in 4 Baugruppen gliedern.

Die erste Baugruppe bildet die Objekterkennung. Zu ihr gehören die Sony PS3 Eye Kamera und der Raspberry Pi samt SD Karte. Die PS3 Eye Kamera macht die Aufnahmen, welche an den Raspberry Pi weitergeleitet werden. Dort werden sie mithilfe der Programmbibliothek OpenCV verarbeitet.

Die zweite Baugruppe bildet die Fortbewegung. Sie besteht aus 2 Stepper Motoren, welche über ein Arduino Breadboard mit dem Raspberry Pi verbunden sind. An die Stepper Motoren sind zwei Räder angeschlossen, welche nach Belieben einzeln gesteuert werden können. Je nachdem in welche Richtung sich die Räder drehen, kann man den Roboter als gesamte Einheit beliebig fortbewegen.

Die dritte Baugruppe ist die Stromversorgung der Stepper Motoren. Die Stromversorgung wird mithilfe eines Lithium-Polymer-Akkumulators sichergestellt.

Die vierte Baugruppe ist die Stromversorgung des Raspberry Pi, welcher als Controller fungiert. Die Versorgung geschieht mithilfe eines USB-Akkummulators.

Die restlichen Bauteile, welche nachfolgend auch im Aufbau gekennzeichnet sind, dienen oftmals der Stabilisierung des Gesamtsystems. Dazu gehört beispielsweise eine kleinere seperate Holzplatte, auf welcher sich der Raspberry Pi befindet. Zwischen der kleinen Holzplatte und dem Raspberry Pi befindet sich ein kleiner Abstand, welcher der Überhitzung des Raspberry Pi entgegenwirken soll.

Des Weiteren steht diese kleine Holzplatte ein wenig nach oben hervor, um sämtliche Akkumulatoren darunter befestigen zu können. Dadurch wird verhindert, dass die Akkumulatoren am Boden schleifen und die Bewegung des CATchers durch Reibung ausbremsen. Das ist nämlich sehr wahrscheinlich, da die Akkumulatoren (USB und Lithium-Polymer-Akkumulator) einen Großteil des Gewichts ausmachen und dementsprechend eine große Reibung aufweisen können. Es finden zudem auch die Kabel der Stepper Motoren zwischen der großen und kleinen Holzplatte Platz. Die Stepper Motoren befinden sich unter der großen Holzplatte und sind in der Vogelperspektive nicht sichtbar. Sie finden dennoch, der Übersichtlichkeit halber, in unsererem Aufbau Erwähnung.

Die Stepper Motoren werden durch Kabelbinder festgehalten. Jene Bauteile, welche während des Entwicklungsprozesses des Roboters oft entfernt werden mussten, sind am Roboter mit Klettverschluss befestigt. Das ermöglichte uns die Bauteile bei Bedarf abzunehmen und im späteren Arbeitsverlauf wieder an den Roboter zu befestigen. Da wir viele verschiedene Positionen für die Kamera testen mussten, haben wir sie mit Klettverschluss auf der großen Holzplatte montiert. Auch die Akkumulatoren haben wir nur mit Klettverschluss unter die kleine Holzplatte montiert, da wir sie zum Aufladen oft abnehmen mussten und ein statischer Aufbau dies schwieriger gemacht hätte.

Das Stützrad ist nur mit Klettverschluss angebracht worden, da es sich direkt unter der Kamera befindet und wir die Kamera nicht mit Bohrschrauben für das Stützrad beschädigen wollten.

Bildverarbeitung & Objekterkennung:

Das Anschließen der Kamera ist sehr simpel. Da es sich um eine USB-Kamera handelt, muss diese lediglich direkt an den USB Port des Raspberry Pis angeschlossen werden. Allerdings müssen auf dem Raspberry Pi die richtigen Treiber für die Kamera installiert sein, da ansonsten die Kamera nicht unterstützt wird. Es ist daher ratsam sich ein offizielles Kameramodul für den Raspberry Pi anzuschaffen, wenn man sichergehen möchte, dass das Modell unterstützt wird. Jedoch sind diese Kameramodule mit einem sehr hohen Preis verbunden, weshalb wir uns für eine inoffizielle USB-Variante entschieden haben.

Bei uns kommt als Kamera die Sony PS3 Eye Kamera zum Einsatz. Sie ist für ihren geringen Preis recht schnell und verlässlich. Es gab keine größeren Schwierigkeiten bei der Installation der passenden Treiber.

Bei noch günstigeren oder älteren Kameras kann es jedoch zu Problemen mit den Treibern kommen, wir wir mit der ersten Kamera feststellen mussten. Bei der ersten Kamera handelte es sich um eine ältere, gebrauchte USB-Kamera. Die Sony PS3 Eye Kamera ist somit die zweite Kamera, mit der wir gearbeitet haben.

Die Aufnahmen der Kamera haben wir auf Grundlage von Methoden aus der freien Programmbibliothek OpenCV verarbeitet. OpenCV stellt uns Methoden zur Verfügung, um aus unseren rohen Aufnahmen der Kamera schrittweise das Zielobjekt herauszustellen. So haben wir einen Anhaltspunkt, wohin und wie lange wir uns bewegen müssen.

In unserem Quellcode ist die Bildverarbeitung eine eigene Datei. Diese Datei ist wie auch unsere anderen Programmteile in der Programmiersprache Python geschrieben, da wir für Python viele Beispiele der Objekterkennung mit OpenCV gefunden haben.

Eine sehr detaillierte Anleitung, wie man OpenCV und die passenden Treiber auf dem Raspberry Pi installiert, findet sich hier: https://www.pyimagesearch.com/2016/04/18/install-guide-raspberry-pi-3-raspbian-jessie-opencv-3/

Die wichtigsten Programme, die in dieser Anleitung installiert werden, sind OpenCV, NumPy und die Python-Entwicklungsumgebung. NumPy ist ein Programm, welches aus unseren Aufnahmen ein mehrdimensionales Array erstellt. Es werden zu jedem Pixel die jeweiligen Informationen zu seinem Farbton, seiner Sättigung und seiner Helligkeit gespeichert. Diese drei Kriterien bilden die Basis unseres HSV-Farbraums, mit dem wir verfahren werden.

„H“ steht hierbei für hue, also den Farbton. „S“ steht für saturation, die Sättigung und „v“ (value) für den Helligkeitswert eines Pixels.

Anhand dieser Werte können wir unter der Anwendung von Weichzeichnern Konturen definieren. Mit dem Weichzeichner verschwimmen die Grenzen und der Kontrast zwischen einzelnen Farbbereichen wird schwächer. Es gibt dadurch weniger ineinander greifende Übergänge und man kann die Flächen besser separieren. Unser Programm verfügt über ein User Interface, mit welchem wir die einzelnen Grenzwerte unseres Farbraums (H, S, V) wählen können. Das User Interface wird folgendermaßen mit der createTrackbar-Methode aus der Bibliothek von OpenCV erstellt:

def createCalibUi():
 
  cv2.namedWindow(CalibWindowName)
 
  # Wir erstellen Schieberegler für die oberen und unteren Grenzwerte
  cv2.createTrackbar('lowHue',  CalibWindowName, targetColorLow[0],  255, nothing)
  cv2.createTrackbar('highHue', CalibWindowName, targetColorHigh[0], 255, nothing)
 
  cv2.createTrackbar('lowSat',  CalibWindowName, targetColorLow[1],  255, nothing)
  cv2.createTrackbar('highSat', CalibWindowName, targetColorHigh[1], 255, nothing)
 
  cv2.createTrackbar('lowVal',  CalibWindowName, targetColorLow[2],  255, nothing)
  cv2.createTrackbar('highVal', CalibWindowName, targetColorHigh[2], 255, nothing)
 
  cv2.waitKey(1)

Um die aktuellen Werte zu erhalten, fragen wir diese mithilfe einer Getter-Methode ab. Wir weisen daraufhin den aktuellen Wert der passenden Variable zu. Wir können die Werte auch mit der print-Methode ausgeben, um ein Feedback zu bekommen und um sicherzugehen, dass sich die Werte bei einer Verschiebung der Schieberegler ändern.

def updateCalibFromUi():
 
  # Wir fragen die aktuellen Werte ab und weisen sie unseren HSV-Variablen zu
  lowHue =  cv2.getTrackbarPos('lowHue',  CalibWindowName)
  lowSat =  cv2.getTrackbarPos('lowSat',  CalibWindowName)
  lowVal =  cv2.getTrackbarPos('lowVal',  CalibWindowName)
  highHue = cv2.getTrackbarPos('highHue', CalibWindowName)
  highSat = cv2.getTrackbarPos('highSat', CalibWindowName)
  highVal = cv2.getTrackbarPos('highVal', CalibWindowName)
 
  global targetColorLow
  global targetColorHigh
 
  targetColorLow  = np.array([lowHue, lowSat, lowVal])
  targetColorHigh = np.array([highHue,highSat,highVal])
 
  print(lowHue, lowSat, lowVal, highHue, highSat, highVal)
 

Die einzelnen Grenzwerte sind also mit Schiebereglern einstellbar, wie in der nächsten Abbildung sichtbar wird. Durch die vorher bereits erwähnte Weichzeichnung des Bildes blenden wir zu feine Details aus, die die Erkennung der Kontur erschweren. Wir behandeln das Bild als Array und prüfen für jedes Element, ob die dazu aufgenommenen Informationen innerhalb unserer Grenzwerte liegen. Liegen sie innerhalb unserer Grenzwerte, so werden sie mit ihren ursprünglichen Werten eingeblendet (blau). Liegen sie außerhalb der von uns gewählten Grenzwerte, werden sie ausgeblendet (schwarz). In der Abbildung erkennt man auch eine mögliche Fehlerquelle. Es wird zusätzlich zu dem Tischtennisball auch seine Reflektion am Regal als Kontur erkannt. Man kann das zwar mit strikteren Grenzwerten vermeiden, aber durch Lichtfluktuationen kann sich der einzustellende Bereich während der Kalibrierung ändern. Dadurch kann es mitunter schwierig sein, für eine spiegelnde Oberfläche oder schwankende Lichtverhältnisse, gute Grenzwerte zu bestimmen.

Möchte man ein neues Objekt anvisieren, so ist darauf zu achten, dass man nicht nur die HSV-Grenzwerte verschieben muss. Man muss außerdem auch darauf achten, dass das neue Objekt den selben Durchmesser wie die hierzu im Code ermittelte Konstante hat. Wir haben unser Programm mit dem tatsächlichen Durchmesser unseres Testobjekts kalibriert. So können wir anhand des Durchmessers recht zuverlässig die Distanz zu dem Objekt bestimmen.

Schließlich kann unser Programm nicht wissen, ob das Objekt wirklich groß ist und deshalb viel Bildfläche einnimmt oder ob sich das Objekt sehr nah an der Kamera befindet und dadurch groß wirkt. Es ist also sinnvoll mit einer festen Größe zu arbeiten, da unser Programm nicht den Kontext eines Bildes kennt. Der Durchmesser als interne Konstante lässt sich also auch genauso wie der Farbraum beliebig kalibrieren und an das Objekt anpassen.

Wir führen anschließend mehrere Messungen durch, bei denen die Distanz und die eingenommene Bildfläche variieren, aber uns bekannt sind. Wir untersuchen diese Messungen auf Regelmäßigkeiten.

Abstand zum Objekt (cm) Radius des Objekts (px)
200 ~ 13
100 ~ 25
50 ~ 50
25 ~ 100

In der rechten Spalte finden sich die verschiedenen Abstände wieder. In der linken Spalte stehen die dazugehörigen Radien. Den Messwerten können wir entnehmen, dass es sich um eine antiproportionale Zuordnung handelt. Die dazugehörigen Radien bestimmen wir mit einer Methode aus der OpenCV-Bibliothek. Wir legen hierzu auf die Kontur unseres Objektes einen Kreis an, der die minimale Fläche dieser Kontur umfasst. Von diesem Kreis wird dann der Radius genommen.

In unserer finalen Version des Codes gehen wir von einem Plastikbecher mit einem Durchmesser von ungefähr 8 cm aus. Bei der Kalibrierung haben wir festgestellt, dass unser Objekt mit einem 8 cm Durchmesser bei einer Distanz von 100 cm zur Kamera, in der Aufnahme 25 Pixel breit ist.

Diese Werte benutzen wir bei der Berechnung der Distanz und des Winkels. Dafür müssen wir zuerst das Verhältnis zwischen den Werten aus der Kalibrierung und den aktuell erkannten Werten bestimmen.

factor = double(25.0 /(2*rd))

Je größer das Objekt in der Aufnahme wahrgenommen wird, desto kleiner ist die Distanz zwischen ihm und der Kamera.

distance = int(factor * 100.0)

Die Distanz zur Kamera ist hierbei auch gleichzeitig die Breite der Linie, in welcher das Objekt liegt. Darauf basierend können wir den Umwandlungsfaktor aufstellen, der die wahrgenommene Pixelbreite in die tatsächliche Größe umwandelt.

perception = distance/320.0

Wir benutzen den Umwandlungsfaktor, um die reale Größe des Objektes zu bestimmen. Der Durchmesser dient uns hierbei als Orientierungswert, um sicherzugehen, dass unsere Berechnung richtig verlaufen ist.

width = int(rd * 2 * perception)

Die Anzahl der Pixel von der Bildmitte zum Zentrum des Objektes zeigt uns den Abstand zum direkten Pfad, den unser Roboter einschlagen würde. Diesen Abstand nennen wir „Offset“.

offset = int((xd-160) * perception)

Basierend auf der ermittelten Distanz zur Kamera und dem Offset können wir den Winkel „alpha“ zum Objekt bestimmen.

rad2grad = 57.296
alpha = int(math.atan(offset*1.0/distance)*rad2grad)

Fortbewegung:

Die Fortbewegung des Roboters wird mithilfe der Stepper Motoren ermöglicht. An den jeweiligen Enden der Stepper Motoren sind Räder angeschlossen, welche den Roboter zu unserem Objekt bewegen sollen. Je nachdem welches der beiden Räder sich in eine Richtung dreht, kann man eine Bewegung für das Gesamtsystem bestimmen. Die folgende Tabelle ist hilfreich, um sich vorzustellen wie sich der Roboter fortbewegt. Dabei stellen wir uns vor, wir würden das jeweilige Rad von der Seite anschauen, um die Drehung zu bestimmen.

Drehung des linken Rads Drehung des rechten Rads Gesamtbewegung
gegen den Uhrzeigersinn im Uhrzeigersinn Vorwärtsbewegung
im Uhrzeigersinn gegen den Uhrzeigersinn Rückwärtsbewegung
gegen den Uhrzeigersinn gegen den Uhrzeigersinn Rechtsdrehung
im Uhrzeigersinn im Uhrzeigersinn Linksdrehung

Die Stepper Motoren sind über das Arduino Breadboard an die GPIO-Pins des Raspberry Pis angeschlossen. Über die selben GPIO Pins werden auch die Stepper Motoren angesteuert. Das sieht im Code folgendermaßen aus:

DIRR = 8  # Direction GPIO Pin
DIRL = 4  # Direction GPIO Pin
STEPR = 7  # Step GPIO Pin
STEPL = 14  # Step GPIO Pin

Die Drehrichtung wird durch einen Parameter bestimmt. Dabei steht der Wert 0 für eine Umdrehung gegen den Uhrzeigersinn (Counterclockwise Rotation). Der Wert 1 hingegen steht für eine Umdrehung im Uhrzeigersinn (Clockwise Rotation).

CW = 1     # Clockwise Rotation
CCW = 0    # Counterclockwise Rotation

Durch Messungen haben wir außerdem festgestellt, dass die 200 Schritte einer ganzen Umdrehung 20 cm in der Realität entsprechen.

SPR = 200   # Steps per Revolution

Dieses Verhältnis können wir für unsere Fortbewegung nutzen. Wir schreiben eine Funktion, die Centimeter in dafür erforderliche Schritte für den Stepper Motor umrechnet.

def steps(value) :
  return int(value)
 
def cm_to_steps(value) :
  return int(value/20.0*SPR)

Auf Grundlage unseres Wissens zu den Drehrichtungen und Einheiten können wir entsprechende Funktionen definieren. Wir unterteilen diese Funktionen in jene, die den Roboter geradeaus fortbewegen (drive) und jene, die ihn in eine Richtung drehen (rotate). Das heißt es gibt eine Funktion für die Vorwärts- und Rückwärtsbewegung und eine für die Links- oder Rechtsdrehung.

Die Funktion für die Vor- und Rückwärtsbewegung besteht im Wesentlichen aus der notwendigen Konfiguration der Drehrichtungen und der Entfernung, die zurückgelegt werden soll. Die notwendigen Konfigurationen für eine Vorwärts- oder Rückwärtsbewegung können wir der obigen Tabelle entnehmen. Bei der Entfernung übernehmen wir die Distanz, die bei der Bildverarbeitung berechnet wurde. Dies geschieht jedoch in einer separaten Datei, welche „main.py“ heißt. Dort setzen wir die Erkenntnisse der Bildverarbeitung in die entsprechenden Bewegungen um.

def drive(dirName, value, unit) :
 
  if unit and unit != 'cm' :
    print("drive: unknown distance")
    return
 
  if dirName == '':
    if value > 0:
      dirName = 'forward'
 
    if value < 0:
      dirName = 'backward'
      value = -value
 
  if dirName == 'forward':
    print("drive: forward %s cm " % value)
    drive_hw(CCW, CW, cm_to_steps(value))
 
  if dirName == 'backward':
    print("drive: backward %s cm " % value)
    drive_hw(CW, CCW, cm_to_steps(value))

Für die Links- und Rechtsdrehung gelten die selben Prinzipien wie bei der Vor- und Rückwärtsbewegung. Wir können die notwendigen Konfigurationen der Drehrichtungen der obigen Tabelle entnehmen. Durch Experimentieren haben wir festgestellt, dass eine ganze Umdrehung der Stepper Motoren einer tatsächlichen Drehung des Roboters um 100 Grad entspricht. Wir können also wie auch bei der vorherigen Funktion „cm_to_steps“ eine Umrechnung durchführen. Wir rechnen also einen gegebenen Winkel in Schritte (steps) um.

def angle_to_steps(value) :
  return (int)(value/100.0*SPR)
def rotate(dirName, value, unit) :
 
  if unit and unit != 'grad' :
    print("rotate: unknown angle")
    return
 
  if dirName == '':
    if value > 0:
      dirName = 'right'
    if value < 0:
      dirName = 'left'
      value = -value
 
  if dirName == 'right':
    print("rotate: right %s grad " % value)
    drive_hw(CW, CW, angle_to_steps(value))
 
  if dirName == 'left':
    print("rotate: left %s grad " % value)
    drive_hw(CCW, CCW, angle_to_steps(value))

Wie auch bei der Vor- und Rückwärtsbewegung wird der berechnete Winkel „alpha“ aus der Bildverarbeitung erst in „main.py“ in eine entsprechende Bewegung umgesetzt.

„Main.py“ dient hierbei als Bindeglied zwischen der Bildverarbeitung „scan.py“ und der Fortbewegung „drive.py“. Er übernimmt die Distanz und den Winkel aus der Bildverarbeitung und setzt sie als Parameter in die entsprechende Fahrfunktion (drive / rotate) ein.

Ergebnis und Diskussion:

Nach fast einem ganzen Semester Arbeit an unserem Roboter können wir ein fertiges Produkt zeigen. Es wurden jedoch einige Ideen zur Umsetzung des Roboters im Verlauf des Semesters verworfen. Eine der verworfenen Ideen war auch eine unserer ersten Ideen.

Ursprünglich hatten wir ein Roboterpaar, bestehend aus Jäger und Gejagtem, geplant. Diese Idee wurde jedoch sehr schnell verworfen, da wir gemerkt haben, dass die Konstruktion eines einzigen Roboters viel Zeit in Anspruch nimmt.

Der schwierigste Teil an dieser Idee wäre aber wahrscheinlich erneut die Bildverarbeitung gewesen. Da wir in dem Szenario eines Roboterpaars davon ausgehen, dass sich die Roboter gegenseitig „sehen“ müssen, würde unsere Art der Bildverarbeitung nicht genügen. Sie wäre zu langsam und ungenau, um in Echtzeit verwertbare Ergebnisse zum Aufenthaltsort zu liefern.

Die Kontur die wir bei der Bildverarbeitung zur rechnerischen Approximation des Durchmessers und dadurch der Distanz genutzt haben, müsste bei einem komplexen System wie einem Roboter stark vereinfacht werden. Diese starke Vereinfachung der Konturen hätte durch eine Weichzeichnung der Aufnahmen bewirkt werden können. Allerdings wird dadurch auch der berechnete Durchmesser ungenauer.

Eine andere Option das Roboterpaar umzusetzen, wäre eine deutlich kompaktere Bauweise unseres Roboters und des dazugehörigen Gegenspielers gewesen. Uns fiel allerdings schon trotz reichlich Platz die Verkabelung nicht leicht.

Die größte Schwäche unserer derzeitigen Bildverarbeitung sind Lichtfluktuationen. Die Objekterkennung funktioniert bei konstanten Lichtverhältnissen recht zuverlässig. Sobald sich jedoch die Lichtverhältnisse ändern, kommt es dazu, dass das eigentliche Objekt entweder nicht erkannt oder ein falscher Bildbereich als Objekt erkannt wird. Falsche Bildbereiche werden markiert, da ihre Werte durch eine Veränderung der Lichtverhältnisse innerhalb unserer gewählten Grenzwerte liegen.

Es ist zwar möglich mit den Schiebereglern die Grenzwerte anzupassen, jedoch ist ein HSV-Farbraum nicht intuitiv für die Handhabung, da Menschen Bilder nicht als Arrays mit Werten sehen. Das Objekt hätte eventuell anhand einer anderen Eigenschaft erkannt werden können. Dies hätte unter Umständen andere Sensoren vorausgesetzt. Wir haben uns dennoch für die USB-Kamera als Sensor entschieden, da die Aufnahmen einer Kamera am ehesten der Sicht eines Menschen entsprechen.

Menschen sehen zwar keine Arrays mit HSV-Werten, sie sehen aber dennoch Farben und Formen. Eine Wärmebildkamera als alternativer Sensor wäre für uns nicht in Frage gekommen, da ihre Ansteuerung zu aufwendig ist und Menschen keine Wärmebilder sehen.

Ein Farbsensor hätte eine zu geringe Reichweite gehabt, um das Objekt aus einer größeren Distanz wahrzunehmen. Aus selbigem Grund haben wir uns auch nicht für eine auf Ultraschall basierende Objekterkennung entschieden.

Die Ultraschallsensoren, die wir seitlich an unserem Roboter angebracht haben, haben wir letztendlich in unserem Code nicht implementiert, da es vermehrt zu Ausfällen oder zur fehlerhaften Funktionsweise kam. Sie hätten den Code auch ohne einen ersichtlichen hohen Mehrwert verkompliziert. Sie tragen nichts zur Objekterkennung, unserem Hauptanliegen in diesem Projekt, bei.

Die Nutzung eines Raspberry Pi statt eines Arduinos war notwendig, da die Bildverarbeitung viel Leistung verbraucht. Diese Leistung kann ein Arduino nicht liefern.

Im Großen und Ganzen sind wir mit der Umsetzung unseres Projekts zufrieden.

Der Quellcode: catcher_quellcode_2018.zip

projektewise1718/catcherpublic/start.txt · Zuletzt geändert: 2018/05/17 10:23 von d.golovko