Skip to content

Add Ultralytics video detection models - #326

Open
praksharma wants to merge 54 commits into
devfrom
prakhar/yolo_model_image_detection
Open

praksharma wants to merge 54 commits into
devfrom
prakhar/yolo_model_image_detection

Conversation

@praksharma

@praksharma praksharma commented Jul 17, 2026 •

Copy link
Copy Markdown
Member

Adds the support for Ultralytics-based object detection for video data.

The implementation uses TokTagger’s native data loader and an in-memory dataset, avoiding Ultralytics’ required on-disk dataset structure.

Currently implemented

  • YOLO video object-detection training and prediction
  • Supported pretrained checkpoints:
    • YOLOv8n
    • YOLO11n
    • YOLO26n
    • YOLO26x
  • YOLO26 P2 architecture for small-object detection
    • Currently trained from random initialization because a compatible pretrained P2 checkpoint is not available

TODO

  • RT-DETR x and l models
  • Unit tests

Changes

  • Adds Ultralytics under optional-dependencies in pyproject.toml.
  • Registers the YOLO video-detection models with TokTagger.
  • Adds the implementation under toktagger/api/models/ultralytics_detection.

Model storage

models/
├── pretrained/
│   └── ultralytics/
│       ├── yolo/
│       │   └── <pretrained_checkpoint>.pt
│       └── rtdetr/
│           └── <pretrained_checkpoint>.pt
└── <model_id>/
           ├── args.yaml
           ├── results.csv
           └── weights/
               ├── best.pt
               └── last.pt

Follow-up

TokTagger tracks whether a model is usable through the Ray actor’s _trained flag. The generic worker normally restores this state by finding a <model_id> checkpoint and calling wrapped_load().

Ultralytics checkpoints use a nested project/model directory instead, so the Ultralytics actor currently finds its checkpoint and restores the trained state itself. Should we think about a more generic checkpoint-discovery hook?

Addressed in: #346

Training-data assumption

This initial implementation assumes that every frame in a validated video sample has been reviewed. Frames without validated bounding boxes are included as negative examples.

Whole-frame labels proposed in #225 should eventually allow explicitly reviewed negative frames to be distinguished from unreviewed frames.

More discussion on this topic: #326 (comment)

@praksharma praksharma self-assigned this Jul 17, 2026
@praksharma praksharma added the enhancement New feature or request label Jul 17, 2026

@samueljackson92 samueljackson92 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broadly looks good. Please also fix the ruff linting warnings.

Comment thread toktagger/api/models/ultralytics_detection/video_detection.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/video_detection.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/video_detection.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/video_detection.py Outdated
Comment thread pyproject.toml Outdated
@praksharma
praksharma requested a review from wk9874 July 20, 2026 09:22
@praksharma
praksharma requested review from abdullah-ukaea and Copilot and removed request for Copilot July 28, 2026 21:57
@praksharma
praksharma marked this pull request as ready for review July 28, 2026 21:58
@praksharma
praksharma requested a review from Copilot July 29, 2026 08:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for Ultralytics-backed object detection models for TokTagger video samples, using TokTagger’s data loader and an in-memory dataset/manifest rather than Ultralytics’ on-disk dataset layout.

Changes:

  • Introduces an in-memory Ultralytics detection dataset + custom DetectionTrainer to train from TokTagger-provided samples/annotations.
  • Adds YOLO video detection models (including a YOLO26 P2 architecture variant) and frame-by-frame prediction output as VideoBoundingBox annotations.
  • Adds pretrained checkpoint download/caching utilities and pins ultralytics as a models optional dependency.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
toktagger/api/models/ultralytics_detection/video_detection.py Video frame iteration, training manifest creation, and YOLO-based per-frame prediction emitting VideoBoundingBox annotations.
toktagger/api/models/ultralytics_detection/base.py In-memory dataset, custom Ultralytics trainer adapter, checkpoint discovery, and shared Ultralytics training scaffolding.
toktagger/api/models/ultralytics_detection/utils.py Pretrained checkpoint URL mapping, download/caching, and device/cache-dir helpers.
toktagger/api/models/ultralytics_detection/init.py Package initialization for the new Ultralytics model implementation.
toktagger/api/models/init.py Registers/imports the new Ultralytics YOLO video detection models when model deps are enabled.
pyproject.toml Adds ultralytics==8.4.98 under the models optional dependency group.
Comments suppressed due to low confidence (1)

