Skip to content

Add Ultralytics video detection models - #326

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

Add Ultralytics video detection models#326
praksharma wants to merge 21 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 on lines +121 to +130
def build_video_frame_manifest(
samples: list[Sample],
annotations: list[list[Annotation]],
class_map: dict[str, int],
data_loader: TokTaggerDataLoader,
) -> list[DetectionRecord]:
"""
Convert validated video samples into frame-level training records.
Hash table has been used to speed up things.
"""
Comment thread toktagger/api/models/__init__.py
Comment thread toktagger/api/models/ultralytics_detection/base.py Outdated
Comment on lines +161 to +164
if isinstance(frame_image.values, str):
raise TypeError(
"Expected raw image bytes but received a base64 string."
)

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.

Some video loaders, including ArrayDataLoader and UDACameraDataLoader, return a base64 string even when return_raw=True. This check therefore makes YOLO training and prediction fail for those loaders.

Could we support both raw bytes and base64 images here? Otherwise, we should clearly restrict this model to loaders that support raw image bytes.

Here is an idea to support both image formats in this model below.

The model could decode base64 values as well as accepting raw bytes:

if isinstance(frame_image.values, str):
    image_bytes = base64.b64decode(frame_image.values)
else:
    image_bytes = bytes(frame_image.values)

This would allow images returned by ArrayDataLoader and UDACameraDataLoader to reach YOLO.

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.

Instead of that, I would just make sure all image-based data loaders have the return_raw option implemented...

Potentially we should make another image dataloader base class which implements that logic, which each of ImageDataLoader, ArrayDataLoader, UDACameraDataLoader can inherit from

),
)
except FileNotFoundError:
break

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 frame loop currently reaches the end of a video cleanly only when using ImageDataLoader, because it expects the loader to raise FileNotFoundError.

I was looking into the other valid video loaders and found they behave differently:

  • ArrayDataLoader raises DataLoaderError for an out-of-range frame
  • UDACameraDataLoader wraps its loading failures in DataLoaderError.

As a result, YOLO training or prediction can fail for other data loaders instead of stopping after the final frame.

Below is an idea to give all video loaders the same end-of-video signal.

Simply catching every DataLoaderError would not be safe:

except DataLoaderError:
    break

DataLoaderError can also mean that a NumPy file is invalid, the data has the wrong shape, or an external data source failed. Catching it here would hide those real errors and incorrectly treat them as the end of the video.

One option would be to introduce a more specific exception in the data-loader layer:

class FrameNotFoundError(DataLoaderError):
    """The requested video frame does not exist."""

Each video loader would raise FrameNotFoundError only when the requested frame is unavailable. The YOLO iterator could then stop safely without hiding other failures:

except FrameNotFoundError:
    break

This approach would require changes in:

  • toktagger/api/core/data_loaders.py
  • toktagger/api/models/ultralytics_detection/video_detection.py
  • toktagger/api/models/temp.py

That shared loader change may be outside the scope of this PR to be fair. If this PR intentionally supports only ImageDataLoader, could we validate that explicitly, return a clear error for unsupported loaders and put this in the PR description? A follow up PR could then introduce the common frame @wk9874, what do you think? or should we do it 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.

I agree with implementing FrameNotFoundError, probably worth doing it either as part of this PR, or as a separate small PR which is merged before this one

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 on lines +397 to +414
overrides = {
"model": model_path,
"epochs": epochs,
"batch": self.batch,
"imgsz": self.imgsz,
"workers": self.workers,
"device": self.get_device().type,
"project": str(training_output_root),
"name": self.id,
"exist_ok": True,
"save": True,
"plots": False,
"val": has_validation_data,
"close_mosaic": 0,
}
if learning_rate > 0:
overrides["lr0"] = learning_rate
return overrides

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.

Could you do me a favour and try GPU training and prediction, then check git status? After GPU training, I consistently get an untracked yolo26n.pt in the TokTagger repository root:

Untracked files:
    yolo26n.pt

It looks like Ultralytics’ AMP check downloads a second copy instead of using the checkpoint already in TokTagger’s cache. Do you see the same behaviour?

If so, could we configure Ultralytics to reuse the pretrained checkpoint under MODEL_STORAGE so weight files are not created in the source directory?

Although I comment on this override block the main code block responsible is below on line 461 I think.

trainer = ToktaggerDetectionTrainer(
    overrides=overrides,
    train_dataset=train_dataset,
    val_dataset=validation_dataset,
    class_names=self.class_names,
    progress_callback=self.log_progress,
)
trainer.train()

@praksharma praksharma Aug 10, 2026

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.

Thanks to copilot, I was able to find the culprit in thousands of lines of code. For GPU training there is a list of checks being carried out. And this specific line is the culprit:

