The horizon
Your paint_masks from unit 0.1.2 finds the paint pixels, but the controller wants two numbers: how far the car is from lane centre in metres (the offset, positive when left of centre) and which way it points relative to the lane in radians (the heading, positive when pointing left of the lane). This unit builds the box in between, estimate_lane(obs), which returns a LaneEstimate(offset_m, heading_rad, valid).
The bridge from pixels to metres is one assumption. A 2008 lane-detection paper states it in its first step:
"To get the IPM of the input image, we assume a flat road, and use the camera intrinsic (focal length and optical center) and extrinsic (pitch angle, yaw angle, and height above ground) parameters to perform this transformation." (Aly, 2008, §II.A)
IPM, inverse perspective mapping, redraws the picture as if seen from straight above, so "lanes that appear to converge at the horizon line are now vertical and parallel." You will do it for just the pixels you need.
In this flat-world teaching simulator the ground is exactly a plane, and you know every camera number. The camera sits m above the ground, directly above the front axle, tipped down by a pitch of . Its focal length is pixels: a ray at angle from the direction the camera points lands pixels from the image centre, which is column , row .
The horizon is where a ray parallel to the ground lands. That ray points 5° above the camera's axis, so it lands rows above the centre: row . Rows above it see sky, which is why rows 0 to 75 of the 0.1.2 frame were all sky.
How we got row 75.5
The camera's axis points below horizontal, so a ray that runs parallel to the ground is above the axis. The rule from above says a ray at angle from the axis lands pixels from the image centre. With , a calculator gives , so
The ray points above the axis, so it lands above the centre. Row numbers count downwards from the top of the picture, so going up means subtracting: .
Rows are distances
Take a row below the horizon. Its ray points below the camera's axis, and the axis points below horizontal, so the ray falls at
below horizontal. It starts at height and meets the ground a distance ahead: a right-angled triangle with , so

Worked example. How far ahead is the ground seen in row 150?
- , and .
- , and .
- m.
The column never appears in the formula: every pixel in row 150 sees ground 2.91 m ahead. A row is a distance.
Camera.pixel_to_ground(u, v) does this for you: it takes a column and a row and returns the ground point (forward, left) in metres from the camera, left positive, or NaN (not a number) above the horizon. The lab checks the worked example against it; module 1.4 opens it up.
The distances are uneven: at the bottom one row is 2 cm of road, near row 100 it is 36 cm, and five rows below the horizon you are past 50 m. Rows near the horizon are too coarse to trust, so the box measures in two bands: the near band, rows 125 to 179 (2.1 to 4.4 m ahead), and the far band, rows 95 to 115 (5.6 to 11.5 m).
How we got the row sizes and band distances
Each number is the row formula from the worked example, and then , applied to one more row (with , , and m):
| row | |||
|---|---|---|---|
| 179 | 34.22° | 0.6801 | 2.058 m |
| 178 | 33.95° | 0.6732 | 2.080 m |
| 125 | 17.51° | 0.3155 | 4.438 m |
| 115 | 14.06° | 0.2504 | 5.592 m |
| 101 | 9.11° | 0.1604 | 8.730 m |
| 100 | 8.75° | 0.1540 | 9.091 m |
| 95 | 6.97° | 0.1222 | 11.454 m |
| 80 | 1.60° | 0.0280 | 50.057 m |
Each is worked out from unrounded values, so dividing the rounded columns can differ in the last digit. Reading off the table:
- The near band runs from row 125 to row 179, which is 4.438 m to 2.058 m ahead: 2.1 to 4.4 m. The far band runs from row 95 to row 115, which is 11.454 m to 5.592 m ahead: 5.6 to 11.5 m.
- One row of road is the difference between neighbouring rows. At the bottom, m, which is 2 cm. Near row 100, m, which is 36 cm.
- Row 80 sees ground 50.057 m ahead, past 50 m. The horizon is at row 75.5, so row 80 is only rows below it: about five.
Metres per pixel
The tempting shortcut: "The lane is 3.6 m wide. Count the columns it spans, divide, and you know the metres per column; multiply any pixel's column by it." That fails, because a pixel column is not a fixed number of metres. Sideways distances shrink with distance, just as a far-off car looks smaller.