toktagger/api/models/ultralytics_detection/video_detection.py:239

  • decode_frame_image() decodes frames via OpenCV (BGR), but predictions are run on that array without converting to RGB. The training dataset path explicitly converts decoded images to RGB before feeding Ultralytics, so prediction is currently using a different channel order than training/pretrained weights expect, which can significantly degrade detection quality.
    image = cv2.imdecode(
        encoded_image,
        cv2.IMREAD_COLOR,
    )


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread toktagger/api/models/ultralytics_detection/utils.py
Comment thread toktagger/api/models/ultralytics_detection/video_detection.py
Comment thread toktagger/api/models/__init__.py
Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/video_detection.py
Comment thread toktagger/api/models/ultralytics_detection/video_detection.py
Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/video_detection.py Outdated
Comment on lines +197 to +210
frame_manifest.append(
{
"shot_id": int(sample.shot_id),
"frame": frame,
# ImageData stores raw encoded bytes as a JSON-compatible
# list of integers. Convert it back into bytes here.
"image": bytes(frame_image.values),
"boxes": boxes,
"classes": classes,
"labels": labels,
"track_ids": track_ids,
}
)
sample_record_count += 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is acceptable for the initial implementation, but I think we should document an important assumption here. No code changes are required, just think about this and maybe add a comment in the PR description.

The manifest adds every frame from a validated sample. Frames without bounding boxes are passed to YOLO as negative examples. However, validation currently applies to the whole sample, and in practice an annotator may validate a sample without reviewing every frame. In that case, an unreviewed frame containing an object could incorrectly be treated as background.

Are we happy to make the assumption that every frame in a validated sample has been reviewed? If not, a simple short-term alternative would be to include only frames containing validated bounding boxes and discard the remaining frames. The trade-off is that the model would not receive any empty frames as negative examples.

Longer term, we plan to address this through whole-frame labels in #225 as @wk9874 suggested. That would allow annotators to explicitly label frames as, for example, “no UFO”, so the model could distinguish confirmed negative frames from unreviewed frames.

No change is required for this initial PR, but just wanted to make you aware Prakhar, maybe add a small comment at the end of the pr description so it is documented somewhere?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I’ve documented this in the PR description.

Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/base.py
@abdullah-ukaea

Copy link
Copy Markdown
Collaborator

Non-blocking functional feedback:

YOLO training currently produces a large amount of terminal output. Would it be worth making Ultralytics quieter by default and keeping the detailed logs behind a verbose/debug setting?

The full output is useful for debugging, but most users will probably monitor training through the UI and only need key progress updates, warnings, and errors in the terminal.

example of terminal output