https://github.com/ultralytics/ultralytics/blob/b3bcdaf963957cd84d373c16abf2c6d11ce04654/ultralytics/utils/checks.py#L1030

INterestingly, Ultralytics has two different settings. overrides are per-training-run arguments and settings are global Ultralytics configuration, stored in a temp file settings.json.

https://github.com/ultralytics/ultralytics/blob/89cb7da4ee647e7247cc2ce4518403d045d94892/tests/test_cli.py#L37

All the settings are defined here:

https://github.com/ultralytics/ultralytics/blob/89cb7da4ee647e7247cc2ce4518403d045d94892/ultralytics/utils/__init__.py#L1357

More specifically, I think we just change the weights_dir in the __init__ method of that class and I hope it works. Unfortunately, I don't have a laptop with GPU to test this. So, I am not sure if the nano model will be created in every model_id folder for AMP checks after this commit 7036a28.

@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

@praksharma praksharma closed this Aug 10, 2026
@praksharma praksharma reopened this Aug 11, 2026
@praksharma

Copy link
Copy Markdown
Member Author

The PR is ready for a review. I have created a new branch (will create a PR once I commit something) to standardise the image-based dataloaders return_raw flag.

@abdullah-ukaea

abdullah-ukaea commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

I understand now that this selection is valid for P2 in this training form. It controls the P2 model size and selects the regular checkpoint used to initialize compatible layers.

