Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
60d27cc
feat: added TaxonOberved intermediate model between the Taxon and Pro…
mohamedelabbas1996 May 10, 2025
5f99b33
renamed the TaxonObserved intermediate model to ProjectTaxon and adde…
mohamedelabbas1996 May 12, 2025
3c7518d
renamed the ProjectTaxon related name to project_taxa on the Occurren…
mohamedelabbas1996 May 12, 2025
19fe985
feat: added set occurrence as representative for a taxon
mohamedelabbas1996 May 13, 2025
ecafdd6
Merge branch 'deployments/ood.antenna.insectai.org' of github.com:Rol…
mihow May 14, 2025
826e2fa
sandbox: another implementation for representative occurrences
mihow May 14, 2025
99a2711
feat: added featuring/unfeaturing an occurrence
mohamedelabbas1996 May 15, 2025
53e1f2b
feat: returned representative occurrences images
mohamedelabbas1996 May 15, 2025
e689e9d
tests: added tests for Taxon representative images
mohamedelabbas1996 May 16, 2025
b56277d
tests: added tests for taxon representative image
mohamedelabbas1996 May 16, 2025
04bf213
feat: present new backend images in UI
annavik May 16, 2025
c717ac2
feat: added external reference image caption
mohamedelabbas1996 May 16, 2025
70cc705
Merge branch 'feat/add-example-occurrences-for-taxa' of https://githu…
mohamedelabbas1996 May 16, 2025
b2effc3
copy: update "Selected occurrence" -> "Featured occurrence"
annavik May 16, 2025
6466bb8
feat: setup UI controls for feature and unfeature occurrence
annavik May 16, 2025
79d92aa
feat: returned the featured field with the occurrence list and detail…
mohamedelabbas1996 May 16, 2025
7b53f16
Merged changes
mohamedelabbas1996 May 16, 2025
6ce6d2e
feat: added the featured field to the occurrence list and details res…
mohamedelabbas1996 May 16, 2025
518534a
removed flat-bug
mohamedelabbas1996 May 16, 2025
f7b6eab
feat: add feature control to occurrence gallery view
annavik May 16, 2025
e78f543
Merge branch 'feat/add-example-occurrences-for-taxa' of https://githu…
annavik May 16, 2025
e5216bd
feat: use backend value for featured status + cleanup
annavik May 16, 2025
a246fae
fix: returned the best detection public image url
mohamedelabbas1996 May 16, 2025
81c9568
Merge branch 'feat/add-example-occurrences-for-taxa' of https://githu…
mohamedelabbas1996 May 16, 2025
76b9277
Merge branch 'deployments/ood.antenna.insectai.org' into feat/add-exa…
annavik May 16, 2025
8a9d057
chore: fix migration conflicts
mihow May 16, 2025
f93ea6d
Merge branch 'deployments/ood.antenna.insectai.org' of github.com:Rol…
mihow May 16, 2025
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
95 changes: 91 additions & 4 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from ami.base.fields import DateStringField
from ami.base.serializers import DefaultSerializer, MinimalNestedModelSerializer, get_current_user, reverse_with_params
from ami.jobs.models import Job
from ami.main.models import Tag, create_source_image_from_upload
from ami.main.models import Tag, create_source_image_from_upload, get_media_url
from ami.ml.models import Algorithm
from ami.ml.serializers import AlgorithmSerializer
from ami.users.models import User
Expand All @@ -25,18 +25,27 @@
Occurrence,
Page,
Project,
ProjectTaxon,
S3StorageSource,
Site,
SourceImage,
SourceImageCollection,
SourceImageUpload,
TaxaList,
Taxon,
get_media_url,
validate_filename_timestamp,
)


def build_image_entry(path, title, caption=None):
url = get_media_url(path) if path else None
return {
"title": title,
"caption": caption,
"sizes": {"original": url if url else None},
}


class ProjectNestedSerializer(DefaultSerializer):
class Meta:
model = Project
Expand Down Expand Up @@ -449,6 +458,16 @@ def get_occurrences(self, obj):
)


class ProjectTaxonNestedSerializer(DefaultSerializer):
class Meta:
model = ProjectTaxon
fields = [
"id",
# "project_id",
# "featured_occcurence_id",
]


