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
56 changes: 55 additions & 1 deletion ami/base/filters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db.models import F, OrderBy
from rest_framework.filters import OrderingFilter
from django.forms import FloatField
from rest_framework.filters import BaseFilterBackend, OrderingFilter


class NullsLastOrderingFilter(OrderingFilter):
Expand All @@ -8,3 +9,56 @@ def get_ordering(self, request, queryset, view):
if not values:
return values
return [OrderBy(F(value.lstrip("-")), descending=value.startswith("-"), nulls_last=True) for value in values]


class ThresholdFilter(BaseFilterBackend):
"""
Filter a numeric field by a minimum value.

Usage:

Filter occurrences by their determination score:
GET /occurrences/?score=0.5
This will return all occurrences with a determination score greater than or equal to 0.5.

Customize the query_param and filter_param to match your API and model fields using
the create method.

Example:

DeterminationScoreFilter = ThresholdFilter.create(
query_param="classification_threshold",
filter_param="determination_score",
)
OODScoreFilter = ThresholdFilter.create("determination_ood_score")

class OccurrenceViewSet(DefaultViewSet):
filter_backends = DefaultViewSetMixin.filter_backends + [
DeterminationScoreFilter,
OODScoreFilter,
]
"""

query_param = "score"
filter_param = "score"

def filter_queryset(self, request, queryset, view):
value = FloatField(required=False).clean(request.query_params.get(self.query_param))
if value:
filters = {f"{self.filter_param}__gte": value}
queryset = queryset.filter(**filters)
return queryset

@classmethod
def create(cls, query_param: str, filter_param: str | None = None) -> type["ThresholdFilter"]:
class_name = f"{cls.__name__}_{query_param}"
if filter_param is None:
filter_param = query_param
return type(
class_name,
(cls,),
{
"query_param": query_param,
"filter_param": filter_param,
},
)
2 changes: 2 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,8 @@ class Meta:
"events_count",
"occurrences",
"gbif_taxon_key",
"last_detected",
"best_determination_score",
]


Expand Down
47 changes: 36 additions & 11 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from ami.base.filters import NullsLastOrderingFilter
from ami.base.filters import NullsLastOrderingFilter, ThresholdFilter
from ami.base.pagination import LimitOffsetPaginationWithPermissions
from ami.base.permissions import (
CanDeleteIdentification,
Expand Down Expand Up @@ -1024,6 +1024,11 @@ def filter_queryset(self, request, queryset, view):
return queryset


OccurrenceDeterminationScoreFilter = ThresholdFilter.create(
query_param="classification_threshold", filter_param="determination_score"
)


class OccurrenceViewSet(DefaultViewSet, ProjectMixin):
"""
API endpoint that allows occurrences to be viewed or edited.
Expand All @@ -1041,6 +1046,7 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin):
OccurrenceVerified,
OccurrenceVerifiedByMeFilter,
OccurrenceTaxaListFilter,
OccurrenceDeterminationScoreFilter,
]
filterset_fields = [
"event",
Expand All @@ -1060,7 +1066,6 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin):
"determination_score",
"event",
"detections_count",
"created_at",
]

def get_serializer_class(self):
Expand All @@ -1085,14 +1090,7 @@ def get_queryset(self) -> QuerySet["Occurrence"]:
qs = qs.with_detections_count().with_timestamps() # type: ignore
qs = qs.with_identifications() # type: ignore

if self.action == "list":
qs = (
qs.all()
.filter(determination_score__gte=get_active_classification_threshold(self.request))
.order_by("-determination_score")
)

else:
if self.action != "list":
qs = qs.prefetch_related(
Prefetch(
"detections", queryset=Detection.objects.order_by("-timestamp").select_related("source_image")
Expand All @@ -1101,7 +1099,30 @@ def get_queryset(self) -> QuerySet["Occurrence"]:

return qs

@extend_schema(parameters=[project_id_doc_param])
@extend_schema(
parameters=[
project_id_doc_param,
OpenApiParameter(
name="classification_threshold",
description="Filter occurrences by minimum determination score.",
required=False,
type=OpenApiTypes.FLOAT,
),
OpenApiParameter(
name="taxon",
description="Filter occurrences by determination taxon ID. Shows occurrences determined as this taxon "
"or any of its child taxa.",
required=False,
type=OpenApiTypes.INT,
),
OpenApiParameter(
name="collection_id",
description="Filter occurrences by the collection their detections' source images belong to.",
required=False,
type=OpenApiTypes.INT,
),
]
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)

Expand Down Expand Up @@ -1133,6 +1154,9 @@ def filter_queryset(self, request, queryset, view):
return queryset


TaxonBestScoreFilter = ThresholdFilter.create("best_determination_score")


class TaxonViewSet(DefaultViewSet, ProjectMixin):
"""
API endpoint that allows taxa to be viewed or edited.
Expand All @@ -1144,6 +1168,7 @@ class TaxonViewSet(DefaultViewSet, ProjectMixin):
CustomTaxonFilter,
TaxonCollectionFilter,
TaxonTaxaListFilter,
TaxonBestScoreFilter,
]
filterset_fields = [
"name",
Expand Down
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"leaflet": "^1.9.3",
"lodash": "^4.17.21",
"lucide-react": "^0.454.0",
"nova-ui-kit": "^1.1.30",
"nova-ui-kit": "^1.1.31",
"plotly.js": "^2.25.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
10 changes: 8 additions & 2 deletions ui/src/app.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,20 @@
position: relative;
}