There are two ways we could make this clearer:

  • Simple fix: Just update the existing field description to: “Pretrained YOLO checkpoint to fine-tune. For P2 models, compatible weights are transferred from this checkpoint, while new P2-specific layers are initialized randomly.” only a docstring change, no code change.
  • Ideal fix: Give the P2 model its own training parameter schema. This would allow the P2 form to offer only compatible model choices and use a clearer field name such as “Base YOLO checkpoint,” with a P2-specific explanation. (This is related to a comment I made below in the second code review Add Ultralytics video detection models #326 (comment) ) this would involve a minor code change.

Could we also update the PR description? It currently says the P2 model trains from random initialization, but the latest implementation transfers compatible weights from the selected checkpoint.

regular yolo training form yoloP2 training form

@abdullah-ukaea abdullah-ukaea left a comment

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 very close to being ready. Training and prediction both worked with no errors during my nvidia GPU testing, and most of my original comments have been addressed.

Before merging, could you complete the follow up data-loader PR? YOLO is offered to all video projects but currently relies on ImageDataLoader behaviour, so I think that work should be merged first.

I’m happy to update uv.lock. I’ve also left a few comments about test coverage, rare edge cases, and separating the normal YOLO and P2 schemas. Otherwise, this looks nearly ready to merge.

Comment thread pyproject.toml
Comment on lines +426 to +431
@ModelRegistry.register(
"yolo_ufo_p2",
["video"],
YoloTrainParams,
YoloPredictParams,
)

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.

Was YOLO11n intentionally removed? I suspect it was left out because there is no matching P2 architecture, but that should not prevent users from selecting it for the regular YOLO model.

The normal YOLO and P2 models currently share YoloTrainParams, so they must expose the same checkpoint options. It looks like yolo11n.pt was removed because it has no matching P2 architecture, but this also removes YOLO11n from the normal yolo_ufo form even though the PR description says it is supported.

Could we give P2 its own training parameter schema? The normal schema could continue to offer YOLO11n, while the P2 schema would offer only checkpoints with matching P2 architectures. This would also let us give the P2 checkpoint field the clearer description discussed above. If you disagree and there is a good reason to omit yolo11n and keep the current schema that is fine, at least update the pr description.

other related code:

YoloModelName = Literal[
    "yolov8n.pt",
    "yolo26n.pt",
    "yolo26m.pt",
    "yolo26l.pt",
    "yolo26x.pt",
]

..
..

@ModelRegistry.register(
    "yolo_ufo",
    ["video"],
    YoloTrainParams,
    YoloPredictParams,
)

Comment on lines +91 to +94
def iter_sample_frames(
data_loader: TokTaggerDataLoader,
sample: Sample,
) -> Iterator[ImageData]:

@abdullah-ukaea abdullah-ukaea Aug 12, 2026

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.

Add a basic unit test for this? see this comment for more detail #326 (comment)

Comment on lines +133 to +138
def build_video_frame_manifest(
samples: list[Sample],
annotations: list[list[Annotation]],
class_map: dict[str, int],
data_loader: TokTaggerDataLoader,
) -> list[DetectionRecord]:

@abdullah-ukaea abdullah-ukaea Aug 12, 2026

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.

Add a basic unit test for this? see this comment for more detail #326 (comment)

Comment on lines +230 to +232
def decode_frame_image(frame_image: ImageData) -> np.ndarray:
"""Decode raw TokTagger image bytes for Ultralytics prediction."""
if isinstance(frame_image.values, str):

@abdullah-ukaea abdullah-ukaea Aug 12, 2026

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.

Add a basic unit test for this? see this comment for more detail #326 (comment)

Comment on lines +310 to +315
def predict(
self,
samples: list[Sample],
params: YoloPredictParams,
data_params=None,
) -> list[list[AnnotationBase]]:

@abdullah-ukaea abdullah-ukaea Aug 12, 2026

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.

Add a basic unit test for this? see this comment for more detail #326 (comment)

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 functions below needs some test coverage:

  • iter_sample_frames()
  • build_video_frame_manifest()
  • decode_frame_image
  • predict()

Could we add some focused unit tests for this new functionality? The PR currently has no tests for frame iteration, manifest creation, image decoding or converting YOLO results into bounding-box annotations.

These tests should not need to train a real model. Small test images and mocked data loaders/YOLO predictions could cover:

  • Reaching the end of a video
  • Frames with no annotations
  • Multiple boxes on one frame
  • Bounding-box coordinate conversion
  • Predictions containing no detections
  • Loading best.pt, with fallback to last.pt
  • Restoring a model after its Ray actor is recreated

Comment on lines +73 to +83
# Download to a temporary file so an interrupted transfer cannot leave a
# partial checkpoint at the final cache path.
temporary_path = model_path.with_name(f"{model_path.name}.tmp")
temporary_path.unlink(missing_ok=True)

try:
urlretrieve(MODEL_URLS[model_name], temporary_path)
temporary_path.replace(model_path)
except Exception:
temporary_path.unlink(missing_ok=True)
raise

@abdullah-ukaea abdullah-ukaea Aug 12, 2026

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.

Could two training jobs try to download the same checkpoint at the same time?

Every Ray actor uses the same .tmp path, so one actor could delete or replace another actor’s download. Also, urlretrieve() has no explicit timeout, so a failed connection could leave training waiting for a long time.

Could we use the existing filelock dependency to allow only one download per checkpoint at a time, and add a network timeout? After obtaining the lock, we should check again whether another actor has already completed the download.

Here is a example code snippet of a potential solution:

from filelock import FileLock
from urllib.request import urlopen

  lock_path =
  model_path.with_suffix(".lock")

  with FileLock(lock_path):
      # Another actor may have downloaded it
      while we waited.
      if model_path.exists() and not
      force_download:
          return model_path

      with urlopen(MODEL_URLS[model_name],
      timeout=60) as response:

          temporary_path.write_bytes(respons
          e.read())

      temporary_path.replace(model_path)

Comment on lines +147 to +148
REMOVE BELOW COMMENT AFTER SOMEONE HAS TESTED IT ON A NVIDIA GPU.
https://github.com/ukaea/toktagger/pull/326#discussion_r3754183103

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 you can remove this I tested your branch on a nvidia gpu.


# TYPE_CHECKING is always false while the application runs.Type checkers treat it as true
# Othewise this will become a circular import.
if TYPE_CHECKING:

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 a bit of a code smell - can we just define YoloTrainParams in this file, and import it inside video_detection instead?

shot_id: int
frame: int
image: bytes
boxes: list[list[float]]

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.

If this is always x1, x2, y1, y2, could narrow the typing to list[tuple[float, float, float, float]]

"https://github.com/ultralytics/assets/releases/download/v8.4.0"
)

MODEL_URLS = {

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 seems somewhat redundant - isn't it always just f"{BASE_URL/{file_name}"?

The model is downloaded into TokTagger's cache when it is not already
available. Setting ``force_download`` replaces an existing cached model.
"""
if model_name not in MODEL_URLS:

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.

Feels like you can get rid of MODEL_URLs and just use MODEL_FAMILIES 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.

Otherwise we risk a key being added to one but not the other, leading to a KeyError on line 54

)


def iter_sample_frames(

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.

At some point you had that logic which did a coarse search until there was a non black frame, and then iterated from there - should we add that in here?

self,
samples: list[Sample],
params: YoloPredictParams,
data_params=None,

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.

Here, you could add a this_frame_only parameter to YoloPredictParams. If this is enabled (and data_params is available, which means the prediction was requested from an individual sample page), then take the frame number from data_params and return predictions only for that single frame.

Add in the description of the parameter that 'this is ignored for multi-sample predictions' or something


result = results[0]

if result.boxes is None or len(result.boxes) == 0:

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.

can this just be if not result.boxes?

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