VHS-Roboter: Roboterarm-Steuerung mit Teach-Modus, Sequenzen und lernender Regelung

EmbodiedVHS-Roboter · 2026
PythonnumpyCAN-Bus (python-canmotorbridge)PinocchioMeshCatFastAPIWebSocketVanilla JSffmpeg

Projekt-Übersicht

Bei der Digitalisierung von Videokassetten gibt es viele kleine Handgriffe: eine Kassette greifen, in einen Videorekorder einlegen, nach der Aufnahme wieder entnehmen und ablegen. Der VHS-Roboter soll genau diese Arbeitsschritte übernehmen, die bisher manuell erledigt werden. Grundlage ist ein gekaufter Seeed Studio reBot Arm B601-RS: sechs Gelenke plus Greifer, sieben RobStride-Motoren an einem CAN-Bus mit 1 Mbit/s.

Die Steuerungssoftware ist vollständig eigen. Sie umfasst:

  • einen Teach-Modus, in dem ich den Arm von Hand führe und Posen speichere oder ganze Bewegungen aufzeichne,
  • Sequenzen aus Posen, Bewegungen, Greiferschritten, Wartezeiten und Code-Lesen,
  • eine Web-Oberfläche mit 3D-Ansicht, Simulator, Koordinaten-Editor und Statusanzeigen,
  • eine Sicherheitslogik, deren Prinzipien ich aus der praktischen Arbeit am Arm abgeleitet habe,
  • und eine Iterative Learning Control (ILC), die wiederkehrende Bahnfehler von Lauf zu Lauf korrigiert.

Wichtig zur Einordnung: Die ILC ist klassische lernende Regelung, kein maschinelles Lernen. Es wird kein Modell trainiert, sondern ein gemessener Fehlerverlauf mit festen Regeln in die nächste Sollbahn eingerechnet. Ein Imitation-Learning-Ansatz mit LeRobot ist ein Ausblick für diesen Arm, aber nicht Teil dieses Projekts.

Der aktuelle Stand: Der Arm greift in einer Sequenz eine Kassette per Kontakterkennung und bringt sie zurück; eine weitere Sequenz schiebt sie in einen Schacht. Der nächste Ausbauschritt ist der vollständige Kassettenzyklus als eigener Zustandsautomat.

Ausgangslage

Die Kinematik eines 6-Achs-Arms ist gelöst, die Bibliotheken existieren. Die Zeit ging in andere Fragen:

  • Was passiert, wenn man die Motoren abschaltet? Der Arm fällt. Ohne Stromfluss hält kein Gelenk.
  • Wie bringt man einem Arm eine Bewegung bei, ohne Koordinaten einzutippen? Für das Einlegen einer Kassette ist Vormachen schneller als Programmieren.
  • Warum wackelt der Greifer, obwohl jede Gelenkbahn sauber geplant ist? Haftreibung, Momentwelligkeit der Motoren und schwach gedämpfte Regler addieren sich zu sichtbarem Ruckeln.
  • Wie bleibt das alles bedienbar? Eine Kommandozeile reicht zum Testen, aber nicht für den Alltag.

Dazu kommt die Hardware-Realität eines neuen Arms: Kommunikation, Fehlerbits und gemeldete Winkel der Motoren müssen laufend überwacht und auf Plausibilität geprüft werden, statt ihnen blind zu vertrauen.

Architektur

flowchart TB
  UI["Web-Oberfläche: 3D, Bibliothek, Sequenz-Editor, Koordinaten-Editor"] -->|"REST und WebSocket"| S["FastAPI-Server"]
  S --> RS["RobotService: ein Thread besitzt den CAN-Kanal"]
  S --> PV["Vorschau-Service mit Simulator"]
  RS --> TC["Teach-Modus: Schwerkraftkompensation"]
  RS --> MV["Fahren: Min-Jerk, 200 Hz"]
  RS --> WD["Watchdog: Motorfehler, Temperatur, Folgefehler"]
  MV --> ILC["ILC-Korrektur je Bewegung"]
  TC --> AL["armlib: Grenzen, Schwerkraftmodell, Posen"]
  MV --> AL
  AL -->|"CAN 1 Mbit/s"| ARM["reBot Arm B601-RS"]
  S --> VIS["Vision: Kamera, QR-Codes"]

Zentrale Designentscheidung ist, dass genau ein Thread den CAN-Kanal besitzt. Alle Kommandos aus der Oberfläche laufen über eine Queue in diesen RobotService. Das verhindert, dass zwei Programmteile gleichzeitig Sollwerte schicken, und macht das Verhalten im Fehlerfall vorhersagbar.

