LECTURE 5 · PART II
Computer Vision for Self-Driving Cars
Finding Lane Lines: colour, regions, edges & the Hough transform
With practical implementation · Lab 5 & Assignment 2
Dr. Muhammad Kazim
Department of Intelligent Systems
University of Lahore
Where we are
Recall the last slide of Part I — “Lane finding using LAB colour space”. Today we start from scratch and build our own lane finder, step by step.
By the end of this lecture and Lab 5 you should be able to:
cv2.HoughLinesP.
The pipeline you will have built by the end of the lab (generated with the reference solution on a Udacity test image).
01
Self-driving cars & computer vision
How does a car “see” the road?

A human driver: eyes + brain

A self-driving car: cameras, radar, LiDAR + software
When we drive, we use our eyes to decide where to go. The lane lines on the road act as our constant reference for where to steer. One of the very first things a self-driving car must do is therefore detect the lane lines automatically — with an algorithm.
Perception is computer vision: the camera is the only sensor that can read lane paint, signs and traffic lights. Today we build the lane-line part of perception.
Lane lines from our reference pipeline (frame 120 of solidYellowLeft.mp4). Boxes and numbers are illustrative.
| ① Sensors | one 960×540 camera frame; a new one every 40 ms (25 fps) |
| ② Perception | left & right lane lines (this lecture) + 3 vehicles ahead |
| ③ Localisation | in the middle lane, ≈ 0.1 m left of lane centre, driving parallel to the lines |
| ④ Predict & plan | lead car is far ahead and in its lane → keep lane, keep speed |
| ⑤ Control | a tiny steering correction to the right; throttle unchanged |
| Sensor | Measures | Strengths | Weaknesses |
|---|---|---|---|
| Camera | Light intensity & colour (2D image) | Cheap · high resolution · reads lane paint, signs, traffic lights | No direct depth · sensitive to glare, night, rain, fog |
| Radar | Range & relative speed (radio waves) | Works in bad weather · measures velocity directly | Low resolution · cannot see paint or colour |
| LiDAR | 3D point cloud (laser time-of-flight) | Accurate 3D shape & distance · works at night | Expensive · degraded by heavy rain/snow · no colour |
| GPS / IMU | Global position, acceleration, rotation | Absolute position · high-rate motion | Metre-level GPS error · drift · lost in tunnels |
Real vehicles fuse several sensors. Lane markings, however, are a visual feature — painted to be seen — so they are found with cameras.

Input: a frame from a forward-facing camera

Output: the left and right lane lines, marked

Quiz question
Which of these features could be useful in identifying lane lines on the road?
02
Colour selection
Cue #1 — lane paint is bright white or yellow

The lane lines here are white. So what is “white” to a computer?
So: keep pixels that are bright in R, G and B; black out the rest.

The road image split into its R, G and B channels

In each channel: dark pixels ≈ 0, bright pixels ≈ 255
![Pure white = [?, ?, ?]](assets/05_sdc_lane_lines/pure_white.jpg)
Quiz question
What colour is pure white in our combined [R, G, B] image?
Tutorial · step 1 of 3
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
import numpy as np
# Read in the image and print out some stats
image = mpimg.imread('test.jpg')
print('This image is:', type(image),
'with dimensions:', image.shape) # (540, 960, 3)
# Grab the x and y size and make a copy of the image
ysize = image.shape[0]
xsize = image.shape[1]
# Note: always make a copy rather than simply using "="
color_select = np.copy(image)
Always copy! a = b does not copy a NumPy array — both names point to the same data, so blacking out pixels in one destroys the other.
Watch the dtype: mpimg.imread gives uint8 0–255 for .jpg but float32 0–1 for .png.
Tutorial · step 2 of 3
# Define our colour selection criteria: the MINIMUM R, G, B values we allow
red_threshold = 0
green_threshold = 0
blue_threshold = 0
rgb_threshold = [red_threshold, green_threshold, blue_threshold]
# Identify pixels BELOW the threshold in ANY channel (boolean OR "|")
thresholds = (image[:,:,0] < rgb_threshold[0]) \
| (image[:,:,1] < rgb_threshold[1]) \
| (image[:,:,2] < rgb_threshold[2])
color_select[thresholds] = [0, 0, 0] # black them out
# Display the image
plt.imshow(color_select)
plt.show()
thresholds is a boolean mask of shape (540, 960) — True where a pixel fails. Indexing with it changes all failing pixels at once: no loops.
With all thresholds at 0, nothing fails: every pixel is kept. Your job is to find better values …
Tutorial · step 3 of 3 · live demo
Kept: 100% of pixels