A point metres to the left and about metres ahead appears roughly columns from the centre, so one column covers about
Measured with pixel_to_ground, one column covers 0.0189 m in row 150 (2.91 m ahead), 0.0574 m in row 100 (9.09 m ahead) and 0.312 m in row 80 (50.1 m ahead), each close to ; the 5° pitch accounts for the differences.
Worked example. The 3.6 m lane spans columns in row 150 but in row 100 (the two column sizes again, with one more digit). Slide the car 0.5 m left and the white line moves columns in row 150, but only in row 100; the lab measures both. To turn a pixel into metres you need its row as well as its column.
From masks to a lane estimate
Offset and heading describe the lane centre, halfway between the yellow line and the white line, which are 3.6 m apart. The box finds two points on it and draws a straight line through them:
- Masks:
yellow, white = paint_masks(obs.frame). - Centroids: in each band, each line's centroid is the mean column and mean row of its paint pixels there. The centroid of a thin line lies on the line, so it is a point on the paint;
pixel_to_groundturns it into(forward, left). - Lane centre: in each band, the midpoint of the yellow and white ground points. That gives a near point and a far point , forward first.
- Line: its slope, the metres it drifts left per metre ahead, is .
At the car, forward , the line is at left ; if the lane centre is 0.4 m to the car's right, the car is 0.4 m left of the lane centre. The line points left of the car; if the lane points left of the car, the car points right of the lane. So
Both minus signs switch from the lane seen by the car to the car seen by the lane.

Worked example. The car is on the straight road, truly 0.4 m left of centre and turned 0.05 rad left (the frame above).
- Near band: the yellow line's 553 pixels have their centroid at column 83.02, row 159.85: ground point (2.55, 1.27). The white line's 543 pixels give (2.87, −2.33). Their midpoint, the near lane centre, is (2.71, −0.53).
- Far band: yellow (99 pixels) at (7.05, 1.05), white (104 pixels) at (7.33, −2.57), lane centre (7.19, −0.76).
- : the lane centre drifts right as it goes ahead.
- , so the offset is m (truth 0.40), and the heading is rad (truth 0.05). The arithmetic uses unrounded values.
How we got the four ground points
pixel_to_ground turns a centroid (column, row) into a ground point in two parts. Take the near yellow centroid, at column 83.02 and row 159.85.
Forward. The row formula depends on the row alone:
Left. The picture's centre is column 159.5, so the centroid is columns to its left (a column left of centre is on the car's left, so this is positive). One column in row 159.85 covers 0.01666 m: that is how far pixel_to_ground moves the ground point sideways when the column goes up by one, the measurement described in "Metres per pixel". Then
That is the ground point . The other three work the same way. A centroid right of the picture's centre has a negative number of columns, so it lands on the car's right:
| centroid | column | row | forward | columns left of centre | metres per column | left |
|---|---|---|---|---|---|---|
| near yellow | 83.02 | 159.85 | 2.55 m | 76.48 | 0.01666 | 1.27 m |
| near white | 284.94 | 151.03 | 2.87 m | −125.44 | 0.01861 | −2.33 m |
| far yellow | 136.04 | 106.96 | 7.05 m | 23.46 | 0.04467 | 1.05 m |
| far white | 214.86 | 105.81 | 7.33 m | −55.36 | 0.04637 | −2.57 m |
What valid=False is for
Sometimes there is nothing to measure: turn the car 0.6 rad left on a straight road and the white line leaves the near band: 0 pixels, so no lane centre and no line. The box counts a line as seen in a band only with at least 3 pixels there.
What should estimate_lane return then? Offset 0 and heading 0 is a lie: it tells the controller "you are perfectly centred" when you know nothing, and the controller will act on it. Return LaneEstimate(math.nan, math.nan, False). valid=False says plainly there is no estimate, and the NaNs spoil any sum that forgets to check, so the mistake shows up.
What to do then is the controller's decision: the provided controller checks valid first and, when it is False, holds the wheel straight. With the provided box on gentle, all 1,778 steps are valid.
Straight roads only
Your box fits a straight line, which is exact on a straight road: on 40 centred, aligned frames along it, every estimate is within 0.01 m and 0.003 rad. On a bend the lane centre is a curve, and a straight line through two points on a curve, a chord, does not point along the curve at the car. The estimate is wrong even when the car is placed perfectly:

