Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
455f866
feat(api): scaffold anybug YOLO26 detector and staged pipeline registry
mihow Jul 23, 2026
b0ea995
feat(anybug): wire uploaded YOLO26 weight, pin ultralytics 8.4.0, fix…
mihow Jul 23, 2026
1afa2b6
fix(worker): route the anybug pipeline through the worker's execution…
mihow Jul 23, 2026
c67cfad
feat(worker): run any registry pipeline via the shared stage engine
mihow Jul 23, 2026
7f52240
fix(anybug): feed YOLO26 detector HWC uint8 on the async worker path
mihow Jul 23, 2026
692786c
fix(anybug): make YOLO26 input-format conversion an asserted contract
mihow Jul 23, 2026
0ca45e5
fix(anybug): tag uncroppable detections and align crop bounds across …
mihow Jul 23, 2026
5c034bc
chore(anybug): lock ultralytics into uv.lock
mihow Jul 23, 2026
ce742f4
fix(api): cap reported insect-order confidence at 0.9 instead of halv…
mihow Jul 24, 2026
9ba91c6
test(api): pin one localization algorithm per advertised pipeline
mihow Jul 24, 2026
e562567
fix(api): build the pipeline config test from the pipeline registry
mihow Jul 24, 2026
f18832d
test: request a Wikimedia thumbnail width that Wikimedia still serves
mihow Jul 24, 2026
0771a85
test(api): pick test images in filename order so selection is determi…
mihow Jul 24, 2026
e2b6fe4
docs(test): say which half of the one-detector invariant each test co…
mihow Jul 24, 2026
7a54c27
fix(api): give each pipeline its own name so the picker shows them apart
mihow Jul 24, 2026
35c8d95
docs: drop the claim that the anybug weight and dependency are missing
mihow Jul 24, 2026
1a9df18
docs: describe score_scale as a constant scale, not a cap
mihow Jul 24, 2026
a6fc571
fix(anybug): pin YOLO26 inference resolution to the trained 1024
mihow Jul 24, 2026
d1cab07
feat(anybug): swap the server detector to the larger YOLO26x flavor
mihow Jul 24, 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
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "trapdata"
version = "0.6.0"
description = "Companion software for automated insect monitoring stations"
authors = [{ name = "Michael Bunsen", email = "notbot@gmail.com" }]
license = { text = "MIT" }
license = { text = "AGPL-3.0" }
readme = "README.md"
requires-python = ">=3.10,<3.13"
urls = { Homepage = "https://github.com/RolnickLab/ami-data-companion", Repository = "https://github.com/RolnickLab/ami-data-companion" }
Expand Down Expand Up @@ -31,6 +31,8 @@ dependencies = [
"torch>=2.5",
"torchvision>=0.20",
"typer>=0.12.3,<1",
# Ultralytics is AGPL-3.0. YOLO26 support first ships in v8.4.0.
"ultralytics>=8.4.0",
"uvicorn>=0.20",
]

Expand Down
353 changes: 196 additions & 157 deletions trapdata/antenna/worker.py

Large diffs are not rendered by default.

454 changes: 350 additions & 104 deletions trapdata/api/api.py

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions trapdata/api/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ def __getitem__(self, idx):
if not image_data:
return None
bbox = detection.bbox
coords = bbox.x1, bbox.y1, bbox.x2, bbox.y2
assert all(coord is not None for coord in coords)
# Clamp to the image's pixel bounds so this PIL-crop runner and the
# worker's tensor-slicing runner crop the SAME in-bounds region (see
# BoundingBox.clamp_to_bounds); PIL otherwise zero-pads out-of-bounds.
coords = bbox.clamp_to_bounds(image_data.width, image_data.height)
image_data = image_data.crop(coords) # type: ignore
image_data = self.image_transforms(image_data)

Expand Down
19 changes: 17 additions & 2 deletions trapdata/api/models/classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ class APIMothClassifier(
):
task_type = "classification"

# Multiplies the softmax scores before they are stored. A value below 1.0
# tempers a chronically over-confident model so the UI does not read every
# prediction as near-certain. This is a display stopgap only: it scales all
# scores by the same positive constant, so it is argmax-invariant and never
# changes which label is predicted or any downstream label gate.
score_scale: float = 1.0

