Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ee3bc1b
Add null detections test; update save_results
vanessavmac Jan 19, 2026
573dee5
Unit test, display processed timeline and collection stats
vanessavmac Feb 11, 2026
3f06833
Merge branch 'main' into 484-make-it-clear-what-images-have-not-been-…
vanessavmac Feb 11, 2026
342c3d2
Address review comments
vanessavmac Feb 19, 2026
fd6d22c
Merge branch 'main' into 484-make-it-clear-what-images-have-not-been-…
vanessavmac Feb 19, 2026
93aa8b0
Assert single detector algorithm; move num detections to foreground
vanessavmac Feb 28, 2026
1b12e14
Address review comments
vanessavmac Feb 28, 2026
f917853
Address more review comments
vanessavmac Feb 28, 2026
7c9a848
Fix tests
vanessavmac Feb 28, 2026
20cbccf
Merge branch 'main' into 484-make-it-clear-what-images-have-not-been-…
vanessavmac Feb 28, 2026
15487cb
Merge branch 'main' into 484-make-it-clear-what-images-have-not-been-…
vanessavmac Mar 11, 2026
31dea6e
Remove left over code
vanessavmac Mar 11, 2026
0f7abf8
Merge branch 'main' into 484-make-it-clear-what-images-have-not-been-…
vanessavmac Mar 13, 2026
80df653
Add comment addressing review
vanessavmac Mar 13, 2026
c0132bd
Merge branch '484-make-it-clear-what-images-have-not-been-processed' …
vanessavmac Mar 13, 2026
5585785
chore(ui): move session detail timeline UI to split/session-detail-ui
mihow Mar 17, 2026
8fba25a
fix(ml): only skip images with null-only detections in filter_process…
mihow Mar 17, 2026
8f15591
fix(ml): use algorithm-specific lookup for null detections in get_or_…
mihow Mar 17, 2026
10d8c45
fix(main): avoid N+1 queries from get_was_processed in admin list view
mihow Mar 17, 2026
2a61fbb
fix(ml): exclude null detections from classification check in filter_…
mihow Mar 18, 2026
3a7d9f9
docs(ml): document all 5 branches of filter_processed_images logic
mihow Mar 18, 2026
fd36611
Address minor coderabbit review comment
vanessavmac Mar 20, 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 ami/main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ class SourceImageAdmin(AdminBase):
"checksum",
"checksum_algorithm",
"created_at",
"get_was_processed",
)

list_filter = (
Expand Down
2 changes: 2 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,7 @@ class Meta:
"source_images",
"source_images_count",
"source_images_with_detections_count",
"source_images_processed_count",
"occurrences_count",
"taxa_count",
"description",
Expand Down Expand Up @@ -1498,6 +1499,7 @@ class EventTimelineIntervalSerializer(serializers.Serializer):
captures_count = serializers.IntegerField()
detections_count = serializers.IntegerField()
detections_avg = serializers.IntegerField()
was_processed = serializers.BooleanField()


class EventTimelineMetaSerializer(serializers.Serializer):
Expand Down
13 changes: 10 additions & 3 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from ami.utils.storages import ConnectionTestResult

from ..models import (
NULL_DETECTIONS_FILTER,
Classification,
Deployment,
Detection,
Expand Down Expand Up @@ -375,7 +376,7 @@ def timeline(self, request, pk=None):
)
resolution = datetime.timedelta(minutes=resolution_minutes)

qs = SourceImage.objects.filter(event=event)
qs = SourceImage.objects.filter(event=event).with_was_processed() # type: ignore

# Bulk update all source images where detections_count is null
update_detection_counts(qs=qs, null_only=True)
Expand All @@ -401,7 +402,7 @@ def timeline(self, request, pk=None):
source_images = list(
qs.filter(timestamp__range=(start_time, end_time))
.order_by("timestamp")
.values("id", "timestamp", "detections_count")
.values("id", "timestamp", "detections_count", "was_processed")
)

timeline = []
Expand All @@ -418,6 +419,7 @@ def timeline(self, request, pk=None):
"captures_count": 0,
"detections_count": 0,
"detection_counts": [],
"was_processed": False,
}

while image_index < len(source_images) and source_images[image_index]["timestamp"] <= interval_end:
Expand All @@ -429,6 +431,9 @@ def timeline(self, request, pk=None):
interval_data["detection_counts"] += [image["detections_count"]]
if image["detections_count"] >= max(interval_data["detection_counts"]):
interval_data["top_capture"] = SourceImage(pk=image["id"])
# Track if any image in this interval was processed
if image["was_processed"]:
interval_data["was_processed"] = True
image_index += 1

