You want a list of every course, including courses with no active enrollments. You write a LEFT JOIN, add a filter, and suddenly some courses disappear. The SQL runs without an error, but it answers a different question.
In this guide, we reproduce the issue and compare conditions in WHERE and ON. We also test why OR ... IS NULL is not always a complete repair.
What you need
Use PostgreSQL and a SQL client such as the pgAdmin Query Tool. Each self-contained SELECT uses WITH and VALUES to create temporary input sets for that statement only. No persistent tables are created or changed. Run one complete block at a time. The example was verified on PostgreSQL 18.6; client formatting may differ.
The courses CTE is the left input and enrollments is the right input; aliases c and e refer to them. Our goal is to keep every course and show its active enrollments, if any.
1. Start with the input
WITH courses (course_id, title) AS (
VALUES (101, 'SQL Foundations'),
(102, 'Java API'),
(103, 'Data Modeling'),
(104, 'PostgreSQL Performance')
), enrollments (enrollment_id, course_id, status) AS (
VALUES (1, 101, 'active'),
(2, 101, 'cancelled'),
(3, 102, 'cancelled'),
(4, 104, 'active')
)
SELECT c.course_id, c.title, e.enrollment_id, e.status
FROM courses AS c
LEFT JOIN enrollments AS e ON e.course_id = c.course_id
ORDER BY c.course_id, e.enrollment_id;
Result: five rows.
course_id
title
enrollment_id
status
101
SQL Foundations
1
active
101
SQL Foundations
2
cancelled
102
Java API
3
cancelled
103
Data Modeling
NULL
NULL
104
PostgreSQL Performance
4
active
Course 101 has two enrollments, 102 has only a cancelled enrollment, 103 has none, and 104 has one active enrollment. The unmatched course 103 is retained with NULL in the right-hand columns.
2. A WHERE condition removes rows
WITH courses (course_id, title) AS (
VALUES (101, 'SQL Foundations'),
(102, 'Java API'),
(103, 'Data Modeling'),
(104, 'PostgreSQL Performance')
), enrollments (enrollment_id, course_id, status) AS (
VALUES (1, 101, 'active'),
(2, 101, 'cancelled'),
(3, 102, 'cancelled'),
(4, 104, 'active')
)
SELECT c.course_id, c.title, e.enrollment_id, e.status
FROM courses AS c
LEFT JOIN enrollments AS e ON e.course_id = c.course_id
WHERE e.status = 'active'
ORDER BY c.course_id, e.enrollment_id;
Result: two rows.
course_id
title
enrollment_id
status
101
SQL Foundations
1
active
104
PostgreSQL Performance
4
active
After matching on course ID, WHERE retains rows for which the status condition is true. Cancelled rows fail it; comparing NULL with 'active' yields unknown, not true. Courses 102 and 103 disappear. This query is appropriate if the goal is only active matches; it does not satisfy our requirement to keep every course. This describes logical semantics; the optimizer can choose a different physical plan.
3. Why OR IS NULL is not always enough
WITH courses (course_id, title) AS (
VALUES (101, 'SQL Foundations'),
(102, 'Java API'),
(103, 'Data Modeling'),
(104, 'PostgreSQL Performance')
), enrollments (enrollment_id, course_id, status) AS (
VALUES (1, 101, 'active'),
(2, 101, 'cancelled'),
(3, 102, 'cancelled'),
(4, 104, 'active')
)
SELECT c.course_id, c.title, e.enrollment_id, e.status
FROM courses AS c
LEFT JOIN enrollments AS e ON e.course_id = c.course_id
WHERE e.status = 'active' OR e.enrollment_id IS NULL
ORDER BY c.course_id, e.enrollment_id;
Result: three rows.
course_id
title
enrollment_id
status
101
SQL Foundations
1
active
103
Data Modeling
NULL
NULL
104
PostgreSQL Performance
4
active
Course 103 returns because it had no matching enrollment. Course 102 still disappears: its cancelled enrollment matched the join, so no null-extended row was generated. Its enrollment ID is non-null and its status is not active. This example uses non-null enrollment IDs; nullable identifiers can make this test ambiguous.
4. Put the match condition in ON
WITH courses (course_id, title) AS (
VALUES (101, 'SQL Foundations'),
(102, 'Java API'),
(103, 'Data Modeling'),
(104, 'PostgreSQL Performance')
), enrollments (enrollment_id, course_id, status) AS (
VALUES (1, 101, 'active'),
(2, 101, 'cancelled'),
(3, 102, 'cancelled'),
(4, 104, 'active')
)
SELECT c.course_id, c.title, e.enrollment_id, e.status
FROM courses AS c
LEFT JOIN enrollments AS e
ON e.course_id = c.course_id
AND e.status = 'active'
ORDER BY c.course_id, e.enrollment_id;
Result: four rows.
course_id
title
enrollment_id
status
101
SQL Foundations
1
active
102
Java API
NULL
NULL
103
Data Modeling
NULL
NULL
104
PostgreSQL Performance
4
active
Now a match requires the same course ID and an active status. Courses 102 and 103 are both retained with null right-hand values: neither has an active match. This result alone does not distinguish a cancelled-only course from a course with no enrollments.
A LEFT JOIN does not guarantee one row per course: two active enrollments produce two rows. If you need exactly one, decide whether to aggregate, test existence, or select one enrollment by a defined rule.
Try it yourself
Add a second active enrollment for course 101 and run the final query. Which course appears twice? Then try WHERE e.enrollment_id IS NULL after the final join to find courses with no active match. That is a different question from finding courses with no enrollment of any status.
Why can a rectangular object detector box include so much empty background? When a ship appears at an angle, a rectangle aligned with the image edges may be much larger than a rotated rectangle around the same prediction. In this hands-on Python example, we use a YOLO oriented bounding box (OBB) model, inspect its four corner points and compare two ways of drawing that same prediction.
Important: this is a comparison of box geometry, not a test of two different detectors or a measurement of detection accuracy. A predicted box may miss part of an object, identify the wrong object, or overlap another prediction.
Watch the tutorial
Watch the full YouTube tutorial for the prediction, the geometry and its limitations.
Prefer a quick demonstration? Watch the YouTube Short.
See the difference
The same predicted rectangle drawn two ways. Left: its orange axis-aligned enclosing rectangle. Right: the model's green oriented box. This is the highest-confidence prediction in this run.
For this selected prediction, the orange rectangle has about 3.75 times the area of the green one. This ratio is calculated from the predicted coordinates and describes this single example. It does not say how accurately either box matches the true ship.
What is an oriented bounding box?
An axis-aligned bounding box is parallel to the image edges. An OBB can rotate to follow an object's orientation. Ultralytics exposes the four corners through result.obb.xyxyxyxy and a center, width, height and rotation representation through result.obb.xywhr. The angles in the latter are in radians. An OBB has no inherent front or back direction: rotating a rectangle by 180 degrees produces the same rectangle. See the Ultralytics OBB documentation; online documentation may describe versions newer than the one tested here.
Neither a standard box nor an OBB is a segmentation mask. Both are rectangles and can contain background. A smaller rectangle is not automatically a better or more accurate detection; that requires comparison with appropriate ground-truth annotations.
The image and model
The input below is a synthetic harbor image from the course. It is shown so you can see exactly what the program receives. You can save the image for this demonstration or use your own permitted image; other inputs may yield different results.
Input: a synthetic 1024 × 1024 harbor image from the course.
We used the official YOLOv8n-OBB checkpoint locally. It is an aerial-image-oriented model, so the example uses ships. The tested model's SHA-256 is fa6e4cd2691f132875c143135affaa66b5d89394ebb1d07d19770a9b6382c1b8. Choose a model appropriate to the objects you want to detect.
Set up the example
The actual run used Windows CPU, Python 3.13.9, Ultralytics 8.4.117, OpenCV 5.0.0, NumPy 2.5.1 and PyTorch 2.14.1+cpu. The code below was run in that existing environment; a fresh installation has not been independently verified. On Windows, create an environment and install the direct package:
Installing packages and obtaining the checkpoint require internet access unless they are already available locally. The command pins the direct package but not every dependency. Place the image in your project folder as input.png, the downloaded checkpoint as yolov8n-obb.pt, and the complete example below as obb_demo.py.
Complete Python example
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Run one oriented detector and draw its axis-aligned enclosing rectangles.
The horizontal boxes are computed from the *same* oriented predictions; they
are not predictions from a second model. Model detections are not ground truth.
"""
import argparse
import hashlib
import json
from pathlib import Path
import cv2
import numpy as np
from ultralytics import YOLO
def sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--image', type=Path, required=True)
parser.add_argument('--weights', type=Path, required=True)
parser.add_argument('--output', type=Path, default=Path('output'))
args = parser.parse_args()
for path in (args.image, args.weights):
if not path.is_file():
raise FileNotFoundError(path)
image = cv2.imread(str(args.image))
if image is None:
raise ValueError(f'Cannot read image: {args.image}')
args.output.mkdir(parents=True, exist_ok=True)
model = YOLO(str(args.weights))
result = model.predict(source=str(args.image), conf=0.25, imgsz=1024,
device='cpu', verbose=False)[0]
if result.obb is None or len(result.obb) == 0:
raise ValueError('No oriented detections in this image')
corners = result.obb.xyxyxyxy.cpu().numpy()
xywhr = result.obb.xywhr.cpu().numpy()
scores = result.obb.conf.cpu().numpy()
classes = result.obb.cls.cpu().numpy().astype(int)
lower = corners.min(axis=1)
upper = corners.max(axis=1)
obb_area = xywhr[:, 2] * xywhr[:, 3]
span = upper - lower
axis_area = span[:, 0] * span[:, 1]
if np.any(obb_area <= 0):
raise ValueError('Invalid oriented box area')
ratios = axis_area / obb_area
if np.any(ratios < 0.999):
raise ValueError('Axis-aligned box should enclose its oriented box')
rotated = image.copy()
aligned = image.copy()
rows = []
for i, points in enumerate(corners):
poly = np.rint(points).astype(np.int32)
lo = tuple(np.floor(lower[i]).astype(int))
hi = tuple(np.ceil(upper[i]).astype(int))
cv2.polylines(rotated, [poly], True, (34, 220, 66), 3, cv2.LINE_AA)
cv2.rectangle(aligned, lo, hi, (0, 192, 255), 3, cv2.LINE_AA)
rows.append({'id': i + 1, 'class': result.names[int(classes[i])],
'confidence': float(scores[i]), 'corners': points.tolist(),
'axis_box_xyxy': [*lower[i].tolist(), *upper[i].tolist()],
'obb_area': float(obb_area[i]), 'axis_area': float(axis_area[i]),
'axis_over_obb': float(ratios[i]),
'angle_deg': float(np.degrees(xywhr[i, 4]))})
def save(path, img):
if not cv2.imwrite(str(path), img):
raise RuntimeError(f'Failed to write {path}')
save(args.output / 'annotated_obb.png', rotated)
save(args.output / 'annotated_axis.png', aligned)
# Highest-scoring detection is used as an example, not as ground truth.
best = int(np.argmax(scores))
x1, y1 = np.floor(lower[best] - 50).astype(int)
x2, y2 = np.ceil(upper[best] + 50).astype(int)
h, w = image.shape[:2]
x1, y1, x2, y2 = max(0, x1), max(0, y1), min(w, x2), min(h, y2)
if x1 >= x2 or y1 >= y2:
raise ValueError('Could not crop selected detection')
save(args.output / 'crop_original.png', image[y1:y2, x1:x2])
# Draw only selected detection on each crop so nearby boxes do not mislead.
a = image.copy()
b = image.copy()
cv2.rectangle(a, tuple(np.floor(lower[best]).astype(int)),
tuple(np.ceil(upper[best]).astype(int)), (0, 192, 255), 4, cv2.LINE_AA)
cv2.polylines(b, [np.rint(corners[best]).astype(np.int32)], True,
(34, 220, 66), 4, cv2.LINE_AA)
save(args.output / 'crop_axis.png', a[y1:y2, x1:x2])
save(args.output / 'crop_obb.png', b[y1:y2, x1:x2])
save(args.output / 'comparison.png', np.hstack((a[y1:y2, x1:x2], b[y1:y2, x1:x2])))
report = {'image_sha256': sha256(args.image), 'weights_sha256': sha256(args.weights),
'image_shape_hw': [h, w], 'model': 'yolov8n-obb', 'device': 'cpu',
'confidence_threshold': 0.25, 'imgsz': 1024,
'count': len(rows), 'classes': sorted(set(x['class'] for x in rows)),
'mean_axis_over_obb': float(ratios.mean()),
'selected_id': best + 1, 'selected_axis_over_obb': float(ratios[best]),
'selected_crop_xyxy': [int(x1), int(y1), int(x2), int(y2)],
'detections': rows,
'note': 'Axis boxes computed from the same predicted OBB corners, not a second detector. No ground truth assessed.'}
(args.output / 'result.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print(json.dumps({k: v for k, v in report.items() if k != 'detections'}, indent=2))
if __name__ == '__main__':
main()
The script saves the original crop, an orange horizontal-box view, a green OBB view, a side-by-side comparison and the coordinates and ratios in output/result.json. It explicitly checks that the image and weights exist, then runs a single CPU prediction at confidence 0.25 and image size 1024.
How the comparison is calculated
The model returns four corner points for each predicted oriented box.
The code takes the minimum and maximum x and y values of those same points to obtain an enclosing axis-aligned rectangle.
It divides the area of that horizontal rectangle by the area of the oriented box.
This calculation compares the enclosing geometry of an OBB prediction, not the output of a conventional object-detection model. Rounding the outline for drawing can make rendered line positions differ slightly from the floating-point coordinates used for areas.
What this run actually found
On this one synthetic image, the model returned 9 boxes labelled ship. The mean ratio of axis-aligned enclosing area to OBB area was about 2.73 for those predictions. The numbers are not a count of verified ships, a recall score, an accuracy benchmark or a prediction for other images.
All oriented predictions from the same run. The upper-right ship has overlapping boxes, including one that covers only part of the ship.
The overlapping boxes on the upper-right ship illustrate a real limitation: even if the boxes are rotated, predictions can be duplicated or incomplete. Confidence is a model score, not proof that a box is correct. Inspect the image and evaluate against labelled data before relying on a model for an application.
Where can this help?
Rotated boxes can be useful for aerial images, rotated packages, vehicles and other elongated objects. Suitability depends on the model and its training data; this specific example does not establish that the supplied checkpoint can detect every object or domain. Try your own permitted image and compare the resulting boxes with the actual objects.
Explore the course lesson on oriented boxes and other practical computer vision projects: see the current promotional offer. Coupon terms and availability may change. You can also use the course link.
The Ultralytics software and pretrained weights retain their own license terms; see the assets license, Ultralytics source and licensing information for your intended use. Other dependencies have their own licenses.
I have prepared a new English-language course, Practical SQL with PostgreSQL: From Queries to Real Projects, for people who want to build database skills through practical examples.
What does the course cover?
The course begins with relational database foundations, tables, keys and SQL queries, then moves through topics used in everyday database work:
Filtering and joining data, aggregation, subqueries, common table expressions and window functions.
Working with NULL, dates, text, regular expressions, views, JSON and composite types.
Understanding query plans, indexes and performance, as well as exploring planner controls and scheduling.
Introducing PL/pgSQL functions, procedures and triggers, followed by roles, privileges, data exchange and backup and restore.
Practice with TrainingHub
The examples use TrainingHub, a synthetic database for a training business. You can practice working with courses, customers, enrollments and payments while following demonstrations in pgAdmin. The course includes practical exercises, downloadable materials, worked solutions and short knowledge checks.
The demonstrated local setup uses Docker Desktop and Windows. Some optional extension labs require additional configuration.
Can Python blur people in a video automatically? This demonstration uses a pretrained YOLO detector and OpenCV to find person bounding boxes in each frame and blur the detected rectangles. It creates a new video locally.
This is a visual-effect demonstration, not a guarantee of anonymity. A detector can miss a person, a rectangle can leave identifying detail outside it, and the blur may be too weak. Check all output carefully and use an appropriate, independently validated privacy process before sharing real footage.
Both images below are from frame 120 of the same synthetic input. The first is the source frame; the second is an actual frame from the processed video, not a simulated illustration. The rectangular area comes from one detected person and includes padding around the box.
Frame 120 of the permitted synthetic input, before detection and blurring.Frame 120 of the processed MP4 at confidence threshold 0.25; the detected person rectangle and some surrounding background have been blurred.
How the example works
Read the input. A video is a sequence of images called frames. OpenCV reads its frames, dimensions and frames per second (FPS).
Detect people. YOLO predicts boxes in each frame. A bounding box is a rectangle around a predicted object; the script requests class 0, which is person for this checkpoint. Confidence is a model score, not a guarantee that a person or all of their pixels are detected.
Blur the selected region. The code expands each box by 8% in each direction, clips it to the frame, and applies a 71 × 71 Gaussian blur to the nonempty rectangle. This is a region of interest, or ROI. The rest of the frame remains outside that operation.
Write the result. OpenCV writes the processed frames to a new MP4 at the input video's reported FPS. The sample does not track identities across frames, and it does not copy an audio track to the output.
Bounding boxes cover rectangles, not exact person outlines: some background can be blurred as well. A person-class detector is not automatically a face or licence-plate detector. Different videos, versions and settings can produce different detections.
What you need
The actual run used Windows CPU with Python 3.13.9, Ultralytics 8.4.24, OpenCV 4.13.0, NumPy 2.3.5 and PyTorch 2.10.0+cpu. Use a permitted video and the official YOLOv8n checkpoint. The demonstration input is a synthetic exercise clip; course students can find squat_demo.mp4 in the 04_zasoby folder of the lesson 52 practice ZIP and copy it to the project folder as input.mp4. A different input can produce different results.
On Windows Command Prompt, create a virtual environment and install the packages:
Package installation and acquiring weights require an internet connection unless already cached. This command pins the direct package, not every dependency; a fresh online installation has not been independently verified. The demonstrated prediction was repeated on Windows CPU with the available environment and local files.
Place the permitted source video as input.mp4, the model as yolov8n.pt, and the code below as blur_people.py in the project directory. The script checks that the input and weights exist; it doesn't silently choose an alternative model or video.
Complete Python example
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Detect people and blur their detected rectangles in a video on CPU.
This example demonstrates a visual effect. Missed detections or weak blur may
leave identifying details visible: do not treat output as anonymized footage.
"""
import argparse
import json
import math
import os
import time
from pathlib import Path
import cv2
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--video', type=Path, required=True)
parser.add_argument('--weights', type=Path, required=True)
parser.add_argument('--output', type=Path, default=Path('blur_output'))
parser.add_argument('--confidence', type=float, default=0.25)
args = parser.parse_args()
if not (0 <= args.confidence <= 1):
raise ValueError('confidence must be between 0 and 1')
for name, path in (('Video', args.video), ('Weights', args.weights)):
if not path.is_file():
raise FileNotFoundError(f'{name} not found: {path}')
args.output.mkdir(parents=True, exist_ok=True)
config = args.output / 'config'
config.mkdir(exist_ok=True)
os.environ.setdefault('YOLO_CONFIG_DIR', str(config.resolve()))
os.environ.setdefault('YOLO_OFFLINE', 'true')
os.environ.setdefault('YOLO_AUTOINSTALL', 'false')
from ultralytics import YOLO
import torch
torch.set_num_threads(4)
model = YOLO(str(args.weights))
cap = cv2.VideoCapture(str(args.video))
if not cap.isOpened():
raise RuntimeError(f'Cannot open video: {args.video}')
fps = cap.get(cv2.CAP_PROP_FPS)
width, height = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
if not math.isfinite(fps) or fps <= 0 or width <= 0 or height <= 0:
raise ValueError('Video FPS and dimensions must be valid')
out = args.output / 'blurred.mp4'
writer = cv2.VideoWriter(str(out), cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height))
if not writer.isOpened():
raise RuntimeError(f'Cannot create video: {out}')
frame_no = 0
person_boxes = 0
frames_with_detections = 0
records = []
begin = time.perf_counter()
try:
while True:
ok, frame = cap.read()
if not ok:
break
result = model.predict(frame, classes=[0], conf=args.confidence, imgsz=640,
device='cpu', verbose=False)[0]
boxes = []
for box in result.boxes:
x1, y1, x2, y2 = map(float, box.xyxy[0].tolist())
# Expand by 8 percent to reduce unblurred edges, and clamp to the image.
pad_x, pad_y = (x2 - x1) * 0.08, (y2 - y1) * 0.08
left = max(0, math.floor(x1 - pad_x))
top = max(0, math.floor(y1 - pad_y))
right = min(width, math.ceil(x2 + pad_x))
bottom = min(height, math.ceil(y2 + pad_y))
if right <= left or bottom <= top:
continue
boxes.append({'xyxy': [left, top, right, bottom],
'confidence': round(float(box.conf.item()), 6)})
region = frame[top:bottom, left:right]
frame[top:bottom, left:right] = cv2.GaussianBlur(region, (71, 71), 0)
if boxes:
frames_with_detections += 1
person_boxes += len(boxes)
records.append({'frame': frame_no, 'boxes': boxes})
writer.write(frame)
frame_no += 1
finally:
cap.release()
writer.release()
seconds = time.perf_counter() - begin
if frame_no == 0:
raise ValueError('Input contained no decodable frames')
summary = {'frames': frame_no, 'frames_with_person_detections': frames_with_detections,
'frames_without_person_detections': frame_no - frames_with_detections,
'person_boxes': person_boxes, 'fps_source': fps, 'width': width, 'height': height,
'confidence': args.confidence, 'imgsz': 640, 'box_padding_fraction': 0.08,
'gaussian_kernel': [71, 71], 'device': 'cpu', 'processing_seconds': round(seconds, 3),
'processing_frames_per_second': round(frame_no / seconds, 3),
'output': str(out), 'warning': 'Blur is not a privacy or anonymization guarantee.'}
(args.output / 'result.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
(args.output / 'detections.json').write_text(json.dumps(records, indent=2), encoding='utf-8')
print(json.dumps(summary, indent=2))
if __name__ == '__main__':
main()
The script writes blur_output/blurred.mp4, a machine-readable blur_output/result.json summary, and blur_output/detections.json with per-frame box coordinates and scores. The detection JSON reports predictions: its presence does not establish that the output is anonymous or correct.
Read the result
For this specific test, the input has 241 frames at 24 FPS, with a frame size of 1280 × 720. With a confidence threshold of 0.25, image size 640, and this particular checkpoint, the program returned one person box in each of the 241 frames. This is a count of model outputs in this sample. It is not recall, pixel coverage, a guarantee of complete concealment, or evidence for other recordings.
The example clamps box coordinates before slicing to avoid indexing outside the frame. cv2.GaussianBlur smooths pixels inside that rectangle, while VideoWriter saves the resulting frames. See the OpenCV guide to image smoothing and the Ultralytics object-blurring guide for further background; the current online documentation can describe a later version than the one tested here.
What happens when a person is not detected?
In a separate run we deliberately raised --confidence to 0.99. The detector returned zero person boxes in all 241 frames, so no person rectangle was blurred. In the 0.25 run, the frame-120 person box had a model score of approximately 0.938850, below the deliberately selected 0.99 threshold. This controlled threshold experiment is deliberately different from the 0.25 demonstration. It illustrates a missed detection, not an estimate of the model's error rate in real use. To reproduce this test, use the same input and model:
Frame 120 of the output at the deliberately raised confidence threshold 0.99: no person rectangle was returned and the source remains visible.
If no box is returned for a frame, this script does not infer a hidden location from earlier frames: that frame is written without that ROI being blurred. Even if a box is returned, it may be misplaced or insufficient. Appearance, clothing, posture, faces outside the box, reflections or background context can still identify someone. Strong visual obscuration does not by itself establish legal anonymization.
Try it yourself
Start with footage you have permission to use, without real personal data where possible.
Run the script with the tested settings and inspect the original alongside the output, including difficult frames, the start and end, small subjects and partial occlusions.
Open detections.json and compare its boxes to what is actually visible. Detection count alone is not a ground-truth assessment.
Try a different confidence threshold using --confidence and compare the same frames. A lower threshold may add detections as well as false positives; it does not guarantee that all people are hidden.
Ultralytics software and model weights retain their own terms; review the Ultralytics assets license and licensing information for your intended use. Other dependencies have their own licenses.
Can you estimate how fast a vehicle moves using a video? In this Python example, YOLO detects vehicles, a tracker connects detections between frames, and we divide a tracked point’s image displacement by the elapsed video time.
The output in this demonstration is the magnitude of average image-plane velocity in pixels per second (px/s), not road speed in km/h. It is computed from net image displacement, not the distance travelled along the full path. The source is a synthetic traffic video. It has no established real-world scale, so a physical speed cannot be recovered from it just by naming a conversion factor.
Output from frame 96 of the sample: the yellow box surrounds the selected track, while red points trace its detected box centers. The displayed value is the magnitude of net image displacement divided by four seconds.
From video to a measurable result
Open the video and read its frame rate. A frame is one image in a sequence, and FPS is the number of frames per second.
Detect vehicles and associate detections across frames. The tracker assigns an ID to an association; it is not a permanent identity or a guarantee of correct tracking.
For one continuous track, find the chosen point at the start and end of the measurement interval. In this example the point is the center of its bounding box.
Divide the distance in image pixels by the elapsed video time. Label the answer in px/s.
The sample is 1280 × 720 pixels at 24 frames per second. We use the vehicle associated with track ID 2 in frames 0–96. The straight-line screen displacement of its box center is about 302.85 pixels in 4 seconds, or 75.7 px/s. The calculation and output concern this track and interval, not every vehicle in the scene.
How is speed calculated?
For two observations from the same tracked object at frame indices f0 and f1, a constant-frame-rate video gives elapsed_seconds = (f1 - f0) / fps. The two image positions are (x0, y0) and (x1, y1). This example uses the straight-line displacement sqrt((x1 - x0)² + (y1 - y0)²). Dividing it by elapsed seconds gives the magnitude of average image-plane velocity in pixels per second over that interval.
This is net displacement, not total path length, and it is not an instantaneous speed. A curved or back-and-forth path can yield a smaller result than summing every successive movement. FPS must describe the source video correctly; variable-frame-rate footage needs appropriate presentation timestamps rather than assuming constant spacing.
What you need
Use Python and a permitted traffic video. The demonstration ran on Windows, Python 3.13.9, Ultralytics 8.4.24, OpenCV 4.13.0, NumPy 2.3.5, PyTorch 2.10.0+cpu and lap 0.5.13. Other versions, input videos and hardware may change detections and IDs. Install the required packages in a virtual environment; downloading packages and model weights needs an internet connection unless they are already available locally.
This command pins two direct packages, not the complete dependency tree. A clean online install of this exact command has not been independently tested; platform, version and hardware differences can affect compatibility and results.
Place an input video and the official yolov8n.pt checkpoint where the script expects them. Course students can find the sample traffic_counter_highway_clip.mp4 in 04_zasoby and yolov8n.pt in 02_praktyka of the lesson 51 practice ZIP. Copy them into the project folder as input.mp4 and yolov8n.pt, respectively; do not assume the video is otherwise licensed for public redistribution. Use your own permitted video if the sample is unavailable: exact tracks and speeds will differ.
Complete Python example
Save the following as measure_screen_speed.py. The source is the same file used for the demonstration. Check the paths and parameters before running.
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Measure image-plane displacement of one tracked car; no real-world calibration."""
import argparse
import json
import math
import os
from pathlib import Path
import cv2
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--video', type=Path, required=True)
parser.add_argument('--weights', type=Path, required=True)
parser.add_argument('--output', type=Path, default=Path('speed_output'))
parser.add_argument('--track-id', type=int, default=2)
parser.add_argument('--end-frame', type=int, default=96)
args = parser.parse_args()
for name, path in [('Video', args.video), ('Weights', args.weights)]:
if not path.is_file():
raise FileNotFoundError(f'{name} not found: {path}')
args.output.mkdir(parents=True, exist_ok=True)
(args.output / 'config').mkdir(exist_ok=True)
os.environ.setdefault('YOLO_CONFIG_DIR', str((args.output / 'config').resolve()))
os.environ.setdefault('YOLO_OFFLINE', 'true')
os.environ.setdefault('YOLO_AUTOINSTALL', 'false')
from ultralytics import YOLO
import torch
torch.set_num_threads(4)
model = YOLO(str(args.weights))
cap = cv2.VideoCapture(str(args.video))
if not cap.isOpened():
raise RuntimeError('Could not open video')
fps = cap.get(cv2.CAP_PROP_FPS)
if not math.isfinite(fps) or fps <= 0:
raise ValueError('Invalid video frame rate')
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
writer = cv2.VideoWriter(str(args.output / 'annotated.mp4'),
cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height))
if not writer.isOpened():
raise RuntimeError('Could not create output video')
start = None
path = []
result = None
try:
for frame_number in range(args.end_frame + 1):
ok, frame = cap.read()
if not ok:
raise RuntimeError(f'Video ended at frame {frame_number}')
prediction = model.track(frame, persist=True, device='cpu', imgsz=640,
conf=0.20, classes=[2, 3, 5, 7], tracker='bytetrack.yaml',
verbose=False)[0]
boxes = prediction.boxes
matches = [] if boxes.id is None else [box for box in boxes
if box.id is not None and int(box.id.item()) == args.track_id]
if len(matches) != 1:
raise ValueError(f'Expected one track ID {args.track_id} in frame {frame_number}; got {len(matches)}')
xyxy = [float(v) for v in matches[0].xyxy[0].tolist()]
center = ((xyxy[0]+xyxy[2])/2, (xyxy[1]+xyxy[3])/2)
if start is None:
start = center
path.append(tuple(round(x) for x in center))
elapsed = frame_number / fps
displacement = math.dist(start, center)
mean_px_s = displacement / elapsed if elapsed else None
cv2.rectangle(frame, (round(xyxy[0]),round(xyxy[1])),
(round(xyxy[2]),round(xyxy[3])), (0,220,255), 2)
for pt in path:
cv2.circle(frame, pt, 2, (0,0,255), -1)
cv2.circle(frame, path[-1], 6, (0,0,255), -1)
cv2.rectangle(frame, (10,18), (710,102), (30,30,30), -1)
cv2.putText(frame, f'Track ID {args.track_id} | frame {frame_number} | {elapsed:.2f} s',
(25,55), cv2.FONT_HERSHEY_SIMPLEX, .8, (255,255,255), 2)
label = 'Screen displacement / elapsed time: ' + (f'{mean_px_s:.1f} px/s' if mean_px_s is not None else 'N/A')
cv2.putText(frame, label, (25,88), cv2.FONT_HERSHEY_SIMPLEX, .72, (255,255,255), 2)
writer.write(frame)
if frame_number in (0, 48, args.end_frame):
if not cv2.imwrite(str(args.output / f'frame_{frame_number:03d}.png'), frame):
raise RuntimeError('Could not write frame')
if frame_number == args.end_frame:
result = {'track_id': args.track_id, 'first_frame': 0, 'last_frame': frame_number,
'fps': fps, 'width': width, 'height': height, 'elapsed_s': elapsed,
'start_xy': start, 'end_xy': center, 'displacement_px': displacement,
'mean_screen_displacement_px_per_s': mean_px_s,
'definition': 'straight-line image-plane displacement divided by elapsed time',
'not_kmh': True}
finally:
writer.release()
cap.release()
(args.output/'result.json').write_text(json.dumps(result, indent=2), encoding='utf-8')
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()
The defaults select track ID 2 from frames 0 through 96 for this specific example. The script produces speed_output/annotated.mp4, sample annotated PNG frames and speed_output/result.json. It raises an error if the chosen ID is missing in any frame rather than silently guessing an association.
Follow the code
Read the source.cv2.VideoCapture opens the input and reads the recorded FPS, width and height. The loop uses zero-based frame numbers and stops at the requested end frame.
Detect and associate.model.track(..., persist=True, tracker='bytetrack.yaml') detects the selected vehicle classes and retains tracker state across frames. The code requires exactly one match for the selected ID in every frame; if an association is absent, it stops instead of filling gaps.
Pick a point. For each matching box, the midpoint between its left and right coordinates and between its top and bottom coordinates is the box center. The first center becomes the starting position; subsequent centers add visible points to the red trail.
Calculate and save. For each later frame, math.dist(start, center) returns the straight-line pixel distance from the starting center. Dividing by elapsed source-video time gives the running net displacement per second; the script saves an annotated MP4 and the final coordinates and numbers in JSON.
The defaults are chosen for this particular clip: ID 2 must be present already in frame 0 and remain present through frame 96. For another video, inspect its tracks and set --track-id and --end-frame to an association that satisfies that condition. The same numeric ID in another run is not necessarily the same physical object. This small script is deliberately strict; it does not automatically find an arbitrary new car later in a clip.
How to check the number
Compare the same vehicle at both endpoints. Check that the displayed ID remains associated with that vehicle throughout the measured interval. Read the endpoint coordinates, frame indices and source FPS; calculate the displacement and elapsed time independently. A different track, box jitter, occlusion or camera movement can change the answer. An ID switch can make an apparently precise calculation describe the wrong association.
For this run, frame 0 gives center approximately (692.45, 306.86), and frame 96 gives (966.27, 436.23). The elapsed time is (96 − 0) / 24 = 4 seconds; sqrt((966.27 − 692.45)² + (436.23 − 306.86)²) is approximately 302.85 pixels. Thus net displacement per second is about 302.85 / 4 = 75.7 px/s. These rounded coordinates are for illustration; the program computes with full precision. We visually inspected sampled frames of the association; this is not a ground-truth accuracy measurement.
Why this is not a speedometer
A pixel is a location in the image, not a fixed distance on the road. Perspective means the same road distance can occupy a different number of pixels in different parts of the image. A moving camera, distortion, an inaccurate frame rate and a box center that shifts as the view changes introduce further errors.
A physical speed estimate requires a calibrated mapping between image and ground-plane positions, a valid time base and a suitable known reference. A single global meters-per-pixel factor is valid only under suitable geometric conditions and assumptions. Even after calibration, compare estimates with independently measured ground truth across representative cases. The Ultralytics speed-estimation guide also describes FPS and scale as inputs and calls the output an estimate.
This demonstration is educational; it is not a validated instrument for enforcement or safety-critical decisions.
How can you remove the background around one object without cutting it out by hand? In this Python tutorial, a pretrained YOLO segmentation model predicts a bus mask on a synthetic city image. We turn that mask into an alpha channel and save a PNG with a transparent background.
Our example selects one predicted bus on a 1024 × 1024 image and runs locally on a Windows CPU. In this run the model returned 12 predictions; those are model outputs, not a verified count of physical objects. A cyclist partly blocks the bus, and the predicted mask does not reconstruct the hidden part.
Actual selected instance from the demonstration. The yellow rectangle and green mask are different outputs of the same segmentation prediction.
Box versus mask versus alpha
A bounding box gives a rectangular region. It can contain background or another object, as the cyclist in this image illustrates. An instance segmentation mask marks pixels assigned to one predicted object. That boundary is an estimate; inspect it around occlusion, fine details and similar colors. See the Ultralytics instance segmentation documentation.
A PNG alpha channel controls transparency. In this binary example, an alpha value of 0 means transparent and 255 means opaque. A checkerboard is only a way to visualize transparent pixels: it is not the content of the alpha channel. This code keeps the image's full canvas size instead of cropping to the box.
What you need
The demonstrated run used Windows, Python 3.13.9, Ultralytics 8.4.24, PyTorch 2.10.0+cpu, OpenCV 4.13.0 and NumPy 2.3.5. Other versions or hardware can produce different results. Create a project folder and a virtual environment using Windows Command Prompt:
The command pins Ultralytics, not the full set of transitive dependencies; the exact fresh online installation has not been independently tested. Package installation and obtaining the checkpoint require an internet connection unless you already have suitable local files. Once the tested environment and inputs were present, the prediction and export were repeated with network access blocked.
Put an image and a segmentation checkpoint beside the script
Place a permitted color image as image.png and the official YOLOv8n-seg checkpoint as yolov8n-seg.pt alongside the Python file. This example image is included in lesson 37 of my Computer Vision course. For a different permitted input, predictions and output will differ.
The tested checkpoint has SHA-256 a7cd8f929e1903d78a12a48efecab430209f18dc46cb96c3599a5980c63c423c. A hash identifies file bytes; it does not by itself grant usage rights. The model file is not hosted by this article.
Complete runnable example
Save the following as remove_background.py in the project folder. The script checks that the local files exist, selects the highest-scoring bus prediction and stops with an explicit message if there is no usable mask or no bus. The target class is an intentional choice for this image; change it deliberately for another object.
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Isolate the highest-scoring bus prediction as a transparent PNG."""
from pathlib import Path
import os
ROOT = Path(__file__).resolve().parent
config_dir = ROOT / 'config'
config_dir.mkdir(exist_ok=True)
os.environ['YOLO_CONFIG_DIR'] = str(config_dir)
os.environ['YOLO_OFFLINE'] = 'true'
import cv2
import numpy as np
import torch
from ultralytics import YOLO
def save_png(path, image):
if not cv2.imwrite(str(path), image):
raise OSError(f'Could not save {path}')
def main():
image_path = ROOT / 'image.png'
weights_path = ROOT / 'yolov8n-seg.pt'
for path in (image_path, weights_path):
if not path.is_file():
raise FileNotFoundError(f'Required local file: {path}')
image = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
if image is None:
raise ValueError(f'Cannot decode image: {image_path}')
torch.set_num_threads(4)
model = YOLO(str(weights_path))
result = model.predict(
source=image, conf=0.25, iou=0.70, imgsz=640, device='cpu',
retina_masks=True, max_det=300, verbose=False,
)[0]
if result.masks is None or result.boxes is None:
raise ValueError('No segmentation masks were returned for this image.')
candidates = [i for i, box in enumerate(result.boxes)
if result.names[int(box.cls.item())] == 'bus']
if not candidates:
raise ValueError('No bus prediction passed the confidence threshold.')
selected = max(candidates, key=lambda i: float(result.boxes[i].conf.item()))
box = result.boxes[selected]
mask = result.masks.data[selected].cpu().numpy().astype(np.uint8)
if mask.shape != image.shape[:2]:
raise ValueError(f'Mask shape {mask.shape} differs from image {image.shape[:2]}.')
alpha = mask * 255
isolated = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
isolated[:, :, 3] = alpha
isolated[mask == 0, :3] = 0 # Do not keep hidden background pixels.
output = ROOT / 'output'
output.mkdir(exist_ok=True)
save_png(output / 'isolated_bus.png', isolated)
save_png(output / 'mask.png', alpha)
highlighted = image.copy()
highlighted[mask == 1] = (
image[mask == 1].astype(np.float32) * 0.55
+ np.array([45, 190, 70], dtype=np.float32) * 0.45
).astype(np.uint8)
left, top, right, bottom = [round(float(v)) for v in box.xyxy[0].tolist()]
cv2.rectangle(highlighted, (left, top), (right, bottom), (40, 220, 255), 3)
save_png(output / 'selected_mask_and_box.png', highlighted)
print('Image width x height:', image.shape[1], 'x', image.shape[0])
print('Total returned predictions:', len(result.boxes))
print('Selected class:', result.names[int(box.cls.item())])
print('Selected confidence score:', round(float(box.conf.item()), 4))
print('Selected box xyxy:', [round(float(v), 1) for v in box.xyxy[0].tolist()])
print('Foreground pixels:', int(mask.sum()))
print('Background pixels:', int(mask.size - mask.sum()))
print('Saved:', output / 'isolated_bus.png')
if __name__ == '__main__':
main()
From the project directory, run:
.venv\Scripts\python.exe remove_background.py
The script writes output/isolated_bus.png with four BGRA channels, output/mask.png as the alpha mask and output/selected_mask_and_box.png for inspection. The rectangle and colored mask are diagnostic overlays; they are not drawn onto the transparent PNG.
Follow the image through the code
Read and predict. OpenCV reads a color image, then the local segmentation model runs on the CPU. The example fixes conf=0.25, iou=0.70 and imgsz=640. retina_masks=True requests masks at the original image resolution; the explicit shape check rejects a mismatch instead of silently combining misaligned arrays.
Select one instance. The script filters predictions labelled bus, then chooses the highest confidence among those candidates. It uses the same selected index for the box and mask. Confidence is a model score, not a calibrated guarantee that the label or every mask pixel is correct.
Make transparency. The returned mask in this run contains 0 and 1. Multiplying it by 255 creates the alpha plane. OpenCV adds an alpha channel to its BGR image to make BGRA; the code assigns the alpha values and clears hidden background color pixels. It then saves PNG, which preserves transparency.
Actual transparent PNG displayed over a page-provided checkerboard. The image keeps its original 1024 × 1024 canvas. The bicycle rider occludes part of the bus; the script does not invent the unseen portion.
Read and verify the result
The recorded run returned 12 predictions in total. The selected bus had score approximately 0.9458 and box [48.2, 412.2, 476.4, 716.6] in original-image coordinates. Its binary mask produced 98,828 opaque pixels and 949,748 transparent pixels on a 1024 × 1024 canvas. These are measurements of this output, not a pixel-accuracy evaluation against a ground-truth mask.
To check your saved file instead of relying on the checkerboard display, save this separate snippet as verify_alpha.py in the project folder. It reads the PNG with its alpha channel intact and reports its shape and distinct alpha values:
import cv2
import numpy as np
image = cv2.imread("output/isolated_bus.png", cv2.IMREAD_UNCHANGED)
if image is None or image.ndim != 3 or image.shape[2] != 4:
raise ValueError("Expected a readable image with four BGRA channels")
print("Shape:", image.shape)
print("Alpha values:", np.unique(image[:, :, 3]).tolist())
From the project folder, run .venv\Scripts\python.exe verify_alpha.py.
For the demonstrated output it reports a shape of (1024, 1024, 4) and alpha values [0, 255]. A different image or mask can produce different dimensions and counts. A viewer with a solid background may not make transparency obvious; verify the alpha channel or place the image on a contrasting background.
What this example does not do
Segmentation predicts the visible instance; it does not recover bus pixels hidden behind the cyclist. The binary alpha can also produce rough edges, and the model can include background or miss the object. If there is no suitable bus, inspect the input and class label; lowering the threshold may expose weaker candidates but does not guarantee a correct mask. Validate with representative images and an appropriate reference before relying on the output in a real application.
Can an object detector look for categories you describe in words? In this Python tutorial we use a pretrained YOLO-World model and an image, choose text labels, and inspect the bounding boxes it returns. We then change the label list and run the model again.
Our example uses a synthetic office image from my course and a local Windows CPU run. With one list the model returned four predictions; with an expanded list it returned nine predictions. Those counts are model outputs, not verified counts of real objects or proof of accuracy.
Actual focused-list detections from the recorded run. This picture is a synthetic teaching example.
What does “no training needed” mean?
YOLO-World is an open-vocabulary object detector: it can accept text class names before prediction. Here we load pretrained YOLO-World and CLIP components and do not train them again. They were trained earlier; “no additional training” does not mean that the model learned from nothing. It also does not guarantee detection of every item or phrase. See the Ultralytics YOLO-World documentation and the original research paper.
What you need
The demonstrated run used Windows 11, Python 3.13.9, Ultralytics 8.4.116, PyTorch 2.13.0+cpu, OpenCV 4.14.0 and NumPy 2.5.1. It used the local YOLOv8s-World checkpoint and the OpenAI CLIP ViT-B/32 text encoder. The installation commands pin Ultralytics and the CLIP fork, not every transitive dependency. Other software versions or devices can change the results. Git is needed for the command that installs the fork from its repository.
From a Windows Command Prompt, create a project folder, then create and install into its virtual environment:
The tested course package includes an already built source-bound wheel for the CLIP fork. The Git-based installation above is an alternative way to get the identified source; this exact fresh online installation was not part of the offline replay. Installing packages and obtaining the files requires a network connection unless you have them available locally.
The tested detector checkpoint SHA-256 is 095f5266bb9b654bd5ad9e21e9cdeda78e0f2c8460f5d652eaf04bab7ee251cf; the CLIP file SHA-256 is 40d365715913c9da98579312b702a82c18be219cc2a73407c4526f58eba950af. A hash identifies bytes; it does not itself establish usage rights. Model files are not hosted by this article.
Configure a project-local weights folder
The text encoder must find its local CLIP checkpoint when the class names are set. Run the following small setup script once, as a separate process. It places Ultralytics settings in this project’s config folder and sets its weights directory to this project’s weights folder. This avoids editing your user-wide Ultralytics settings for the demonstration.
Save as setup_local.py:
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Store an isolated Ultralytics weights-directory setting for this example."""
from pathlib import Path
import os
ROOT = Path(__file__).resolve().parent
config_dir = ROOT / 'config'
config_dir.mkdir(exist_ok=True)
os.environ['YOLO_CONFIG_DIR'] = str(config_dir)
import cv2 # Import first in the demonstrated Windows environment.
from ultralytics import settings
settings.update({'weights_dir': str(ROOT / 'weights'), 'sync': False})
print('Project weights directory:', ROOT / 'weights')
print('Run world_demo.py as a separate process after this one exits.')
Run it from the project directory:
.venv\Scripts\python.exe setup_local.py
Then start the following script as a new process so the weights-directory setting is read during initialization. If you move the project folder later, run the setup script again before prediction.
Complete runnable example
Save the following as world_demo.py:
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Compare two text vocabularies with a local pretrained YOLO-World model."""
from pathlib import Path
import os
ROOT = Path(__file__).resolve().parent
# Keep Ultralytics settings in this project, not in your user-wide profile.
config_dir = ROOT / 'config'
config_dir.mkdir(exist_ok=True)
os.environ['YOLO_CONFIG_DIR'] = str(config_dir)
os.environ['YOLO_OFFLINE'] = 'true'
import cv2 # Import first in the demonstrated Windows environment.
import torch
from ultralytics import YOLOWorld
from ultralytics.utils import WEIGHTS_DIR
def main():
image = ROOT / 'image.png'
model_file = ROOT / 'yolov8s-world.pt'
clip_file = ROOT / 'weights' / 'clip' / 'ViT-B-32.pt'
if Path(WEIGHTS_DIR).resolve() != (ROOT / 'weights').resolve():
raise RuntimeError('Configure this project weights directory first; see the setup command.')
for path in (image, model_file, clip_file):
if not path.is_file():
raise FileNotFoundError(f'Required local file: {path}')
torch.set_num_threads(4)
model = YOLOWorld(str(model_file))
groups = {
'focused': ['person', 'backpack', 'laptop', 'cup'],
'broad': ['person', 'backpack', 'laptop', 'cup', 'chair', 'desk'],
'single_cup': ['cup'],
'single_coffee_cup': ['coffee cup'],
}
output = ROOT / 'output'
output.mkdir(exist_ok=True)
for name, classes in groups.items():
model.set_classes(classes)
result = model.predict(
source=str(image), conf=0.25, iou=0.70, imgsz=640, device='cpu',
half=False, rect=True, augment=False, agnostic_nms=False,
max_det=300, verbose=False,
)[0]
print(f'\n{name}: {classes} -> {len(result.boxes)} predictions')
for box in result.boxes:
label = result.names[int(box.cls.item())]
score = float(box.conf.item())
coords = [round(float(v), 1) for v in box.xyxy[0].tolist()]
print(f' {label}: score={score:.3f} xyxy={coords}')
annotated = result.plot()
if not cv2.imwrite(str(output / f'{name}.png'), annotated):
raise OSError(f'Could not save output for {name}')
print(f'\nSaved annotated images in {output}')
if __name__ == '__main__':
main()
From the project directory, run:
.venv\Scripts\python.exe world_demo.py
For each vocabulary, model.set_classes(classes) configures the class names with the CLIP text encoder. model.predict(...)[0] then runs a new prediction on the image; the subsequent loop reads its boxes. The code saves an annotated image per vocabulary to output/. The illustrations on this page are the actual saved outputs from the demonstrated execution, using Ultralytics’ plotting style.
All four runs use the same input image and checkpoint, conf=0.25, iou=0.70, imgsz=640 and CPU. The two standalone prompts are separate one-class vocabularies; the focused and broad lists are separate runs too. Changing the list can change predictions and scores for shared classes, so the broad result should not be treated as simply appending new boxes to an unchanged prior result. Confidence is a model score, not a calibrated probability that a particular box is correct.
Read the result: focused versus broad
The focused list asks for person, backpack, laptop and cup. In the recorded execution it returned four predictions. The broad list retains those words and adds chair and desk; it returned nine predictions. The change in count does not establish that every additional prediction is correct.
Actual broad-list output. Inspect overlaps and questionable boxes rather than equating nine predictions with nine objects.
For example, the expanded run includes a desk box scoring approximately 0.286 over a region that appears to contain a chair. Another nearby chair prediction overlaps the region. This is a reason to inspect the image and outputs, not evidence that every overlap is a duplicate. Changing the vocabulary also changed the laptop score from about 0.924 to 0.909 in this particular run.
Does wording matter?
We also tried two separate one-class runs on the same image. With ["cup"] the model returned one prediction with score approximately 0.917. With ["coffee cup"] it returned one prediction around the same visible item with score approximately 0.830. These two scores and boxes are observations for this image and these prompts; they do not prove that either phrase is generally better or measure accuracy. Inspect the predictions before relying on them.
Try it yourself
Choose an image you have permission to use and predict which objects you expect the model to find.
Change just one class name or list in groups, run the script again, and inspect the saved image and printed rows.
Compare false positives, missed objects and scores. Keep the input and other settings fixed if you want an interpretable comparison.
For a real application, test a representative labelled set covering the relevant objects, viewpoints and conditions. This single synthetic scene is not an accuracy benchmark, a validated real-time system or a surveillance application. The OpenAI CLIP model card discusses research scope, limitations and potential bias.
Troubleshooting
“Configure this project weights directory first”: run setup_local.py from the same project and then start world_demo.py in a separate process. Re-run setup after moving the project.
Missing checkpoint or image: inspect the file paths shown above; the script checks them before model initialization.
CLIP import or installation problem: verify that the tested Ultralytics CLIP fork is installed in this virtual environment, and that the correct kernel or Python executable is used. The separately named PyPI clip project is not a substitute for the specified fork.
No detection or an unexpected box: check the input, class wording, visible output and threshold. A lower threshold may show additional weak candidates, but does not automatically improve correctness.
How can you make an object detector recognize classes from your own labelled images? In this guide we fine-tune a pretrained YOLOv8n model on a small dataset, then inspect a prediction with Python.
Our example uses 12 synthetic training images and 4 synthetic validation images containing boats and buoys. In one local CPU run, the model predicted two boats and one buoy on the selected validation image. This is an educational example: the validation set helped select the checkpoint, and the result does not establish accuracy on independent real-world data.
Actual prediction from the recorded CPU run on val_02.png. This is a validation image, not an independent final test.
What you will build
Organize labelled images into training and validation folders.
Understand the normalized YOLO bounding-box format.
Point a dataset configuration at those folders.
Fine-tune a local pretrained model and load the saved checkpoint.
Run a prediction and interpret its limitations.
This is fine-tuning: it starts from pretrained weights, rather than initializing a detector from scratch. You can use your own appropriately licensed images and annotations. The particular synthetic fixture shown here is supplied with lesson 29 of my Computer Vision: OpenCV, Machine Learning and YOLO course.
1. Install the dependencies
The recorded experiment ran on Windows 11 using Python 3.13.9, Ultralytics 8.4.116 and PyTorch 2.13.0+cpu. The commands below create a Windows environment and pin the direct Ultralytics dependency; they do not lock every transitive dependency. Other versions, operating systems and hardware may yield different results.
Installing packages and obtaining the initial checkpoint need an internet connection unless you already have the required files cached. The training demonstration itself used local images and local weights. Your environment's dependencies and model files must be available first.
2. Organize images and annotations
Create this layout next to the training script. Every image has a corresponding label file with the same base name:
For example, train_00.png corresponds to train_00.txt. For a different dataset, change the image paths and class names to match your data. Do not place images without permission into your project or public downloads.
Read one YOLO annotation row
Each row describes one object using five values:
class_index x_center y_center width height
The index identifies a class; the remaining four numbers are normalized by the image width or height and typically range from 0 to 1. In this fixture, the first row of labels/train/train_00.txt is:
0 0.271484 0.691406 0.277344 0.187500
Class 0 means boat. The center is approximately (0.271484, 0.691406); its width and height are 0.277344 and 0.187500. The input is 256 × 256 pixels, so the x center is approximately 0.271484 × 256 = 69.5 pixels from the left edge. The rectangle below is computed from this exact row, rather than drawn by eye.
One image and the box defined by the example row. Inspect all objects, not just a single row, before training.
Create and check a label file from pixel coordinates
Here is a small, reproducible way to turn pixel coordinates into a YOLO label. This example uses the synthetic train_00.png image from the course fixture. Its four rectangles contain two boats and two buoys. For this demonstration we reconstructed their pixel corners from the fixture’s existing annotations; they are not newly measured by an annotation tool. For your own image, determine the correct rectangle for every object first—for example using a suitable annotation tool—and use its pixel corners in place of these values.
For each box, left and right are horizontal pixel positions, and top and bottom are vertical positions measured from the upper-left corner. The code divides horizontal values by the image width and vertical values by its height. Class 0 is boat; class 1 is buoy. Use the same numeric mapping in your data.yaml.
Inside the project folder from step 1, create a separate annotation-demo folder and copy the example image to annotation-demo/input/train_00.png. Save the complete script below as annotation-demo/annotate_example.py. This image is 256 × 256 pixels; adapt the dimensions check, filename, class IDs, coordinates and class names to use a different dataset.
The script uses Pillow, included in the demonstrated environment. If it is not installed in your environment, run .venv\Scripts\python.exe -m pip install pillow from the project folder. Then, still from the project folder, run:
"""Create a new YOLO label from pixel corners; never overwrite an existing label."""
from pathlib import Path
from PIL import Image, ImageDraw
image_path = Path(__file__).parent / 'input' / 'train_00.png'
output_dir = Path(__file__).parent / 'output'
image = Image.open(image_path).convert('RGB')
width, height = image.size
assert (width, height) == (256, 256)
# class_id, left, top, right, bottom in pixels. Origin: upper-left.
# These corners were reconstructed from this fixture's existing annotations.
boxes = [
(0, 34, 153, 105, 201),
(0, 118, 122, 181, 172),
(1, 46, 189, 66, 209),
(1, 186, 149, 212, 175),
]
rows = []
for class_id, left, top, right, bottom in boxes:
assert class_id in (0, 1) and 0 <= left < right <= width and 0 <= top < bottom <= height
x_center = (left + right) / (2 * width)
y_center = (top + bottom) / (2 * height)
box_width = (right - left) / width
box_height = (bottom - top) / height
rows.append(f'{class_id} {x_center:.6f} {y_center:.6f} {box_width:.6f} {box_height:.6f}')
output_dir.mkdir(parents=True, exist_ok=True)
label_path = output_dir / f'{image_path.stem}.txt'
with label_path.open('x', encoding='utf-8', newline='\n') as label_file:
label_file.write('\n'.join(rows) + '\n')
# Reconstruct the saved boxes for visual verification of the written bytes.
saved_rows = label_path.read_text(encoding='utf-8').splitlines()
assert len(saved_rows) == len(boxes)
overlay = image.copy()
draw = ImageDraw.Draw(overlay)
for row in saved_rows:
cls, xc, yc, bw, bh = row.split()
xc, yc, bw, bh = map(float, (xc, yc, bw, bh))
rect = ((xc-bw/2)*width, (yc-bh/2)*height, (xc+bw/2)*width, (yc+bh/2)*height)
draw.rectangle(rect, outline='#ffdb40' if cls == '0' else '#5effac', width=2)
overlay.save(output_dir / 'train_00_saved_labels.png')
print('Input:', image_path.name, width, 'x', height)
print('New label:', label_path.name)
print('Saved rows:')
print('\n'.join(saved_rows))
print('Verified overlay:', 'train_00_saved_labels.png')
The script creates annotation-demo/output/train_00.txt with four lines and annotation-demo/output/train_00_saved_labels.png, which draws the rectangles reconstructed from the saved label file. The first line is 0 0.271484 0.691406 0.277344 0.187500. Inspect the overlay and correct any missing or misplaced boxes before using a label for training.
The file is opened in exclusive-create mode to protect existing labels. Running the same demo again with the same output path raises FileExistsError. To repeat it, use a new demo directory or a different output filename; do not overwrite a label without reviewing it. Once your annotations for the entire image are complete and checked, put the label file under the matching labels/train or labels/val directory with the same base name as the image.
3. Create the dataset configuration
For the example layout, save this as synthetic_harbor_dataset/data.yaml:
The training and validation paths identify separate groups of images. The names mapping defines the meaning of each class index in this custom dataset. Check paths and labels against your actual installed Ultralytics version and project layout; the example was run with the YAML beside these folders.
4. Obtain the starting weights
Download the official YOLOv8n checkpoint as a separate setup step and place it next to train_yolo.py. The file used in the demonstration has SHA-256 f59b3d833e2ff32e194b5bb8e08d211dc7c5bdf144b90d2c8412c47ccfc83b36. Review the model asset license and Ultralytics licensing information for your use. A checksum identifies bytes; it does not by itself grant usage rights.
5. Train and predict with the complete script
Save the following as train_yolo.py in the project folder. It checks for the local checkpoint, dataset YAML and selected validation image, fine-tunes the checkpoint, loads the selected best.pt file, and saves the prediction as prediction_val_02.jpg.
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Fine-tune a local YOLO checkpoint on an annotated dataset and inspect a validation image."""
from pathlib import Path
import cv2
from ultralytics import YOLO
def main():
root = Path(__file__).resolve().parent
dataset = root / "synthetic_harbor_dataset"
data_yaml = dataset / "data.yaml"
validation_image = dataset / "images" / "val" / "val_02.png"
starting_weights = root / "yolov8n.pt"
for file_path in (data_yaml, validation_image, starting_weights):
if not file_path.is_file():
raise FileNotFoundError(f"Required local file: {file_path}")
model = YOLO(str(starting_weights))
training = model.train(
data=str(data_yaml), epochs=50, imgsz=256, batch=4, workers=0,
device="cpu", project=str(root / "runs"), name="custom_yolo",
exist_ok=False, verbose=False, plots=False, mosaic=0.0,
seed=0, deterministic=True,
)
best_weights = Path(training.save_dir) / "weights" / "best.pt"
if not best_weights.is_file():
raise FileNotFoundError(f"Training did not produce a checkpoint: {best_weights}")
final_model = YOLO(str(best_weights))
result = final_model(
str(validation_image), conf=0.25, iou=0.30, device="cpu", verbose=False,
)[0]
print(f"Best checkpoint: {best_weights}")
for box in result.boxes:
class_name = result.names[int(box.cls.item())]
print(f"{class_name}: confidence {float(box.conf.item()):.3f}")
output = root / "prediction_val_02.jpg"
if not cv2.imwrite(str(output), result.plot(line_width=1, font_size=8)):
raise OSError(f"Could not save prediction image: {output}")
print(f"Saved validation-image prediction: {output}")
if __name__ == "__main__":
main()
data selects the YAML. epochs=50 means 50 passes through the training set; imgsz=256 selects the processing size, batch=4 controls batch size, and device="cpu" chooses the CPU. The seed and deterministic setting help reproduce this particular example, but they do not guarantee bit-identical results across every environment. Training duration depends on the computer.
6. Inspect the actual result
In the recorded run, prediction on val_02.png at conf=0.25 and iou=0.30 returned two boat boxes and one buoy box. These matched the three labelled classes in that particular image. The checkpoint and image shown in the video belong to that run; a new run can produce different confidences or boxes.
A box confidence is the model's score for that prediction, not a measurement of how often the system will be correct in real life. The four validation images were used during training and checkpoint selection. Their prediction and validation metrics are therefore not an independent final test.
7. Troubleshoot the common problems
Required local file is missing: check the project layout and filenames, including the checkpoint and data.yaml. For your own dataset, change validation_image to an existing image.
Images or labels are not found: check matching filename stems and whether train and val resolve to the intended folders.
Boxes look wrong: check that the annotations use normalized center coordinates and width/height, rather than pixel corners. Draw them on the source image before training.
No box appears: inspect your labels, classes and input, then investigate the confidence threshold and training output. Lowering a threshold changes which predictions appear; it does not guarantee correct detections.
Training is slow: CPU runtime depends on your hardware, configuration and dataset. Reduce or adjust a test run deliberately, and report the actual settings when comparing results.
What to do next
Try adding an image you have permission to use and manually checking every annotation. For a real application, collect representative examples across cameras and lighting, prevent similar images from leaking between splits, inspect errors, and evaluate on a separate test set that was not used for training or checkpoint selection. A tiny synthetic fixture teaches the steps; it does not establish production reliability.