(YoloVideoDetectionP2Model pid=3904) Ultralytics 8.4.98 🚀 Python-3.12.3 torch-2.13.0+cu130 CUDA:0 (NVIDIA GeForce RTX 4090 Laptop GPU, 16376MiB)
INFO:     127.0.0.1:47698 - "GET /projects/6a6fba37aad4c943840dd3f2/models HTTP/1.1" 200 OK
(YoloVideoDetectionP2Model pid=3904) engine/trainer: agnostic_nms=False, amp=True, angle=1.0, augment=False, auto_augment=randaugment, batch=2, bgr=0.0, box=7.5, cache=False, cfg=None, classes=None, close_mosaic=0, cls=0.5, cls_pw=0.0, cls_remap=True, compile=False, conf=None, copy_paste=0.0, copy_paste_mode=flip, cos_lr=False, cutmix=0.0, data=None, degrees=0.0, deterministic=True, device=0, dfl=1.5, dis=6.0, distill_model=None, dnn=False, dropout=0.0, dynamic=False, embed=None, end2end=None, epochs=2, erasing=0.4, exist_ok=True, fliplr=0.5, flipud=0.0, format=torchscript, fraction=1.0, freeze=None, hsv_h=0.015, hsv_s=0.7, hsv_v=0.4, imgsz=1024, iou=0.7, keras=False, kobj=1.0, line_width=None, lr0=0.1, lrf=0.01, mask_ratio=4, max_det=300, mixup=0.0, mode=train, model=yolo26-p2.yaml, momentum=0.937, mosaic=1.0, multi_scale=0.0, name=6a70ac4bafed39bb42331724, nbs=64, nms=False, opset=None, optimize=False, optimizer=auto, overlap_mask=True, patience=100, perspective=0.0, plots=False, pose=12.0, pretrained=True, profile=False, project=/home/gs8173/.cache/toktagger/models/6a6fba37aad4c943840dd3f2/ultralytics/yolo, quantize=None, rect=False, resume=False, retina_masks=False, rle=1.0, save=True, save_conf=False, save_crop=False, save_dir=/home/gs8173/.cache/toktagger/models/6a6fba37aad4c943840dd3f2/ultralytics/yolo/6a70ac4bafed39bb42331724, save_frames=False, save_json=False, save_period=-1, save_txt=False, scale=0.5, seed=0, shear=0.0, show=False, show_boxes=True, show_conf=True, show_labels=True, simplify=True, single_cls=False, source=None, split=val, stream_buffer=False, task=detect, time=None, tracker=tracktrack.yaml, translate=0.1, val=False, verbose=True, vid_stride=1, visualize=False, warmup_bias_lr=0.1, warmup_epochs=3.0, warmup_momentum=0.8, weight_decay=0.0005, workers=0, workspace=None
(YoloVideoDetectionP2Model pid=3904) Overriding model.yaml nc=80 with nc=1
(YoloVideoDetectionP2Model pid=3904) WARNING ⚠️ no model scale passed. Assuming scale='n'.
(YoloVideoDetectionP2Model pid=3904)
(YoloVideoDetectionP2Model pid=3904)                    from  n    params  module                                       arguments
(YoloVideoDetectionP2Model pid=3904)   0                  -1  1       464  ultralytics.nn.modules.conv.Conv             [3, 16, 3, 2]
(YoloVideoDetectionP2Model pid=3904)   1                  -1  1      4672  ultralytics.nn.modules.conv.Conv             [16, 32, 3, 2]
(YoloVideoDetectionP2Model pid=3904)   2                  -1  1      6640  ultralytics.nn.modules.block.C3k2            [32, 64, 1, False, 0.25]
(YoloVideoDetectionP2Model pid=3904)   3                  -1  1     36992  ultralytics.nn.modules.conv.Conv             [64, 64, 3, 2]
(YoloVideoDetectionP2Model pid=3904)   4                  -1  1     26080  ultralytics.nn.modules.block.C3k2            [64, 128, 1, False, 0.25]
(YoloVideoDetectionP2Model pid=3904)   5                  -1  1    147712  ultralytics.nn.modules.conv.Conv             [128, 128, 3, 2]
(YoloVideoDetectionP2Model pid=3904)   6                  -1  1     87040  ultralytics.nn.modules.block.C3k2            [128, 128, 1, True]
(YoloVideoDetectionP2Model pid=3904)   7                  -1  1    295424  ultralytics.nn.modules.conv.Conv             [128, 256, 3, 2]
(YoloVideoDetectionP2Model pid=3904)   8                  -1  1    346112  ultralytics.nn.modules.block.C3k2            [256, 256, 1, True]
(YoloVideoDetectionP2Model pid=3904)   9                  -1  1    164608  ultralytics.nn.modules.block.SPPF            [256, 256, 5, 3, True]
(YoloVideoDetectionP2Model pid=3904)  10                  -1  1    249728  ultralytics.nn.modules.block.C2PSA           [256, 256, 1]
(YoloVideoDetectionP2Model pid=3904)  11                  -1  1         0  torch.nn.modules.upsampling.Upsample         [None, 2, 'nearest']
(YoloVideoDetectionP2Model pid=3904)  12             [-1, 6]  1         0  ultralytics.nn.modules.conv.Concat           [1]
(YoloVideoDetectionP2Model pid=3904)  13                  -1  1    119808  ultralytics.nn.modules.block.C3k2            [384, 128, 1, True]
(YoloVideoDetectionP2Model pid=3904)  14                  -1  1         0  torch.nn.modules.upsampling.Upsample         [None, 2, 'nearest']
(YoloVideoDetectionP2Model pid=3904)  15             [-1, 4]  1         0  ultralytics.nn.modules.conv.Concat           [1]
(YoloVideoDetectionP2Model pid=3904)  16                  -1  1     34304  ultralytics.nn.modules.block.C3k2            [256, 64, 1, True]
(YoloVideoDetectionP2Model pid=3904)  17                  -1  1         0  torch.nn.modules.upsampling.Upsample         [None, 2, 'nearest']
(YoloVideoDetectionP2Model pid=3904)  18             [-1, 2]  1         0  ultralytics.nn.modules.conv.Concat           [1]
(YoloVideoDetectionP2Model pid=3904)  19                  -1  1      8704  ultralytics.nn.modules.block.C3k2            [128, 32, 1, True]
(YoloVideoDetectionP2Model pid=3904)  20                  -1  1      9280  ultralytics.nn.modules.conv.Conv             [32, 32, 3, 2]
(YoloVideoDetectionP2Model pid=3904)  21            [-1, 16]  1         0  ultralytics.nn.modules.conv.Concat           [1]
(YoloVideoDetectionP2Model pid=3904)  22                  -1  1     24064  ultralytics.nn.modules.block.C3k2            [96, 64, 1, True]
(YoloVideoDetectionP2Model pid=3904)  23                  -1  1     36992  ultralytics.nn.modules.conv.Conv             [64, 64, 3, 2]
(YoloVideoDetectionP2Model pid=3904)  24            [-1, 13]  1         0  ultralytics.nn.modules.conv.Concat           [1]
(YoloVideoDetectionP2Model pid=3904)  25                  -1  1     95232  ultralytics.nn.modules.block.C3k2            [192, 128, 1, True]
(YoloVideoDetectionP2Model pid=3904)  26                  -1  1    147712  ultralytics.nn.modules.conv.Conv             [128, 128, 3, 2]
(YoloVideoDetectionP2Model pid=3904)  27            [-1, 10]  1         0  ultralytics.nn.modules.conv.Concat           [1]
(YoloVideoDetectionP2Model pid=3904)  28                  -1  1    463104  ultralytics.nn.modules.block.C3k2            [384, 256, 1, True, 0.5, True]
(YoloVideoDetectionP2Model pid=3904)  29    [19, 22, 25, 28]  1    211304  ultralytics.nn.modules.head.Detect           [1, 1, True, [32, 64, 128, 256]]
(YoloVideoDetectionP2Model pid=3904) YOLO26-p2 summary: 329 layers, 2,515,976 parameters, 2,515,976 gradients, 7.5 GFLOPs
(YoloVideoDetectionP2Model pid=3904)
(YoloVideoDetectionP2Model pid=3904) AMP: running Automatic Mixed Precision (AMP) checks...
(YoloVideoDetectionP2Model pid=3904) AMP: checks passed ✅
(YoloVideoDetectionP2Model pid=3904) optimizer: 'optimizer=auto' found, ignoring 'lr0=0.1' and 'momentum=0.937' and determining best 'optimizer', 'lr0' and 'momentum' automatically...
(YoloVideoDetectionP2Model pid=3904) optimizer: AdamW(lr=0.002, momentum=0.9) with parameter groups 145 weight(decay=0.0), 161 weight(decay=0.0005), 161 bias(decay=0.0)
(YoloVideoDetectionP2Model pid=3904) Image sizes 1024 train, 1024 val
(YoloVideoDetectionP2Model pid=3904) Using 0 dataloader workers
(YoloVideoDetectionP2Model pid=3904) Logging results to /home/gs8173/.cache/toktagger/models/6a6fba37aad4c943840dd3f2/ultralytics/yolo/6a70ac4bafed39bb42331724
(YoloVideoDetectionP2Model pid=3904) Starting training for 2 epochs...
(YoloVideoDetectionP2Model pid=3904)
(YoloVideoDetectionP2Model pid=3904)       Epoch    GPU_mem   box_loss   cls_loss   dfl_loss  Instances       Size
INFO:     127.0.0.1:36742 - "GET /projects/6a6fba37aad4c943840dd3f2/models HTTP/1.1" 200 OK
        1/2      1.14G          0      68.36          0          0       1024: 0% ──────────── 0/3  3.3s
        1/2       1.2G          0      69.09          0          0       1024: 33% ━━━━──────── 1/3 1.1s/it 3.6s<2.1s
        1/2       1.2G     0.6244      69.61  0.0009133          1       1024: 66% ━━━━━━━━─        1/2       1.2G     0.6244      69.61  0.0009133          1       1024: 100% ━━━━━━━━━━━━ 3/3 1.4s/it 4.3s