Original

Colour selection applied
red_threshold = green_threshold = blue_threshold = 200 the lane lines are clearly visible and almost everything else is black. But some pixels around the periphery (horizon, cars, signs) survive, so it is still hard to extract the lines automatically …
Use a colour space that separates brightness from hue: in HLS, white = high L; yellow = hue ≈ 30–70° with high S.
hls = cv2.cvtColor(img, cv2.COLOR_RGB2HLS)
white = cv2.inRange(hls, (0, 200, 0), (180, 255, 255))
yellow = cv2.inRange(hls, (15, 30, 115), (35, 204, 255))
mask = cv2.bitwise_or(white, yellow)
03
Region of interest masking
Cue #2 — lane lines always appear in the same part of the image

The camera is fixed to the car, so the lane is always inside this region …

… so we mask out everything else
We assume the front-facing camera is mounted in a fixed position. Then the lane lines always appear in the same general region, and we only consider pixels for colour selection inside it. Here a triangle (simplest case); later a quadrilateral; in principle any polygon.
Tutorial · region of interest
region_select = np.copy(image)
# Triangle vertices. Origin (0, 0) is the TOP-LEFT corner!
left_bottom = [0, 539]
right_bottom = [900, 300] # not sensible yet: tune in the quiz
apex = [400, 0]
# Fit lines y = A x + B through each pair of vertices
fit_left = np.polyfit((left_bottom[0], apex[0]),
(left_bottom[1], apex[1]), 1)
fit_right = np.polyfit((right_bottom[0], apex[0]),
(right_bottom[1], apex[1]), 1)
fit_bottom = np.polyfit((left_bottom[0], right_bottom[0]),
(left_bottom[1], right_bottom[1]), 1)
# Coordinates of every pixel, then test "inside the triangle"
XX, YY = np.meshgrid(np.arange(0, xsize), np.arange(0, ysize))
region_thresholds = (YY > (XX*fit_left[0] + fit_left[1])) & \
(YY > (XX*fit_right[0] + fit_right[1])) & \
(YY < (XX*fit_bottom[0] + fit_bottom[1]))
region_select[region_thresholds] = [255, 0, 0] # paint it red
plt.imshow(region_select)
np.polyfit(xs, ys, 1) returns [A, B] of the line through two points.np.meshgrid gives the x and y of every pixel as two arrays.& → one mask.Image coordinates: x → right, y ↓ down. Think of an image as a matrix: element (0, 0) is the top-left.
# colour test: True where a pixel is too dark in any channel
color_thresholds = (image[:,:,0] < rgb_threshold[0]) | \
(image[:,:,1] < rgb_threshold[1]) | \
(image[:,:,2] < rgb_threshold[2])
# Mask colour AND region selection
color_select[color_thresholds | ~region_thresholds] = [0, 0, 0]
# Paint pixels red where BOTH the colour and region selections are met
line_image[~color_thresholds & region_thresholds] = [255, 0, 0]
A pixel is a lane pixel only if it passes the colour test and lies inside the region: ~color_thresholds & region_thresholds.

Original

Region + colour selection

Lane pixels identified
Quiz
The colour thresholds are given (200, 200, 200). Modify left_bottom, right_bottom and apex so that only the lane lines are picked out.
Use the live demo two slides back (tick “region”) or Lab 5B.
left_bottom = [0, 539], right_bottom = [900, 539], apex = [475, 320] — a triangle that hugs the lane and cuts off the horizon.
cv2.fillPoly improved
def region_of_interest(img, vertices):
mask = np.zeros_like(img) # black canvas
fill = (255,) * img.shape[2] if img.ndim == 3 else 255
cv2.fillPoly(mask, vertices, fill) # white polygon
return cv2.bitwise_and(img, mask) # keep inside
h, w = image.shape[:2]
# trapezoid as FRACTIONS of the size -> works at any resolution
vertices = np.array([[(0.05*w, h), (0.46*w, 0.6*h),
(0.54*w, 0.6*h), (0.95*w, h)]], dtype=np.int32)
masked = region_of_interest(image, vertices)
A fixed region is an assumption: it fails on hills, sharp curves, lane changes, or if the camera moves.

