Tutorial 2 · reinforcement learning from pixels

Learning to drive from the camera

In tutorial 1 the robot was told exactly where it was. Now it gets nothing but the picture from its own front camera: no position, no map, no speed. From those pictures alone the Ackermann car learns to drive the ε track as fast as it can without leaving it. Everything runs on your own PC; count on about an hour of training.

1The goal

The robot is the Ackermann car: a drive motor on the rear wheels and a steering servo for the front wheels, like a real car. The course is the ε track: a 42.6 m loop behind the start square with bends both ways and small hills. A lap counts when the car is back at the chequered line; leaving the painted band ends the attempt.

The trained network driving a full lap in real time. Top right: the only thing it sees, a 64 × 48 grey image from the car's camera, ten times a second.
50.0 sLine follower that knows its distance to the centre line, full throttle
53.2 sPPO from the camera, halfway through training (200 000 steps)
46.6 sPPO from the camera after 400 000 steps (49 min), never off the band

The network ends up faster than the line follower, even though that one knows its exact position. The line follower sticks to the centre line; the network found out that it may cut the bends inside the band, which makes the lap shorter.

2What you need

The same as in tutorial 1: the robotSimulatorAPI repository, Node.js 22+, Chrome and Python 3.10+ with

pip install stable-baselines3 gymnasium pillow

The script for this tutorial is python/track_vision_rl.py. Learning from images is heavier than learning from 10 numbers: a GPU helps here (see training speed), but a modern laptop CPU does it too.

node server.mjs                                     # terminal 1: the simulators
python python/track_vision_rl.py baseline         # terminal 2
python python/track_vision_rl.py train
python python/track_vision_rl.py eval
python python/track_vision_rl.py watch --gif lap.gif

3The track and the camera

Every Botopia robot has a small camera at its front; in the simulators you see its picture in the top right corner of the 3D view. Through the API you get that picture with view: "robot". The ε track is painted to be seen: a light band, white edge lines and a dashed centre line on a dark floor. The top row below is the camera at full size, the bottom row is what the network gets.

Four camera pictures of the track (straight, right-hand bend, left-hand bend, around the middle prong) in colour, and below each the same picture at 64 by 48 pixels in grey
Straight before a hill, a right-hand bend, the left-hand inner bowl, round the middle prong. At 64 × 48 the edge lines and dashes are still clear; that is enough to steer.

The camera picture shows only what a real camera would see: the path trail, the turning circle and other helper drawings of the simulator are left out, and so are shadows (they cost a lot of drawing time and add nothing for the track).

4From pixels to an observation

One step = the action, 0.1 s of simulation, and the new camera image, all in one API call. The image option makes the server add the JPEG to the step's answer; with eight simulators in one batch call that is eight images per call:

IMAGE = {"view": "robot", "width": 64, "quality": 0.9}
results = api.step_many([{"id": d.id, "action": d.command(a), "dt": 0.1, "image": IMAGE}
                         for d, a in zip(drivers, actions)])

def to_gray(jpeg):                                 # JPEG bytes → (48, 64, 1) numbers 0 … 255
    return np.asarray(Image.open(io.BytesIO(jpeg)).convert("L"), dtype=np.uint8)[:, :, None]

Grey, 64 × 48. Colour adds little on this track and small images train much faster. Three images stacked. One picture shows where the car is, not how fast it moves or which way it is turning. The last three pictures together do (the dashes move between them), so the observation is a 48 × 64 × 3 block: VecFrameStack(venv, 3).

5Actions, reward, attempts

Action: throttle and steering