def __init__(
self,
source_images: typing.Iterable[SourceImage],
Expand Down Expand Up @@ -84,10 +91,12 @@ def post_process_batch(self, logits: torch.Tensor):
labels = [self.category_map[i] for i in class_indices]
logit = logits[i].tolist()

# score_scale tempers over-confident models for display; it is a
# positive constant so the stored logits and argmax are unaffected.
result = ClassifierResult(
labels=labels,
logit=logit,
scores=pred.tolist(),
scores=(pred * self.score_scale).tolist(),
)

batch_results.append(result)
Expand Down Expand Up @@ -230,4 +239,10 @@ class MothClassifierGlobal(APIMothClassifier, GlobalMothSpeciesClassifier):


class InsectOrderClassifier(APIMothClassifier, InsectOrderClassifier2025):
pass
# Scales every reported order score by a constant 0.9. The model has no "not
# an insect" class, so it is as confident on non-animal crops (smudges, plant
# matter) as on real ones; the scale keeps its output away from certainty
# without flattening the range. This is a display guard, not a calibration: it
# lowers low scores as well as high ones. Argmax-invariant, so the stored
# logits and the Lepidoptera gate are unaffected. See RolnickLab/ami-ml#75.
score_scale = 0.9
63 changes: 62 additions & 1 deletion trapdata/api/models/localization.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import datetime
import typing

from trapdata.ml.models.localization import MothObjectDetector_FasterRCNN_2023
from trapdata.ml.models.localization import (
AnyBugObjectDetector_YOLO26,
MothObjectDetector_FasterRCNN_2023,
)

from ..datasets import LocalizationImageDataset
from ..schemas import AlgorithmReference, BoundingBox, DetectionResponse, SourceImage
Expand Down Expand Up @@ -56,3 +59,61 @@ def save_detection(image_id, coords):
def run(self) -> list[DetectionResponse]:
super().run()
return self.results


class APIAnyBugDetector(APIInferenceBaseClass, AnyBugObjectDetector_YOLO26):
"""API wrapper around the YOLO26 "any-bug" detector.

Mirrors :class:`APIMothDetector`'s request/response plumbing (dataset,
per-image ``DetectionResponse`` assembly) while inheriting the YOLO26
inference, de-letterboxing, and EXIF-neutralizing behavior from
:class:`AnyBugObjectDetector_YOLO26`.
"""

task_type = "localization"

def __init__(self, source_images: typing.Iterable[SourceImage], *args, **kwargs):
self.source_images = source_images
self.results: list[DetectionResponse] = []
super().__init__(*args, **kwargs)

def reset(self, source_images: typing.Iterable[SourceImage]):
self.source_images = source_images
self.results = []

def get_dataset(self):
return LocalizationImageDataset(
self.source_images, self.get_transforms(), batch_size=self.batch_size
)

def get_source_image(self, source_image_id: int) -> SourceImage:
for source_image in self.source_images:
if source_image.id == source_image_id:
return source_image
raise ValueError(f"Source image with id {source_image_id} not found")

def save_results(self, item_ids, batch_output, seconds_per_item, *args, **kwargs):
detections: list[DetectionResponse] = []

def save_detection(image_id, coords):
bbox = BoundingBox(x1=coords[0], y1=coords[1], x2=coords[2], y2=coords[3])
detection = DetectionResponse(
source_image_id=image_id,
bbox=bbox,
inference_time=seconds_per_item,
algorithm=AlgorithmReference(name=self.name, key=self.get_key()),
timestamp=datetime.datetime.now(),
crop_image_url=None,
)
return detection

for image_id, image_output in zip(item_ids, batch_output):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Pass an explicit strict= to zip. item_ids and batch_output are expected to be equal length; making that explicit (strict=True) turns a silent truncation into a loud error if they ever diverge (and clears the Ruff B905 warning). The same applies to the identical APIMothDetector.save_results at Line 52.

Proposed change
-        for image_id, image_output in zip(item_ids, batch_output):
+        for image_id, image_output in zip(item_ids, batch_output, strict=True):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for image_id, image_output in zip(item_ids, batch_output):
for image_id, image_output in zip(item_ids, batch_output, strict=True):
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 113-113: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@trapdata/api/models/localization.py` at line 113, Update both
`APIMothDetector.save_results` loops to call `zip` with `strict=True`, including
the loop over `item_ids` and `batch_output`, so mismatched lengths raise an
error instead of being silently truncated.

Source: Linters/SAST tools

for coords in image_output:
detection = save_detection(image_id, coords)
detections.append(detection)

self.results += detections

def run(self) -> list[DetectionResponse]:
super().run()
return self.results
17 changes: 17 additions & 0 deletions trapdata/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ def to_path(self):
def to_tuple(self):
return (self.x1, self.y1, self.x2, self.y2)

def clamp_to_bounds(self, width: int, height: int) -> tuple[int, int, int, int]:
"""Clamp this box to an image's pixel bounds as integer ``(x1, y1, x2, y2)``.

Both crop runners go through this so they see the SAME in-bounds region
for a box that touches or extends past the image edge: the ``/process``
path's PIL ``Image.crop`` zero-pads out-of-bounds coordinates, while the
worker's tensor slicing wraps negative indices, so without a shared clamp
the two paths would return different crops for the same detection. A box
entirely outside the image collapses to a zero-area (degenerate) region,
which the caller treats as uncroppable.
"""
x1 = max(0, min(int(self.x1), width))
x2 = max(0, min(int(self.x2), width))
y1 = max(0, min(int(self.y1), height))
y2 = max(0, min(int(self.y2), height))
return x1, y1, x2, y2


class SourceImage(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="ignore", arbitrary_types_allowed=True)
Expand Down
45 changes: 23 additions & 22 deletions trapdata/api/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from trapdata.api.api import (
CLASSIFIER_CHOICES,
PIPELINE_CHOICES,
PipelineChoice,
PipelineRequest,
PipelineResponse,
Expand All @@ -15,7 +16,7 @@
)
from trapdata.api.schemas import PipelineConfigRequest
from trapdata.api.tests.image_server import StaticFileTestServer
from trapdata.api.tests.utils import get_test_images, get_pipeline_class
from trapdata.api.tests.utils import get_pipeline_class, get_test_images
from trapdata.tests import TEST_IMAGES_BASE_PATH

logging.basicConfig(level=logging.INFO)
Expand Down Expand Up @@ -61,40 +62,40 @@ def test_pipeline_request(self):
self.fail(f"Pipeline request did not return a valid response: {e}")

def test_pipeline_config_with_binary_classifier(self):
binary_classifier_pipeline_choice = "moth_binary"
BinaryClassifier = CLASSIFIER_CHOICES[binary_classifier_pipeline_choice]
binary_classifier_instance = BinaryClassifier(source_images=[], detections=[])
BinaryClassifierResponse = make_algorithm_response(binary_classifier_instance)
"""A pipeline's advertised config lists one algorithm per configured
stage, terminal classifier last. A species pipeline runs detector, binary
moth filter, then species classifier, so it advertises three; the
binary-only pipeline has no intermediate filter, so it advertises two.
"""
binary_pipeline_slug = "moth_binary"
binary_pipeline = PIPELINE_CHOICES[binary_pipeline_slug]
binary_classifier = binary_pipeline.terminal(source_images=[], detections=[])
binary_classifier_key = make_algorithm_response(binary_classifier).key

species_classifier_pipeline_choice = "quebec_vermont_moths_2023"
SpeciesClassifier = CLASSIFIER_CHOICES[species_classifier_pipeline_choice]
species_classifier_instance = SpeciesClassifier(source_images=[], detections=[])
SpeciesClassifierResponse = make_algorithm_response(species_classifier_instance)
species_pipeline_slug = "quebec_vermont_moths_2023"
species_pipeline = PIPELINE_CHOICES[species_pipeline_slug]
species_classifier = species_pipeline.terminal(source_images=[], detections=[])
species_classifier_key = make_algorithm_response(species_classifier).key

# Test using a pipeline that finishes with a full species classifier
# A pipeline that finishes with a full species classifier.
pipeline_config = make_pipeline_config_response(
SpeciesClassifier,
slug=species_classifier_pipeline_choice,
species_pipeline,
slug=species_pipeline_slug,
)

self.assertEqual(len(pipeline_config.algorithms), 3)
self.assertEqual(
pipeline_config.algorithms[-1].key, SpeciesClassifierResponse.key
)
self.assertEqual(
pipeline_config.algorithms[1].key, BinaryClassifierResponse.key
)
self.assertEqual(pipeline_config.algorithms[-1].key, species_classifier_key)
self.assertEqual(pipeline_config.algorithms[1].key, binary_classifier_key)

# Test using a pipeline that finishes only with a binary classifier
# A pipeline that finishes only with a binary classifier.
pipeline_config_binary_only = make_pipeline_config_response(
BinaryClassifier, slug=binary_classifier_pipeline_choice
binary_pipeline, slug=binary_pipeline_slug
)

self.assertEqual(len(pipeline_config_binary_only.algorithms), 2)
self.assertEqual(
pipeline_config_binary_only.algorithms[-1].key, BinaryClassifierResponse.key
pipeline_config_binary_only.algorithms[-1].key, binary_classifier_key
)
# self.assertTrue(pipeline_config_binary_only.algorithms[-1].terminal)

def test_processing_with_only_binary_classifier(self):
binary_classifier_pipeline_choice = "moth_binary"
Expand Down
Loading
Loading