INFO:     127.0.0.1:36752 - "PUT /projects/6a6fba37aad4c943840dd3f2/models/6a70ac4bafed39bb42331724 HTTP/1.1" 200 OK
(YoloVideoDetectionP2Model pid=3904)
(YoloVideoDetectionP2Model pid=3904)       Epoch    GPU_mem   box_loss   cls_loss   dfl_loss  Instances       Size
        2/2      1.21G          0      67.54          0          0       1024: 0% ──────────── 0/3  0.1s
        2/2      1.23G     0.8771      70.25   0.001202          1       1024: 33% ━━━━─────        2/2      1.23G     0.5847      70.18  0.0008012          0       1024: 100% ━━━━━━━━━━━━ 3/3 9.4it/s 0.3s
                 Class     Images  Instances      Box(P          R      mAP50  mAP50-95): 50% ━━━━━━────── 1/2 3.3s/it 1.0s<3.3s
                 Class     Images  Instances      Box(P          R      mAP50  mAP50-95): 100% ━━━━━━━━━━━━ 2/2 1.9it/s 1.0s
(YoloVideoDetectionP2Model pid=3904)                    all          6          1          0          0          0          0
INFO:     127.0.0.1:36756 - "PUT /projects/6a6fba37aad4c943840dd3f2/models/6a70ac4bafed39bb42331724 HTTP/1.1" 200 OK
(YoloVideoDetectionP2Model pid=3904)
(YoloVideoDetectionP2Model pid=3904) 2 epochs completed in 0.002 hours.

