A classical OpenCV pipeline that locates traffic-sign candidates in a live webcam feed using HSV colour segmentation, Canny edge detection and contour filtering, then labels each region by template matching against four reference signs. No machine learning is involved.
The program opens the default camera and processes every frame in turn. Traffic signs are visually distinctive — strong reds and blues, high-contrast borders, compact convex shapes — and this project exploits exactly those properties with hand-tuned image processing instead of a learned model. Colour segmentation isolates the regions that could plausibly be a sign, edge and contour analysis reduces them to a handful of bounding boxes, and each box is compared against four template images to decide which sign it resembles.
Originally developed as a university computer-vision project. It uses classical image processing throughout — there is no neural network, no training step and no learned features.
Scope, stated plainly: input is a live camera stream only. There is no code path for reading image files or video files, the recognised vocabulary is four signs, and the project contains no labelled evaluation. See Evaluation and Limitations.
Webcam frame (640x480 requested)
│
▼
HSV colour segmentation
red (H 0-10 and 160-179, S>=100, V>=100)
blue (H 100-140, S>=100, V>=100)
white (S<=50, V>=180)
masks OR-combined, then applied back to the colour frame
│
▼
Gaussian blur (7x7, sigma 2.0)
│
▼
Greyscale → Canny edge detection (100, 200)
│
▼
Contour extraction (RETR_TREE, CHAIN_APPROX_NONE)
│
▼
Candidate filtering, per contour
polygon approximation (epsilon = 0.02 x perimeter) → bounding box
keep if aspect ratio < 3 and area > 1500 and contour is outermost
│
▼
Template matching (TM_CCOEFF_NORMED, edge map vs edge map)
each of the 4 templates resized to the box, best score wins
│
▼
Temporal vote → bounding box + label + FPS overlay
Templates are preprocessed once at startup with the same kind of transform applied to frames: Gaussian blur (7×7, σ 2.6) → greyscale → Canny (210, 220). Matching therefore compares an edge map against an edge map, not raw pixels, which is what makes it tolerant of the colour and brightness differences between a printed reference sign and a camera image.
| Technique | Where | Why it is used |
|---|---|---|
| HSV colour thresholding | colorSegmentation |
Hue separates sign colours from the scene far more stably than RGB does under changing brightness. Red wraps around the hue circle, so it needs two ranges (0–10 and 160–179). |
| Mask combination (bitwise OR / AND) | colorSegmentation |
Red, blue and white masks are unioned, then applied to the frame so that only sign-coloured pixels survive into the edge stage. |
| Gaussian blur | frame loop, loadTemplate |
Suppresses sensor noise and mask speckle so Canny produces continuous contours instead of fragments. |
| Canny edge detection | frame loop, loadTemplate |
Reduces both frames and templates to thin binary outlines, giving a representation that is comparable across lighting conditions. |
| Contour extraction | detectSign |
Turns the edge image into discrete closed regions that can be measured and filtered individually. |
| Contour hierarchy filtering | detectSign |
Keeping only outermost contours (hierarchy[…,3] == -1) discards the inner pictogram outlines so each sign yields one box, not several nested ones. |
| Polygon approximation | detectSign |
approxPolyDP smooths each contour before its bounding box is taken, so noisy outlines do not inflate the box. |
| Geometric filtering | detectSign |
Signs are compact, so candidates with an aspect ratio ≥ 3 or an area ≤ 1500 px are rejected before the expensive matching step. |
| Template matching | templateMatching |
Normalised cross-correlation (TM_CCOEFF_NORMED) between the candidate's edges and each resized template's edges assigns the class. |
| Temporal majority vote | templateMatching |
A rolling buffer of recent best matches is reduced with statistics.mode, intended to stop the label flickering between frames. |
Note what is not used: there are no morphological operations, no Hough transforms, no vertex-count shape classification (the polygon approximation feeds only the bounding box), and no learned model of any kind.
Exactly four, fixed in main.py and named after their template files:
| Class | Template | Sign shape and colour |
|---|---|---|
no_enter |
no_enter.png |
Red circle, white bar |
bumps |
bumps.png |
Red-bordered triangle, black pictogram |
give_way |
give_way.png |
Red-bordered inverted triangle |
pedestrian |
pedestrian.png |
Blue square, white triangle and pictogram |
Adding a class means dropping a PNG into the repository root and adding its filename to
template_paths — the label is derived from the filename with the extension stripped.
Figures produced by tools/pipeline_demo.py, which imports the
functions from main.py unmodified and runs them on a synthetic frame built by
compositing the repository's own four template images onto a flat background.
This is an illustration of the processing stages, not a measure of accuracy. The input is the template images themselves, which is the easiest possible case — nothing about real-world performance can be inferred from it.
All four signs survive colour segmentation, produce clean closed contours, and pass the geometric filter. The per-region template scores behind panel 4 are:
| Candidate region | Best match | Score | Runner-up |
|---|---|---|---|
| Red circle | no_enter |
0.221 | give_way (0.021) |
| Inverted triangle | give_way |
0.233 | no_enter (0.016) |
| Blue square | pedestrian |
0.181 | bumps (0.001) |
| Red triangle | bumps |
0.121 | no_enter (0.007) |
The matcher picks the correct template for all four, and by a wide relative margin. Two things
are still worth noticing: the absolute correlation peaks are low (0.12–0.23) even in this ideal
case, which is characteristic of sparse edge-map correlation; and every box in panel 4 is
labelled pedestrian, because the drawn label is the mode of a buffer shared by all regions
rather than each region's own best match. That behaviour comes from the original code, is
reproduced here unchanged, and is explained under
Known Implementation Limitations.
There is no evaluation in this project. No labelled test set, no ground-truth annotations, no accuracy, precision or recall figures — none were produced when the project was written, and none are invented here.
No FPS figure was recorded historically either. main.py computes and overlays a live FPS
counter, but that value is only displayed on screen; it is not logged or stored anywhere in the
repository.
Reproduced measurements — obtained now, on the synthetic frame described above, and clearly not a substitute for a real evaluation:
| Measurement | Value |
|---|---|
| Processing chain, 640×480 frame with 4 sign candidates | median 8.1 ms (min 7.7, max 28.0 over 30 runs) |
| Environment | Python 3.11, OpenCV 5.0.0, Apple Silicon |
| Excluded | camera capture, window rendering |
The cost scales with the number of candidate regions that survive filtering, since every surviving region is correlated against all four templates. A cluttered scene will therefore be slower than this figure suggests, and no claim of real-time performance is made on the basis of one synthetic frame.
.
├── main.py Entire implementation: segmentation, edges, contours, matching, UI
├── no_enter.png Template — red circle with white bar
├── bumps.png Template — uneven road warning triangle
├── give_way.png Template — give way / yield triangle
├── pedestrian.png Template — pedestrian crossing square
├── requirements.txt Third-party dependencies
└── tools/
└── pipeline_demo.py Renders the README figures from the templates above
main.py is a single flat module of six functions: colorSegmentation, loadTemplate,
drawRectangle, detectSign, templateMatching and main.
Requires Python 3 and a working webcam — the program calls cv2.VideoCapture(0) and has no
file-input mode.
python3 -m pip install -r requirements.txtpython3 main.pyA window opens showing the camera feed with candidate boxes, labels and an FPS counter drawn on it. Press q to quit. Templates are loaded by bare filename, so run the command from the repository root.
To regenerate the README figures without a camera:
python3 tools/pipeline_demo.py- Python 3
- OpenCV (
opencv-python) — colour conversion, blurring, Canny, contours, template matching, GUI - NumPy — HSV threshold bounds and array handling
- Python standard library —
statisticsfor the label vote,timefor the FPS counter
Nothing else. There is no deep-learning framework, no training pipeline and no dataset loader in this project.
Documented against the code as written. The original implementation is preserved unchanged, so the notes below describe it exactly as it behaves rather than as it might be rewritten.
Inherent to the classical approach itself:
- Fixed, hand-tuned thresholds. Every constant — the HSV bounds, the Canny hysteresis pair, the 1500 px area floor, the aspect-ratio cap of 3 — was chosen by hand for particular conditions. Nothing adapts to the scene.
- Colour segmentation gates everything. A sign whose colours fall outside the HSV bands never reaches the edge stage, so dusk, strong shadow, glare, colour cast or a faded sign removes it entirely.
- Template matching is not scale- or rotation-invariant. Templates are rescaled to each candidate box, but a rotated or perspective-skewed sign correlates poorly against a fronto-parallel template.
- Four classes only, all of them clean rasterised pictograms rather than photographs of real signs, which widens the gap between template and camera appearance.
- No shape classification.
approxPolyDPis computed but its vertex count is never used, so a triangle and a circle are separated only by the correlation score. - Any sufficiently large, sign-coloured, roughly square object is a candidate — the filter is purely geometric.
These are characteristics of the historical implementation, retained as written:
- The displayed label reflects a global vote, not the region's own match.
templateMatchingcomputes the correct best match per region, then drawsstatistics.mode(past)— the mode of a buffer shared across every region and every frame. All boxes in a frame consequently show the same label, visible in the figure above. The intent was to stabilise labels over time; the effect is that per-region identity is not shown. - The acceptance threshold provides almost no rejection. A match is accepted when
bestScore > 0.001, a value almost any correlation clears, so weak matches still enter the vote buffer. - The vote buffer is cleared, not slid. When
pastexceeds 50 entries it is emptied outright rather than dropping its oldest element, so the label history restarts periodically. - Bounding-box width and height are transposed.
cv2.boundingRectreturns(x, y, w, h), while the historical code assigns the result tox, y, h, w. Because signs are near-square the drawn boxes usually look correct, and the aspect-ratio test is unaffected since it takes the larger dimension over the smaller; an elongated candidate, however, is cropped and boxed with its dimensions transposed. - No guard on frame capture. The return flag from
cap.read()is ignored, so the program raises if the camera drops a frame or is unavailable. import timeappears three times.
- No labelled data and no measured accuracy exist for this project, so its real-world detection and recognition performance is simply unknown.
The four template PNGs are 1024 px rasterisations of standard traffic-sign pictograms, carrying only ImageMagick creation timestamps from 2022 in their metadata — no author, source or copyright field. Their origin is not documented anywhere in the repository, and no licensing claim is made about them here.
For the same reason no LICENSE file has been added: the code and the bundled images would
need to be licensed separately, and the images' rights are unclear.
Originally developed as a university computer-vision project and retained as an example of classical image-processing and visual-recognition work.
