diff --git a/ami/main/admin.py b/ami/main/admin.py index c6170b153..5e2a368f1 100644 --- a/ami/main/admin.py +++ b/ami/main/admin.py @@ -265,6 +265,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 d49a414a5..adf07f385 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -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", @@ -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): 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 60a7dc0f4..031d7185e 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -1739,6 +1739,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 @@ -1838,7 +1849,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=[])).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: """ @@ -2008,6 +2027,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"), @@ -3716,7 +3736,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, ) ) @@ -3827,7 +3863,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/models/pipeline.py b/ami/ml/models/pipeline.py index f76822e3a..6334e865d 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 +from ami.ml.models.algorithm import Algorithm, AlgorithmCategoryMap, AlgorithmTaskType from ami.ml.schemas import ( AlgorithmConfigResponse, AlgorithmReference, @@ -406,7 +406,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 +488,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: @@ -810,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, @@ -866,6 +914,15 @@ 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, + algorithms_known=algorithms_known, + 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 f63e6e1a1..7449c59e6 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 + 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 6d029492b..c94977ebf 100644 --- a/ami/ml/tests.py +++ b/ami/ml/tests.py @@ -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): diff --git a/ui/src/data-services/models/capture-set.ts b/ui/src/data-services/models/capture-set.ts index f56c8af2e..3605a0f9b 100644 --- a/ui/src/data-services/models/capture-set.ts +++ b/ui/src/data-services/models/capture-set.ts @@ -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 @@ -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 } 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/capture-sets/capture-set-columns.tsx b/ui/src/pages/project/capture-sets/capture-set-columns.tsx index 2172046c0..9a0a92d71 100644 --- a/ui/src/pages/project/capture-sets/capture-set-columns.tsx +++ b/ui/src/pages/project/capture-sets/capture-set-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/capture-sets/capture-sets.tsx b/ui/src/pages/project/capture-sets/capture-sets.tsx index 3f7c1f8d2..a029137ef 100644 --- a/ui/src/pages/project/capture-sets/capture-sets.tsx +++ b/ui/src/pages/project/capture-sets/capture-sets.tsx @@ -28,6 +28,7 @@ export const CaptureSets = () => { 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..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,6 +8,7 @@ import { useDynamicPlotWidth } from './useDynamicPlotWidth' const fontFamily = 'Mazzard, sans-serif' const lineColorCaptures = '#4E4F57' const lineColorDetections = '#5F8AC6' +const lineColorProcessed = '#00ff1a' 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 + ? timelineTick.numCaptures // Show number of captures so value is visible + : 0 + : 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 319817bde..1ef441493 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -149,6 +149,7 @@ 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, @@ -433,6 +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_RECORDS]: 'Total records', [STRING.FIELD_LABEL_TOTAL_SIZE]: 'Total size', [STRING.FIELD_LABEL_TRAINING_IMAGES]: 'Reference images',