@abdullah-ukaea

Copy link
Copy Markdown
Collaborator

To be clear this is a very good PR ! @praksharma Training and prediction works well with no errors. Just some bugs, edge cases and other thoughts I had to get the yolo training to be more robust or user friendly.

Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
@wk9874

wk9874 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

@praksharma the weights saving / loading has now changed in the models base class & worker, which should hopefully make your life easier (but will definitely require changes!)

Each model is now given a directory named with its model ID, and you can save any number of files in there. There is an optional weights_filename parameter passed to load(), which should be used if available. This points to the actual weights file to load into the model, and is used for loading pretrained weights into the UI (eg, if I pointed to /my/user/dir/yolo_weights.pt, your model's load() method would be provided /my/user/dir as the results dir and yolo_weights.pt to load in

If weights_filename is not provided to load, there should be some defined fallback behaviour. Eg in your case, it would likely be to load with the file at results_dir.joinpath("best.pt") if it exists, else results_dir.joinpath("last.pt")

See here for more details: #346

@abdullah-ukaea

Copy link
Copy Markdown
Collaborator

@wk9874 ready for review and can be merged if your happy with it

Comment thread tests/api/models/test_ultralytics_video_detection.py
Comment thread toktagger/api/models/ultralytics_detection/video_detection.py Outdated
)


def _find_first_useful_frame(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, utils.py?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to add tests for this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, moved in commit 2662b75 and tests done in commit 51d7c7a

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should add tests for the coarse search stuff here

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests added in commit 51d7c7a

Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
return model_path


def get_torch_device() -> torch.device:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may need renaming to not be confusing, since it will ignore cuda GPUs (intentionally)

Alternatively (and probably preferred) - have this function accept a use_cuda: bool flag or something, which basically does the check currently done in base.py L381:

if `use_cuda and torch.cuda.is_available():
   return torch.device("cuda")

And then remove that check from base.py L381, instead passing the self.gpu_available into this func as use_cuda.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in commit 81f716d

return cache_dir


def resolve_weights_path(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code inside here seems to be duplicated inside the save method in base.py? can we consolidate the two?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attempted to consolidate in 711f00d

default=0.2,
ge=0,
le=1,
description="Intersection-over-union threshold.",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk what this means - can we rephrase to be clearer?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worded it better in commit d540f7f

Comment thread toktagger/api/models/ultralytics_detection/video_detection.py Outdated

return fallback_frame

return initial_frame

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thiiiink the logic in this function is correct, but I find it a bit hard to read. Could we maybe refactor it to be more readable, or add comments which explain the logic?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't refactor but I did improve variable names and added more comments in commit 0eac05d

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants