Skip to content

Commit 3bd833d

Browse files
author
AI Assistant
committed
feat(pipeline): YOLOv8 + ByteTrack video processing via supervision; CLI command; deps & mypy config
1 parent bd131ef commit 3bd833d

4 files changed

Lines changed: 114 additions & 1 deletion

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Edge-deployable computer vision application scaffold, optimized for Python 3.11+
66

77
- Typer-powered CLI with `run` command
88
- Simple CV pipeline with OpenCV (dummy inference + annotation)
9+
- Video processing pipeline with YOLOv8 detection + ByteTrack MOT using `supervision`
910
- Strict typing (mypy --strict), linting (ruff), tests (pytest)
1011
- Pre-commit hooks configured
1112
- Dockerfile optimized for CPU-based CV workloads
@@ -61,6 +62,9 @@ poetry run yardvision run --source 0
6162

6263
# From video file with display window
6364
poetry run yardvision run --source ./sample.mp4 --display
65+
66+
# Process a video file and save annotated output
67+
poetry run yardvision process-video ./input.mp4 ./output.mp4 --model yolov8m.pt
6468
```
6569

6670
### Tests

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ opencv-python-headless = "^4.9.0.80"
1919
Pillow = "^10.4.0"
2020
onnxruntime = "^1.18.0"
2121
tqdm = "^4.66.4"
22+
ultralytics = "^8.2.0"
23+
supervision = "^0.18.0"
2224

2325
[tool.poetry.group.dev.dependencies]
2426
pytest = "^8.2.1"
@@ -88,6 +90,12 @@ ignore_missing_imports = true
8890
[tool.mypy.onnxruntime.*]
8991
ignore_missing_imports = true
9092

93+
[tool.mypy.ultralytics.*]
94+
ignore_missing_imports = true
95+
96+
[tool.mypy.supervision.*]
97+
ignore_missing_imports = true
98+
9199
[build-system]
92100
requires = ["poetry-core>=1.8.0"]
93101
build-backend = "poetry.core.masonry.api"

src/vision/pipeline/processor.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass
4-
from typing import Iterator, Optional
4+
from typing import Iterator
55

66
import cv2
77
import numpy as np
@@ -94,3 +94,87 @@ def process_stream(self, source: str, display: bool = False) -> None:
9494
except Exception: # noqa: BLE001
9595
logger.warning("Failed to destroy windows (likely headless environment)")
9696

97+
98+
class VideoProcessor:
99+
"""Batch video processor using YOLOv8 + ByteTrack via supervision.
100+
101+
This processor reads frames from an input video, performs detection and
102+
tracking, annotates results, and writes an output video.
103+
"""
104+
105+
def __init__(self, model_path: str = "yolov8m.pt") -> None:
106+
# Lazy imports to avoid importing heavy deps where not needed
107+
from ultralytics import YOLO # type: ignore[import-not-found]
108+
import supervision as sv # type: ignore[import-not-found]
109+
110+
self._sv = sv
111+
self._model = YOLO(model_path)
112+
# ByteTrack tracker
113+
self._tracker = sv.ByteTrack()
114+
# Annotators
115+
self._box_annotator = sv.BoundingBoxAnnotator()
116+
self._label_annotator = sv.LabelAnnotator()
117+
# COCO class name mapping from model
118+
try:
119+
self._class_names = self._model.model.names # type: ignore[attr-defined]
120+
except Exception: # noqa: BLE001
121+
# Fallback to standard COCO mapping indices used by YOLOv8
122+
self._class_names = {
123+
0: "person",
124+
1: "bicycle",
125+
2: "car",
126+
3: "motorcycle",
127+
5: "bus",
128+
7: "truck",
129+
}
130+
131+
logger.info("Initialized VideoProcessor with model: {}", model_path)
132+
133+
def _filter_detections(self, detections: "np.ndarray | object") -> "object":
134+
"""Filter detection classes to person (0), car (2), truck (7).
135+
136+
Works with supervision.Detections instance which supports numpy-like
137+
indexing using a boolean mask.
138+
"""
139+
sv = self._sv
140+
assert isinstance(detections, sv.Detections)
141+
allowed = np.array([0, 2, 7])
142+
mask = np.isin(detections.class_id, allowed)
143+
return detections[mask]
144+
145+
def process_video(self, input_path: str, output_path: str) -> None:
146+
import supervision as sv # type: ignore[import-not-found]
147+
148+
video_info = sv.VideoInfo.from_video_path(input_path)
149+
frames = sv.get_video_frames_generator(input_path)
150+
151+
# Use a broadly supported codec for MP4 writing in headless envs
152+
with sv.VideoSink(output_path, video_info, codec="mp4v") as sink:
153+
for frame in frames:
154+
# Inference
155+
result = self._model(frame, verbose=False)[0]
156+
detections = sv.Detections.from_ultralytics(result)
157+
detections = self._filter_detections(detections)
158+
159+
# Tracking
160+
tracked = self._tracker.update_with_detections(detections)
161+
162+
# Labels for annotation
163+
labels = []
164+
for i in range(len(tracked)):
165+
class_id = int(tracked.class_id[i]) if tracked.class_id is not None else -1
166+
confidence = float(tracked.confidence[i]) if tracked.confidence is not None else 0.0
167+
track_id = int(tracked.tracker_id[i]) if tracked.tracker_id is not None else -1
168+
class_name = self._class_names.get(class_id, str(class_id))
169+
labels.append(f"{class_name} #{track_id} {confidence:.2f}")
170+
171+
# Annotation
172+
annotated = self._box_annotator.annotate(scene=frame.copy(), detections=tracked)
173+
annotated = self._label_annotator.annotate(
174+
scene=annotated,
175+
detections=tracked,
176+
labels=labels,
177+
)
178+
179+
sink.write_frame(annotated)
180+

src/yardvision/cli.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from vision.core.config import AppConfig, load_config
1111
from vision.pipeline.processor import FrameProcessor
12+
from vision.pipeline.processor import VideoProcessor
1213

1314

1415
install_rich_traceback(show_locals=True)
@@ -50,6 +51,22 @@ def run(
5051
raise typer.Exit(code=1) from exc
5152

5253

54+
@app.command("process-video")
55+
def process_video(
56+
input_path: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True),
57+
output_path: Path = typer.Argument(...),
58+
model: str = typer.Option("yolov8m.pt", help="YOLOv8 model weights path or alias"),
59+
) -> None:
60+
"""Process an input video file and save annotated detections/tracks to output."""
61+
try:
62+
processor = VideoProcessor(model_path=str(model))
63+
processor.process_video(str(input_path), str(output_path))
64+
console.print(f"[green]Saved:[/green] {output_path}")
65+
except Exception as exc: # noqa: BLE001
66+
console.print(f"[red]Error:[/red] {exc}")
67+
raise typer.Exit(code=1) from exc
68+
69+
5370
if __name__ == "__main__":
5471
app()
5572

0 commit comments

Comments
 (0)