Die gemeinsame Basis armlib.py enthält Gelenkgrenzen, das Schwerkraftmodell inklusive empirischer Korrekturen, den Posenspeicher und die Min-Jerk-Trajektorie. Posen und Sequenzen liegen als YAML-Dateien vor, aufgezeichnete Bewegungen als JSON. Kinematik und Schwerkraftmodell stammen aus Seeeds Beispielcode auf Basis von Pinocchio; darauf setzen meine eigenen Schichten auf.

Technische Umsetzung

Teach-Modus: Vormachen statt Programmieren

Im Teach-Modus kompensiert die Steuerung die Schwerkraft so, dass der Arm scheinbar schwebt und sich von Hand führen lässt. Das klingt einfach, ist aber nur so gut wie das Massenmodell. Ich habe das Schulter- und Ellbogengelenk mit Messfahrten vermessen und eine Korrekturkarte abhängig von beiden Gelenkwinkeln hinterlegt, weil das Herstellermodell den Arm in der Ruhepose sonst um etwa 20 Grad anhob.

Im Teach-Modus gibt es zwei Arten, Wissen festzuhalten:

  • Posen speichern die sechs Armgelenke unter einem Namen. Der Greifer ist bewusst nicht Teil einer Pose, sondern wird über Presets („offen“, „geschlossen“) gesteuert.
  • Bewegungen zeichnen die Handführung mit 50 Hz auf. Stillstand am Anfang und Ende wird abgeschnitten, die Rohdaten bleiben unverändert gespeichert.

Weil Handführung zittert, wird beim Abspielen nicht-destruktiv geglättet: wahlweise mit einem nullphasigen Gauß-Filter in drei Stärken oder als „Ideallinie“ über Ramer-Douglas-Peucker und Catmull-Rom-Splines. Start und Ende bleiben exakt erhalten. In einem Test an einer verrauschten Bahn sank die Spitzenbeschleunigung so von 450 auf 1,7 rad/s² bei weniger als einem Grad mittlerer Abweichung.

Eine zweite Stufe ist das Idealisieren einer Aufnahme: Die Steuerung rechnet die aufgezeichnete Bahn über die Vorwärtskinematik in Werkzeugkoordinaten um, reduziert sie auf wenige Wegpunkte mit Toleranzen für Position und Orientierung und erzeugt daraus lineare Strecken. Eine knapp 27 Sekunden lange Handaufnahme „Kassette nehmen“ wurde so zu vier Strecken mit maximal 18 mm und 5 Grad Abweichung.

Sequenzen und Koordinaten-Editor

Sequenzen sind schlichte YAML-Listen. Ein Beispiel aus dem Projekt:

- gripper: open
- path: take casette_ideal
  speed: 1.0
- gripper: grip          # schließen bis Kontakt, ohne Kontakt stoppt die Sequenz
- path: take casette_ideal zurück
  speed: 1.0

Der Schritt grip schließt den Greifer, bis sich die Backen trotz Zielvorlauf kaum noch bewegen, und stellt dann eine definierte Haltekraft ein. Greift er ins Leere, bricht die Sequenz ab, statt mit leerem Greifer weiterzufahren. Nach dem Greifen rechnet die Steuerung das Gewicht der Kassette ins Schwerkraftmodell ein; ohne diesen Ausgleich sank der Greifer nach dem Zugreifen um bis zu 18 mm ab.

Für Bewegungen, die man nicht gut vormachen kann, etwa das gerade Einschieben in einen Schacht, gibt es einen Koordinaten-Editor. Dort definiere ich Wegpunkte in Millimetern, wähle Punkt-zu-Punkt- oder Linearbewegung und die Ausrichtung des Greifers. Die inverse Kinematik probiert mehrere Startwerte und wählt die gültige Lösung, die der aktuellen Stellung am nächsten liegt, damit der Arm nicht unerwartet umschlägt. Jede Bahn wird vor dem Speichern gegen Gelenkgrenzen und die Tischebene geprüft.

Seit Kurzem gibt es außerdem einen Sequenzschritt zum Lesen von QR-Codes über eine Kamera, mit einstellbarem Suchbereich und Fokus. Damit kann eine Sequenz künftig prüfen, welche Kassette sie gerade in der Hand hat. Die Einbindung dieses Schritts in den Kassettenzyklus ist ein nächster Schritt.