You can predict the error. On a bend of radius turning left, the lane centre metres ahead has drifted about to the left. With the near point metres ahead and the far point , the line through them has
so the box reports an offset of and a heading of where both should be 0. With m and m, that predicts errors of 0.12 m and 0.062 rad on gentle ( m) and 0.49 m and 0.24 rad on curvy ( m). On 40 centred frames in each road's bends, the median errors are 0.13 m and 0.065 rad on gentle, as predicted, and 0.60 m and 0.30 rad on curvy: there, your lane's radius is only 18.2 m on right bends, and the far midpoint sits about 0.1 m inside the lane centre.
How we got 0.12 m, 0.062 rad, 0.49 m and 0.24 rad
Put the numbers into the two formulas above: m and m are how far ahead the near and far points are, and is the bend radius. The predicted offset is . The predicted heading is the angle whose tangent is the slope , and gives that angle in radians.
On gentle, m:
On curvy, m:
The slope is 0.2475, but the heading is the angle, and for a slope this big the angle is a little smaller than the slope.
How we got 18.2 m
The bends of curvy have radius 20 m along the road's centre line, the yellow line. Your lane's centre is 1.8 m to the right of that line. A right bend turns towards the right, so your lane is on the inside of the turn, and a circle on the inside is smaller:
On a left bend your lane is on the outside, so the radius is m. Putting into the formulas above instead of 20 raises the predicted errors from 0.49 m and 0.24 rad to m and rad; with 21.8 they fall to 0.45 m and 0.22 rad.
This is a bias, not noise: on a left bend the box always says "left of centre, pointing right", so the controller steers further left and the car cuts to the inside. That is why the grader tests your function on straight roads only: none of those bend frames falls within its tolerances of 0.25 m and 0.05 rad. Better lane models fit curves through many rows; that is module 1.5, "Classical lane perception". Unit 0.1.4 shows what this bias does to the car on curvy.
Swap your perception into the car
Car(perception=estimate_lane) is the provided car with your perception inside: every 0.05 s it calls your function with an Observation holding the camera frame obs.frame, the speed obs.speed and the time obs.t.
Check 0.1.3.b drives that car around gentle: it must complete the lap with a mean lateral error below 0.5 m (the provided box manages 0.38 m), and it refuses a car still running the provided perception.
One flipped sign turns a working box into a dangerous one. With the offset's sign wrong, the car believes it is left of centre when it is right, and steers away from the centre; each correction makes the error bigger, and the bigger error a bigger wrong correction. This is positive feedback. In the lab the error roughly doubles every half second until the car leaves the lane after 5 s, still on the first straight.
Go deeper
- Stanford CS231A Course Notes 1, "Camera Models" (Hata and Savarese), §2 "Pinhole cameras": the similar triangles behind .
- Aly, "Real time Detection of Lane Markers in Urban Streets" (2008), §II.A "Inverse Perspective Mapping (IPM)" and Figures 2 and 3: the flat-road top view of a real street. The rest can wait for module 1.5.
- Szeliski, "Computer Vision: Algorithms and Applications" (2nd ed.), §2.1.4 "3D to 2D projections", subsections "Perspective" and "Camera intrinsics". A graduate textbook (free after a short form); keep it for module 1.4.