@media only screen and (max-width: $medium-screen-breakpoint) {
@media (max-width: $medium-screen-breakpoint) {
.main {
padding: 24px 24px 96px;
}
}

@media only screen and (max-width: $small-screen-breakpoint) {
@media (max-width: $small-screen-breakpoint) {
.main {
padding: 16px 16px 80px;
}
}

@media print {
.wrapper {
height: auto;
}
}
2 changes: 1 addition & 1 deletion ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const App = () => (
/>
</Helmet>
<div id={APP_CONTAINER_ID} className={styles.wrapper}>
<div id={INTRO_CONTAINER_ID}>
<div id={INTRO_CONTAINER_ID} className="no-print">
<Header />
</div>
<Routes>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,23 @@
}
}

@media only screen and (max-width: $small-screen-breakpoint) {
@media (max-width: $small-screen-breakpoint) {
.licenseInfoContent {
text-align: left;
}

.blueprintContent {
flex-direction: row;

&.empty {
display: none;
}
}
}

@media print {
.blueprintContent {
display: grid;
align-items: start;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
}
39 changes: 24 additions & 15 deletions ui/src/components/blueprint-collection/blueprint-collection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { LicenseInfo } from 'components/license-info/license-info'
import { Icon, IconType } from 'design-system/components/icon/icon'
import { EyeIcon } from 'lucide-react'
import { buttonVariants, Tooltip } from 'nova-ui-kit'
import { useState } from 'react'
import { ReactNode, useState } from 'react'
import { Link } from 'react-router-dom'
import styles from './blueprint-collection.module.scss'

Expand All @@ -16,26 +16,35 @@ export interface BlueprintItem {
to?: string
}

export const BlueprintCollection = ({ items }: { items: BlueprintItem[] }) => (
<div
className={classNames(styles.blueprint, {
[styles.empty]: items.length === 0,
})}
>
{items.length > 0 && (
export const BlueprintCollection = ({
children,
showLicenseInfo,
}: {
children: ReactNode
showLicenseInfo?: boolean
}) => (
<div className={classNames(styles.blueprint)}>
{showLicenseInfo ? (
<div className={styles.licenseInfoContent}>
<LicenseInfo />
</div>
)}
<div className={styles.blueprintContent}>
{items.map((item) => (
<BlueprintItem key={item.id} item={item} />
))}
</div>
) : null}
<div className={styles.blueprintContent}>{children}</div>
</div>
)

const BlueprintItem = ({ item }: { item: BlueprintItem }) => {
export const BlueprintItem = ({
item,
}: {
item: {
id: string
image: { src: string; width: number; height: number }
label: string
timeLabel: string
countLabel: string
to?: string
}
}) => {
const [size, setSize] = useState({
width: item.image.width,
height: item.image.height,
Expand Down
28 changes: 28 additions & 0 deletions ui/src/components/determination-score.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { BasicTooltip } from 'design-system/components/tooltip/basic-tooltip'
import { IdentificationScore } from 'nova-ui-kit'
import { STRING, translate } from 'utils/language'

export const DeterminationScore = ({
confirmed,
score,
scoreLabel,
tooltip,
}: {
confirmed?: boolean
score?: number
scoreLabel?: string
tooltip?: string
}) => {
if (score === undefined || scoreLabel === undefined) {
return <span>{translate(STRING.VALUE_NOT_AVAILABLE)}</span>
}

return (
<BasicTooltip content={tooltip}>
<div className="flex items-center gap-3">
<IdentificationScore confirmed={confirmed} confidenceScore={score} />
<span>{scoreLabel}</span>
</div>
</BasicTooltip>
)
}
5 changes: 4 additions & 1 deletion ui/src/components/filtering/filter-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { X } from 'lucide-react'
import { Button } from 'nova-ui-kit'
import { useFilters } from 'utils/useFilters'
import { AlgorithmFilter, NotAlgorithmFilter } from './filters/algorithm-filter'
import { BooleanFilter } from './filters/boolean-filter'
import { CollectionFilter } from './filters/collection-filter'
import { DateFilter } from './filters/date-filter'
import { ImageFilter } from './filters/image-filter'
Expand All @@ -21,21 +22,23 @@ const ComponentMap: {
[key: string]: (props: FilterProps) => JSX.Element
} = {
algorithm: AlgorithmFilter,
best_determination_score: ScoreFilter,
classification_threshold: ScoreFilter,
collection: CollectionFilter,
date_end: DateFilter,
date_start: DateFilter,
deployment: StationFilter,
detections__source_image: ImageFilter,
event: SessionFilter,
include_unobserved: BooleanFilter,
job_type_key: TypeFilter,
not_algorithm: NotAlgorithmFilter,
pipeline: PipelineFilter,
source_image_collection: CollectionFilter,
source_image_single: ImageFilter,
status: StatusFilter,
taxon: TaxonFilter,
taxa_list_id: TaxaListFilter,
taxon: TaxonFilter,
verified_by_me: VerifiedByFilter,
verified: VerificationStatusFilter,
}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/filtering/filter-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const FilterSection = ({
defaultOpen,
title = 'Filters',
}: FilterSectionProps) => (
<Box className="w-full h-min shrink-0 p-2 rounded-lg md:w-72 md:p-4 md:rounded-xl">
<Box className="w-full h-min shrink-0 p-2 rounded-lg md:w-72 md:p-4 md:rounded-xl no-print">
<Collapsible.Root
className="space-y-4"
defaultOpen={window.innerWidth >= BREAKPOINTS.MD ? defaultOpen : false}
Expand Down
Loading