Web-Oberfläche und Simulator

Die Oberfläche läuft im Browser, ohne Build-Schritt, und zeigt den Arm live in 3D. Kopfzeile mit den Sicherheitsknöpfen (Teach, Halten, Ablegen, Motoren aus, FREEZE, NOT-AUS) ist immer sichtbar; Escape löst FREEZE aus. Eine Statusleiste zeigt je Motor Winkel, Moment, Strom und Temperatur.

Besonders wertvoll ist der Vorschau-Modus: Eine zweite Instanz des Service läuft mit einem simulierten Arm und demselben Code für Posen, Bewegungen und Sequenzen. Sie startet an der aktuellen Stellung des echten Arms, während dieser unberührt bleibt. So lässt sich jede neue Sequenz erst virtuell abspielen.

Iterative Learning Control gegen wiederkehrende Bahnfehler

Beim Abspielen aufgezeichneter Bewegungen wippte der Greifer sichtbar. Die Telemetrie (50 Hz, Soll und Ist je Gelenk, Momente) zeigte etwas Wichtiges: Der Fehlerverlauf über dem Gelenkwinkel korrelierte zwischen Läufen mit gleichen Reglerparametern zu 0,96 bis 0,99. Ein so reproduzierbarer Fehler ist die Voraussetzung für Iterative Learning Control.

Das Prinzip: Der gemessene Fehler eines Laufs wird gefiltert, um die Regelverzögerung verschoben, skaliert und von der Sollbahn des nächsten Laufs abgezogen. Der Kern in tools/ilc.py ist reines numpy:

def update(corr: np.ndarray, err: np.ndarray, rate: float) -> np.ndarray:
    """One ILC step. corr, err: (n, 6) rad, err = measured - planned."""
    n = len(err)
    idx = np.minimum(np.arange(n) + LEAD, n - 1)
    new = corr - L_GAIN * err[idx]
    new = gauss(new, SIGMA_S * rate)
    new = np.clip(new, -CMAX, CMAX)
    return new * taper(n, rate)[:, None]

Die Lernrate liegt bei 0,6, die Verschiebung bei drei Takten (15 ms bei 200 Hz), die Korrektur ist auf ±2 Grad begrenzt und wird am Anfang weich eingeblendet. Wird ein Lauf um mehr als 15 Prozent schlechter, stellt die Steuerung die vorige Korrektur wieder her. Jede Korrektur ist an einen Schlüssel aus Bahn, Tempo, Ausgaberate und Reglerparametern gebunden; ändert sich einer davon, wird sie nicht mehr angewendet.

In der Simulation mit einem künstlich eingeprägten Wippen sank die Abweichung in vier Läufen von 11,1 auf 2,5 mm. Am echten Arm sank der Gelenkfehler in fünf Läufen von 0,43 auf 0,045 Grad rms – der Nachlauf der Gelenke war weg. Die Analyse hat zugleich gezeigt, wo das Verfahren endet: Bahngenauigkeit am Werkzeug braucht mehr als Gelenkgenauigkeit. Das Wippen vorne am Greifer ist kein fester Störverlauf, sondern eine selbst erregte Schwingung des schwach gedämpften Lagereglers am ausgestreckten Arm, und eine solche Schwingung lässt sich nicht von Lauf zu Lauf herauslernen. ILC ist damit das Werkzeug für reproduzierbare Gelenkfehler; das Wippen bin ich an der Ursache angegangen.

Ursachen statt Symptome: Filter, Dämpfung, Momentwelligkeit

Mehr Dämpfung war der naheliegende Schritt, aber die Geschwindigkeitsrückmeldung der Motoren ist verrauscht; ein höherer Dämpfungswert verstärkte das Rauschen zu Momentspitzen. Bank-Tests einzelner Gelenke zeigten, dass ein Motorparameter für den Geschwindigkeitsfilter auch im verwendeten Regelpfad wirkt: Mit stärkerer Filterung drittelte sich das Rauschen, und die Dämpfung an Schulter und Ellbogen ließ sich verdoppeln. Das Auf-und-ab-Wippen sank damit von 3,8 auf 2,3 mm.

Das seitliche Pendeln hatte eine andere Ursache. Die Rucke an der Basis traten im Abstand von 2,86 Grad auf, in beiden Richtungen und bei jeder Geschwindigkeit an denselben Winkeln. Das entspricht genau einer elektrischen Umdrehung des Motors hinter dem Getriebe, also 126 Perioden je Abtriebsumdrehung. Das gemessene Moment folgte zu 98 bis 99 Prozent einer Sinusform mit dieser Periode. Die Kompensation ist eine Vorsteuerung am gemessenen Winkel:

def ripple_ff(self) -> np.ndarray:
    f = np.zeros(self.n)
    for i, (n_per, a, b) in RIPPLE_FF.items():
        name = f"joint{i + 1}"
        st = self.arm._motor_map[name].get_state() if name in self.arm._motor_map else None
        if st is None:
            continue
        ph = n_per * (float(st.pos) + armlib.ZERO_OFFSET.get(name, 0.0))
        f[i] = a * math.cos(ph) + b * math.sin(ph)
    return f

Mit dieser Vorsteuerung sank das seitliche Pendeln auf einer Testbahn von 2,3 auf 0,7 mm. Inzwischen ist sie für vier Gelenke aktiv.

Sicherheit

Ein Roboterarm mit 6,5 kg Eigengewicht und Gelenkmotoren mit bis zu 36 Nm verzeiht wenig. Die Sicherheitslogik folgt wenigen klaren Prinzipien, die ich aus der praktischen Arbeit am Arm abgeleitet habe.

Freeze statt Torque-off. Ein Arm, der gegen die Schwerkraft arbeitet, fällt, sobald seine Motoren stromlos sind, und eine Positionsanzeige allein beweist nicht, dass er sicher aufliegt. Deshalb gilt: Im Zweifel wird die aktuelle Pose aktiv gehalten, nicht abgeschaltet. Das Werkzeug freeze.py übernimmt den Arm aus jedem Zustand ohne Lücke im Drehmoment, hält ihn mit Schwerkraftkompensation und fährt nur dann selbstständig in die Ruhepose, wenn die Schulter im vorderen Arbeitsfenster steht. Jede Exception im Service führt zu starrem Halten, nie zum Abschalten. „Motoren aus“ ist nur in der Ruhepose erlaubt, sonst wird erst abgelegt. Der echte NOT-AUS bleibt verfügbar, verlangt aber eine Bestätigung, weil der Arm dabei fällt.

Weiche Gelenkgrenzen vor dem Teach-Modus. Bevor ein Arm von Hand geführt werden darf, braucht er virtuelle Wände. Ein Schwerkraftmodell gilt nur in dem Bereich, in dem es vermessen wurde; deshalb gibt es hinter der Senkrechten keine Schwerkraftkompensation ohne vermessenes Modell. Die physischen Anschläge habe ich von Hand vermessen; die Software-Grenzen liegen fünf Grad darunter. Die Wände lassen sich für uneingeschränktes Führen abschalten. Unabhängig davon prüft der Koordinaten-Editor jede Bahn gegen Gelenkgrenzen und Tischebene, und die Überwachung des Folgefehlers bleibt immer aktiv.

Überwachung im Betrieb. Ein Watchdog liest die Fehlerbits aller Motoren mit. Fällt ein Motor aus, wird er wieder eingeschaltet, aber eine laufende Bewegung endet sofort, und der Arm hält dort, wo er gerade ist. So kann eine Bahn nach einem Spannungseinbruch, der Motoren zurücksetzt, nicht einfach weiter Sollwerte schicken. Läuft ein Gelenk seinem Sollwert um mehr als acht Grad hinterher, bricht die Bewegung ab. Ab 70 °C Motortemperatur warnt die Oberfläche, ab 80 °C werden neue Fahrten verweigert; Ablegen bleibt erlaubt:

def _temp_guard(self):
    """Refuse new motions while a motor is too hot (except the way down: cmd_home sets _allow_hot)."""
    if getattr(self, "_allow_hot", False) or getattr(self, "preview", False):
        return
    temps = self.b.motor_temps()
    hot = {k: v for k, v in temps.items() if v >= TEMP_STOP_C}
    if hot:
        raise RuntimeError("Motor zu heiß: " + ", ".join(f"{k} {v:.0f} °C" for k, v in hot.items())
                           + f" (Grenze {TEMP_STOP_C:.0f} °C) - keine neue Fahrt. Arm ablegen und abkühlen lassen")