class TaxonNoParentNestedSerializer(DefaultSerializer):
class Meta:
model = Taxon
Expand Down Expand Up @@ -521,9 +540,35 @@ class TaxonListSerializer(DefaultSerializer):
occurrences = serializers.SerializerMethodField()
parents = TaxonParentSerializer(many=True, read_only=True, source="parents_json")
parent_id = serializers.PrimaryKeyRelatedField(queryset=Taxon.objects.all(), source="parent")
cover_image_url = serializers.SerializerMethodField()
featured_occurrences = serializers.SerializerMethodField()
images = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField() # Deprecated, use images instead @TODO
tags = serializers.SerializerMethodField()

def get_images(self, obj):
images = {
"external_reference": build_image_entry(
obj.cover_image_url, "External reference image", obj.cover_image_credit
),
"most_recently_featured": build_image_entry(
getattr(obj, "featured_detection_path", None),
"Featured occurrence",
),
"highest_determination_score": build_image_entry(
getattr(obj, "highest_score_detection_path", None),
"Most confident prediction",
),
}

return images

def get_featured_occurrences(self, obj):
"""
Return the prefetched featured occurrences attached via `to_attr="prefetched_featured_occurrences"`.
"""
featured = getattr(obj, "prefetched_featured_occurrences", [])
return TaxonOccurrenceNestedSerializer(featured, many=True, context=self.context).data

def get_tags(self, obj):
tag_list = getattr(obj, "prefetched_tags", [])
return TagSerializer(tag_list, many=True, context=self.context).data
Expand All @@ -539,13 +584,15 @@ class Meta:
"details",
"occurrences_count",
"occurrences",
"featured_occurrences",
"tags",
"last_detected",
"best_determination_score",
"cover_image_url",
"unknown_species",
"created_at",
"updated_at",
"images",
]

def get_occurrences(self, obj):
Expand Down Expand Up @@ -758,9 +805,44 @@ class TaxonSerializer(DefaultSerializer):
parent = TaxonNoParentNestedSerializer(read_only=True)
parent_id = serializers.PrimaryKeyRelatedField(queryset=Taxon.objects.all(), source="parent", write_only=True)
parents = TaxonParentSerializer(many=True, read_only=True, source="parents_json")
cover_image_url = serializers.SerializerMethodField()
featured_occurrences = serializers.SerializerMethodField()
images = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField() # Deprecated, use images instead @TODO
tags = serializers.SerializerMethodField()

def get_images(self, obj):
images = {
"external_reference": build_image_entry(
obj.cover_image_url, "External reference image", obj.cover_image_credit
),
"most_recently_featured": build_image_entry(
getattr(obj, "featured_detection_path", None),
"Featured occurrence",
),
"highest_determination_score": build_image_entry(
getattr(obj, "highest_score_detection_path", None),
"Most confident prediction",
),
}

return images

def get_featured_occurrences(self, obj):
"""
Return a list of featured occurrences from prefetched featured occurrences.
"""
featured = [occ for occ in getattr(obj, "prefetched_featured_occurrences", [])]
return TaxonOccurrenceNestedSerializer(featured, many=True, context=self.context).data

def _get_best_detection_image_url(self, occurrence):
"""
Given an occurrence, return the public URL of its best detection's source image.
"""
detection = getattr(occurrence, "best_detection", None)
if detection and detection.source_image:
return detection.source_image.public_url
return None

def get_tags(self, obj):
# Use prefetched tags
tag_list = getattr(obj, "prefetched_tags", [])
Expand All @@ -785,7 +867,11 @@ class Meta:
"fieldguide_id",
"cover_image_url",
"cover_image_credit",
"featured_occurrences",
# "featured_detection_image_url",
"unknown_species",
"last_detected", # @TODO this has performance impact, review
"images",
]

def get_cover_image_url(self, obj):
Expand Down Expand Up @@ -1281,6 +1367,7 @@ class Meta:
"first_appearance_time",
"duration",
"duration_label",
"featured",
"determination",
"detections_count",
"detection_images",
Expand Down
49 changes: 48 additions & 1 deletion ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,49 @@ def get_queryset(self) -> QuerySet["Occurrence"]:

return qs

@action(detail=True, methods=["post", "delete"], url_path="feature")
def feature(self, request, pk=None):
"""
Set this occurrence as the representative example for its taxon in the current project.
"""
occurrence = self.get_object()
if not occurrence:
return Response({"detail": "This occurrence does not exist."}, status=status.HTTP_404_NOT_FOUND)

# Ensure the occurrence has a determination (taxon)
taxon = occurrence.determination
if not taxon:
return Response(
{"detail": "This occurrence has no taxon assigned (determination)."},
status=status.HTTP_400_BAD_REQUEST,
)

# Ensure the occurrence is assigned to a project
project = occurrence.project
if not project:
return Response(
{"detail": "This occurrence is not associated with any project."}, status=status.HTTP_400_BAD_REQUEST
)

# Get or create the ProjectTaxon entry
try:
# Feature or unfeature the occurrence based on the request method
if request.method == "DELETE":
# Unfeature the occurrence
occurrence.featured = False
else:
# Feature the occurrence
occurrence.featured = True
# Set featured at to current date time
occurrence.featured_at = timezone.now()
occurrence.save(update_determination=False)
return Response({"detail": "Representative occurrence updated successfully."}, status=status.HTTP_200_OK)

except Exception as e:
raise e
return Response({"detail": f"Error: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

@extend_schema(parameters=[project_id_doc_param])
@extend_schema(
parameters=[
project_id_doc_param,
Expand Down Expand Up @@ -1372,7 +1415,10 @@ def get_queryset(self) -> QuerySet:

if project:
include_unobserved = True # Show detail views for unobserved taxa instead of 404
# @TODO move to a QuerySet manager
qs = qs.with_featured_occurrences(project=project) # type: ignore
qs = qs.with_example_image_paths(project=project)

# @TODO deprecated in favor of with_example_image_paths
qs = qs.annotate(
best_detection_image_path=models.Subquery(
Occurrence.objects.filter(
Expand All @@ -1384,6 +1430,7 @@ def get_queryset(self) -> QuerySet:
output_field=models.TextField(),
)
)

if self.action == "list":
include_unobserved = self.request.query_params.get("include_unobserved", False)
qs = self.get_taxa_observed(qs, project, include_unobserved=include_unobserved)
Expand Down
30 changes: 0 additions & 30 deletions ami/main/migrations/0066_populate_cached_occurence_fields.py

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def add_inital_tags(apps, schema_editor):

class Migration(migrations.Migration):
dependencies = [
("main", "0066_populate_cached_occurence_fields"),
("main", "0065_detection_favorite_occurrence_best_detection_and_more"),
]

operations = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Generated by Django 4.2.10 on 2025-05-16 16:14

from django.db import migrations, models
import django.db.models.deletion
import pgvector.django.vector


class Migration(migrations.Migration):
dependencies = [
("ml", "0023_alter_algorithm_task_type"),
("main", "0066_tag_taxon_tags"),
]

operations = [
migrations.RenameField(
model_name="detection",
old_name="favorite",
new_name="featured",
),
migrations.AddField(
model_name="detection",
name="featured_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="occurrence",
name="featured",
field=models.BooleanField(default=False, help_text="Use this occurrence to represent a Taxon in the UI"),
),
migrations.AddField(
model_name="occurrence",
name="featured_at",
field=models.DateTimeField(
blank=True, help_text="The date and time this occurrence was featured", null=True
),
),
migrations.AlterField(
model_name="taxon",
name="projects",
field=models.ManyToManyField(blank=True, related_name="taxa", to="main.project"),
),
migrations.CreateModel(
name="ProjectTaxon",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"mean_feature_vector",
pgvector.django.vector.VectorField(
default=None,
dimensions=2048,
help_text="Cluster center for this taxon in this project",
null=True,
),
),
(
"featured_detection",
models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="main.detection"
),
),
(
"featured_occcurence",
models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="main.occurrence"
),
),
(
"mean_feature_vector_algorithm",
models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="ml.algorithm"),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="project_taxa", to="main.project"
),
),
(
"taxon",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="project_taxa", to="main.taxon"
),
),
],
options={
"unique_together": {("taxon", "project")},
},
),
]
Loading