My robot drove itself down a trail. Then it met a corner.
Straights: solved. Tested five times on a straight section, 94–100 % of the time on the trail. Corners: it runs wide every time, and two field days of tuning couldn't fix it. Here's everything that happened since the kitchen — and why the next step is a racetrack on my kitchen floor.
Last time, this was a phone in my hand, walking a kitchen. This time the phone is bolted to an RC truck (the same one that was an obstacle in the kitchen video), and the truck drives itself. I call it the Robo Bump Truck.
Nothing about the method changed. That's the point of this log.
1 · What changed on the truck
It now carries a Jetson Orin Nano in a printed cradle, a Pixhawk flight controller running ArduRover, a RadioMaster MT12 transmitter talking to an ExpressLRS receiver, and an iPhone on a wedge mount looking down the trail. The phone's video camera is the only thing the model sees, and it's all the model uses to drive.
2 · Training: two models, no hand-drawn labels
The whole project rests on one idea: expensive sensors teach, cheap sensors ship. The phone carries LiDAR, motion tracking and an accelerometer. All of it is used to label the training images automatically. None of it is used to drive. The field calls this self-supervised learning, in a specific sense: the robot's own other sensors write the labels, and no human does. The training step itself is ordinary supervised learning against that answer key — the word "self" refers to who wrote the labels, not to how the model learns. The point of it, beyond the shipping argument, is time: every hour the sensors spend labelling is an hour a human doesn't. There are two models, and each has a different teacher. If you want to dig deeper, the obstacle model is in the lineage of Berkeley's BADGR, which labels images from the robot's own collision and bumpiness sensors; the steering model is in the lineage of ETH's Wild Visual Navigation, which labels images from the path the robot actually traversed.
2.1 · The obstacle model (DangerNet) — taught by bumps
This is Log #1's method. I walk up to each obstacle, and at arm's length I bump the phone body with my free hand — the phone never touches the obstacle. The accelerometer marks the moment; the LiDAR marks where the obstacle is; and that spot is projected back into every frame of the approach as "no-go." The floor I walk over in the next few seconds is projected back into those same frames as "go" (Figure 2). Thirty bumps became 147,731 labelled image patches.
2.2 · The steering model — taught by where the truck went
The model that drove the truck has a different teacher: the truck's own path. I drive a lap by hand while the phone records video and its motion-tracking pose. Afterwards, the path the truck actually took is projected back into every frame, and the image patches that path passes through are labelled "trail" (Figure 3). No LiDAR in this chain at all, and no human drew a line to teach it.
I did paint trail centrelines by hand on 53 frames — but to check the result, never to train it. On held-out data the model's centre landed about twice as close to my hand-painted centre as the pose labels it learned from.
2.3 · One backbone, two heads
Both models are the same shape (Figure 4): a frozen DINOv2 vision backbone that turns each image into a grid of 850 patch features, and on top of it a tiny logistic-regression head, one per model, that scores each patch. The obstacle head answers "must I not hit this?"; the steering head answers "is this trail?". Each head is 385 numbers and trains in a couple of minutes on a laptop. At runtime they see RGB pixels, nothing else.
Each head answers one yes/no question for every patch, independently, and the answer is a score from 0 to 1 (Figure 5). A heat map is just those 850 scores painted over the frame.
On this trip the obstacle model ran display-only. It was trained handheld at walking height, and at truck height on a trail it's out of its depth — literally. It gets retrained from the truck next. The steering model is what drove. Which expensive sensor does the teaching depends on which model you mean (Figure 6), and neither one keeps it.
3 · Live inference: getting the picture to the robot, fast
3.1 · The robot's eye is a phone, and it talks over one cable
The video gets from the phone to the Orin over a single USB cable. No capture card, no wifi, no
radio link for the video. That sounds unremarkable until you try it. The published failure reports from people trying this
describe the phone never appearing at all: usbmuxd
#281, filed on the same phone and iOS
version I'm using, and record3d
#120. It works here because the Orin's
usbmuxd is started with preflight disabled: the device enumerates and a tunnel binds before the
pairing handshake completes, which is the whole bypass.
Once it worked, I wanted to know whether it kept working, because the neighbouring reports describe setups that connect and then die: usbmuxd #215, "it works fine for a random amount of minutes, until it dies", and record3d #72, a robot streaming from an iPhone over USB with "variable lag in the video stream (1-3 seconds)", open since 2023. So, a soak test. Thirty minutes continuous:
| delivered rate | 15.02 frames/s sustained |
| frames | 27,000 |
| gaps over 1 s | 0 |
| disconnects | 0 |
That was indoors, on a bench, in a cool room. Outside is a hotter place, and other people's phones have overheated doing this. Hold that thought — the field had opinions about it.
3.2 · How long does the robot take to see something?
This matters because it sets the floor under everything downstream. The loop has three stages: perception (what am I looking at?), control (what should I do about it?) and actuation (move the steering servo). This delay sits in front of all three. Control engineers call it dead time: however good the rest of the loop is, it is acting on a world that has already moved on. At 1 m/s, 60 ms is 6 cm of travel before the robot sees anything at all. Dead time is also what turns a confident controller into an oscillating one, so I needed the number.
The obvious approach doesn't work. You can't ask the phone "when did you see this?" The phone's clock and the robot's clock are different clocks, and the question spans both.
Here's the intuition, and it's a yodel. You're alone on one side of a valley and you want to know how long your voice takes to get across it. There's nobody over there to shout back. So you rig a billboard on the far side that flashes the instant it hears you, you yodel "Yo!", and you run a stopwatch until you see the flash. No partner required: you made something happen at a moment you knew, and you timed the consequence coming back.
(The yodel is for human intuition only. Nothing in the real rig makes a sound: light crosses a valley far too fast to time with a stopwatch, which is exactly why the real measurement is all visual, and why the slow thing being measured is the camera pipeline rather than any distance.)
So the robot rigs the same trick with light, on a billboard it controls (Figure 7). It serves a web page that flashes black to white on a schedule set by its own clock; I put that page on a monitor and pointed the phone at it. However long it takes for that flash to turn up in a frame the robot has actually received — that's how long the robot takes to see the world.
About 60 milliseconds. Eleven clean flashes, mean 60.5 ms, standard deviation 4.6 ms.
Two things I have to say about that number. It's an upper bound: it includes the monitor's own display lag, which this method can't separate out, so the camera path alone is faster. And "eleven clean" means I threw four out: they came back at 322 to 990 ms, and in each of those the camera was still recovering its exposure after the screen went dark, so it never saw a flash at all — excluded on a brightness rule I'd set before seeing the numbers. I posted the 60 ms figure on a forum before writing that down, which was the wrong order.
3.3 · How a frame becomes a steering command
Every frame, the steering model scores each patch for "trail." In each row, the probability-weighted centre of the patches it calls trail is that row's trail centre, rows where that mass is spread out are dropped, and a line is fitted through the rest. That is the drawn trail centre. A steering law called pure pursuit picks a goal point on that line a fixed distance ahead, the lookahead, and computes the curvature (in 1/m: how tightly the truck must turn) that would carry it through that point; a gain scales how hard it steers for that curvature. The Orin converts the result to a servo pulse width (in microseconds) and hands it to the flight controller, which drives the steering servo (Figure 8). Every stage (the fitted line, the curvature, the command, and what the radio and servo actually did) is logged per frame, so every pass can be replayed afterwards. That log is how every fault in this post was found.
4 · Day one: it drove itself
First real trail, and it worked.
Five passes down the straight at the settings I'd ended up on (lookahead 2.0 m, gain about 1.8), judged by a tripod I set up on the bank — a fixed camera watching the truck, with a tracker reading the ground colour underneath it. 94 to 100 % on the trail on every pass, one excursion longer than two seconds in 227 seconds of driving.
I want to be exact about what that number covers: the tripod's view was fixed down the straight, and the corner was out of frame. So it's a measurement of the straight. It is also not the robot grading its own homework, which is the part I care about — the robot's own confidence had no vote.
Then the corner. It saw the turn and started it, and then it went one of two ways. At a low gain it turned too gently: the curvature it was commanding climbed from 0.33 to 0.74 1/m, it asked for about 55 % of steering lock, and it arced wide into the grass. With the gain turned up it hunted everywhere, straight or corner: 152 sign changes of the steering command in 319 seconds, weaving back and forth across the trail.
The fix for the zigzag wasn't the gain. Standing there watching it, my read was "we need to look farther up the trail," which turns out to be pure pursuit's damping knob. Pushing the lookahead from 1.5 m to 2.0 m cut the weaving by more than half (0.45 to 0.19 direction changes per second). Nothing at that lookahead fixed the running wide.
5 · Day two: the app broke, then the corner didn't yield
Day two was the first field use of Robot Eyes, the iPhone app I'd built overnight. I'd been recording training data with a scanner app and streaming live video with a separate one, and no app did both from the same camera session. I wanted to gather training and validation data while the model drove on the same frames. It's the tool that later lets you replay exactly what the model saw and did. So I wrote one. It failed in the field in a way I'm glad happened early.
The phone got hot. ARKit throttled the camera to 20 frames/s, which is documented behaviour. The bug was mine: the app decimated frames by a fixed divider, so when capture dropped to a third, what reached the robot dropped to a third with it: 5 frames/s. The steering loop ended up running at 5 Hz. I fixed it sitting in the car between the afternoon and evening sessions, so I could keep recording training data and driving live off the same frames that same day; the evening ran at the full rate; the divider is now recomputed every second from the measured capture rate, so the app targets a frame rate instead of a ratio. The proper bench acceptance that night found two more defects, which is the argument for benches.
Heat is still the open problem, and I'd rather say what I'm doing about it than pretend it's solved. What's actually in: the app now reports its own thermal state every second and halves the video bitrate when iOS says the phone is struggling, so the robot at least knows, and I can read it back afterwards from the session file. What I want to try, none of it tested yet: airplane mode, to stop the phone spending power on radios it doesn't need; a small travel router on the truck so the phone isn't running wifi at all; and settling whether running it on the charger helps or hurts, because a charging battery is its own heat source. The phone is doing camera capture, depth, motion tracking and video encoding at once, in the sun, which is a lot to ask of something designed to live in a pocket.
The second fault I found with my hands rather than any log. Driving by hand on the way to the trail, the truck tracked along the left edge, in the grass, exactly parallel to the trail. Strange. I trimmed it straight on the transmitter. Then, under the model, it did exactly the same thing again. The transmitter's trim and the model's idea of "straight" turn out to be two different numbers that can't see each other: a steering servo is commanded with a pulse width, in microseconds, and the flight controller holds a centre value it calls straight (1500 µs). The truck's actual straight was 1463 µs (Figure 9). So every time the model asked for "no steering," it got a little left: a constant bias, in control-theory terms a steady-state error. Setting the flight controller's centre to 1463 fixed it. Pose data later confirmed the value independently: fitting the truck's recorded path curvature against the servo pulse, the zero crossing lands at 1461.2 µs — 1.8 µs from the number I set by feel.
With both fixed, the straight held: 99–100 % on the trail across three passes, measured on the straight. But that number is graded differently: the tripod wasn't up, so it comes from a second small classifier on the robot that reports whether the camera is looking at trail, the robot's own trail head (Figure 10). It's the robot marking its own homework, which is exactly what day one's number wasn't, so it isn't comparable and I'm not claiming it as an improvement.
And then I spent the rest of the session on the corner, systematically: one knob at a time. I kept a matrix table of the settings, and I moved the tripod to the corner and used it to count how many times the truck left the trail, because I wanted empirical validation and it was too much to hold in my head. Every lookahead from 1.0 to 3.0 metres at one gain, plus one pass at a higher gain: seven passes, one per cell, and the log of each one pulled and graded before the next.
It didn't work. Not one of them held the turn. Every pass left the trail in the bend or just after it, and rejoined further on. Short lookahead, long lookahead, more gain: the truck ran wide every single time.
And the sweep couldn't even tell me which way to go next. A 2.5 m lookahead graded worst and 2.8 m graded best, which isn't physics — it's two single passes at different speeds on different entry lines. Across two field days I'd tried every lookahead from one to three metres and gains from 1.0 to 2.1. None of them made the corner, and the differences between them were noise.
6 · The trail can't measure this problem
That's the actual finding, and it took me embarrassingly long to see it as one.
I was trying to tune the steering law with a test that couldn't resolve the thing being tuned. The throttle was my trigger finger, so no two passes entered the bend at the same speed or on the same line, and with one pass per setting I couldn't rank two lookaheads half a metre apart under that spread. Speed and entry line were uncontrolled variables, and they were bigger than the variable I was changing.
So the answer isn't a better gain. It's a controlled course, where speed is held and the same lap repeats, and a grader that needs neither a tripod nor the robot's opinion.
I built a racetrack out of blue tape on my kitchen floor.
That's the next log.
What I'm not claiming
- Corners. At all. Two field days, every setting I could turn, unsolved.
- The 94–100 % and 99–100 % figures are the straight, not the trail as a whole, and they were graded by different referees.
- The 30-minute streaming soak was indoors on a bench. The one outdoor test thermally throttled, which is the opposite of a pass.
- One park, one operator, one vehicle, daylight, dry. No transfer claim of any kind.
- The USB cable carries video. Whether it also charges the phone on the truck, I haven't measured.
- Three global-shutter cameras are on the bench being evaluated as a future upgrade for the truck. Everything above runs on the phone.
Glossary
- Self-supervised learning (as used here): supervised training where the labels are written by the robot's other sensors (LiDAR, motion tracking, accelerometer) rather than by a person. There is still an answer key; a sensor wrote it. (The pretrained DINOv2 backbone is self-supervised in the other, stricter sense: it learned from images with no labels at all.)
- Per-patch classifier: each head answers one yes/no question for every one of the 850 patches in a frame, independently: DangerNet asks "is this patch dangerous?", the steering head asks "is this patch trail?". The output is a score from 0 to 1 per patch, which is what the heat maps show; a score is an output, not one of the 385 learned weights.
- Backbone / head: the backbone (DINOv2) is a large pretrained vision model that turns an image into a grid of feature vectors and is never retrained; a head is a small classifier on top of those features. Each head here has 385 parameters.
- Patch: one cell of the 34 × 25 grid the backbone divides each image into; every label and every prediction is per patch.
- Pose: the phone's motion-tracking estimate of where the camera is and which way it points, every frame. The steering model's labels come from it.
- Pure pursuit: a steering law: aim at a goal point a fixed distance ahead on the desired path and steer along the arc that reaches it.
- Lookahead: how far up the path the goal point sits, in metres. Longer damps oscillation but cuts corners.
- Gain: a multiplier on the computed steering. Too low turns too gently; too high oscillates.
- Curvature (1/m): how tightly a path bends; the reciprocal of the turning radius. 0.74 1/m is a 1.35 m radius.
- Pulse width (µs): how a servo is commanded: a pulse between roughly 1000 and 2000 microseconds, with ~1500 as centre.
- Steady-state error: a constant offset the controller never corrects on its own. The 37 µs neutral offset was one.
- Control loop rate (Hz): how many times per second the robot sees a frame and issues a command. 15 Hz by design; 5 Hz when the app broke.
- Glass-to-application latency: the time from light hitting the camera to the frame being available to the robot's software. ~60 ms here, as an upper bound.
- Thermal throttling: the phone reducing camera rate to control temperature. Documented iOS behaviour, not a fault.
- Soak test: running the system continuously for a long time to find failures that only appear with time.
- On-trail %: fraction of driving time the truck was over trail rather than grass, per pass. Day one: judged by a tripod camera with a colour tracker. Day two: the robot's own trail head.
Full build notes, including every wall I hit and how I got past it, are in the troubleshooting guide. Video: Log #1's film is here; Log #2's six short clips land on the same channel, one a week. Previous log: I rearranged my kitchen to fool my robot. It found the truck anyway.