Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,10 @@ class Meta:
"width",
"height",
"size",
"size_display",
"detections_count",
"occurrences_count",
"taxa_count",
"detections",
]

Expand Down Expand Up @@ -986,6 +989,8 @@ class Meta:
"source_images",
"source_images_count",
"source_images_with_detections_count",
"occurrences_count",
"taxa_count",
"jobs",
"created_at",
"updated_at",
Expand Down
85 changes: 72 additions & 13 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,12 @@ class SourceImageViewSet(DefaultViewSet):
GET /captures/1/
"""

queryset = SourceImage.objects.all()
queryset = (
SourceImage.objects.all()
.with_occurrences_count() # type: ignore
.with_taxa_count()
# .with_detections_count()
)

serializer_class = SourceImageSerializer
filterset_fields = ["event", "deployment", "deployment__project", "collections"]
Expand All @@ -385,6 +390,8 @@ class SourceImageViewSet(DefaultViewSet):
"timestamp",
"size",
"detections_count",
"occurrences_count",
"taxa_count",
"deployment__name",
"event__start",
]
Expand Down Expand Up @@ -536,6 +543,8 @@ class SourceImageCollectionViewSet(DefaultViewSet):
SourceImageCollection.objects.all()
.with_source_images_count() # type: ignore
.with_source_images_with_detections_count()
.with_occurrences_count()
.with_taxa_count()
.prefetch_related("jobs")
)
serializer_class = SourceImageCollectionSerializer
Expand All @@ -548,6 +557,7 @@ class SourceImageCollectionViewSet(DefaultViewSet):
"method",
"source_images_count",
"source_images_with_detections_count",
"occurrences_count",
]

@action(detail=True, methods=["post"], name="populate")
Expand Down Expand Up @@ -721,6 +731,38 @@ def filter_queryset(self, request, queryset, view):
return queryset


class OccurrenceCollectionFilter(filters.BaseFilterBackend):
"""
Filter occurrences by the collection their detections source images belong to.
"""

query_param = "collection"

def filter_queryset(self, request, queryset, view):
collection_id = IntegerField(required=False).clean(request.query_params.get(self.query_param))
if collection_id:
# Here the queryset is the Occurrence queryset
return queryset.filter(detections__source_image__collections=collection_id)
else:
return queryset


class TaxonCollectionFilter(filters.BaseFilterBackend):
"""
Filter taxa by the collection their occurrences belong to.
"""

query_param = "collection"

def filter_queryset(self, request, queryset, view):
collection_id = IntegerField(required=False).clean(request.query_params.get(self.query_param))
if collection_id:
# Here the queryset is the Taxon queryset
return queryset.filter(occurrences__detections__source_image__collections=collection_id)
else:
return queryset


class OccurrenceViewSet(DefaultViewSet):
"""
API endpoint that allows occurrences to be viewed or edited.
Expand All @@ -730,8 +772,17 @@ class OccurrenceViewSet(DefaultViewSet):

serializer_class = OccurrenceSerializer
# filter_backends = [CustomDeterminationFilter, DjangoFilterBackend, NullsLastOrderingFilter, SearchFilter]
filter_backends = DefaultViewSetMixin.filter_backends + [CustomOccurrenceDeterminationFilter]
filterset_fields = ["event", "deployment", "project", "determination__rank"]
filter_backends = DefaultViewSetMixin.filter_backends + [
CustomOccurrenceDeterminationFilter,
OccurrenceCollectionFilter,
]
filterset_fields = [
"event",
"deployment",
"project",
"determination__rank",
"detections__source_image",
]
ordering_fields = [
"created_at",
"updated_at",
Expand Down Expand Up @@ -874,29 +925,37 @@ def filter_taxa_by_observed(self, queryset: QuerySet) -> tuple[QuerySet, bool]:
"occurrences__deployment"
)
event_id = self.request.query_params.get("event") or self.request.query_params.get("occurrences__event")
collection_id = self.request.query_params.get("collection")

filter_active = any([occurrence_id, project_id, deployment_id, event_id])
filter_active = any([occurrence_id, project_id, deployment_id, event_id, collection_id])

if not project_id:
# Raise a 400 if no project is specified
raise api_exceptions.ValidationError(detail="A project must be specified")

queryset = super().get_queryset()
try:
if project_id:
project = Project.objects.get(id=project_id)
queryset = queryset.filter(occurrences__project=project)
if occurrence_id:
occurrence = Occurrence.objects.get(id=occurrence_id)
# This query does not need the same filtering as the others
return queryset.filter(occurrences=occurrence).distinct(), True
elif project_id:
project = Project.objects.get(id=project_id)
queryset = super().get_queryset().filter(occurrences__project=project)
elif deployment_id:
queryset = queryset.filter(occurrences=occurrence)
if deployment_id:
deployment = Deployment.objects.get(id=deployment_id)
queryset = super().get_queryset().filter(occurrences__deployment=deployment)
elif event_id:
queryset = queryset.filter(occurrences__deployment=deployment)
if event_id:
event = Event.objects.get(id=event_id)
queryset = super().get_queryset().filter(occurrences__event=event)
queryset = queryset.filter(occurrences__event=event)
if collection_id:
queryset = queryset.filter(occurrences__detections__source_image__collections=collection_id)
except exceptions.ObjectDoesNotExist as e:
# Raise a 404 if any of the related objects don't exist
raise NotFound(detail=str(e))

