A PyBullet simulation of a 3-wheel omnidirectional robot driven by a local vision-language model (Gemma 4 via Ollama). Each cycle:
- Capture a 640×480 RGB frame from the robot's forward camera.
- Send the frame to Ollama; receive a JSON movement command.
- Execute the command (advance / strafe / rotate) for its duration.
- Check for coin pickups by proximity; loop.
The same Python module split is designed to map cleanly onto a Raspberry Pi + Arduino deployment later — see the hardware section below.
- Python 3.10+ (developed on 3.14 on Windows 11)
- A local Ollama server with a multimodal model.
Tested:
gemma4:latest(8B, Q4_K_M, ~9.6 GB on disk). - A GPU for rendering and inference. Tested on an RTX 5060 Laptop (8 GB VRAM). Ollama handles partial CPU offload automatically.
cd opus_build
python -m venv .venv
.venv/Scripts/python -m pip install -r requirements.txtMake sure Ollama is running and the model is pulled:
ollama serve # in one terminal
ollama pull gemma4:latestDefaults live in config.py. Override any of these with an
environment variable or a .env file next to config.py:
| env var | default | what it does |
|---|---|---|
OLLAMA_HOST |
http://localhost:11434 |
Ollama server URL |
OLLAMA_MODEL |
gemma4:latest |
Any multimodal Ollama tag works |
OLLAMA_TIMEOUT_S |
180 |
Cold model loads can take ~30 s |
Other knobs (speeds, camera FOV, world/coin geometry, sim tick rate)
are plain Python constants — edit config.py directly.
| Command | What it does |
|---|---|
.venv/Scripts/python -m main |
Full end-to-end loop. GUI opens, Gemma drives until the coin is collected. |
.venv/Scripts/python -m sim.world |
GUI preview of the world (no robot, no LLM). |
.venv/Scripts/python -m sim.teleop |
Manual keyboard drive (W/A/S/D/Q/E/Space/R/+/-/Esc). |
.venv/Scripts/python -m sim.camera |
Capture one frame from the start pose, save PNG to logs/. |
Close the PyBullet window or press Esc to exit. The loop auto-exits on
all coins collected.
- A small room with a checkerboard floor, four walls, a wooden table, a blue box, a green cylinder, and a yellow sphere (the target).
- The robot (dark cylinder, orange "nose" arrow, green "camera eye" on top-front) spawns in the corner opposite the coin.
- In the terminal: each Gemma decision with its reasoning and the
robot's pose before/after the command. Per-run transcripts go to
logs/run-YYYYMMDD-HHMMSS.log.
opus_build/
├── main.py # live loop: capture -> LLM -> execute -> pickup check
├── config.py # central constants + env var overrides
├── sim/
│ ├── world.py # build_world() + check_collections()
│ ├── robot.py # OmniRobot (chassis + camera mount + velocity control)
│ ├── camera.py # image_cap(robot) -> np.ndarray
│ ├── movement.py # 7 primitives + command_exec() + ACTION_DISPATCH
│ └── teleop.py # keyboard driver for manual testing
├── llm/
│ ├── prompts.py # SYSTEM_PROMPT + VALID_ACTIONS
│ └── client.py # img_out(frame) -> {action, speed_mmps, duration_ms, reasoning}
├── assets/ # (placeholder — most assets come from pybullet_data)
├── logs/
│ ├── DEBUG_NOTES.md # dated dev journal of every bug + fix
│ └── run-*.log # per-run transcripts
└── tests/ # (scaffold only — not implemented in v1)
+--------------+ +---------------+ +-----------------+
| sim.camera | frame | llm.client | cmd | sim.movement |
| image_cap() | ------> | img_out() | ------> | command_exec() |
+--------------+ +---------------+ +-----------------+
^ |
| v
+-----+-----+ +------+------+
| sim.world | <--- check_collections() <-------------- | sim.robot |
+-----------+ | OmniRobot |
+-------------+
The dashed boundary between "sensing + thinking" (sim.camera +
llm.client) and "acting" (sim.movement + sim.robot) is
intentional — it's the same boundary we'd cross on real hardware.
The seven movement primitives live in sim/movement.py
and mirror the Arduino Omni3WD API 1:1. Each Python primitive takes
(robot, speed_mmps); each Arduino method takes speedMMPS.
| Python primitive | Arduino equivalent | Body-frame effect |
|---|---|---|
advance(robot, v) |
Omni3WD::setCarAdvance(v) |
+X (forward) |
backoff(robot, v) |
Omni3WD::setCarBackoff(v) |
−X (reverse) |
left(robot, v) |
Omni3WD::setCarLeft(v) |
+Y (strafe left) |
right(robot, v) |
Omni3WD::setCarRight(v) |
−Y (strafe right) |
rotate_left(robot, v) |
Omni3WD::setCarRotateLeft(v) |
+Z angular (CCW from above) |
rotate_right(robot, v) |
Omni3WD::setCarRotateRight(v) |
−Z angular (CW from above) |
stop(robot) |
Omni3WD::setCarStop() |
zero |
Dispatched by name via ACTION_DISPATCH (a dict keyed on the strings
listed in llm/prompts.VALID_ACTIONS). command_exec(robot, command)
applies the velocity every physics tick for duration_ms then stops.
- Add a new action (e.g. a diagonal
advance_left): write the primitive insim/movement.py, add it toACTION_DISPATCHand toVALID_ACTIONSinllm/prompts.py, and describe it inSYSTEM_PROMPTso Gemma knows when to emit it. The JSON-schema validator inllm/client._coerce_commandwill then accept it. - Change speed/duration limits: edit
config.SPEED_MAX_MMPS(used by_coerce_commandto clampspeed_mmps) and the duration clamp constants at the top of_coerce_commandinllm/client.py(currently[300, 3000]ms). - Change the robot geometry (bigger chassis, different wheel
layout, different camera offset): edit
ROBOT_RADIUS,ROBOT_HEIGHT,CAMERA_OFFSET_X,CAMERA_OFFSET_Zinconfig.py. Visuals are composed insim/robot.OmniRobot._spawn. - Change the world: obstacles live in
sim/world._build_obstacles; walls in_build_walls; the coin in_build_collectiblesplusconfig.COLLECTIBLE_POSITIONS. Add multiple entries to spawn multiple coins —check_collections()already handles the list. - Swap the LLM: any multimodal model served by Ollama with JSON
output works. Set
OLLAMA_MODELto e.g.llava:latestorqwen2.5vl:7b. If you change models, re-tune theMOTION MODELblock of the system prompt — the numbers there are calibrated for the current robot + physics (rotate at 120 mm/s for 1500 ms ≈ 40° of turn after friction damping). - Switch off the PID-lite damping:
sim/robot.OmniRobot.__init__setslateralFriction=0.3, linearDamping=0.1, angularDamping=0.1. Raising friction makes the robot more sluggish; lowering it makes commanded velocity closer to nominal.
The Python module boundaries were drawn to match a Raspberry Pi +
Arduino split. Nothing below has been tested end-to-end on hardware
yet; the original MotorWheel / Omni3WD firmware lives at
../rb-nex-02/lib/MotorWheel/ in this repo.
| Module | Sim | Real hardware |
|---|---|---|
sim.camera.image_cap |
PyBullet getCameraImage |
Pi: OpenCV VideoCapture or picamera2 |
llm.client.img_out |
HTTP to local Ollama | Pi (local Ollama) or remote server (cloud API) |
sim.movement.command_exec |
Apply velocity in PyBullet | Pi encodes command → Arduino executes it |
sim.movement primitives |
resetBaseVelocity on the body |
Arduino: Omni3WD::setCar*() methods |
sim.world.check_collections |
Proximity despawn | External pickup detector (ultrasonic / IR) — or drop |
A line-oriented text protocol is simple and matches the Arduino's serial loop. Example mapping of the command dict:
{"action": "advance", "speed_mmps": 180, "duration_ms": 2000}
--> A180 2000\n
On the Arduino side, parse the first char as the action, the two ints as speed/duration, then:
switch (action) {
case 'A': omni->setCarAdvance(speed); break;
case 'B': omni->setCarBackoff(speed); break;
case 'L': omni->setCarLeft(speed); break;
case 'R': omni->setCarRight(speed); break;
case 'Q': omni->setCarRotateLeft(speed); break;
case 'E': omni->setCarRotateRight(speed); break;
case 'S': omni->setCarStop(); break;
}
omni->delayMS(duration); // runs the PID loop during the wait
omni->setCarStop();Omni3WD::delayMS() calls PIDRegulate() every sample period, so the
wheel controllers keep the commanded speeds stable for the full
duration — functionally equivalent to the sim's "reapply velocity
each physics tick" trick.
Everything in llm/ (client code + prompt) is hardware-agnostic. Even
the system prompt's MOTION MODEL numbers may transfer — they describe
what the commands should achieve, not what the sim does internally.
On real hardware you will want to recalibrate those numbers once with
a measured drive, since the friction profile is different.
Full dev journal at logs/DEBUG_NOTES.md. The
three things most likely to bite a future dev:
- Gemma latency is 15-20 s per call and ~30-50 s on cold starts. The loop prints "gemma (+Ns)" so you know what's normal.
- Friction damps commanded speeds to ~68% of nominal. The motion
model in the prompt is calibrated to actual damped numbers, not
nominal — if you change
lateralFrictioninsim/robot.py, re-measure before editing the prompt. - Gemma can be noisy on small targets at the edge of frame. The coin is deliberately sized at 18 cm radius for reliable detection; a smaller coin will trigger spin-and-search oscillation loops. If you shrink the target, tighten the prompt's "roughly centered" threshold.
Git tags map to milestones:
m1-scaffold project skeleton + git init
m2-world textured room, walls, obstacles
m3-robot OmniRobot + 7 primitives + teleop
m4-camera image_cap 640x480 RGB
m5-llm Ollama client with JSON output
m6-e2e end-to-end live loop (with earlier red-cube target)
m6b-coin replaced target with collectible yellow sphere
v1.0 documented + cleaned up, successful end-to-end collection run
Each tag points at a commit with a clear scope; bug-fix commits sit between them.