# Set a meaningful average detection count to display for the interval
Expand Down Expand Up @@ -705,6 +710,7 @@ class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin):
SourceImageCollection.objects.all()
.with_source_images_count() # type: ignore
.with_source_images_with_detections_count()
.with_source_images_processed_count()
.prefetch_related("jobs")
)
serializer_class = SourceImageCollectionSerializer
Expand All @@ -720,6 +726,7 @@ class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin):
"method",
"source_images_count",
"source_images_with_detections_count",
"source_images_processed_count",
"occurrences_count",
]

Expand Down Expand Up @@ -894,7 +901,7 @@ class DetectionViewSet(DefaultViewSet, ProjectMixin):
API endpoint that allows detections to be viewed or edited.
"""

queryset = Detection.objects.all().select_related("source_image", "detection_algorithm")
queryset = Detection.objects.exclude(~NULL_DETECTIONS_FILTER).select_related("source_image", "detection_algorithm")
Comment thread
vanessavmac marked this conversation as resolved.
Outdated
serializer_class = DetectionSerializer
filterset_fields = ["source_image", "detection_algorithm", "source_image__project"]
ordering_fields = ["created_at", "updated_at", "detection_score", "timestamp"]
Expand Down
55 changes: 52 additions & 3 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ class TaxonRank(OrderedEnum):
]
)

NULL_DETECTIONS_FILTER = Q(bbox__isnull=True) | Q(bbox=[])


def get_media_url(path: str) -> str:
"""
Expand Down Expand Up @@ -1739,6 +1741,17 @@ def with_taxa_count(self, project: Project | None = None, request=None):
taxa_count=Coalesce(models.Subquery(taxa_subquery, output_field=models.IntegerField()), 0)
)

def with_was_processed(self):
"""
Annotate each SourceImage with a boolean `was_processed` indicating
whether any detections exist for that image.

This mirrors `SourceImage.get_was_processed()` but as a queryset
annotation for efficient bulk queries.
"""
processed_exists = models.Exists(Detection.objects.filter(source_image_id=models.OuterRef("pk")))
Comment thread
vanessavmac marked this conversation as resolved.
return self.annotate(was_processed=processed_exists)
Comment thread
vanessavmac marked this conversation as resolved.


class SourceImageManager(models.Manager.from_queryset(SourceImageQuerySet)):
pass
Expand Down Expand Up @@ -1838,7 +1851,15 @@ def size_display(self) -> str:
return filesizeformat(self.size)

def get_detections_count(self) -> int:
return self.detections.distinct().count()
# Detections count excludes detections without bounding boxes
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 be a queryset method as well? I see it already exists before this PR.

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.

Not changed in this round — lower priority since it's only used per-instance (admin detail, tests). Could be a follow-up.

# Detections with null bounding boxes are valid and indicates the image was successfully processed
return self.detections.exclude(NULL_DETECTIONS_FILTER).count()

def get_was_processed(self, algorithm_key: str | None = None) -> bool:
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 could introduce n+1 queries, we have a queryset method for with_was_processed. I know we need a was_processed attribute on the model so the serializer knows it exists, but our pattern so far has been to return None for this placeholder model method and add a docstring pointing to the query annotation instead.

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.

It appears this is only used in the admin & tests. The was_processed query annotation is what's used in the serializer / API View

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.

Addressed: get_was_processed now reads from the was_processed annotation when available (hasattr(self, 'was_processed')), falling back to a DB query otherwise. SourceImageAdmin.get_queryset calls .with_was_processed() so the admin list view uses the annotation. See commit 10d8c45.

if algorithm_key:
return self.detections.filter(detection_algorithm__key=algorithm_key).exists()
else:
return self.detections.exists()
Comment thread
vanessavmac marked this conversation as resolved.
Outdated