# @TODO need to return the models.Q filter used, so we can use it for counts and related occurrences.
return queryset, filter_active
return queryset.distinct(), filter_active

def filter_by_classification_threshold(self, queryset: QuerySet) -> QuerySet:
"""
Expand Down
75 changes: 75 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,35 @@ def delete_source_image(sender, instance, **kwargs):
instance.deployment.save()


class SourceImageQuerySet(models.QuerySet):
def with_occurrences_count(self):
return self.annotate(
occurrences_count=models.Count(
"detections__occurrence",
filter=models.Q(
detections__occurrence__determination_score__gte=settings.DEFAULT_CONFIDENCE_THRESHOLD
),
distinct=True,
)
)

def with_taxa_count(self):
return self.annotate(
taxa_count=models.Count(
"detections__occurrence__determination",
filter=models.Q(
detections__occurrence__determination_score__gte=settings.DEFAULT_CONFIDENCE_THRESHOLD
),
distinct=True,
)
)


class SourceImageManager(models.Manager):
def get_queryset(self) -> SourceImageQuerySet:
return SourceImageQuerySet(self.model, using=self._db)


@final
class SourceImage(BaseModel):
"""A single image captured during a monitoring session"""
Expand Down Expand Up @@ -1177,6 +1206,8 @@ class SourceImage(BaseModel):
detections: models.QuerySet["Detection"]
collections: models.QuerySet["SourceImageCollection"]

objects = SourceImageManager()

def __str__(self) -> str:
return f"{self.__class__.__name__} #{self.pk} {self.path}"

Expand Down Expand Up @@ -1224,6 +1255,15 @@ def public_url(self, raise_errors=False) -> str | None:
# backwards compatibility
url = public_url

def size_display(self) -> str:
"""
Return the size of the image in human-readable format.
"""
if self.size is None:
return filesizeformat(0)
else:
return filesizeformat(self.size)

def get_detections_count(self) -> int:
return self.detections.distinct().count()

Expand Down Expand Up @@ -1318,6 +1358,14 @@ def get_dimensions(self) -> tuple[int | None, int | None]:
return self.width, self.height
return None, None

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

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

def update_calculated_fields(self, save=False):
if self.path and not self.timestamp:
self.timestamp = self.extract_timestamp()
Expand Down Expand Up @@ -2615,6 +2663,25 @@ def with_source_images_with_detections_count(self):
)
)

def with_occurrences_count(self):
return self.annotate(
occurrences_count=models.Count(
"images__detections__occurrence",
filter=models.Q(
images__detections__occurrence__determination_score__gte=settings.DEFAULT_CONFIDENCE_THRESHOLD
),
distinct=True,
)
)

def with_taxa_count(self):
return self.annotate(
taxa_count=models.Count(
"images__detections__occurrence__determination",
distinct=True,
)
)


class SourceImageCollectionManager(models.Manager):
def get_queryset(self) -> SourceImageCollectionQuerySet:
Expand Down Expand Up @@ -2665,6 +2732,14 @@ def source_images_with_detections_count(self) -> int | None:
# return self.images.filter(detections__isnull=False).count()
return None

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

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

def get_queryset(self):
return SourceImage.objects.filter(project=self.project)

Expand Down
2 changes: 1 addition & 1 deletion ui/src/data-services/models/capture-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class CaptureDetails extends Capture {
}

get sizeLabel(): string {
return `${this._capture.size} B`
return `${this._capture.size_display}`
}

get totalCaptures(): number | undefined {
Expand Down
8 changes: 8 additions & 0 deletions ui/src/data-services/models/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ export class Capture {
return this._capture.detections_count ?? 0
}

get numOccurrences(): number {
return this._capture.occurrences_count ?? 0
}

get numTaxa(): number {
return this._capture.taxa_count ?? 0
}

get sessionId(): string | undefined {
return this._capture.event?.id
}
Expand Down
8 changes: 8 additions & 0 deletions ui/src/data-services/models/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,12 @@ export class Collection extends Entity {
0
)}%)`
}

get numOccurrences(): number {
return this._data.occurrences_count
}

get numTaxa(): number {
return this._data.taxa_count
}
}
29 changes: 28 additions & 1 deletion ui/src/pages/collection-details/capture-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const columns: (projectId: string) => TableColumn<Capture>[] = (
},
{
id: 'detections',
name: translate(STRING.FIELD_LABEL_DETECTIONS),
name: 'Detections',
sortField: 'detections_count',
styles: {
textAlign: TextAlign.Right,
Expand All @@ -92,4 +92,31 @@ export const columns: (projectId: string) => TableColumn<Capture>[] = (
<BasicTableCell value={item.numDetections} />
),
},
{
id: 'occurrences',
name: 'Occurrences',
sortField: 'occurrences_count',
styles: {
textAlign: TextAlign.Right,
},
renderCell: (item: Capture) => (
<Link
to={getAppRoute({
to: APP_ROUTES.OCCURRENCES({ projectId }),
filters: { detections__source_image: item.id },
})}
>
<BasicTableCell value={item.numOccurrences} theme={CellTheme.Bubble} />
</Link>
),
},
{
id: 'taxa',
name: 'Taxa',
sortField: 'taxa_count',
styles: {
textAlign: TextAlign.Right,
},
renderCell: (item: Capture) => <BasicTableCell value={item.numTaxa} />,
},
]
Loading