Computer Vision I (CSCI 3240U)
Faculty of Science, Ontario Tech University
http://vclab.science.ontariotechu.ca
Check Canvas for Due Date
The goal of this lab is to compute image gradients and use them to find the edges that matter in a driving scene.
This is the first half of a two-part problem. Here you will produce an edge map of the road — a set of pixels that plausibly lie on a road boundary or a lane marking. In Lab 6 you will come back to these same images and fit actual lines to those pixels using the Hough transform and RANSAC. By the end of Lab 6 you will have something that genuinely detects road boundaries. Today you build the raw material.
Gradients are the workhorse of classical computer vision. Corner detection (Lab 7), local descriptors (Lab 7) and optical flow are all built on what you write here.
Computer Vision: Algorithms and Applications (2nd ed.), Szeliski — Sec. 3.2.3, 7.2.1.
Band-pass and steerable filters; edge detection.
This lab uses the KITTI Road benchmark. See Getting the KITTI data — run
fetch-kitti.sh --left-only; you do not need the stereo
images yet.
Three sample scenes are included with the handout in ../data/kitti-samples so you can start before the download finishes. Work with all three categories:
um_* — urban marked roadumm_* — urban multiple marked
lanesuu_* — urban unmarked roadA method that only works on um images is not
finished.
Parsing KITTI’s calibration format and decoding its colour-coded ground truth is plumbing, not computer vision, and debugging it eats time you should be spending on the lab. A small helper module is provided: kitti.py.
import kitti
ds = kitti.Dataset("path/to/data_road") # point at the extracted folder
img = ds.image("um_000032") # left image, RGB uint8
cal = ds.calib("um_000032") # cal.K, cal.fx, cal.baseline, ...
pos, valid = ds.ground_truth("um_000032") # boolean masksIt deliberately does not implement anything you are asked to write yourself — no edge detection, no region of interest, no scoring. You are free to ignore it and do your own file handling if you prefer.
Start with the toy image square.png, a white square over a black background. A synthetic image makes it far easier to tell whether your gradients are correct. Once your code works, move to the KITTI images.
Produce the orientation histogram for a um image and for
a uu image. The marked road should show structure that the
unmarked one does not. Describe it, and say which orientations you would
expect lane markings to occupy given where the camera sits.
For an image \(I\) and its rotated
version \(I_\mathrm{rot}\), show that
the gradient magnitude histogram stays roughly the same while the
orientation histogram changes. Implement a method that returns
True if two histograms are the same and False
otherwise.
Hold on to what you observe here. It is exactly the problem you will have to solve when you build a rotation-invariant descriptor in Lab 7.
Raw gradient magnitude is not an edge map. Turn it into one.
Scale gradient magnitudes to \([0, 255]\), choose a threshold, and keep the pixels above it.
Show results for a threshold that is clearly too low, one that is clearly too high, and your chosen value. Say how you chose it. A threshold picked by staring at one image is a threshold that will fail on the next one — can you choose it from the image statistics instead?
Implement or apply the Canny edge detector and compare its output against your simple threshold on the same image.
Canny adds two things your threshold does not have: non-maximum suppression and hysteresis. Explain, with cropped examples from a KITTI image, what each one buys you.
You may use cv.Canny here, but you must explain what it
is doing.
Most of a KITTI image is sky, buildings and trees, and none of it is road. Define a region of interest — a polygon covering the part of the image where road can plausibly appear — and discard edges outside it.
Show your ROI and the edge map before and after masking. Roughly what fraction of your edge pixels did the ROI remove?
A fixed polygon is fine for now. Note in your write-up what would break it.
This is the part that separates “it looks about right” from “it works”.
KITTI provides hand-labelled ground truth in gt_image_2.
The masks are colour coded: magenta is the positive
region (road, or ego lane), red is the negative region,
and black marks “do not care” pixels which must be
excluded from any score you compute.
The ground truth marks the road region, not its boundary. Extract the boundary of the magenta region — its outline — and treat those pixels as the reference edge map.
This is the first lab in which you evaluate your own output against a reference, so here is the machinery. You will use it again in Labs 5, 6 and 9.
Every pixel you could have flagged falls into one of four boxes. Write \(P\) for the set of pixels you marked as edge, and \(R\) for the set of reference boundary pixels:
| in \(R\) (really a boundary) | not in \(R\) | |
|---|---|---|
| in \(P\) (you said edge) | true positive (TP) | false positive (FP) |
| not in \(P\) | false negative (FN) | true negative (TN) |
From these, \[ \text{precision} = \frac{TP}{TP+FP}, \qquad \text{recall} = \frac{TP}{TP+FN}. \]
Precision answers “of the things I flagged, how many were right?” Recall answers “of the things I should have found, how many did I find?” They trade off: flag every pixel and recall is \(1\) while precision is terrible; flag one pixel you are certain about and precision is \(1\) while recall is nearly \(0\).
The F1 score is their harmonic mean, used when you want one number: \[ F_1 = \frac{2 \cdot \text{precision} \cdot \text{recall}}{\text{precision} + \text{recall}}. \]
Note that accuracy, \((TP+TN)/(\text{everything})\), is useless here. The boundary is under half a percent of the image, so predicting “no edge anywhere” scores over 99% accurate. Never report accuracy on a problem this unbalanced.
Your edge sits one pixel to the left of the reference. Counted strictly, that is both a false positive and a false negative — you are punished twice for being almost exactly right. Since the reference was traced by a human, that is unreasonable.
So we match within a tolerance \(\tau\). A detected pixel counts as a true positive if there is some reference pixel within \(\tau\) of it, and a reference pixel counts as found if there is some detected pixel within \(\tau\) of it. Note this makes precision and recall separate calculations rather than two readings of one contingency table — that is normal for boundary evaluation.
The cheap way to compute both is a distance
transform.
scipy.ndimage.distance_transform_edt(~mask) returns, for
every pixel, the distance to the nearest True pixel of
mask. So:
d_to_ref = distance_transform_edt(~reference) # distance to nearest reference pixel
d_to_pred = distance_transform_edt(~predicted) # distance to nearest detected pixel
precision = (d_to_ref [predicted] <= tau).mean()
recall = (d_to_pred[reference] <= tau).mean()Two lines, no loops. Restrict both to the valid (non-void) pixels first.
KITTI marks some pixels black, meaning the annotator declined to label them. Those must be dropped from both sets before you count anything — otherwise your numbers depend on how much unlabelled area happens to be in each image and are not comparable between scenes.
Before you measure anything, understand what you are measuring.
The reference boundary is a thin curve — roughly \(0.4\%\) of the pixels in the image. A correct edge detector will also fire on the kerb, the lane markings, other vehicles, buildings and trees, because those are all genuine edges. So the number of edge pixels you produce will exceed the number of reference pixels by an order of magnitude or more, and precision will be low no matter how good your detector is. That is a property of the task at this stage, not a bug in your code. Do not go chasing it.
What you can meaningfully measure right now is whether the boundary survives, and how much junk you carry along with it. An edge pixel a couple of pixels away from the reference should not count as a miss, so use a tolerance of \(\tau\) pixels. Report:
Use \(\tau = 3\) pixels, over at least 10 images from each of the three categories. Remember to exclude the “do not care” pixels from every count.
Sweep your edge threshold and plot recall against selectivity (a log scale on the selectivity axis is easiest to read). Mark the operating point you would hand to Lab 6, and justify it: a detector that keeps \(95\%\) of the boundary but hands on \(50\times\) as many candidates may well be a worse starting point than one that keeps \(80\%\) and hands on \(5\times\).
Then answer:
um, umm, uu)
gives the best recall? The answer is probably not the one you expected —
explain what is going on in the images that accounts for it.Keep your numbers. In Lab 6 you will fit lines to these candidates, and precision finally becomes a fair thing to measure.
Your notebook must contain the following.
um vs uu comparison.Via Canvas. Please submit a single executed Jupyter notebook — one that has been run top to bottom, so that every figure and number listed above is visible in the submitted file. Code that has not been executed cannot be marked.
numpy and
scipy array operations, reusing the convolution you wrote
in Lab 3. You may use cv.Canny in Part 2.