A few weeks ago, my portfolio was mostly tabular data — CSVs, groupby(), Seaborn charts, the occasional RAG pipeline. Useful skills, but all of it lived inside static datasets. I wanted to try something that moved: a model that could look at a live video feed and tell me what it was seeing, frame by frame, in real time.
This post walks through what I built, what broke along the way, and what I'd tell someone starting the same project today.
The goal
Detect people and vehicles — starting from a single photo, then working up to a live webcam feed. Nothing exotic. The interesting part wasn't the end result; it was the gap between "it works on one image" and "it works live, continuously, without falling over."
Stack: YOLOv8 (Ultralytics) for detection, OpenCV for video I/O and drawing, Python holding it all together.
Stage 1 — Prove it works on a single image first
Before touching a webcam, I ran YOLOv8 on one static test photo. This sounds like a trivial step, and it is — which is exactly why I didn't skip it.
import cv2
import matplotlib.pyplot as plt
from ultralytics import YOLO
image = cv2.imread("bike_test.jpg")
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
plt.imshow(image)
plt.axis("off")
One thing that trips up almost everyone the first time: OpenCV reads images in BGR, not RGB. Skip the cv2.cvtColor(..., cv2.COLOR_BGR2RGB) step and your colors come out looking sunburnt — reds and blues swapped. It's a one-line fix, but only if you know to look for it.
From there, loading and running YOLOv8 is almost anticlimactic:
model = YOLO("yolov8n.pt") # the "n" (nano) variant — fastest, good enough for a first pass
results = model("bike_test.jpg")
results[0].save(filename="output.jpg")
Three lines, and I had bounding boxes around a bicycle in the test image. Sanity-checking on a single frame here saved me from debugging model-loading issues and real-time performance issues at the same time later — one problem at a time.
Stage 2 — Take it live
Once the model worked on a photo, the next step was pointing it at a webcam instead of a file path. Conceptually it's the same call, just wrapped in a loop:
model = YOLO('yolov8n.pt')
cap = cv2.VideoCapture(0) # 0 = default webcam
while True:
success, frame = cap.read()
if not success:
break
results = model(frame, stream=True)
for result in results:
for box in result.boxes:
label = result.names[int(box.cls)]
if label == 'person':
x1, y1, x2, y2 = map(int, box.xyxy[0])
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.putText(frame, label, (x1, y1 - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
cv2.imshow('Live Person Detection', frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
stream=True matters here — it tells Ultralytics to process frames as a generator instead of batching them, which keeps memory use flat instead of growing frame after frame. The first time I ran this and saw a green box lock onto me and follow me around the frame, it didn't feel like "a few lines of code" — it felt like the thing had noticed me.
Stage 3 — Reaching too far, and what I learned from pulling back
The natural next step felt obvious: why stop at "there's a person," when I could also guess their emotion and gender? I wired in DeepFace on top of the YOLO output, cropping each detected person and running facial analysis on the crop.
It worked — briefly, on paper. In practice, DeepFace pulls in TensorFlow as a dependency, and TensorFlow on Windows has a well-earned reputation for being finicky about versions, CUDA, and DLLs. I lost more time fighting installation errors than I spent on the actual computer vision.
I tried a leaner alternative — swapping DeepFace for two small models loaded directly through OpenCV's own dnn module (a Caffe model for gender, an ONNX model for emotion), which avoids the TensorFlow dependency entirely. It's a genuinely good pattern, and I'd recommend it to anyone who wants this feature. But for this project, I made a deliberate call to cut it. Not every feature that's technically possible is worth the added fragility, especially in something meant to be a clean, demonstrable portfolio piece. I'd rather ship two stages that run reliably every time than three stages where the last one might not run on your machine.
That's the real lesson from this project, more than any single line of code: knowing when to cut scope is part of the engineering, not a failure of it.
What I'd tell someone starting this today
- Validate on one static example before going real-time. It isolates whether the problem is your model setup or your real-time loop.
-
cv2.cvtColor(..., cv2.COLOR_BGR2RGB)is not optional if you're displaying OpenCV images anywhere outside OpenCV's ownimshow. -
stream=Trueon video keeps Ultralytics from silently ballooning memory usage over a long-running loop. -
Clamp your bounding boxes.
max(0, x1)andmin(frame.shape[1], x2)cost you two lines and save you from a crash the moment a detection box clips the edge of the frame. -
Heavier isn't always better. A dependency like TensorFlow-via-DeepFace can cost you more in reliability than it gives you in features. Lighter, more surgical tools (OpenCV's own
dnnmodule, in this case) are often the better trade for a portfolio project meant to just work when someone clones it.
What's next
I'm keeping the gender/emotion layer as a documented "next step" rather than shipping it half-working — I'd rather build it properly with the OpenCV DNN approach once I've got a clean way to package the model downloads. Beyond that, I want to add an on-screen FPS counter so I can actually quantify the cost of each added layer, instead of eyeballing "does this feel slower."
If you want to see the full notebook, it's on my GitHub — feedback and issues welcome, especially if you spot something I'd benefit from unlearning early rather than later.
Repo: github.com/sandipsubedi0
Connect: linkedin.com/in/sandip-subedi01
Top comments (0)