Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
c635e30
scaffold Ultralytics video detection model
praksharma Jul 17, 2026
a36e3ea
add Ultralytics utilities
praksharma Jul 27, 2026
a81671e
working version of yolo models
praksharma Jul 27, 2026
5ef7bc2
add Ultralytics training progress callback
praksharma Jul 28, 2026
55a41df
implement Ultralytics YOLO video detection
praksharma Jul 28, 2026
82156ad
improved model download
praksharma Jul 29, 2026
1d7e8a6
restore labels from checkpoints instead of hardcoded class name
praksharma Jul 29, 2026
7edcc04
Merge remote-tracking branch 'origin/dev' into prakhar/yolo_model_ima…
praksharma Aug 10, 2026
c192f56
adapt Ultralytics models to directory storage #346
praksharma Aug 10, 2026
32952aa
add Ultralytics training output supression
praksharma Aug 10, 2026
ae0a6a2
delay pretrained model download until training
praksharma Aug 10, 2026
0dd1698
initialise P2 models from YOLO26 checkpoints
praksharma Aug 10, 2026
2149b75
define Ultralytics training records with Pydantic
praksharma Aug 10, 2026
7036a28
cache weights used by Ultralytics AMP checks to prevent a nano model …
praksharma Aug 10, 2026
3365b60
switch from relative imports to absolute imports
praksharma Aug 10, 2026
d923665
remove redundant letterbox check
praksharma Aug 10, 2026
e74c903
Better typing for YoloTrainParams in base.py
praksharma Aug 11, 2026
d269d54
Yolo P2 model typing and bug fixes
praksharma Aug 11, 2026
43b8d31
apply ruff formatting and lint fixes
praksharma Aug 11, 2026
6f9bdd2
Update lockfile for Ultralytics dependency
abdullah-ukaea Aug 12, 2026
e189613
Merge dev and regenerate Ultralytics lockfile
abdullah-ukaea Aug 12, 2026
98df223
Move YOLO training parameters into the base detection module
abdullah-ukaea Sep 4, 2026
e14550f
Use fixed coordinate tuples for detection boxes
abdullah-ukaea Sep 4, 2026
0686036
Build pretrained model URLs from the shared base URL
abdullah-ukaea Sep 4, 2026
c03aba1
Add an option to predict only the current video frame
abdullah-ukaea Sep 4, 2026
c1e5baa
Simplify the YOLO empty-box check
abdullah-ukaea Sep 4, 2026
365e470
removed debug comment from docstring in utils
abdullah-ukaea Sep 4, 2026
a497280
Use a temporary file to protect downloaded checkpoints
abdullah-ukaea Sep 4, 2026
3e6b948
Add focused YOLO video detection tests for frame loading, manifests, …
abdullah-ukaea Sep 4, 2026
baed99b
Separate regular YOLO and P2 training schemas
abdullah-ukaea Sep 4, 2026
37d2b91
improve wording
abdullah-ukaea Sep 4, 2026
dcf0fde
Add coarse search: to allow optional skipping of initial black frames…
abdullah-ukaea Sep 7, 2026
b5d54db
Fix short-video frame searching and consolidate duplicate refinement …
abdullah-ukaea Sep 7, 2026
8970920
Merge branch 'dev' into prakhar/yolo_model_image_detection
abdullah-ukaea Sep 7, 2026
08a94da
Improve missing-frame handling in video detection and tests
abdullah-ukaea Sep 7, 2026
d44c9bf
Regenerate the UV lock file for the branch
abdullah-ukaea Sep 7, 2026
6cc8b40
Fix YOLO training progress status updates for the shared model API
abdullah-ukaea Sep 7, 2026
d34d758
Move Ultralytics video detection tests into the models test directory
abdullah-ukaea Sep 14, 2026
2662b75
Move YOLO frame search and decoding helpers into the utilities module
abdullah-ukaea Sep 14, 2026
9b3be84
changed YoloTrainParams.epochs default from 2 to 100
abdullah-ukaea Sep 14, 2026
29a7e30
Rename the YOLO training output setting to enable debug logging
abdullah-ukaea Sep 14, 2026
2d7fffe
Removed the unnecessary contextlib URL comment
abdullah-ukaea Sep 14, 2026
6ba701e
Add clear typing for Ultralytics detection dataset items
abdullah-ukaea Sep 14, 2026
9fc9d9e
Converted UltralyticsDetectionItem to a validated Pydantic model
abdullah-ukaea Sep 14, 2026
d4c66c7
Validate Ultralytics training batches with a Pydantic model
abdullah-ukaea Sep 14, 2026
81f716d
Centralize CUDA, MPS, and CPU device selection for Ultralytics models
abdullah-ukaea Sep 14, 2026
711f00d
Share the canonical Ultralytics checkpoint path logic between loading…
abdullah-ukaea Sep 14, 2026
d540f7f
Clarified the iou_threshold description
abdullah-ukaea Sep 14, 2026
ab11414
Renamed max_det to max_detection
abdullah-ukaea Sep 14, 2026
0eac05d
improved function readability with clearer variable names and comment…
abdullah-ukaea Sep 14, 2026
51d7c7a
added tests for course to search
abdullah-ukaea Sep 14, 2026
7ea420e
add RT-DETR video detection model
praksharma Sep 21, 2026
ea45337
ruff checks
praksharma Sep 21, 2026
483089e
RT-DETR test added
praksharma Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Comment thread
abdullah-ukaea marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies = [
models = [
"torch",
"ray>=2.49.1",
"ultralytics==8.4.98",
"mlflow>=3.1.4",
"safetensors>=0.7.0",
"huggingface-hub>=1.22.0",
Expand Down
390 changes: 390 additions & 0 deletions tests/api/models/test_ultralytics_video_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
import pytest

Comment thread
abdullah-ukaea marked this conversation as resolved.
pytest.importorskip("ray")
pytest.importorskip("ultralytics")

from types import SimpleNamespace

import cv2
import numpy
import torch
from ultralytics.engine.results import Boxes

from toktagger.api.core.data_loaders import FrameNotFoundError
from toktagger.api.models.ultralytics_detection import video_detection
from toktagger.api.models.ultralytics_detection import utils as ultralytics_utils
from toktagger.api.schemas.annotations import VideoBoundingBox
from toktagger.api.schemas.data import ImageData, ImageParams
from toktagger.api.schemas.samples import Sample, ShotData


class FakeDataLoader:
def __init__(self, frame_image: ImageData):
self.frame_image = frame_image
self.calls = []

def get_sample(self, sample, params):
self.calls.append((sample, params))
return self.frame_image


class FakePredictionModel:
def __init__(self, results=None):
self.calls = []
self.results = [SimpleNamespace(boxes=None)] if results is None else results

def predict(self, **kwargs):
self.calls.append(kwargs)
return self.results


class FrameSearchDataLoader:
def __init__(self, last_valid_frame=None):
self.last_valid_frame = last_valid_frame
self.calls = []

def get_sample(self, sample, params):
self.calls.append(params.frame)
if self.last_valid_frame is not None and params.frame > self.last_valid_frame:
raise FrameNotFoundError
return ImageData(frame=params.frame, values=[])


def make_sample() -> Sample:
return Sample(
shot_id=30421,
data=ShotData(protocol="uda", signal_names=["rba"]),
_id="sample",
project_id="project",
validated_annotations=False,
)


def make_model(data_loader, prediction_model):
model = object.__new__(video_detection.YoloVideoDetectionModel)
model._prediction_model = prediction_model
model.data_loader = data_loader
model.get_device = lambda: SimpleNamespace(type="cpu")
return model


def test_find_first_useful_frame_refines_coarse_probe(monkeypatch):
sample = make_sample()
data_loader = FrameSearchDataLoader()
initial_frame = ImageData(frame=0, values=[])
monkeypatch.setattr(
ultralytics_utils,
"_is_useful_frame",
lambda frame_image: frame_image.frame >= 10,
)

selected_frame = ultralytics_utils._find_first_useful_frame(
data_loader,
sample,
initial_frame,
)

assert selected_frame.frame == 10
assert data_loader.calls == [25, *range(1, 11)]


def test_find_first_useful_frame_scans_short_video_boundary(monkeypatch):
sample = make_sample()
data_loader = FrameSearchDataLoader(last_valid_frame=20)
initial_frame = ImageData(frame=0, values=[])
monkeypatch.setattr(
ultralytics_utils,
"_is_useful_frame",
lambda frame_image: frame_image.frame >= 10,
)

selected_frame = ultralytics_utils._find_first_useful_frame(
data_loader,
sample,
initial_frame,
)

assert selected_frame.frame == 10
assert data_loader.calls == [25, *range(1, 11)]


def test_iter_sample_frames_stops_at_end_of_video():
sample = make_sample()
frames = [
ImageData(frame=3, values=[1]),
ImageData(frame=4, values=[2]),
]
calls = []

def get_sample(iterated_sample, params):
calls.append((iterated_sample, params))
if params.frame is None:
return frames[0]
if params.frame == 4:
return frames[1]
raise FrameNotFoundError

data_loader = SimpleNamespace(get_sample=get_sample)

assert list(video_detection.iter_sample_frames(data_loader, sample)) == frames
assert [params.frame for _, params in calls] == [None, 4, 5]
assert all(params.return_raw for _, params in calls)


def test_build_video_frame_manifest_includes_negative_frames_and_boxes(monkeypatch):
sample = make_sample()
frames = [
ImageData(frame=0, values=[1, 2]),
ImageData(frame=1, values=[3, 4]),
]
annotations = [
[
VideoBoundingBox(
label="alpha",
created_by="manual",
frame=1,
track_id="track-alpha",
x_min=10,
y_min=20,
width=30,
height=40,
),
VideoBoundingBox(
label="beta",
created_by="manual",
frame=1,
track_id="track-beta",
x_min=2,
y_min=3,
width=4,
height=5,
),
]
]

def iter_frames(
data_loader,
iterated_sample,
skip_initial_black_frames: bool = False,
):
return iter(frames)

monkeypatch.setattr(video_detection, "iter_sample_frames", iter_frames)

manifest = video_detection.build_video_frame_manifest(
samples=[sample],
annotations=annotations,
class_map={"alpha": 7, "beta": 3},
data_loader=object(),
)

assert len(manifest) == 2
assert manifest[0].frame == 0
assert manifest[0].image == bytes([1, 2])
assert manifest[0].boxes == []
assert manifest[0].classes == []
assert manifest[1].frame == 1
assert manifest[1].image == bytes([3, 4])
assert manifest[1].boxes == [
(10.0, 20.0, 40.0, 60.0),
(2.0, 3.0, 6.0, 8.0),
]
assert manifest[1].classes == [7, 3]


def test_decode_frame_image_returns_bgr_array():
expected = numpy.array(
[
[[1, 2, 3], [10, 20, 30]],
[[40, 50, 60], [70, 80, 90]],
],
dtype=numpy.uint8,
)
encoded_success, encoded = cv2.imencode(".png", expected)
assert encoded_success

decoded = video_detection.decode_frame_image(
ImageData(frame=0, values=list(encoded.tobytes()))
)

assert decoded.shape == (2, 2, 3)
assert decoded.dtype == numpy.uint8
assert numpy.array_equal(decoded, expected)


def test_predicts_only_requested_video_frame(monkeypatch):
sample = make_sample()
frame_image = ImageData(frame=7, values=[0])
data_loader = FakeDataLoader(frame_image)
prediction_model = FakePredictionModel()
model = make_model(data_loader, prediction_model)
decoded_frames = []

def decode_frame(frame):
decoded_frames.append(frame)
return "decoded"

monkeypatch.setattr(video_detection, "decode_frame_image", decode_frame)

predictions = model.predict(
[sample],
video_detection.YoloPredictParams(this_frame_only=True),
ImageParams(name="image", frame=7, return_raw=False),
)

assert predictions == [[]]
assert len(data_loader.calls) == 1
loaded_sample, loaded_params = data_loader.calls[0]
assert loaded_sample is sample
assert loaded_params.frame == 7
assert loaded_params.return_raw is True
assert decoded_frames == [frame_image]
assert len(prediction_model.calls) == 1


def test_this_frame_only_is_ignored_without_data_params(monkeypatch):
sample = make_sample()
data_loader = object()
prediction_model = FakePredictionModel()
model = make_model(data_loader, prediction_model)
frames = [ImageData(frame=0, values=[0]), ImageData(frame=1, values=[0])]
iterator_calls = []
decoded_frames = []

def iter_frames(
loader,
iterated_sample,
skip_initial_black_frames: bool = False,
):
iterator_calls.append((loader, iterated_sample, skip_initial_black_frames))
return iter(frames)

def decode_frame(frame):
decoded_frames.append(frame)
return "decoded"

monkeypatch.setattr(video_detection, "iter_sample_frames", iter_frames)
monkeypatch.setattr(video_detection, "decode_frame_image", decode_frame)

predictions = model.predict(
[sample],
video_detection.YoloPredictParams(this_frame_only=True),
data_params=None,
)

assert predictions == [[]]
assert iterator_calls == [(data_loader, sample, False)]
assert decoded_frames == frames
assert len(prediction_model.calls) == 2


def test_predict_converts_yolo_boxes_to_video_annotations(monkeypatch):
sample = make_sample()
frame_image = ImageData(frame=5, values=[0])
boxes = Boxes(
torch.tensor(
[
[10.2, 20.1, 30.8, 50.9, 0.8, 0],
[3.1, 4.2, 15.2, 24.3, 0.35, 1],
]
),
orig_shape=(100, 100),
)
prediction_model = FakePredictionModel(
[SimpleNamespace(boxes=boxes, names={0: "alpha", 1: "beta"})]
)
data_loader = object()
model = make_model(data_loader, prediction_model)

def iter_frames(
loader,
iterated_sample,
skip_initial_black_frames: bool = False,
):
return iter((frame_image,))

monkeypatch.setattr(video_detection, "iter_sample_frames", iter_frames)
monkeypatch.setattr(
video_detection,
"decode_frame_image",
lambda frame: numpy.zeros((100, 100, 3), dtype=numpy.uint8),
)

predictions = model.predict(
[sample],
video_detection.YoloPredictParams(),
)

assert len(predictions) == 1
assert all(
isinstance(annotation, VideoBoundingBox) for annotation in predictions[0]
)
assert [annotation.label for annotation in predictions[0]] == ["alpha", "beta"]
assert [
(annotation.x_min, annotation.y_min, annotation.width, annotation.height)
for annotation in predictions[0]
] == [(10, 20, 21, 31), (3, 4, 12, 20)]
assert [annotation.frame for annotation in predictions[0]] == [5, 5]
assert [annotation.uncertainty for annotation in predictions[0]] == pytest.approx(
[0.2, 0.65]
)
assert [annotation.created_by for annotation in predictions[0]] == [
"yolo_ufo",
"yolo_ufo",
]
assert [annotation.validated for annotation in predictions[0]] == [
False,
False,
]
assert [annotation.track_id for annotation in predictions[0]] == [
"pred-30421-5-0",
"pred-30421-5-1",
]


@pytest.mark.parametrize(
"model_class",
[
video_detection.YoloVideoDetectionModel,
video_detection.RTDETRVideoDetectionModel,
],
)
@pytest.mark.parametrize(
("available_weights", "expected_weight"),
[
(("best.pt", "last.pt"), "best.pt"),
(("last.pt",), "last.pt"),
],
)
def test_wrapped_load_restores_best_weights(
tmp_path,
monkeypatch,
model_class,
available_weights,
expected_weight,
):
weights_dir = tmp_path / "weights"
weights_dir.mkdir()
for filename in available_weights:
(weights_dir / filename).touch()

sentinel_model = object()
loaded_paths = []
monkeypatch.setattr(
model_class,
"prediction_model_class",
# staticmethod so self is not injected into fake constructor
staticmethod(
lambda path: loaded_paths.append(path) or sentinel_model,
),
)

model = object.__new__(model_class)
model._trained = False
model.wrapped_load(tmp_path)

expected_path = weights_dir / expected_weight
assert loaded_paths == [str(expected_path)]
assert model._trained_weights_path == expected_path
assert model._prediction_model is sentinel_model
assert model._trained is True
Comment thread
wk9874 marked this conversation as resolved.
Loading
Loading