We found the lane lines — simple, right? Ready to upload to the car and drive autonomously? Not quite yet.
We need shape: detect lines of any colour with computer vision — edges (Canny) and lines (Hough).
04
Canny edge detection
Cue #3 — lane lines are where brightness changes sharply

1 · Convert to grayscale

2 · Dark road next to bright paint …

3 · … the boundary between them is an edge
The Canny detector was developed by John F. Canny in 1986. Idea: convert to grayscale, compute the gradient (how fast brightness changes), then trace out the pixels that follow the strongest gradients. By identifying edges we can detect objects by their shape — regardless of their colour.

\[ \nabla f = \left(\frac{\partial f}{\partial x},\; \frac{\partial f}{\partial y}\right) \qquad |\nabla f| = \sqrt{\left(\tfrac{\partial f}{\partial x}\right)^2 + \left(\tfrac{\partial f}{\partial y}\right)^2} \qquad \theta = \operatorname{atan2}\!\left(\tfrac{\partial f}{\partial y}, \tfrac{\partial f}{\partial x}\right) \]
Magnitude = edge strength; direction θ is perpendicular to the edge; estimated with Sobel kernels (Part I).

Original image

Gradient image — brightness = strength of the gradient
Rapid changes in brightness are where we find the edges. Computing the gradient gives thick edges, though …

Zoom into the lane lines …

… the gradient ridges are thick

Canny keeps only the 1-pixel ridge
Canny thins the edges to the individual pixels that follow the strongest gradients, then extends those strong edges to include pixels down to a lower threshold — as long as they are connected.
high → strong edge ✔low → rejected ✘

Quiz question
The red line shows where we took a cross-section through the image. The wiggles in the blue line are the changes in intensity along it. At which letters do you expect strong edges?
A · B · C · D · E · F
Tutorial · Canny edges

exit-ramp.jpg

cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)

cv2.Canny(blur_gray, 50, 150)
edges = cv2.Canny(gray, low_threshold, high_threshold)
high_threshold are strong edges; below low_threshold are rejected; in between are kept if connected to strong edges.edges is a binary image: white pixels trace the edges, black everywhere else. (OpenCV Canny docs)cv2.GaussianBlur(gray, (k, k), 0) suppresses noise and spurious gradients by averaging.k must be odd (3, 5, 7 …); larger = smoother.Correction to the original lesson. The Udacity notes say cv2.Canny() “applies a 5×5 Gaussian internally”. It does not: OpenCV's Canny computes Sobel derivatives directly (apertureSize=3) with no smoothing. That is precisely why the explicit cv2.GaussianBlur step matters.
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
import numpy as np
import cv2
# Read in the image and convert to grayscale
image = mpimg.imread('exit-ramp.jpg')
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
# Gaussian smoothing / blurring (kernel size must be odd)
kernel_size = 5
blur_gray = cv2.GaussianBlur(gray, (kernel_size, kernel_size), 0)
# Canny with a 1:3 low:high ratio
low_threshold = 50
high_threshold = 150
edges = cv2.Canny(blur_gray, low_threshold, high_threshold)
plt.imshow(edges, cmap='Greys_r')

Lane lines kept, most other clutter removed
Live demo
exit-ramp.jpg
low = 40, high = 120
Gaussian blur 5×5 applied first. Edges drawn black on white.
Too low → texture, trees and signs everywhere. Too high → the lane lines start to break up. Hysteresis is what keeps a faint section that is connected to a strong one.
05
The Hough transform
From a cloud of edge pixels to straight lines

Image

Canny edges: just dots that happen to be lined up

Goal: connect the dots into a line
To find lines we need a model of a line, and a way to find which edge pixels fit it. In 1962 Paul Hough devised a method for representing lines in parameter space — “Hough space” in his honour.


A line \(y = m_0x + b_0\) is fully described by two numbers, \((m_0, b_0)\). Plot m horizontally and b vertically: the whole line becomes a single point. The Hough transform is just the conversion from image space to Hough space.

Question
If a line is a point in Hough space, what will two parallel lines in image space be?

Question
What does a point \((x_0, y_0)\) in image space correspond to in Hough space?

