Ursprünglich veröffentlicht auf Medium im Dezember 2025. Dies ist eine Neuveröffentlichung mit geringfügigen redaktionellen Änderungen und einer kleinen Bereinigung eines absoluten Dateisystempfads, der im ursprünglichen Code-Block enthalten war. Die kanonische URL verweist auf das Original auf Medium.
Am Ende dieses Projekts verfügen Sie über eine Simulationsumgebung, in der ein Roboter mit Differentialantrieb durch ein mit Hindernissen übersätes Lager navigiert. Der Roboter ist mit einer vollständigen Sensorausstattung ausgestattet – Lidar, RGB-Kamera, Tiefenkamera und IMU – und Sie werden einen RL-Agenten mithilfe von Proximal Policy Optimization (PPO) trainiert haben, der sein Ziel konsistent erreicht.
Der vollständige Code befindet sich auf GitHub: github.com/padawanabhi/pybullet_sim.
Wenn du eine 2D-Einführung suchst, die dieselben Konzepte mit einfacherer Physik behandelt, schau dir den 2D-Folgeartikel an – IR-SIM, klassische Steuerung und ein kleineres PPO-Setup.
Bevor Sie mit dem Erstellen beginnen, noch ein kurzer Hinweis zur Installation. Wenn Sie macOS verwenden, könnten Sie auf ein Problem stoßen: pip install pybullet kann zu Kompilierungsfehlern führen, da pip versucht, aus dem Quellcode zu kompilieren, anstatt vorgefertigte Wheels zu verwenden.
Die Lösung ist conda – zuverlässiger für Pakete mit komplexen kompilierten Abhängigkeiten.
conda create -n pybullet_sim python=3.10 conda activate pybullet_sim conda install -c conda-forge pybullet pip install gymnasium stable-baselines3 numpy opencv-python
Wenn Sie Linux oder Windows verwenden, funktioniert pip möglicherweise einwandfrei. Wenn Fehlermeldungen wie „no matching distribution“ oder fehlgeschlagene Wheel-Builds auftreten, ist conda Ihr Freund.
Jeder Roboter in PyBullet beginnt als URDF-Datei – ein universelles Format zur Beschreibung von Robotergeometrie, Gelenken und physikalischen Eigenschaften. Unser Roboter mit Differentialantrieb ist relativ einfach: ein rechteckiger Körper, zwei angetriebene Räder und ein passives Lenkrad für das Gleichgewicht.
Die wichtigsten Designentscheidungen betreffen die Anpassung an reale Proportionen. Unser Roboter hat einen Radstand von 0,22 m (den Abstand zwischen dem linken und dem rechten Rad) und Räder mit einem Radius von 0,05 m. Der Körper wiegt 2 kg, wobei jedes Rad 0,1 kg beiträgt. Diese Zahlen sind wichtig, da die Physiksimulation auf realistischen Massen und Trägheitswerten basiert.
Ein wichtiges Detail: Radgelenke werden als „continuous“ (kontinuierlich) statt als „revolute“ (Drehgelenk) definiert. Ein Drehgelenk hat Winkelbegrenzungen; Räder müssen sich jedoch ununterbrochen frei drehen können. Kontinuierliche Gelenke ermöglichen eine unbegrenzte Drehung in beide Richtungen.
<?xml version="1.0"?> <robot name="diff_drive_robot"> <!-- Base Link (Robot Body) --> <link name="base_link"> <visual> <geometry><box size="0.3 0.2 0.1"/></geometry> <origin xyz="0 0 0.05" rpy="0 0 0"/> <material name="blue"><color rgba="0.2 0.2 0.8 1"/></material> </visual> <collision> <geometry><box size="0.3 0.2 0.1"/></geometry> <origin xyz="0 0 0.05" rpy="0 0 0"/> </collision> <inertial> <mass value="2.0"/> <origin xyz="0 0 0.05" rpy="0 0 0"/> <inertia ixx="0.01" ixy="0" ixz="0" iyy="0.01" iyz="0" izz="0.02"/> </inertial> <!-- Left Wheel --> <link name="left_wheel"> <visual> <geometry><cylinder radius="0.05" length="0.02"/></geometry> <origin xyz="0 0 0" rpy="1.5708 0 0"/> <material name="black"><color rgba="0.1 0.1 0.1 1"/></material> </visual> <collision> <geometry><cylinder radius="0.05" length="0.02"/></geometry> <origin xyz="0 0 0" rpy="1.5708 0 0"/> </collision> <inertial> <mass value="0.1"/> <origin xyz="0 0 0" rpy="1.5708 0 0"/> <inertia ixx="0.0001" ixy="0" ixz="0" iyy="0.0001" iyz="0" izz="0.0001"/> </inertial> <!-- Right Wheel (mirror of left) --> <link name="right_wheel"> <visual> <geometry><cylinder radius="0.05" length="0.02"/></geometry> <origin xyz="0 0 0" rpy="1.5708 0 0"/> <material name="black"><color rgba="0.1 0.1 0.1 1"/></material> </visual> <collision> <geometry><cylinder radius="0.05" length="0.02"/></geometry> <origin xyz="0 0 0" rpy="1.5708 0 0"/> </collision> <inertial> <mass value="0.1"/> <origin xyz="0 0 0" rpy="1.5708 0 0"/> <inertia ixx="0.0001" ixy="0" ixz="0" iyy="0.0001" iyz="0" izz="0.0001"/></inertial></robot> <!-- Caster Wheel (passive) --> <link name="caster_wheel"> <visual> <geometry><sphere radius="0.025"/></geometry> <material name="gray"><color rgba="0.5 0.5 0.5 1"/></material> </visual> <collision><geometry><sphere radius="0.025"/></geometry></collision> <inertial> <mass value="0.05"/> <inertia ixx="0.00001" ixy="0" ixz="0" iyy="0.00001" iyz="0" izz="0.00001"/> </inertial> <!-- Wheel joints (continuous = free rotation) --> <joint name="left_wheel_joint" type="continuous"> <parent link="base_link"/><child link="left_wheel"/> <origin xyz="0 0.11 0.05" rpy="0 0 0"/> <axis xyz="0 1 0"/> <dynamics damping="0.1" friction="0.1"/> </joint> <joint name="right_wheel_joint" type="continuous"> <parent link="base_link"/><child link="right_wheel"/> <origin xyz="0 -0.11 0.05" rpy="0 0 0"/> <axis xyz="0 1 0"/> <dynamics damping="0.1" friction="0.1"/> </joint> <!-- Caster joint (fixed for simplicity) --> <joint name="caster_joint" type="fixed"> <parent link="base_link"/><child link="caster_wheel"/> <origin xyz="-0.12 0 0.025" rpy="0 0 0"/> </joint> <!-- Lidar mount --> <link name="lidar_link"> <visual> <geometry><cylinder radius="0.03" length="0.02"/></geometry> <material name="red"><color rgba="0.8 0.2 0.2 1"/></material> </visual> <inertial> <mass value="0.01"/> <inertia ixx="0.00001" ixy="0" ixz="0" iyy="0.00001" iyz="0" izz="0.00001"/> </inertial> <joint name="lidar_joint" type="fixed"> <parent link="base_link"/><child link="lidar_link"/> <origin xyz="0.1 0 0.11" rpy="0 0 0"/> </joint>
Ein Roboter ohne Sensoren ist nichts weiter als ein Briefbeschwerer mit großen Ambitionen. Unsere Simulation umfasst vier Sensortypen, von denen jeder einen bestimmten Zweck erfüllt.
Lidar ist der primäre Navigationssensor. Mit PyBullets rayTestBatch() senden wir 36 Strahlen in einem 360°-Bogen um den Roboter herum aus, jeder mit einer Reichweite von 5 m. Der Batch-Vorgang ist entscheidend für die Leistung – einzelne Strahltests wären quälend langsam. Jeder Strahl gibt die Entfernung zum nächsten Hindernis zurück und liefert uns so einen 2D-Ausschnitt der Umgebung um den Roboter herum.
RGB- und Tiefenkameras liefern visuelle Informationen. Die RGB-Kamera erfasst, was ein Mensch sehen würde; die Tiefenkamera misst die Entfernung pro Pixel. Wir verwenden eine Auflösung von 320×240 mit einem Sichtfeld von 60°. Die Positionierung der Kamera erforderte einige Versuche – die Kamera sitzt oberhalb und hinter dem Roboterkörper und blickt in Fahrtrichtung des Roboters nach vorne.
IMU (Inertial Measurement Unit) simuliert einen Beschleunigungsmesser und ein Gyroskop. Echte IMU-Sensoren sind verrauscht, daher fügen wir Gaußsches Rauschen hinzu, um die Simulation realistischer zu gestalten. Dies ist beim Training von RL-Agenten wichtig – wenn Ihre Simulation zu sauber ist, könnte Ihre trainierte Strategie auf realer Hardware versagen.
Odometrie berechnet die Geschwindigkeit des Roboters anhand der Radumdrehungen. Die Kinematik des Differentialantriebs ist einfach: Die lineare Geschwindigkeit ist der Durchschnitt beider Radgeschwindigkeiten, und die Winkelgeschwindigkeit hängt von der Differenz zwischen ihnen ab.
PyBullet verfügt über eine integrierte Visualisierung für Kamera-Feeds, die ich zunächst übersehen hatte. Anstatt ein separates matplotlib-Fenster zu verwalten (das ständig den Fokus stahl), können Sie Sensor-Puffer-Vorschauen direkt in der PyBullet-GUI aktivieren:
p.configureDebugVisualizer(p.COV_ENABLE_RGB_BUFFER_PREVIEW, 1) p.configureDebugVisualizer(p.COV_ENABLE_DEPTH_BUFFER_PREVIEW, 1) p.configureDebugVisualizer(p.COV_ENABLE_SEGMENTATION_MARK_PREVIEW, 1)
Dadurch erhältst du RGB-, Tiefen- und Segmentierungsansichten in Registerkarten neben dem Hauptsimulationsfenster. Viel übersichtlicher, als sich mit Fenstermanagern herumzuschlagen.
Hier habe ich meinen ersten großen Fehler gemacht. Mein ursprünglicher Navigationscontroller war… ehrgeizig. Er verfügte über eine Erkennung von Festfahren, Positionsverlaufsverfolgung, Schwingungsprävention und eine Zustandsmaschine mit mehr Modi als ein Schweizer Taschenmesser. Das Ergebnis? Der Roboter blieb stehen, drehte sich im Kreis und kam dem Ziel keinen Schritt näher.
Das Problem waren konkurrierende Verhaltensweisen. Wenn mehrere Teilsysteme alle ihre eigene Vorstellung davon haben, was der Roboter tun soll, entsteht Verwirrung statt Koordination.
Die Lösung bestand in einer radikalen Vereinfachung. Ich habe alles durch einen einfachen PID-Regler ersetzt, der sich auf zwei Dinge konzentriert: den Roboter auf das Ziel auszurichten und vorwärts zu fahren. Die Hindernisvermeidung wurde zu einer einfachen Anpassung des Sollkurses – wenn der Lidar etwas direkt vor dem Roboter erkennt, wird die Zielrichtung mit einer Ausweichrichtung gemischt, je nachdem, auf welcher Seite mehr Freiraum vorhanden ist.
# Vereinfachte Logik zur Hindernisvermeidung if min_front_distance < safety_distance: avoidance_weight = 1.0 - (min_front_distance / safety_distance) desired_heading = blend(goal_heading, avoidance_heading, avoidance_weight)
Der vereinfachte Regler erreichte das Ziel in 892 Schritten durch ein Lager mit 31 Hindernissen. Manchmal ist weniger wirklich mehr.
Nachdem die grundlegende Navigation funktionierte, fügte ich eine ordentliche Wegplanung hinzu. Zwei Algorithmen sind implementiert: A* und RRT.
A* arbeitet auf einem diskretisierten Raster. Lidar-Scans werden in ein Belegungsraster mit einer Auflösung von 0,2 m umgewandelt, und A* findet den optimalen Weg mithilfe einer euklidischen Heuristik. Das geht schnell – etwa 0,1 ms pro Planungszyklus.
RRT (Rapidly-exploring Random Tree) arbeitet in einem kontinuierlichen Raum. Es baut einen Baum auf, indem es zufällig Positionen auswählt und diese mit dem nächstgelegenen vorhandenen Knoten verbindet. RRT ist langsamer (etwa 35 ms), erzeugt aber in komplexen Umgebungen flüssigere Pfade.
Interessanterweise führte RRT in meinen Tests zu einer insgesamt schnelleren Navigation, obwohl A* schneller plant. Die Pfade waren direkter und erforderten weniger Neuplanung. Genau diese Art von kontraintuitivem Ergebnis ist der Grund, warum wir Experimente durchführen, anstatt Annahmen zu treffen.
Frameworks für bestärkendes Lernen wie Stable-Baselines3 erwarten, dass Umgebungen der Gymnasium-Schnittstelle folgen. Das bedeutet, dass reset() und step() implementiert sowie Beobachtungs- und Aktionsräume definiert werden müssen.
Der Beobachtungsraum ist ein 43-dimensionaler Vektor, der normalisierte Lidar-Messwerte (36 Werte), den Kursfehler zum Ziel, lineare und Winkelgeschwindigkeit aus der Odometrie, IMU-Messwerte und – ganz entscheidend – die Entfernung zum Ziel enthält. Letzteres erscheint im Nachhinein offensichtlich, aber ich hatte es zunächst vergessen. Der Agent konnte nicht erkennen, wann er kurz vor dem Erfolg stand.
Der Aktionsraum ist kontinuierlich und umfasst zwei Werte: die lineare Geschwindigkeit (0 bis 0,5 m/s) und die Winkelgeschwindigkeit (-1,5 bis 1,5 rad/s). Die lineare Geschwindigkeit ist so begrenzt, dass nur Vorwärtsbewegung möglich ist. Eine Rückwärtsbewegung zuzulassen, würde die Komplexität ohne großen Nutzen verdoppeln, und der einfachere Aktionsraum ermöglicht ein schnelleres Lernen.
self.action_space = spaces.Box( low=np.array([0.0, -1.5]), high=np.array([0.5, 1.5]), dtype=np.float32 )
Nun zu dem Teil, der mir schlaflose Nächte bereitete. Die ersten Trainingsergebnisse waren miserabel. Der Roboter bewegte sich hin und her, drehte sich im Kreis oder blieb kurz vor dem Ziel stehen. Erfolgsquote: vielleicht 2 von 5 Episoden, wenn ich Glück hatte.
Der Übeltäter war eine Belohnungsfunktion, die zu viel wollte. Ich hatte Boni für Annäherung, Strafen für das Wegbewegen, Strafen für Nullgeschwindigkeit und harte Kollisionsstrafen. All diese Signale konkurrierten miteinander, und der Agent konnte nicht herausfinden, was tatsächlich wichtig war.
Die Lösung lag in der Vereinfachung. Ich reduzierte die Belohnungsfunktion auf das Wesentliche.
def step(self, action): v = np.clip(action[0], 0.0, 0.7) # kein Rückwärtsfahren w = np.clip(action[1], -2.0, 2.0) self.robot.set_velocity(v, w) for _ in range(int(self.time_step / (1/240))): p.stepSimulation() self.step_count += 1 obs = self._get_obs() info = self._get_info() current_distance = info["distance_to_goal"] fortschritt = self.vorherige_entfernung - aktuelle_entfernung self.vorherige_entfernung = aktuelle_entfernung self.jüngster_fortschritt.append(fortschritt) if len(self.jüngster_fortschritt) > 20: self.jüngster_fortschritt.pop(0) Belohnung = 0.0 # 1. PRIMÄR: Fortschritt in Richtung Ziel Belohnung += Fortschritt # 1b. Kursausrichtung, wenn weit vom Ziel entfernt dtheta_abs = abs(obs[2]) if current_distance > 1.0: Belohnung += 0.1 * (1.0 - dtheta_abs) # 2. Ziel erreicht if current_distance < self.goal_threshold: reward += 50.0 # 3. Kollisionsstrafe if info["collision"]: reward -= 10.0 # 4. Zeitstrafe (Effizienz) reward -= 0.01 # 5. Strafe für Festfahren (nur wenn wirklich festgefahren und weit vom Ziel entfernt) if len(self.recent_progress) >= 20: if abs(sum(self.recent_progress[-20:])) < self.stuck_threshold and current_distance > 1.0: reward -= 0.1 # Nähe zum Hindernis (Lidar < 0,3 m) min_lidar = float(np.min(obs[6:42])) if min_lidar < 0,3: Belohnung -= 1,0 * (0,3 - min_lidar) # übermäßige Drehung if abs(action[1]) > 2,0: Belohnung -= 0,2 * (abs(action[1]) - 2,0) beendet = bool(current_distance < self.goal_threshold) abgebrochen = bool(self.step_count >= self.max_steps) or bool(info["collision"]) return obs, Belohnung, beendet, abgebrochen, info
Die endgültige Trainingskonfiguration verwendete PPO mit 4 parallelen Umgebungen auf Apple Silicon (M2). Nach 1 Million Zeitschritten sprachen die Ergebnisse für sich.
Bewertungsergebnisse (5 Episoden):
============================================================
Ausführung von 5 Evaluierungsepisoden
============================================================
Aufbau der Lagerumgebung...
Lager mit 31 Hindernissen erstellt
--- Episode 1/5 ---
Anfängliche Entfernung zum Ziel: 3,56 m
Schritt 50: dist=2,75 m, Belohnung=4,0
Schritt 100: dist=1,70 m, Belohnung=9,4
Schritt 150: dist=0,66 m, Belohnung=11,9
✓ ERFOLG | Schritte: 159 | Belohnung: 61,9 | Endabstand: 0,48 m | Zurückgelegte Strecke: 3,10 m
--- Episode 2/5 ---
Anfängliche Entfernung zum Ziel: 2,72 m
✓ ERFOLG | Schritte: 120 | Belohnung: 58,2 | Enddistanz: 0,49 m | Zurückgelegte Strecke: 2,26 m
--- Episode 3/5 ---
Anfängliche Entfernung zum Ziel: 5,10 m
✓ ERFOLG | Schritte: 275 | Belohnung: 50,3 | Enddistanz: 0,49 m | Zurückgelegte Strecke: 5,17 m
--- Episode 4/5 ---
✓ ERFOLG | Schritte: 150 | Belohnung: 57,8 | Enddistanz: 0,48 m
--- Episode 5/5 ---
✓ ERFOLG | Schritte: 116 | Belohnung: 58,9 | Enddistanz: 0,49 m
============================================================
AUSWERTUNGSERGEBNISSE
Erfolgsquote: 5/5 (100,0 %)
Durchschnittliche Belohnung: 57,43 ± 3,83
Durchschnittliche Episodenlänge: 164,0 ± 57,9 Schritte
Durchschnittliche Strecke: 3,06 m ± 1,09 m
============================================================
Eine Simulation, die langsam läuft, ist eine Simulation, an der man nicht weiterarbeiten wird. Die anfängliche Leistung war miserabel – unter 1 FPS bei aktivierten Visualisierungen. Der Engpass war überraschend: die Erfassung der Kamerabilder und das Zeichnen der Debug-Linien.
Die Lösung bestand darin, rechenintensive Visualisierungen seltener zu aktualisieren. Kamerabilder werden nun alle 10 Frames statt bei jedem Frame aktualisiert. Lidar-Strahlen werden alle 10 Frames gezeichnet, und nur jeder zehnte Strahl wird angezeigt. Die Pfadvisualisierung wird alle 10 Frames aktualisiert, wobei die Debug-Linien länger sichtbar bleiben.
Diese Änderungen steigerten die Leistung der Simulation von 0,8 FPS auf über 4 FPS – immer noch nicht rasend schnell, aber für die Entwicklung brauchbar. Beim Training im Headless-Modus (ohne GUI) ist die Leistung deutlich besser.
Was ich hier gezeigt habe, ist eine Grundlage. Es gibt noch viel Raum für weitere Erkundungen.
Rückblickend stechen einige Prinzipien besonders hervor:
Wir haben einen langen Weg zurückgelegt seit der 2D-Einfachheit von IR-SIM. PyBullet bringt echte Physik, echte Sensoren und echte Herausforderungen mit sich. Aber die Kernprinzipien bleiben dieselben: schrittweise aufbauen, ständig testen, und wenn etwas kaputtgeht (was es wird), vereinfachen, bevor man Komplexität hinzufügt.
Das komplette Projekt, einschließlich aller Trainingsskripte und vortrainierten Modelle, findet ihr auf GitHub. Klont es, macht es kaputt, verbessert es.
Nächstes Mal vielleicht Sim-to-Real-Transfer – den virtuellen Roboter nehmen und ihn etwas steuern lassen, das man tatsächlich anfassen kann. Aber das ist eine Geschichte für einen anderen Beitrag.