Two numbers −1 … 1 from the network. The throttle is mapped to 25 … 100 % (the car never stands still, so it can't learn to “win” by not moving), steering goes straight to the car's turn.

Reward per step

reward = 10 × (metres gained along the track)
       − 0.05                  # every 0.1 s costs a little
       − 10                    # when it leaves the band, and the attempt ends

“Metres along the track” comes from the simulator (progress.distance); the network never sees it, it only feels it through the reward. Faster driving earns more per second, leaving the band costs a lot and ends all future reward: that balance is what makes it fast and careful.

Attempts start anywhere

Every attempt starts at a random spot on the track (reset(start=…)) and lasts at most 30 s. Always starting at the line, the car would see the first bends a thousand times and the last ones hardly ever.

6The network

CnnPolicy uses the convolutional network from DeepMind's Atari work (“NatureCNN”): three layers of small filters that slide over the image and learn to react to edges, lines and their direction, then a layer of 512 numbers that describes the picture, and from there two small outputs:

About 340 000 weights (actor and critic share the image layers), all learned from the reward. Nobody tells the network what an edge line is.

7The time to beat

The benchmark cheats on purpose: it reads its distance to the centre line from the simulator (progress.offset) and steers against it, at a fixed throttle.

python python/track_vision_rl.py baseline
Line follower (knows its offset), throttle 0.6: lap in 67.8 s (63 cm/s), left the band 0 times
Line follower (knows its offset), throttle 1.0: lap in 50.0 s (85 cm/s), left the band 0 times

8Train

python python/track_vision_rl.py train
...
  new best mean reward 141.8: saved track_vision_ppo_best.zip
Saved track_vision_ppo.zip after 48.7 min of training

The heart of it:

venv = VecFrameStack(VecMonitor(make_vec_env(8, "ackermann", url)), 3, channels_order="last")
model = PPO("CnnPolicy", venv, n_steps=256, batch_size=256, n_epochs=4,
            learning_rate=lambda remaining: 1e-4 * remaining,   # goes down to 0
            target_kl=0.03,                                   # stop an update that changes too much
            gamma=0.98, ent_coef=0.005, device="auto")
model.learn(total_timesteps=400_000, callback=KeepBest("track_vision_ppo_best.zip"))
Stepsep_rew_meanep_len_meanWhat is happening
50 00058131follows the band on straights, loses it in bends
100 00076140takes most bends
200 000104171a first full lap: 53.2 s
300 000141233rarely leaves the band any more
400 000126187drives faster and more boldly: lap 46.6 s

The training reward is 10 × the metres driven in an attempt of at most 30 s, minus penalties. It went down a little at the end, yet the final model drives the fastest lap. During training the network still adds random variation to its actions (to keep exploring), and at a higher speed that variation makes it slip off the band now and then. The test lap uses the actions without variation (deterministic=True). So test the final model and track_vision_ppo_best.zip: the best training reward is not always the best lap.

9When training collapses

Our first run used a 2.5 × higher learning rate and no target_kl. It learned faster, and then, at 300 000 steps, it forgot nearly everything within a few minutes:

Training reward of two runs The first run (learning rate 2.5e-4) rises to about 190 and collapses to about 20 after 300 000 steps. The second run (learning rate 1e-4 going down, target_kl 0.03) rises more slowly to about 140 and stays there. 210160110 6010 0100 000200 000 300 000400 000training steps first run: learning rate 2.5e-4 collapse 1e-4 ↘ 0, target_kl 0.03
Average training reward of the last 100 attempts. Red: the first run. Green: the run with smaller, checked updates (the one in the video).

The log showed why. PPO measures how much each update changes the policy: approx_kl. Healthy values are around 0.01. The first run averaged 0.047 with peaks of 0.28, and up to 60 % of its samples were clipped (clip_fraction). Most of those big jumps happened to go well, until one didn't: the car started leaving the band, the new experience was bad, and the next updates made it worse.

Watch approx_kl and clip_fraction in the log. If approx_kl stays well above 0.02, lower the learning rate before you lose a long training run.

10Test and watch

python python/track_vision_rl.py eval
PPO from the camera, lap 1: lap in 46.6 s (91 cm/s), left the band 0 times
PPO from the camera, lap 2: lap in 46.6 s (91 cm/s), left the band 0 times
python python/track_vision_rl.py eval --model track_vision_ppo_best.zip
PPO from the camera, lap 1: lap in 49.8 s (86 cm/s), left the band 0 times

# one lap as a GIF: the 3D view with the network's 64 × 48 picture in the corner
python python/track_vision_rl.py watch --gif lap.gif
# or live, in a visible Chrome:  node server.mjs --show   then   ... watch

A test lap starts at the chequered line, uses the same three-image stack as in training, and stops after one full lap or 3 minutes.

# slower: the network's throttle × 0.6 (it still chooses its own speed in every bend)
python python/track_vision_rl.py watch --speed 0.6
PPO from the camera: lap in 74.7 s (57 cm/s), left the band 0 times
# the same lap in slow motion (only how you watch it, not how it drives)
python python/track_vision_rl.py watch --playback 0.5

11Going faster

The Ackermann car has a top speed of 0.9 m/s. Give it a stronger motor (in the simulator: --vmax, or the Top speed slider on its page) and see what the network does with it. Here: twice as fast, 1.8 m/s.

The final model at a top speed of 1.8 m/s, real time: 29.5 s, never off the band. Note the camera on its mast.

Step 1 · a faster car, the same network

python python/track_vision_rl.py eval --vmax 1.3
python python/track_vision_rl.py eval --vmax 1.8

The model trained at 0.9 m/s steers too late for a faster car:

Top speedLine follower (knows its offset)Model trained at 0.9 m/s
0.9 m/s50.0 s46.6 s, clean
1.1 m/s40.9 s39.1 s, clean
1.3 m/s34.6 s30.7 s, left the band once
1.8 m/s25.0 s25.7 s, left the band 5 times

Step 2 · train at the new speed, and watch it slow down

python python/track_vision_rl.py train --vmax 1.8 --model fast.zip

The result drove clean laps, but in 58.7 s: slower than the original model. Fine-tuning the 0.9 m/s model step by step (1.3, then 1.5 m/s) gave the same picture: whatever the top speed, the network settled at about 0.9 m/s. It had found out that slowing down is the safe answer. Why? The camera sits 8 cm above the floor and sees about a metre of track: at 1.7 m/s a bend appears less than a second before the car is in it.

Step 3 · let it see further ahead

Self-driving model cars put their camera on a mast, tilted down. The Ackermann page can do the same: --camh (height in metres) and --camtilt (degrees down), or indexAckermann.php?camh=0.3&camtilt=24 in the browser.

The same bend seen by the camera at 8, 18, 25 and 35 cm height, in colour and at 64 by 48 pixels in grey
The same spot just before a bend, camera at four heights. From the low camera (left) the bend is a thin line at the horizon; from 25–35 cm the network sees its whole shape. We used 30 cm and 24°.
# 1. train from scratch with the high camera at the new speed (about 25 minutes)
python python/track_vision_rl.py train --vmax 1.8 --camh 0.3 --camtilt 24 --model fast_cam.zip
# 2. keep training from its best version (fine-tuning, --init), with a smaller learning rate
python python/track_vision_rl.py train --vmax 1.8 --camh 0.3 --camtilt 24 --init fast_cam_best.zip --model fast_cam2.zip
# test and watch with the same car and camera it was trained with
python python/track_vision_rl.py eval  --vmax 1.8 --camh 0.3 --camtilt 24 --model fast_cam2.zip
python python/track_vision_rl.py watch --vmax 1.8 --camh 0.3 --camtilt 24 --model fast_cam2.zip
At a top speed of 1.8 m/sLapLeft the band
Low camera, trained at 1.8 m/s58.7 s0
High camera, trained at 1.8 m/s (400 000 steps)39.5 s0
High camera, + 400 000 steps fine-tuning29.5 s (144 cm/s)0
Line follower that knows its offset25.0 s0

The lesson: no learning rate or reward setting made the car faster; giving it a better view did. Before tuning the training, check what the robot can actually see. A model only works with the car and camera it was trained with: always pass the same --vmax, --camh and --camtilt to eval and watch.

12Training speed and the GPU

On the laptop we used (16 CPU cores, RTX 5070), the network on the CPU trained at 100–140 steps per second: 400 000 steps in 49 minutes. Where does the time go?

PartCostWhat helps
Simulating 0.1 s0.3 ms(nothing needed)
Drawing the 64 × 48 camera image (in Chrome, in software)~3.5 mssmall images; --browsers on the server: several Chrome instances draw in parallel
The network: choosing actions and learninglarge part on a CPUa GPU: --device cuda

Unlike tutorial 1, a GPU pays off here: a convolutional network on images is exactly what GPUs are for. Install the CUDA build of PyTorch (RTX 50 series: CUDA 12.8 or newer, see pytorch.org) and train with --device cuda; --device auto (the default) already picks the GPU when PyTorch sees one.

pip uninstall torch
pip install torch --index-url https://download.pytorch.org/whl/cu128
python python/track_vision_rl.py train --device cuda

The script limits PyTorch to 4 CPU threads: with all 16 it competed with the Chrome tabs that run the simulators, and training became three times slower.

13Experiments

TryHowWhat to look for
Another robot--robot skid, 4wd, tracked Its camera sits at another height, it turns differently. Run baseline first.
Fewer images in the stack--stack 1Without motion in the observation, can it still choose its speed?
Colour, or bigger imagesW, H and to_gray in the scriptSlower training: does it learn more?
No minimum throttleDriver(..., min_throttle=0)Does it learn to creep, or to stand still?
More time pressureDriver(..., time_penalty=0.2)Faster laps, or more crashes?
Longer training--steps 800000Does the lap time keep dropping?
Even faster--vmax 2.5 --camh 0.4 --camtilt 30 See going faster: how far can the camera trick take it? Beat the line follower's 25 s.

Next: tutorial 3 splits the job in two: a YOLO segmentation model finds the path, a small policy steers from that mask.

Challenge: a clean lap under 45 s, from the camera only. And a harder one: train on the Ackermann, then test the same model on the 4WD robot (eval --robot 4wd). Does what it learned carry over to another car?

14Troubleshooting

ProblemSolution
ConnectionRefusedErrorStart node server.mjs first and leave it running.
Training much slower than hereServer started with --show? Restart without. Try --browsers 2 or 4 on the server, fewer --envs on a PC with few cores, or a GPU.
The reward rises and then crashesSee when training collapses; use track_vision_ppo_best.zip.
No trained modelRun train first, and eval / watch from the same folder.
Results differ from this pageNormal: another PC, another PyTorch version and the random exploration all change the run. Train longer or test the _best model.