From ee3bc1be7df5ba9bf41918410edfc7848be45ed9 Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Mon, 19 Jan 2026 00:56:14 -0500 Subject: [PATCH 01/10] Add null detections test; update save_results --- ami/ml/models/pipeline.py | 42 +++++++++++++++++++++++++++++++++++++-- ami/ml/schemas.py | 4 ++-- ami/ml/tests.py | 19 ++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/ami/ml/models/pipeline.py b/ami/ml/models/pipeline.py index f76822e3a..413178389 100644 --- a/ami/ml/models/pipeline.py +++ b/ami/ml/models/pipeline.py @@ -8,6 +8,7 @@ import collections import dataclasses +import datetime import logging import time import typing @@ -37,7 +38,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, @@ -406,7 +407,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}" assert str(detection_resp.source_image_id) == str( @@ -485,6 +489,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: @@ -866,6 +871,39 @@ def save_results( "Algorithms and category maps must be registered before processing, using /info endpoint." ) + # Ensure all images have detections + # if not, add a NULL detection (empty bbox) to the results + source_images_with_detections = [detection.source_image_id for detection in results.detections] + source_images_with_detections = set(source_images_with_detections) + null_detections_to_add = [] + + for source_img in results.source_images: + if source_img.id not in source_images_with_detections: + detector_algorithm_reference = None + for known_algorithm in algorithms_known.values(): + if known_algorithm.task_type == AlgorithmTaskType.DETECTION: + detector_algorithm_reference = AlgorithmReference( + name=known_algorithm.name, key=known_algorithm.key + ) + + if detector_algorithm_reference is None: + job_logger.error( + f"Could not identify the detector algorithm. " + f"A null detection was not created for Source Image {source_img.id}" + ) + continue + + null_detections_to_add.append( + DetectionResponse( + source_image_id=source_img.id, + bbox=None, + algorithm=detector_algorithm_reference, + timestamp=datetime.datetime.now(), + ) + ) + + results.detections = results.detections + null_detections_to_add + detections = create_detections( detections=results.detections, algorithms_known=algorithms_known, diff --git a/ami/ml/schemas.py b/ami/ml/schemas.py index 478b4c8fd..b18105163 100644 --- a/ami/ml/schemas.py +++ b/ami/ml/schemas.py @@ -136,14 +136,14 @@ class Config: class DetectionRequest(pydantic.BaseModel): source_image: SourceImageRequest # the 'original' image - bbox: BoundingBox + bbox: BoundingBox | None = None 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 diff --git a/ami/ml/tests.py b/ami/ml/tests.py index 14e4374f2..4ef3ca9e4 100644 --- a/ami/ml/tests.py +++ b/ami/ml/tests.py @@ -687,6 +687,25 @@ 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. + """ + results = self.fake_pipeline_results(self.test_images, self.pipeline) + + # Manually change the results for a single image to a list of empty detections + first_image_id = results.source_images[0].id + new_detections = [detection for detection in results.detections if detection.source_image_id == first_image_id] + results.detections = new_detections + + save_results(results) + + # After save is done, each image should have at least one detection, + # even if the detection list from the PS was empty. + for image in self.test_images: + image.save() + self.assertEqual(image.detections_count, 1) + class TestAlgorithmCategoryMaps(TestCase): def setUp(self): From 573dee5ada228033b375ed55dfac2b8aedc6d638 Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Wed, 11 Feb 2026 11:25:15 -0500 Subject: [PATCH 02/10] Unit test, display processed timeline and collection stats --- ami/main/admin.py | 1 + ami/main/api/serializers.py | 2 + ami/main/api/views.py | 12 +++-- ami/main/models.py | 44 +++++++++++++++++-- ami/ml/tests.py | 23 ++++++---- ui/src/data-services/models/collection.ts | 14 ++++++ ui/src/data-services/models/timeline-tick.ts | 5 +++ .../collections/collection-columns.tsx | 10 +++++ .../pages/project/collections/collections.tsx | 1 + .../playback/activity-plot/activity-plot.tsx | 27 ++++++++++++ ui/src/utils/language.ts | 2 + 11 files changed, 126 insertions(+), 15 deletions(-) diff --git a/ami/main/admin.py b/ami/main/admin.py index 215d8a3be..49e7ebb5c 100644 --- a/ami/main/admin.py +++ b/ami/main/admin.py @@ -266,6 +266,7 @@ class SourceImageAdmin(AdminBase): "checksum", "checksum_algorithm", "created_at", + "get_was_processed", ) list_filter = ( diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 1c5b7a126..9957fedb3 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -1178,6 +1178,7 @@ class Meta: "source_images", "source_images_count", "source_images_with_detections_count", + "source_images_processed_count", "occurrences_count", "taxa_count", "description", @@ -1479,6 +1480,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): diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 9a2770ac8..2e86a66b0 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -375,7 +375,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) @@ -401,7 +401,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 = [] @@ -418,6 +418,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: @@ -435,6 +436,7 @@ def timeline(self, request, pk=None): # Remove zero values and calculate the mode interval_data["detection_counts"] = [x for x in interval_data["detection_counts"] if x > 0] interval_data["detections_avg"] = mode(interval_data["detection_counts"] or [0]) + interval_data["was_processed"] = image["was_processed"] timeline.append(interval_data) current_time = interval_end @@ -705,6 +707,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 @@ -720,6 +723,7 @@ class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin): "method", "source_images_count", "source_images_with_detections_count", + "source_images_processed_count", "occurrences_count", ] @@ -894,7 +898,9 @@ 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(Q(bbox__isnull=True) | Q(bbox=None) | Q(bbox=[])).select_related( + "source_image", "detection_algorithm" + ) serializer_class = DetectionSerializer filterset_fields = ["source_image", "detection_algorithm", "source_image__project"] ordering_fields = ["created_at", "updated_at", "detection_score", "timestamp"] diff --git a/ami/main/models.py b/ami/main/models.py index 515f5286a..3a12b324b 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -1656,6 +1656,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"))) + return self.annotate(was_processed=processed_exists) + class SourceImageManager(models.Manager.from_queryset(SourceImageQuerySet)): pass @@ -1755,7 +1766,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 + # Detections with null bounding boxes are valid and indicates the image was successfully processed + return self.detections.exclude(Q(bbox__isnull=True) | Q(bbox=None) | Q(bbox=[])).count() + + def get_was_processed(self, algorithm_key: str | None = None) -> bool: + if algorithm_key: + return self.detections.filter(detection_algorithm__key=algorithm_key).exists() + else: + return self.detections.exists() def get_base_url(self) -> str | None: """ @@ -3603,7 +3622,23 @@ 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__isnull=False) + & ~models.Q(images__detections__bbox__isnull=True) + & ~models.Q(images__detections__bbox=None) + & ~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, ) ) @@ -3714,7 +3749,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: diff --git a/ami/ml/tests.py b/ami/ml/tests.py index 4ef3ca9e4..13cbe1d76 100644 --- a/ami/ml/tests.py +++ b/ami/ml/tests.py @@ -691,20 +691,25 @@ def test_image_with_null_detection(self): """ Test saving results for a pipeline that returns null detections for some images. """ - results = self.fake_pipeline_results(self.test_images, self.pipeline) + 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 - first_image_id = results.source_images[0].id - new_detections = [detection for detection in results.detections if detection.source_image_id == first_image_id] - results.detections = new_detections + results.detections = [] save_results(results) - # After save is done, each image should have at least one detection, - # even if the detection list from the PS was empty. - for image in self.test_images: - image.save() - self.assertEqual(image.detections_count, 1) + 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): diff --git a/ui/src/data-services/models/collection.ts b/ui/src/data-services/models/collection.ts index e825614e3..81610bdc4 100644 --- a/ui/src/data-services/models/collection.ts +++ b/ui/src/data-services/models/collection.ts @@ -75,6 +75,10 @@ export class Collection 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 @@ -86,6 +90,16 @@ export class Collection extends Entity { )}%)` } + get numImagesProccessed(): 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 } diff --git a/ui/src/data-services/models/timeline-tick.ts b/ui/src/data-services/models/timeline-tick.ts index 49d7553f3..1e432db65 100644 --- a/ui/src/data-services/models/timeline-tick.ts +++ b/ui/src/data-services/models/timeline-tick.ts @@ -10,6 +10,7 @@ export type ServerTimelineTick = { captures_count: number detections_count: number detections_avg: number + was_processed: boolean } export class TimelineTick { @@ -31,6 +32,10 @@ export class TimelineTick { return this._timelineTick.detections_avg ?? 0 } + get wasProcessed(): boolean { + return this._timelineTick.was_processed + } + get numCaptures(): number { return this._timelineTick.captures_count ?? 0 } diff --git a/ui/src/pages/project/collections/collection-columns.tsx b/ui/src/pages/project/collections/collection-columns.tsx index a3ee5018b..740f999d6 100644 --- a/ui/src/pages/project/collections/collection-columns.tsx +++ b/ui/src/pages/project/collections/collection-columns.tsx @@ -104,6 +104,16 @@ export const columns: (projectId: string) => TableColumn[] = ( ), }, + { + id: 'total-processed-captures', + name: translate(STRING.FIELD_LABEL_TOTAL_PROCESSED_CAPTURES), + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Collection) => ( + + ), + }, { id: 'occurrences', name: translate(STRING.FIELD_LABEL_OCCURRENCES), diff --git a/ui/src/pages/project/collections/collections.tsx b/ui/src/pages/project/collections/collections.tsx index efbcb59fd..0e4a579c6 100644 --- a/ui/src/pages/project/collections/collections.tsx +++ b/ui/src/pages/project/collections/collections.tsx @@ -28,6 +28,7 @@ export const Collections = () => { settings: true, captures: true, 'captures-with-detections': true, + 'total-processed-captures': true, status: true, } ) diff --git a/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx b/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx index 2b2c84811..19a53d034 100644 --- a/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx +++ b/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx @@ -8,6 +8,7 @@ import { useDynamicPlotWidth } from './useDynamicPlotWidth' const fontFamily = 'Mazzard, sans-serif' const lineColorCaptures = '#4E4F57' const lineColorDetections = '#5F8AC6' +const lineColorProcessed = '#FF0000' const spikeColor = '#FFFFFF' const textColor = '#303137' const tooltipBgColor = '#FFFFFF' @@ -67,6 +68,32 @@ const ActivityPlot = ({ name: 'Avg. detections', yaxis: 'y2', }, + { + x: timeline.map( + (timelineTick) => new Date(timelineTick.startDate) + ), + y: timeline.map((timelineTick) => + timelineTick.numCaptures > 0 + ? timelineTick.wasProcessed + ? 0 + : 1 + : 0 + ), + customdata: timeline.map((timelineTick) => + timelineTick.numCaptures > 0 + ? timelineTick.wasProcessed + ? 'Yes' + : 'No' + : 'N/A' + ), + hovertemplate: 'Was processed: %{customdata}', + fill: 'tozeroy', + type: 'scatter', + mode: 'lines', + line: { color: lineColorProcessed, width: 1 }, + name: 'Was processed', + yaxis: 'y2', + }, ]} layout={{ height: 100, diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 7d27ca081..90c402ceb 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -140,6 +140,7 @@ export enum STRING { FIELD_LABEL_TIME_OBSERVED, FIELD_LABEL_TIMESTAMP, FIELD_LABEL_TOTAL_FILES, + FIELD_LABEL_TOTAL_PROCESSED_CAPTURES, FIELD_LABEL_TOTAL_SIZE, FIELD_LABEL_TRAINING_IMAGES, FIELD_LABEL_TYPE, @@ -409,6 +410,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_TIME_OBSERVED]: 'Local time observed', [STRING.FIELD_LABEL_TIMESTAMP]: 'Timestamp', [STRING.FIELD_LABEL_TOTAL_FILES]: 'Total files', + [STRING.FIELD_LABEL_TOTAL_PROCESSED_CAPTURES]: 'Total Processed Captures', [STRING.FIELD_LABEL_TOTAL_SIZE]: 'Total size', [STRING.FIELD_LABEL_TRAINING_IMAGES]: 'Reference images', [STRING.FIELD_LABEL_TYPE]: 'Type', From 342c3d204e57e95eab95eede9b069867653efd28 Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Wed, 18 Feb 2026 21:04:15 -0500 Subject: [PATCH 03/10] Address review comments --- ami/main/models.py | 3 +- ami/ml/models/pipeline.py | 81 ++++++++++++------- ui/src/data-services/models/collection.ts | 2 +- .../collections/collection-columns.tsx | 2 +- .../playback/activity-plot/activity-plot.tsx | 6 +- 5 files changed, 57 insertions(+), 37 deletions(-) diff --git a/ami/main/models.py b/ami/main/models.py index f1b2c4bf2..e278c6db6 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -1838,7 +1838,7 @@ def size_display(self) -> str: def get_detections_count(self) -> int: # Detections count excludes detections without bounding boxes # Detections with null bounding boxes are valid and indicates the image was successfully processed - return self.detections.exclude(Q(bbox__isnull=True) | Q(bbox=None) | Q(bbox=[])).count() + return self.detections.exclude(Q(bbox__isnull=True) | Q(bbox=[])).count() def get_was_processed(self, algorithm_key: str | None = None) -> bool: if algorithm_key: @@ -2014,6 +2014,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(Q(bbox__isnull=True) | Q(bbox=[])) .values("source_image_id") .annotate(count=models.Count("id")) .values("count"), diff --git a/ami/ml/models/pipeline.py b/ami/ml/models/pipeline.py index 413178389..6334e865d 100644 --- a/ami/ml/models/pipeline.py +++ b/ami/ml/models/pipeline.py @@ -8,7 +8,6 @@ import collections import dataclasses -import datetime import logging import time import typing @@ -815,6 +814,50 @@ class PipelineSaveResults: total_time: float +def create_null_detections_for_undetected_images( + results: PipelineResultsResponse, + algorithms_known: dict[str, 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 = {int(detection.source_image_id) for detection in results.detections} + null_detections_to_add = [] + + for source_img in results.source_images: + if int(source_img.id) not in source_images_with_detections: + detector_algorithm_reference = None + for known_algorithm in algorithms_known.values(): + if known_algorithm.task_type == AlgorithmTaskType.DETECTION: + detector_algorithm_reference = AlgorithmReference( + name=known_algorithm.name, key=known_algorithm.key + ) + + if detector_algorithm_reference is None: + logger.error( + f"Could not identify the detector algorithm. " + f"A null detection was not created for Source Image {source_img.id}" + ) + continue + + null_detections_to_add.append( + DetectionResponse( + source_image_id=source_img.id, + bbox=None, + algorithm=detector_algorithm_reference, + timestamp=now(), + ) + ) + + return null_detections_to_add + + @celery_app.task(soft_time_limit=60 * 4, time_limit=60 * 5) def save_results( results: PipelineResultsResponse | None = None, @@ -873,36 +916,12 @@ def save_results( # Ensure all images have detections # if not, add a NULL detection (empty bbox) to the results - source_images_with_detections = [detection.source_image_id for detection in results.detections] - source_images_with_detections = set(source_images_with_detections) - null_detections_to_add = [] - - for source_img in results.source_images: - if source_img.id not in source_images_with_detections: - detector_algorithm_reference = None - for known_algorithm in algorithms_known.values(): - if known_algorithm.task_type == AlgorithmTaskType.DETECTION: - detector_algorithm_reference = AlgorithmReference( - name=known_algorithm.name, key=known_algorithm.key - ) - - if detector_algorithm_reference is None: - job_logger.error( - f"Could not identify the detector algorithm. " - f"A null detection was not created for Source Image {source_img.id}" - ) - continue - - null_detections_to_add.append( - DetectionResponse( - source_image_id=source_img.id, - bbox=None, - algorithm=detector_algorithm_reference, - timestamp=datetime.datetime.now(), - ) - ) - - results.detections = results.detections + null_detections_to_add + null_detections = create_null_detections_for_undetected_images( + results=results, + algorithms_known=algorithms_known, + logger=job_logger, + ) + results.detections = results.detections + null_detections detections = create_detections( detections=results.detections, diff --git a/ui/src/data-services/models/collection.ts b/ui/src/data-services/models/collection.ts index 81610bdc4..b36203c2f 100644 --- a/ui/src/data-services/models/collection.ts +++ b/ui/src/data-services/models/collection.ts @@ -90,7 +90,7 @@ export class Collection extends Entity { )}%)` } - get numImagesProccessed(): string { + get numImagesProcessedLabel(): string { const numProcessed = this.numImagesProcessed ?? 0 const pct = this.numImages && this.numImages > 0 diff --git a/ui/src/pages/project/collections/collection-columns.tsx b/ui/src/pages/project/collections/collection-columns.tsx index 740f999d6..f6200179c 100644 --- a/ui/src/pages/project/collections/collection-columns.tsx +++ b/ui/src/pages/project/collections/collection-columns.tsx @@ -111,7 +111,7 @@ export const columns: (projectId: string) => TableColumn[] = ( textAlign: TextAlign.Right, }, renderCell: (item: Collection) => ( - + ), }, { diff --git a/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx b/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx index 19a53d034..9dad9e6e7 100644 --- a/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx +++ b/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx @@ -8,7 +8,7 @@ import { useDynamicPlotWidth } from './useDynamicPlotWidth' const fontFamily = 'Mazzard, sans-serif' const lineColorCaptures = '#4E4F57' const lineColorDetections = '#5F8AC6' -const lineColorProcessed = '#FF0000' +const lineColorProcessed = '#00ff1a' const spikeColor = '#FFFFFF' const textColor = '#303137' const tooltipBgColor = '#FFFFFF' @@ -75,8 +75,8 @@ const ActivityPlot = ({ y: timeline.map((timelineTick) => timelineTick.numCaptures > 0 ? timelineTick.wasProcessed - ? 0 - : 1 + ? timelineTick.numCaptures // Show number of captures so value is visible + : 0 : 0 ), customdata: timeline.map((timelineTick) => From 93aa8b0f84cb852c2e33e22793f66dd844b6218d Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Fri, 27 Feb 2026 19:35:57 -0500 Subject: [PATCH 04/10] Assert single detector algorithm; move num detections to foreground --- ami/ml/models/pipeline.py | 28 ++++++++----------- .../playback/activity-plot/activity-plot.tsx | 26 ++++++++--------- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/ami/ml/models/pipeline.py b/ami/ml/models/pipeline.py index 6334e865d..c209ba012 100644 --- a/ami/ml/models/pipeline.py +++ b/ami/ml/models/pipeline.py @@ -816,7 +816,7 @@ class PipelineSaveResults: def create_null_detections_for_undetected_images( results: PipelineResultsResponse, - algorithms_known: dict[str, Algorithm], + detection_algorithm: Algorithm, logger: logging.Logger = logger, ) -> list[DetectionResponse]: """ @@ -829,28 +829,15 @@ def create_null_detections_for_undetected_images( """ source_images_with_detections = {int(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 int(source_img.id) not in source_images_with_detections: - detector_algorithm_reference = None - for known_algorithm in algorithms_known.values(): - if known_algorithm.task_type == AlgorithmTaskType.DETECTION: - detector_algorithm_reference = AlgorithmReference( - name=known_algorithm.name, key=known_algorithm.key - ) - - if detector_algorithm_reference is None: - logger.error( - f"Could not identify the detector algorithm. " - f"A null detection was not created for Source Image {source_img.id}" - ) - continue - null_detections_to_add.append( DetectionResponse( source_image_id=source_img.id, bbox=None, - algorithm=detector_algorithm_reference, + algorithm=detection_algorithm_reference, timestamp=now(), ) ) @@ -905,6 +892,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") + job_logger.info(f"Algorithms registered for pipeline: \n{', '.join(algorithms_known.keys())}") if results.algorithms: @@ -918,7 +912,7 @@ def save_results( # if not, add a NULL detection (empty bbox) to the results null_detections = create_null_detections_for_undetected_images( results=results, - algorithms_known=algorithms_known, + detection_algorithm=detection_algorithm, logger=job_logger, ) results.detections = results.detections + null_detections diff --git a/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx b/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx index 9dad9e6e7..13318ff35 100644 --- a/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx +++ b/ui/src/pages/session-details/playback/activity-plot/activity-plot.tsx @@ -55,19 +55,6 @@ const ActivityPlot = ({ name: 'Captures', yaxis: 'y', }, - { - x: timeline.map( - (timelineTick) => new Date(timelineTick.startDate) - ), - y: timeline.map((timelineTick) => timelineTick.avgDetections), - hovertemplate: 'Avg. detections: %{y}', - fill: 'tozeroy', - type: 'scatter', - mode: 'lines', - line: { color: lineColorDetections, width: 1 }, - name: 'Avg. detections', - yaxis: 'y2', - }, { x: timeline.map( (timelineTick) => new Date(timelineTick.startDate) @@ -94,6 +81,19 @@ const ActivityPlot = ({ name: 'Was processed', yaxis: 'y2', }, + { + x: timeline.map( + (timelineTick) => new Date(timelineTick.startDate) + ), + y: timeline.map((timelineTick) => timelineTick.avgDetections), + hovertemplate: 'Avg. detections: %{y}', + fill: 'tozeroy', + type: 'scatter', + mode: 'lines', + line: { color: lineColorDetections, width: 1 }, + name: 'Avg. detections', + yaxis: 'y2', + }, ]} layout={{ height: 100, From 1b12e140bac261e4b79ad919a9b6b4c4842cf9ca Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Fri, 27 Feb 2026 20:26:13 -0500 Subject: [PATCH 05/10] Address review comments --- ami/main/api/views.py | 9 ++++--- ami/main/models.py | 26 +++++++++++++------ ami/ml/models/pipeline.py | 7 +++-- .../capture-sets/capture-set-columns.tsx | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 2e86a66b0..ee8cd25d2 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -35,6 +35,7 @@ from ami.utils.storages import ConnectionTestResult from ..models import ( + NULL_DETECTIONS_FILTER, Classification, Deployment, Detection, @@ -430,13 +431,15 @@ 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 # Remove zero values and calculate the mode interval_data["detection_counts"] = [x for x in interval_data["detection_counts"] if x > 0] interval_data["detections_avg"] = mode(interval_data["detection_counts"] or [0]) - interval_data["was_processed"] = image["was_processed"] timeline.append(interval_data) current_time = interval_end @@ -898,9 +901,7 @@ class DetectionViewSet(DefaultViewSet, ProjectMixin): API endpoint that allows detections to be viewed or edited. """ - queryset = Detection.objects.exclude(Q(bbox__isnull=True) | Q(bbox=None) | Q(bbox=[])).select_related( - "source_image", "detection_algorithm" - ) + queryset = Detection.objects.exclude(~NULL_DETECTIONS_FILTER).select_related("source_image", "detection_algorithm") serializer_class = DetectionSerializer filterset_fields = ["source_image", "detection_algorithm", "source_image__project"] ordering_fields = ["created_at", "updated_at", "detection_score", "timestamp"] diff --git a/ami/main/models.py b/ami/main/models.py index 031d7185e..24b2d9598 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -85,6 +85,8 @@ class TaxonRank(OrderedEnum): ] ) +NULL_DETECTIONS_FILTER = Q(bbox__isnull=True) | Q(bbox=[]) + def get_media_url(path: str) -> str: """ @@ -1851,7 +1853,7 @@ def size_display(self) -> str: def get_detections_count(self) -> int: # Detections count excludes detections without bounding boxes # Detections with null bounding boxes are valid and indicates the image was successfully processed - return self.detections.exclude(Q(bbox__isnull=True) | Q(bbox=[])).count() + return self.detections.exclude(NULL_DETECTIONS_FILTER).count() def get_was_processed(self, algorithm_key: str | None = None) -> bool: if algorithm_key: @@ -2027,7 +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(Q(bbox__isnull=True) | Q(bbox=[])) + .exclude(NULL_DETECTIONS_FILTER) .values("source_image_id") .annotate(count=models.Count("id")) .values("count"), @@ -2498,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""" @@ -2566,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( @@ -3723,6 +3736,8 @@ def html(self) -> str: "common_combined", # Deprecated ] +SOURCE_IMAGES_WITH_NULL_DETECTIONS_FILTER = Q(images__detections__isnull=True) + class SourceImageCollectionQuerySet(BaseQuerySet): def with_source_images_count(self): @@ -3737,12 +3752,7 @@ 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) - & ~models.Q(images__detections__bbox__isnull=True) - & ~models.Q(images__detections__bbox=None) - & ~models.Q(images__detections__bbox=[]) - ), + filter=(~models.Q(images__detections__bbox__isnull=True) & ~models.Q(images__detections__bbox=[])), distinct=True, ) ) diff --git a/ami/ml/models/pipeline.py b/ami/ml/models/pipeline.py index c209ba012..48c72af62 100644 --- a/ami/ml/models/pipeline.py +++ b/ami/ml/models/pipeline.py @@ -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 + 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( @@ -827,12 +830,12 @@ def create_null_detections_for_undetected_images( :return: List of DetectionResponse objects with null bbox """ - source_images_with_detections = {int(detection.source_image_id) for detection in results.detections} + 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 int(source_img.id) not in source_images_with_detections: + if source_img.id not in source_images_with_detections: null_detections_to_add.append( DetectionResponse( source_image_id=source_img.id, diff --git a/ui/src/pages/project/capture-sets/capture-set-columns.tsx b/ui/src/pages/project/capture-sets/capture-set-columns.tsx index 9a0a92d71..faf9c8f14 100644 --- a/ui/src/pages/project/capture-sets/capture-set-columns.tsx +++ b/ui/src/pages/project/capture-sets/capture-set-columns.tsx @@ -110,7 +110,7 @@ export const columns: (projectId: string) => TableColumn[] = ( styles: { textAlign: TextAlign.Right, }, - renderCell: (item: Collection) => ( + renderCell: (item: CaptureSet) => ( ), }, From f917853a99db1bfcccea843d3db1f125652b1ffa Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Fri, 27 Feb 2026 20:49:19 -0500 Subject: [PATCH 06/10] Address more review comments --- ami/main/api/views.py | 4 ++-- ami/ml/models/pipeline.py | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index ee8cd25d2..6741900d1 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -603,7 +603,7 @@ def prefetch_detections(self, queryset: QuerySet, project: Project | None = None score = get_default_classification_threshold(project, self.request) prefetch_queryset = ( - Detection.objects.all() + Detection.objects.exclude(NULL_DETECTIONS_FILTER) .annotate( determination_score=models.Max("occurrence__detections__classifications__score"), # Store whether this occurrence should be included based on default filters @@ -901,7 +901,7 @@ class DetectionViewSet(DefaultViewSet, ProjectMixin): API endpoint that allows detections to be viewed or edited. """ - queryset = Detection.objects.exclude(~NULL_DETECTIONS_FILTER).select_related("source_image", "detection_algorithm") + queryset = Detection.objects.exclude(NULL_DETECTIONS_FILTER).select_related("source_image", "detection_algorithm") serializer_class = DetectionSerializer filterset_fields = ["source_image", "detection_algorithm", "source_image__project"] ordering_fields = ["created_at", "updated_at", "detection_score", "timestamp"] diff --git a/ami/ml/models/pipeline.py b/ami/ml/models/pipeline.py index 48c72af62..b4d4fc593 100644 --- a/ami/ml/models/pipeline.py +++ b/ami/ml/models/pipeline.py @@ -37,7 +37,7 @@ update_occurrence_determination, ) from ami.ml.exceptions import PipelineNotConfigured -from ami.ml.models.algorithm import Algorithm, AlgorithmCategoryMap, AlgorithmTaskType +from ami.ml.models.algorithm import Algorithm, AlgorithmCategoryMap from ami.ml.schemas import ( AlgorithmConfigResponse, AlgorithmReference, @@ -415,6 +415,16 @@ def get_or_create_detection( serialized_bbox = None detection_repr = f"Detection {detection_resp.source_image_id} {serialized_bbox}" + assert detection_resp.algorithm, f"No detection algorithm was specified for detection {detection_repr}" + try: + detection_algo = algorithms_known[detection_resp.algorithm.key] + except KeyError: + raise PipelineNotConfigured( + f"Detection algorithm {detection_resp.algorithm.key} is not a known algorithm. " + "The processing service must declare it in the /info endpoint. " + f"Known algorithms: {list(algorithms_known.keys())}" + ) + assert str(detection_resp.source_image_id) == str( source_image.pk ), f"Detection belongs to a different source image: {detection_repr}" @@ -422,6 +432,7 @@ def get_or_create_detection( existing_detection = Detection.objects.filter( source_image=source_image, bbox=serialized_bbox, + detection_algorithm=detection_algo, ).first() # A detection may have a pre-existing crop image URL or not. @@ -440,16 +451,6 @@ def get_or_create_detection( detection = existing_detection else: - assert detection_resp.algorithm, f"No detection algorithm was specified for detection {detection_repr}" - try: - detection_algo = algorithms_known[detection_resp.algorithm.key] - except KeyError: - raise PipelineNotConfigured( - f"Detection algorithm {detection_resp.algorithm.key} is not a known algorithm. " - "The processing service must declare it in the /info endpoint. " - f"Known algorithms: {list(algorithms_known.keys())}" - ) - new_detection = Detection( source_image=source_image, bbox=serialized_bbox, @@ -896,7 +897,7 @@ 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) + detection_algorithm = pipeline.algorithms.get(task_type__in=Algorithm.detection_task_types) except Algorithm.DoesNotExist: raise ValueError("Pipeline does not have a detection algorithm") except Algorithm.MultipleObjectsReturned: From 7c9a848da390534428a946ce3e92348e1a2a916c Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Fri, 27 Feb 2026 21:38:51 -0500 Subject: [PATCH 07/10] Fix tests --- ami/jobs/test_tasks.py | 12 +++++++++++- ami/ml/models/pipeline.py | 22 +++++++++++----------- ui/src/utils/language.ts | 2 +- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/ami/jobs/test_tasks.py b/ami/jobs/test_tasks.py index b37940cdd..189fc7717 100644 --- a/ami/jobs/test_tasks.py +++ b/ami/jobs/test_tasks.py @@ -16,7 +16,8 @@ from ami.jobs.models import Job, JobDispatchMode, JobState, MLJob from ami.jobs.tasks import process_nats_pipeline_result from ami.main.models import Detection, Project, SourceImage, SourceImageCollection -from ami.ml.models import Pipeline +from ami.ml.models import Algorithm, Pipeline +from ami.ml.models.algorithm import AlgorithmTaskType from ami.ml.orchestration.async_job_state import AsyncJobStateManager, _lock_key from ami.ml.schemas import PipelineResultsError, PipelineResultsResponse, SourceImageResponse from ami.users.models import User @@ -179,6 +180,15 @@ def test_process_nats_pipeline_result_mixed_results(self, mock_manager_class): """ mock_manager = self._setup_mock_nats(mock_manager_class) + # Create detection algorithm for the pipeline + detection_algorithm = Algorithm.objects.create( + name="test-detector", + key="test-detector", + task_type=AlgorithmTaskType.LOCALIZATION, + ) + # Update pipeline to include detection algorithm + self.pipeline.algorithms.add(detection_algorithm) + # For this test, we just want to verify progress tracking works with mixed results # We'll skip checking final job completion status since that depends on all stages diff --git a/ami/ml/models/pipeline.py b/ami/ml/models/pipeline.py index b4d4fc593..953656842 100644 --- a/ami/ml/models/pipeline.py +++ b/ami/ml/models/pipeline.py @@ -415,24 +415,14 @@ def get_or_create_detection( serialized_bbox = None detection_repr = f"Detection {detection_resp.source_image_id} {serialized_bbox}" - assert detection_resp.algorithm, f"No detection algorithm was specified for detection {detection_repr}" - try: - detection_algo = algorithms_known[detection_resp.algorithm.key] - except KeyError: - raise PipelineNotConfigured( - f"Detection algorithm {detection_resp.algorithm.key} is not a known algorithm. " - "The processing service must declare it in the /info endpoint. " - f"Known algorithms: {list(algorithms_known.keys())}" - ) - assert str(detection_resp.source_image_id) == str( source_image.pk ), f"Detection belongs to a different source image: {detection_repr}" + # When reprocessing, we don't care which detection algorithm created the existing detection existing_detection = Detection.objects.filter( source_image=source_image, bbox=serialized_bbox, - detection_algorithm=detection_algo, ).first() # A detection may have a pre-existing crop image URL or not. @@ -451,6 +441,16 @@ def get_or_create_detection( detection = existing_detection else: + assert detection_resp.algorithm, f"No detection algorithm was specified for detection {detection_repr}" + try: + detection_algo = algorithms_known[detection_resp.algorithm.key] + except KeyError: + raise PipelineNotConfigured( + f"Detection algorithm {detection_resp.algorithm.key} is not a known algorithm. " + "The processing service must declare it in the /info endpoint. " + f"Known algorithms: {list(algorithms_known.keys())}" + ) + new_detection = Detection( source_image=source_image, bbox=serialized_bbox, diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 1ef441493..ca369187e 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -434,7 +434,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_TIME]: 'Local time', [STRING.FIELD_LABEL_TIMESTAMP]: 'Timestamp', [STRING.FIELD_LABEL_TOTAL_FILES]: 'Total files', - [STRING.FIELD_LABEL_TOTAL_PROCESSED_CAPTURES]: 'Total Processed Captures', + [STRING.FIELD_LABEL_TOTAL_PROCESSED_CAPTURES]: 'Processed captures', [STRING.FIELD_LABEL_TOTAL_RECORDS]: 'Total records', [STRING.FIELD_LABEL_TOTAL_SIZE]: 'Total size', [STRING.FIELD_LABEL_TRAINING_IMAGES]: 'Reference images', From 31dea6e0664741b16a8790f6369f27a40b39d4e6 Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Wed, 11 Mar 2026 00:41:10 -0400 Subject: [PATCH 08/10] Remove left over code --- ami/main/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ami/main/models.py b/ami/main/models.py index 977a0ad5f..ae7111581 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -3772,8 +3772,6 @@ def html(self) -> str: "common_combined", # Deprecated ] -SOURCE_IMAGES_WITH_NULL_DETECTIONS_FILTER = Q(images__detections__isnull=True) - class SourceImageCollectionQuerySet(BaseQuerySet): def with_source_images_count(self): From 80df6537cb43b85ec57c22c28a9d5e79b8767e55 Mon Sep 17 00:00:00 2001 From: Vanessa Mac Date: Fri, 13 Mar 2026 16:59:05 -0400 Subject: [PATCH 09/10] Add comment addressing review --- ami/main/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ami/main/models.py b/ami/main/models.py index ae7111581..3b9c983ba 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -1785,6 +1785,8 @@ def with_was_processed(self): This mirrors `SourceImage.get_was_processed()` but as a queryset annotation for efficient bulk queries. """ + # @TODO: this returns a was processed status for any algorithm. One the session detail view supports + # filtering by algorithm, this should be updated to return was_processed for the selected algorithm. processed_exists = models.Exists(Detection.objects.filter(source_image_id=models.OuterRef("pk"))) return self.annotate(was_processed=processed_exists) From cb3235b21df19bc0d394bca9486ae2f91a82dbbd Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 17 Mar 2026 13:33:23 -0700 Subject: [PATCH 10/10] chore: isolate session detail UI changes --- ami/jobs/tests/test_tasks.py | 12 +--- ami/main/admin.py | 1 - ami/main/api/serializers.py | 2 - ami/main/api/views.py | 15 ++--- ami/main/models.py | 55 +----------------- ami/ml/models/pipeline.py | 57 +------------------ ami/ml/schemas.py | 4 +- ami/ml/tests.py | 24 -------- ui/src/data-services/models/capture-set.ts | 14 ----- .../capture-sets/capture-set-columns.tsx | 10 ---- .../project/capture-sets/capture-sets.tsx | 1 - ui/src/utils/language.ts | 2 - 12 files changed, 11 insertions(+), 186 deletions(-) diff --git a/ami/jobs/tests/test_tasks.py b/ami/jobs/tests/test_tasks.py index daf1b6ae6..25e609244 100644 --- a/ami/jobs/tests/test_tasks.py +++ b/ami/jobs/tests/test_tasks.py @@ -17,8 +17,7 @@ from ami.jobs.models import Job, JobDispatchMode, JobState, MLJob from ami.jobs.tasks import process_nats_pipeline_result from ami.main.models import Detection, Project, SourceImage, SourceImageCollection -from ami.ml.models import Algorithm, Pipeline -from ami.ml.models.algorithm import AlgorithmTaskType +from ami.ml.models import Pipeline from ami.ml.orchestration.async_job_state import AsyncJobStateManager from ami.ml.schemas import PipelineResultsError, PipelineResultsResponse, SourceImageResponse from ami.users.models import User @@ -181,15 +180,6 @@ def test_process_nats_pipeline_result_mixed_results(self, mock_manager_class): """ mock_manager = self._setup_mock_nats(mock_manager_class) - # Create detection algorithm for the pipeline - detection_algorithm = Algorithm.objects.create( - name="test-detector", - key="test-detector", - task_type=AlgorithmTaskType.LOCALIZATION, - ) - # Update pipeline to include detection algorithm - self.pipeline.algorithms.add(detection_algorithm) - # For this test, we just want to verify progress tracking works with mixed results # We'll skip checking final job completion status since that depends on all stages diff --git a/ami/main/admin.py b/ami/main/admin.py index 5e2a368f1..c6170b153 100644 --- a/ami/main/admin.py +++ b/ami/main/admin.py @@ -265,7 +265,6 @@ class SourceImageAdmin(AdminBase): "checksum", "checksum_algorithm", "created_at", - "get_was_processed", ) list_filter = ( diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 0caa7b3e6..6d0d93762 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -1246,7 +1246,6 @@ class Meta: "source_images", "source_images_count", "source_images_with_detections_count", - "source_images_processed_count", "occurrences_count", "taxa_count", "description", @@ -1548,7 +1547,6 @@ class EventTimelineIntervalSerializer(serializers.Serializer): captures_count = serializers.IntegerField() detections_count = serializers.IntegerField() detections_avg = serializers.IntegerField() - was_processed = serializers.BooleanField() class EventTimelineMetaSerializer(serializers.Serializer): diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 75951535a..18536e7d5 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -36,7 +36,6 @@ from ami.utils.storages import ConnectionTestResult from ..models import ( - NULL_DETECTIONS_FILTER, Classification, Deployment, Detection, @@ -379,7 +378,7 @@ def timeline(self, request, pk=None): ) resolution = datetime.timedelta(minutes=resolution_minutes) - qs = SourceImage.objects.filter(event=event).with_was_processed() # type: ignore + qs = SourceImage.objects.filter(event=event) # Bulk update all source images where detections_count is null update_detection_counts(qs=qs, null_only=True) @@ -405,7 +404,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", "was_processed") + .values("id", "timestamp", "detections_count") ) timeline = [] @@ -422,7 +421,6 @@ 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: @@ -434,9 +432,6 @@ 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 @@ -607,7 +602,7 @@ def prefetch_detections(self, queryset: QuerySet, project: Project | None = None score = get_default_classification_threshold(project, self.request) prefetch_queryset = ( - Detection.objects.exclude(NULL_DETECTIONS_FILTER) + Detection.objects.all() .annotate( determination_score=models.Max("occurrence__detections__classifications__score"), # Store whether this occurrence should be included based on default filters @@ -714,7 +709,6 @@ 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 @@ -730,7 +724,6 @@ class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin): "method", "source_images_count", "source_images_with_detections_count", - "source_images_processed_count", "occurrences_count", ] @@ -905,7 +898,7 @@ class DetectionViewSet(DefaultViewSet, ProjectMixin): API endpoint that allows detections to be viewed or edited. """ - queryset = Detection.objects.exclude(NULL_DETECTIONS_FILTER).select_related("source_image", "detection_algorithm") + queryset = Detection.objects.all().select_related("source_image", "detection_algorithm") serializer_class = DetectionSerializer filterset_fields = ["source_image", "detection_algorithm", "source_image__project"] ordering_fields = ["created_at", "updated_at", "detection_score", "timestamp"] diff --git a/ami/main/models.py b/ami/main/models.py index 3b9c983ba..0bad68531 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -85,8 +85,6 @@ class TaxonRank(OrderedEnum): ] ) -NULL_DETECTIONS_FILTER = Q(bbox__isnull=True) | Q(bbox=[]) - def get_media_url(path: str) -> str: """ @@ -1777,19 +1775,6 @@ 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. - """ - # @TODO: this returns a was processed status for any algorithm. One the session detail view supports - # filtering by algorithm, this should be updated to return was_processed for the selected algorithm. - processed_exists = models.Exists(Detection.objects.filter(source_image_id=models.OuterRef("pk"))) - return self.annotate(was_processed=processed_exists) - class SourceImageManager(models.Manager.from_queryset(SourceImageQuerySet)): pass @@ -1889,15 +1874,7 @@ def size_display(self) -> str: return filesizeformat(self.size) def get_detections_count(self) -> int: - # Detections count excludes detections without bounding boxes - # 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: - if algorithm_key: - return self.detections.filter(detection_algorithm__key=algorithm_key).exists() - else: - return self.detections.exists() + return self.detections.distinct().count() def get_base_url(self) -> str | None: """ @@ -2067,7 +2044,6 @@ 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"), @@ -2538,15 +2514,6 @@ 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""" @@ -2615,8 +2582,6 @@ class Detection(BaseModel): source_image_id: int detection_algorithm_id: int - objects = DetectionManager() - def get_bbox(self): if self.bbox: return BoundingBox( @@ -3787,18 +3752,7 @@ 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__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, + "images", filter=models.Q(images__detections__isnull=False), distinct=True ) ) @@ -3909,10 +3863,7 @@ 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 None - - def source_images_processed_count(self) -> int | None: - # This should always be pre-populated using queryset annotations + # return self.images.filter(detections__isnull=False).count() return None def occurrences_count(self) -> int | None: diff --git a/ami/ml/models/pipeline.py b/ami/ml/models/pipeline.py index 953656842..f76822e3a 100644 --- a/ami/ml/models/pipeline.py +++ b/ami/ml/models/pipeline.py @@ -84,9 +84,6 @@ 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 - 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( @@ -409,17 +406,13 @@ def get_or_create_detection( :return: A tuple of the Detection object and a boolean indicating whether it was created """ - if detection_resp.bbox is not None: - serialized_bbox = list(detection_resp.bbox.dict().values()) - else: - serialized_bbox = None + serialized_bbox = list(detection_resp.bbox.dict().values()) detection_repr = f"Detection {detection_resp.source_image_id} {serialized_bbox}" assert str(detection_resp.source_image_id) == str( source_image.pk ), f"Detection belongs to a different source image: {detection_repr}" - # When reprocessing, we don't care which detection algorithm created the existing detection existing_detection = Detection.objects.filter( source_image=source_image, bbox=serialized_bbox, @@ -492,7 +485,6 @@ 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: @@ -818,37 +810,6 @@ 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 - - @celery_app.task(soft_time_limit=60 * 4, time_limit=60 * 5) def save_results( results: PipelineResultsResponse | None = None, @@ -896,13 +857,6 @@ 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__in=Algorithm.detection_task_types) - 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") - job_logger.info(f"Algorithms registered for pipeline: \n{', '.join(algorithms_known.keys())}") if results.algorithms: @@ -912,15 +866,6 @@ def save_results( "Algorithms and category maps must be registered before processing, using /info endpoint." ) - # Ensure all images have detections - # 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 - detections = create_detections( detections=results.detections, algorithms_known=algorithms_known, diff --git a/ami/ml/schemas.py b/ami/ml/schemas.py index 7449c59e6..f63e6e1a1 100644 --- a/ami/ml/schemas.py +++ b/ami/ml/schemas.py @@ -163,14 +163,14 @@ class Config: class DetectionRequest(pydantic.BaseModel): source_image: SourceImageRequest # the 'original' image - bbox: BoundingBox | None = None + bbox: BoundingBox crop_image_url: str | None = None algorithm: AlgorithmReference class DetectionResponse(pydantic.BaseModel): source_image_id: str - bbox: BoundingBox | None = None + bbox: BoundingBox inference_time: float | None = None algorithm: AlgorithmReference timestamp: datetime.datetime diff --git a/ami/ml/tests.py b/ami/ml/tests.py index 03bd198b3..bbc648d6e 100644 --- a/ami/ml/tests.py +++ b/ami/ml/tests.py @@ -735,30 +735,6 @@ 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): diff --git a/ui/src/data-services/models/capture-set.ts b/ui/src/data-services/models/capture-set.ts index 3605a0f9b..f56c8af2e 100644 --- a/ui/src/data-services/models/capture-set.ts +++ b/ui/src/data-services/models/capture-set.ts @@ -75,10 +75,6 @@ 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 @@ -90,16 +86,6 @@ 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 } diff --git a/ui/src/pages/project/capture-sets/capture-set-columns.tsx b/ui/src/pages/project/capture-sets/capture-set-columns.tsx index faf9c8f14..2172046c0 100644 --- a/ui/src/pages/project/capture-sets/capture-set-columns.tsx +++ b/ui/src/pages/project/capture-sets/capture-set-columns.tsx @@ -104,16 +104,6 @@ export const columns: (projectId: string) => TableColumn[] = ( ), }, - { - id: 'total-processed-captures', - name: translate(STRING.FIELD_LABEL_TOTAL_PROCESSED_CAPTURES), - styles: { - textAlign: TextAlign.Right, - }, - renderCell: (item: CaptureSet) => ( - - ), - }, { id: 'occurrences', name: translate(STRING.FIELD_LABEL_OCCURRENCES), diff --git a/ui/src/pages/project/capture-sets/capture-sets.tsx b/ui/src/pages/project/capture-sets/capture-sets.tsx index a029137ef..3f7c1f8d2 100644 --- a/ui/src/pages/project/capture-sets/capture-sets.tsx +++ b/ui/src/pages/project/capture-sets/capture-sets.tsx @@ -28,7 +28,6 @@ export const CaptureSets = () => { settings: true, captures: true, 'captures-with-detections': true, - 'total-processed-captures': true, status: true, } ) diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index fcee968ee..20d5ad0ea 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -153,7 +153,6 @@ export enum STRING { FIELD_LABEL_TIME, FIELD_LABEL_TIMESTAMP, FIELD_LABEL_TOTAL_FILES, - FIELD_LABEL_TOTAL_PROCESSED_CAPTURES, FIELD_LABEL_TOTAL_RECORDS, FIELD_LABEL_TOTAL_SIZE, FIELD_LABEL_TRAINING_IMAGES, @@ -445,7 +444,6 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_TIME]: 'Local time', [STRING.FIELD_LABEL_TIMESTAMP]: 'Timestamp', [STRING.FIELD_LABEL_TOTAL_FILES]: 'Total files', - [STRING.FIELD_LABEL_TOTAL_PROCESSED_CAPTURES]: 'Processed captures', [STRING.FIELD_LABEL_TOTAL_RECORDS]: 'Total records', [STRING.FIELD_LABEL_TOTAL_SIZE]: 'Total size', [STRING.FIELD_LABEL_TRAINING_IMAGES]: 'Reference images',