def get_base_url(self) -> str | None:
"""
Expand Down Expand Up @@ -2008,6 +2029,7 @@ def update_detection_counts(qs: models.QuerySet[SourceImage] | None = None, null

subquery = models.Subquery(
Detection.objects.filter(source_image_id=models.OuterRef("pk"))
.exclude(NULL_DETECTIONS_FILTER)
.values("source_image_id")
.annotate(count=models.Count("id"))
.values("count"),
Expand Down Expand Up @@ -2478,6 +2500,15 @@ def save(self, *args, **kwargs):
super().save(*args, **kwargs)


class DetectionQuerySet(BaseQuerySet):
def null_detections(self):
return self.filter(NULL_DETECTIONS_FILTER)


class DetectionManager(models.Manager.from_queryset(DetectionQuerySet)):
pass


@final
class Detection(BaseModel):
"""An object detected in an image"""
Expand Down Expand Up @@ -2546,6 +2577,8 @@ class Detection(BaseModel):
source_image_id: int
detection_algorithm_id: int

objects = DetectionManager()

def get_bbox(self):
if self.bbox:
return BoundingBox(
Expand Down Expand Up @@ -3703,6 +3736,8 @@ def html(self) -> str:
"common_combined", # Deprecated
]

SOURCE_IMAGES_WITH_NULL_DETECTIONS_FILTER = Q(images__detections__isnull=True)

Comment thread
vanessavmac marked this conversation as resolved.
Outdated

class SourceImageCollectionQuerySet(BaseQuerySet):
def with_source_images_count(self):
Expand All @@ -3716,7 +3751,18 @@ def with_source_images_count(self):
def with_source_images_with_detections_count(self):
return self.annotate(
source_images_with_detections_count=models.Count(
"images", filter=models.Q(images__detections__isnull=False), distinct=True
"images",
filter=(~models.Q(images__detections__bbox__isnull=True) & ~models.Q(images__detections__bbox=[])),
distinct=True,
)
)

def with_source_images_processed_count(self):
return self.annotate(
source_images_processed_count=models.Count(
"images",
filter=models.Q(images__detections__isnull=False),
distinct=True,
)
)

Expand Down Expand Up @@ -3827,7 +3873,10 @@ def source_images_count(self) -> int | None:

def source_images_with_detections_count(self) -> int | None:
# This should always be pre-populated using queryset annotations
# return self.images.filter(detections__isnull=False).count()
return None

def source_images_processed_count(self) -> int | None:
# This should always be pre-populated using queryset annotations
return None

def occurrences_count(self) -> int | None:
Expand Down
58 changes: 56 additions & 2 deletions ami/ml/models/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
update_occurrence_determination,
)
from ami.ml.exceptions import PipelineNotConfigured
from ami.ml.models.algorithm import Algorithm, AlgorithmCategoryMap
from ami.ml.models.algorithm import Algorithm, AlgorithmCategoryMap, AlgorithmTaskType
from ami.ml.schemas import (
AlgorithmConfigResponse,
AlgorithmReference,
Expand Down Expand Up @@ -84,6 +84,9 @@ def filter_processed_images(
task_logger.debug(f"Image {image} needs processing: has no existing detections from pipeline's detector")
# If there are no existing detections from this pipeline, send the image
yield image
elif existing_detections.null_detections().exists(): # type: ignore
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 is unnecessary! it's covered by the first on check if not existing_detections.exists() since we are checking for any detection there.

task_logger.debug(f"Image {image} has a null detection from pipeline {pipeline}, skipping! ")
continue
elif existing_detections.filter(classifications__isnull=True).exists():
# Check if there are detections with no classifications
task_logger.debug(
Expand Down Expand Up @@ -406,7 +409,10 @@ def get_or_create_detection(

:return: A tuple of the Detection object and a boolean indicating whether it was created
"""
serialized_bbox = list(detection_resp.bbox.dict().values())
if detection_resp.bbox is not None:
serialized_bbox = list(detection_resp.bbox.dict().values())
else:
serialized_bbox = None
detection_repr = f"Detection {detection_resp.source_image_id} {serialized_bbox}"
Comment thread
vanessavmac marked this conversation as resolved.