Question
What is the representation in Hough space of two points in image space?

Question
What does the intersection point \((m_0, b_0)\) of the two lines in Hough space correspond to in image space?

Problem: vertical lines have infinite slope — m cannot represent them.


\[ \rho = x\cos\theta + y\sin\theta \qquad \rho:\text{ distance from origin},\quad \theta:\text{ angle of the normal} \]
Every line — including vertical ones — has a finite (ρ, θ). Each point in image space now becomes a sinusoid; a line of points becomes many sinusoids that intersect at one (ρ, θ).

Question
What happens if we run a Hough transform on an image of a square? What will the plot in Hough space look like?
Live demo
Image space — click to add points
Hough space (θ 0→180°, ρ ↓): one sinusoid per point; the circled peak is drawn back as the red line
The masked Canny edges of exit-ramp.jpg voted into a (θ, ρ) accumulator (log scale). The two brightest cells are the left and right lane lines — everything else in the image left only a faint trace.
Tutorial · Hough lines
cv2.HoughLinesP: the parameters
lines = cv2.HoughLinesP(masked_edges, rho, theta, threshold, np.array([]),
min_line_length, max_line_gap)
| Parameter | Meaning | Start with |
|---|---|---|
masked_edges | Input: the binary output of Canny (after the region mask) | — |
rho | Distance resolution of the Hough grid, in pixels | 1–2 |
theta | Angular resolution of the grid, in radians | np.pi/180 (1°) |
threshold | Minimum number of votes (intersections in a grid cell) for a line | 10–30 |
np.array([]) | Placeholder for the optional output array — no need to change it | — |
min_line_length | Shortest line segment (pixels) accepted in the output | 20–50 |
max_line_gap | Largest gap (pixels) between segments that are joined into one line | 5–30 |
Output: an array of segments [[x1, y1, x2, y2]], … — or None if nothing is found. Tip: keyword arguments (minLineLength=…, maxLineGap=…) avoid the placeholder entirely.
# Canny (as before)
blur_gray = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blur_gray, 50, 150)
# Hough transform parameters
rho = 1 # distance resolution (pixels)
theta = np.pi/180 # angular resolution (radians)
threshold = 1 # min votes
min_line_length = 10 # min pixels in a line
max_line_gap = 1 # max gap between segments
line_image = np.zeros_like(image) # blank to draw on
lines = cv2.HoughLinesP(edges, rho, theta, threshold,
minLineLength=min_line_length,
maxLineGap=max_line_gap)
# Draw every segment on the blank
if lines is not None:
for x1, y1, x2, y2 in lines.reshape(-1, 4):
cv2.line(line_image, (x1, y1), (x2, y2), (255, 0, 0), 10)
# Overlay on a 3-channel version of the edge image
color_edges = np.dstack((edges, edges, edges))
combo = cv2.addWeighted(color_edges, 0.8, line_image, 1, 0)
plt.imshow(combo)

Lots of line segments! The parameters are far too permissive, and we have not masked a region yet.

Original

Starting parameters

