Monday, November 9, 2026

YOLO Oriented Bounding Boxes: Detect Rotated Objects with Python

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

Two crops of the same harbor image: an orange horizontal bounding rectangle on the left and a rotated green bounding rectangle on the right
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.

Synthetic overhead view of a harbor with ships at different angles
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:

py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install ultralytics==8.4.117

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()

Run the example from that directory:

.venv\Scripts\python.exe obb_demo.py --image input.png --weights yolov8n-obb.pt --output output

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

  1. The model returns four corner points for each predicted oriented box.
  2. The code takes the minimum and maximum x and y values of those same points to obtain an enclosing axis-aligned rectangle.
  3. 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.

Synthetic harbor with the model's oriented green detection boxes
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.

Code and component licenses

The complete example script above is © 2026 ARTSCI - Author of the AI/ML/CV course materials, licensed under GNU AGPL-3.0 only, without warranty. Preserve applicable notices and provide corresponding source when sharing covered modifications. This code notice does not automatically license the harbor image, videos or other course media.

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.

Sunday, November 8, 2026

Practical SQL with PostgreSQL: A New Hands-On Course

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.

View the course page on Udemy.

Some course materials and narration were created with AI assistance, including AI-generated speech.

How to Blur People in Video with Python, YOLO and OpenCV

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.

Watch the tutorial

See the full demonstration and the missed-detection example in the YouTube tutorial, or watch the short demonstration.

Before and after

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.

Original synthetic video frame showing a person exercising
Frame 120 of the permitted synthetic input, before detection and blurring.
Same frame from the output video, with the detected person blurred
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

  1. Read the input. A video is a sequence of images called frames. OpenCV reads its frames, dimensions and frames per second (FPS).
  2. 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.
  3. 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.
  4. 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:

py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install ultralytics==8.4.24

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()

Run the script from the project directory:

.venv\Scripts\python.exe blur_people.py --video input.mp4 --weights yolov8n.pt --output blur_output

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:

.venv\Scripts\python.exe blur_people.py --video input.mp4 --weights yolov8n.pt --output high_threshold_output --confidence 0.99
Frame from the high confidence experiment with no person rectangle detected
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

  1. Start with footage you have permission to use, without real personal data where possible.
  2. 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.
  3. Open detections.json and compare its boxes to what is actually visible. Detection count alone is not a ground-truth assessment.
  4. 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.

See also my guide to removing an image background with YOLO segmentation, and explore more practical applications in my Computer Vision: OpenCV, Machine Learning and YOLO course.

Code and component licenses

The complete example script on this page is © 2026 ARTSCI - Author of the AI/ML/CV course materials, licensed under GNU AGPL-3.0 only, without warranty. Preserve applicable notices and provide corresponding source when sharing covered modifications. This code notice does not automatically license the sample video, course content or other media.

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.