assert str(detection_resp.source_image_id) == str(
Expand Down Expand Up @@ -485,6 +491,7 @@ def create_detections(

existing_detections: list[Detection] = []
new_detections: list[Detection] = []

for detection_resp in detections:
source_image = source_image_map.get(detection_resp.source_image_id)
if not source_image:
Expand Down Expand Up @@ -810,6 +817,37 @@ class PipelineSaveResults:
total_time: float


def create_null_detections_for_undetected_images(
results: PipelineResultsResponse,
detection_algorithm: Algorithm,
logger: logging.Logger = logger,
) -> list[DetectionResponse]:
"""
Create null DetectionResponse objects (empty bbox) for images that have no detections.

:param results: The PipelineResultsResponse from the processing service
:param algorithms_known: Dictionary of algorithms keyed by algorithm key

:return: List of DetectionResponse objects with null bbox
"""
source_images_with_detections = {detection.source_image_id for detection in results.detections}
null_detections_to_add = []
detection_algorithm_reference = AlgorithmReference(name=detection_algorithm.name, key=detection_algorithm.key)

for source_img in results.source_images:
if source_img.id not in source_images_with_detections:
null_detections_to_add.append(
DetectionResponse(
source_image_id=source_img.id,
bbox=None,
algorithm=detection_algorithm_reference,
timestamp=now(),
)
)

return null_detections_to_add
Comment thread
vanessavmac marked this conversation as resolved.


@celery_app.task(soft_time_limit=60 * 4, time_limit=60 * 5)
def save_results(
results: PipelineResultsResponse | None = None,
Expand Down Expand Up @@ -857,6 +895,13 @@ def save_results(
)

algorithms_known: dict[str, Algorithm] = {algo.key: algo for algo in pipeline.algorithms.all()}
try:
detection_algorithm = pipeline.algorithms.get(task_type=AlgorithmTaskType.DETECTION)
except Algorithm.DoesNotExist:
raise ValueError("Pipeline does not have a detection algorithm")
except Algorithm.MultipleObjectsReturned:
raise NotImplementedError("Multiple detection algorithms per pipeline are not supported")
Comment thread
vanessavmac marked this conversation as resolved.
Comment thread
vanessavmac marked this conversation as resolved.

job_logger.info(f"Algorithms registered for pipeline: \n{', '.join(algorithms_known.keys())}")

if results.algorithms:
Expand All @@ -866,6 +911,15 @@ def save_results(
"Algorithms and category maps must be registered before processing, using /info endpoint."
)

# Ensure all images have detections
Comment thread
vanessavmac marked this conversation as resolved.
# if not, add a NULL detection (empty bbox) to the results
null_detections = create_null_detections_for_undetected_images(
results=results,
detection_algorithm=detection_algorithm,
logger=job_logger,
)
results.detections = results.detections + null_detections
Comment thread
vanessavmac marked this conversation as resolved.

detections = create_detections(
detections=results.detections,
algorithms_known=algorithms_known,
Expand Down
4 changes: 2 additions & 2 deletions ami/ml/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,14 @@ class Config:

class DetectionRequest(pydantic.BaseModel):
source_image: SourceImageRequest # the 'original' image
bbox: BoundingBox
bbox: BoundingBox | None = None
Comment thread
vanessavmac marked this conversation as resolved.
crop_image_url: str | None = None
algorithm: AlgorithmReference


class DetectionResponse(pydantic.BaseModel):
source_image_id: str
bbox: BoundingBox
bbox: BoundingBox | None = None
inference_time: float | None = None
algorithm: AlgorithmReference
timestamp: datetime.datetime
Expand Down
24 changes: 24 additions & 0 deletions ami/ml/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,30 @@ def test_project_pipeline_config(self):
final_config = self.pipeline.get_config(self.project.pk)
self.assertEqual(final_config["test_param"], "project_value")

def test_image_with_null_detection(self):
"""
Test saving results for a pipeline that returns null detections for some images.
"""
image = self.test_images[0]
results = self.fake_pipeline_results([image], self.pipeline)

# Manually change the results for a single image to a list of empty detections
results.detections = []

save_results(results)

image.save()
self.assertEqual(image.get_detections_count(), 0) # detections_count should exclude null detections
total_num_detections = image.detections.distinct().count()
self.assertEqual(total_num_detections, 1)

was_processed = image.get_was_processed()
self.assertEqual(was_processed, True)

# Also test filtering by algorithm
was_processed = image.get_was_processed(algorithm_key="random-detector")
self.assertEqual(was_processed, True)


class TestAlgorithmCategoryMaps(TestCase):
def setUp(self):
Expand Down
14 changes: 14 additions & 0 deletions ui/src/data-services/models/capture-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export class CaptureSet extends Entity {
return this._data.source_images_with_detections_count
}

get numImagesProcessed(): number | undefined {
return this._data.source_images_processed_count
}

get numImagesWithDetectionsLabel(): string {
const pct =
this.numImagesWithDetections && this.numImages
Expand All @@ -86,6 +90,16 @@ export class CaptureSet extends Entity {
)}%)`
}

get numImagesProcessedLabel(): string {
const numProcessed = this.numImagesProcessed ?? 0
const pct =
this.numImages && this.numImages > 0
? (numProcessed / this.numImages) * 100
: 0

return `${numProcessed.toLocaleString()} (${pct.toFixed(0)}%)`
}

get numJobs(): number | undefined {
return this._data.jobs?.length
}
Expand Down
Loading
Loading