Target: optimised + region-masked
Quiz
Add a quadrilateral region with cv2.fillPoly and tune the Hough parameters until only the lane lines remain.
[(0, h), (450, 290), (490, 290), (w, h)]; rho = 2, theta = π/180, threshold = 15 (≥ 15 points per segment), min_line_length = 40, max_line_gap = 20.06
The complete pipeline
Segments → two lane lines → a video stream
GaussianBlur k = 5fillPolyHoughLinesP
For each segment \(m = \frac{y_2 - y_1}{x_2 - x_1}\). Because y points down, the left lane has \(m < 0\) and the right lane \(m > 0\). Drop near-horizontal segments (\(|m| < 0.4\)).
Per side, average \(m\) and \(b = y - m x\) (weight by segment length). Then solve \(x = \frac{y - b}{m}\) at \(y = H\) (bottom) and \(y \approx 0.6H\) (top) and draw one thick line.
def lane_from_segments(segments, side, h, top=0.6):
ms, bs, ws = [], [], []
for x1, y1, x2, y2 in segments:
if x2 == x1:
continue # vertical: skip
m = (y2 - y1) / (x2 - x1)
if abs(m) < 0.4:
continue # near-horizontal noise
if (side == "left") != (m < 0):
continue # wrong side
ms.append(m)
bs.append(y1 - m * x1)
ws.append(np.hypot(x2 - x1, y2 - y1)) # longer = more trust
if not ws:
return None # nothing found this frame
m = np.average(ms, weights=ws)
b = np.average(bs, weights=ws)
y_bot, y_top = h, int(top * h)
return (int((y_bot - b) / m), y_bot,
int((y_top - b) / m), y_top)
draw_lines()).np.polyfit on all segment end-points, or the median slope (robust to outliers).A video is just a sequence of images (25 frames/s here). Apply the same process_image() to every frame:
import imageio.v2 as imageio
reader = imageio.get_reader("solidWhiteRight.mp4")
writer = imageio.get_writer("out.mp4", fps=25)
for frame in reader: # RGB frames
writer.append_data(process_image(frame))
writer.close()
The original Udacity notebook used moviepy's fl_image; its API changed in moviepy 2.x, so Lab 5 uses imageio (or cv2.VideoCapture, which returns BGR).
Per-frame lines jitter — and a car would steer twitchily. Keep a memory of previous frames, e.g. an exponential moving average:
\[ m_t \leftarrow (1-\alpha)\,m_{t-1} + \alpha\,m_{\text{new}},\quad \alpha \approx 0.2 \]
If a frame finds no line, reuse the previous one. This is a first taste of tracking (Lecture 11).
Reference pipeline on solidYellowLeft.mp4: HLS colour selection → Canny → region → Hough → length-weighted average → EMA smoothing.
| Symptom | Cause → fix |
|---|---|
| Yellow looks blue, colour thresholds “do nothing” | cv2.imread / VideoCapture give BGR; mpimg / imageio give RGB → convert once, be consistent. |
| Nothing survives a threshold of 200 | A .png read with mpimg is float 0–1 → scale or use cv2.imread. |
TypeError: 'NoneType' object is not iterable | HoughLinesP returned None → check before looping; reuse the last good lines. |
| Region mask is in the wrong place on the challenge video | Hard-coded pixel vertices → use fractions of width and height. |
| Division by zero / wild extrapolated lines | Vertical (x1 == x2) or near-horizontal segments → filter by slope. |
| Changing one image changes another | a = b aliases arrays → use np.copy. |
cv2.fillPoly error about the point array | Vertices must be an int32 array of shape (1, N, 2). |
Assignment 2 · out Week 6 · due Week 7
Write a pipeline that identifies the lane lines, first on six test images and then on two videos. Adapted from Project 1 of the Udacity Self-Driving Car Engineer Nanodegree (CarND-LaneLines-P1).

After Part 1: raw segments

After Part 2: one line per lane
| Part | Task | Marks |
|---|---|---|
| 1 | Build process_image(); run on all test images | 30 |
| 2 | Improve draw_lines(): average + extrapolate | 30 |
| 3 | Both videos, with smoothing across frames | 20 |
| 4 | Write-up: pipeline, shortcomings, improvements | 20 |
| ★ | challenge.mp4 (bonus) | +10 |
05-lane-finding-project.ipynb with all outputs.test_videos_output/solidWhiteRight.mp4 and solidYellowLeft.mp4.writeup_template.md).Academic integrity. Many solutions to this Udacity project exist online. Write your own code and be ready to explain every line in a short viva — copied code that you cannot explain earns no marks.
07
Beyond straight lines
Where this pipeline breaks — and what real systems do
Our straight lines “cut the corner” on the curve and wobble over the bright patch. Discussion: which assumption breaks first?

Seen in Part I: curved lane, radius of curvature and centre offset
This is Udacity's “Advanced Lane Finding” project — a great self-study extension of Assignment 2.
We return to CNNs, segmentation networks and transformers in Lectures 12–15. Today's pipeline is the baseline every learned model must beat — and it runs in real time on a Raspberry Pi.
All on GitHub: LAB/Lab_5
Credits. Adapted and updated from the Computer Vision Fundamentals / Finding Lane Lines lesson and Project 1 of the Udacity Self-Driving Car Engineer Nanodegree. Lesson illustrations and test data © Udacity, Inc.; project repository under the MIT licence. Used here for teaching purposes.
Questions?
Next: Lab 5 — build your own lane finder, then Assignment 2.
Intelligent Vision Systems; University of Lahore | Dr. Muhammad Kazim