Unplausible Werte sperren. Ein unplausibler Sensorwert ist ein Sicherheitsbefund, kein Schönheitsfehler. Beim Greifer gibt es dafür einen konkreten Grund: Der Motor kennt nach dem Einschalten nur den Rotorwinkel, nicht die Zahl der Rotorumdrehungen, der Abtriebswinkel kann deshalb um Vielfache von 36 Grad falsch sein. Deshalb ist der Greifer nach jedem Start gesperrt, bis seine Referenz plausibel wiederhergestellt oder per langsamer Referenzfahrt neu gesetzt ist. Zusätzlich begrenzt die Software den Abstand zwischen Soll und Ist, sodass die Greifkraft nach oben beschränkt bleibt.

Prüfen ohne Hardware. Ein Programm, das Motoren einschalten kann, ist nie ein Test ohne Hardware. Prüf- und Planungsläufe laufen deshalb nie über das echte Fahrprogramm, sondern über einen Dry-Run, der den Ablauf nur in der 3D-Ansicht zeigt, oder über den Simulator.

Diese Regeln stehen nicht nur im Code, sondern auch in der Projektdokumentation.

Ergebnis

Entstanden ist eine Steuerung, mit der sich Abläufe für den Arm vormachen, verfeinern, virtuell prüfen und am echten Arm abspielen lassen. Die Sequenz „Kassette greifen“ läuft am Arm: Greifer öffnen, aufgezeichnete Bewegung abfahren, bis zum Kontakt greifen, mit Lastausgleich zurückfahren. Die Bahnqualität hat sich durch Filterung, Dämpfung und Kompensation der Momentwelligkeit messbar verbessert. Die Telemetrie jeder echten Wiedergabe wird gespeichert, sodass jede Änderung an Reglerparametern am Ergebnis überprüfbar ist.

Die nächsten Ausbauschritte sind der vollständige Kassettenzyklus als Zustandsautomat mit Abbruchpfad und die Einbindung der Code-Erkennung in diesen Zyklus.

Als Ausblick ist für diesen Arm auch der Weg über LeRobot dokumentiert: Teleoperation mit einem Leader-Arm, Datenaufnahme und darauf trainierte Policies. Das ist eine Option für Abläufe, die sich schlecht als feste Bahnen beschreiben lassen. Im aktuellen Projekt ist keine solche Policy trainiert; alle Bewegungen sind aufgezeichnet, konstruiert oder per ILC nachkorrigiert.

Was ich dabei gelernt habe

  • Abschalten ist keine sichere Aktion. Bei einem Arm, der gegen die Schwerkraft arbeitet, ist aktives Halten der sichere Zustand. Diese Umkehr der Intuition hat die ganze Architektur geprägt.
  • Erst messen, dann regeln. Die wirksamsten Verbesserungen kamen aus der Telemetrie: Korrelationen zwischen Läufen, Fehler über dem Gelenkwinkel statt über der Zeit, Zerlegung des Wippens nach Gelenken.
  • ILC löst reproduzierbare Fehler, keine Schwingungen. Der Gelenkfehler sank um eine Größenordnung. Bahngenauigkeit am Werkzeug braucht aber mehr als Gelenkgenauigkeit: Lernende Regelung ersetzt keine ausreichende Dämpfung.
  • Unplausible Sensorwerte sind ein Stoppsignal. Ein Wert, der nur notiert und nicht gesperrt wird, schützt nichts. Plausibilitätsprüfungen gehören vor jede Bewegung, nicht in die Nachanalyse.
  • Ein Simulator mit demselben Code spart echte Fahrten. Weil Vorschau und echter Betrieb denselben Service nutzen, ist eine Sequenz, die virtuell durchläuft, in der Logik bereits geprüft.

Technologien

  • Hardware: Seeed Studio reBot Arm B601-RS (6 Achsen plus Greifer, RobStride RS06 und RS00), USB-CAN-Adapter, UVC-Kamera
  • Kommunikation: CAN-Bus 1 Mbit/s, python-can, motorbridge, RobStride-Protokoll (MIT- und CSP-Modus)
  • Kinematik und Modell: Pinocchio, URDF des Herstellers, eigene Schwerkraftkorrekturen und Lastausgleich
  • Regelung: Min-Jerk-Trajektorien mit 200 Hz, PD-Regelung mit Schwerkraft- und Momentwellen-Vorsteuerung, Iterative Learning Control mit numpy
  • Oberfläche: FastAPI, uvicorn, WebSocket, Vanilla JavaScript, MeshCat für die 3D-Ansicht
  • Vision: ffmpeg für die Kameraaufnahme, zxing-cpp für QR-Codes
  • Daten: YAML für Posen, Sequenzen und Einstellungen, JSON für Bewegungen, Telemetrie und ILC-Korrekturen