Skip to content
12 changes: 12 additions & 0 deletions ami/main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Site,
SourceImage,
SourceImageCollection,
SourceImageThumbnail,
Tag,
TaxaList,
Taxon,
Expand Down Expand Up @@ -293,6 +294,17 @@ def get_queryset(self, request: HttpRequest) -> QuerySet[Any]:
)


@admin.register(SourceImageThumbnail)
class SourceImageThumbnailAdmin(AdminBase):
"""Admin panel for ``SourceImageThumbnail`` model."""

list_display = ("source_image", "path", "label", "width", "height", "size")
list_filter = ("source_image__deployment__project", "source_image__deployment__data_source", "label")

def get_queryset(self, request: HttpRequest) -> QuerySet[Any]:
return super().get_queryset(request).select_related("source_image", "source_image__deployment")


class ClassificationInline(admin.TabularInline):
model = Classification
extra = 0
Expand Down
15 changes: 14 additions & 1 deletion ami/main/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime

from django.conf import settings
from django.db.models import QuerySet
from guardian.shortcuts import get_perms
from rest_framework import serializers
Expand Down Expand Up @@ -1097,6 +1098,18 @@ class SourceImageListSerializer(DefaultSerializer):
event = EventNestedSerializer(read_only=True)
project = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all(), required=False)
# file = serializers.ImageField(allow_empty_file=False, use_url=True)
thumbnails = serializers.SerializerMethodField()

def get_thumbnails(self, obj: SourceImage) -> dict | None:
return {
label: reverse_with_params(
"sourceimagethumbnail-detail",
args=(obj.pk,),
request=self.context.get("request"),
params={"label": label},
)
for label in settings.THUMBNAILS["SIZES"]
}

class Meta:
model = SourceImage
Expand All @@ -1107,7 +1120,6 @@ class Meta:
"event",
"url",
"path",
# "thumbnail",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Funny that this thumbnail field has been hanging out empty all along! Glad to retire it

"timestamp",
"width",
"height",
Expand All @@ -1118,6 +1130,7 @@ class Meta:
"taxa_count",
"detections",
"project",
"thumbnails",
Comment thread
loppear marked this conversation as resolved.
]


Expand Down
33 changes: 32 additions & 1 deletion ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
import logging
from statistics import mode

from django.conf import settings
from django.contrib.postgres.search import TrigramSimilarity
from django.core import exceptions
from django.core.files.storage import default_storage
from django.db import models
from django.db.models import Prefetch, Q
from django.db.models.functions import Coalesce
from django.db.models.query import QuerySet
from django.forms import BooleanField, CharField, IntegerField
from django.shortcuts import get_object_or_404
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
Expand Down Expand Up @@ -709,6 +711,35 @@ def unstar(self, _request, pk=None) -> Response:
raise api_exceptions.ValidationError(detail="Source image must be associated with a project")


class SourceImageThumbnailViewSet(DefaultReadOnlyViewSet, ProjectMixin):
"""
Endpoint for capture thumbnails
"""

queryset = SourceImage.objects.all()

permission_classes = [ObjectPermission]

def list(self, request):
raise api_exceptions.NotFound(detail=f"No collection of thumbnails")

def retrieve(self, request, pk=None):
_sizes = settings.THUMBNAILS["SIZES"]

label = self.request.query_params.get("label", next(iter(_sizes)))
size = _sizes.get(label, None)
Comment thread
loppear marked this conversation as resolved.
if size is None:
raise api_exceptions.ValidationError(
detail=f"Invalid thumbnail size label provided: {label} not in {', '.join(_sizes.keys())}"
)
obj: SourceImage = self.get_object()
try:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm tempted to say we don't need the SourceImageThumbnail model and can rely on the exists() check in the storage. But then I suppose we have to parse the height/width from the file name, and make sure we have a way to create those consistently. So I could go either way! This will be a big table eventually.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming files are on S3, I don't think we'll want to do filesystem name checks. Table size will be commensurate to SourceImage, could be pruned for age if that becomes an issue, though would lose the "we still have thumbnails if source files are removed" benefit. We don't join to the table just per-image lookups - should probably add a (source_image, label) index.

thumb = obj.find_or_generate_thumbnail_for_label(label)
except exceptions.ObjectDoesNotExist as e:
raise api_exceptions.NotFound(detail=f"{e}")
return redirect(default_storage.url(thumb.path))


class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin):
"""
Endpoint for viewing capture sets or samples of captures.
Expand Down
49 changes: 49 additions & 0 deletions ami/main/migrations/0085_sourceimagethumbnail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 4.2.10 on 2026-05-13 10:17

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


class Migration(migrations.Migration):
dependencies = [
("main", "0084_revoke_delete_job_from_roles"),
]

operations = [
migrations.CreateModel(
name="SourceImageThumbnail",
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)),
("path", models.CharField(blank=True, max_length=255)),
("label", models.CharField(max_length=255)),
("width", models.IntegerField(blank=True, null=True)),
("height", models.IntegerField(blank=True, null=True)),
("size", models.BigIntegerField(blank=True, null=True)),
("last_modified", models.DateTimeField(blank=True, null=True, auto_now_add=True)),
(
"source_image",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thumbnails",
to="main.sourceimage",
),
),
Comment on lines +25 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

SET_NULL on source_image will leave orphaned thumbnail rows and storage objects when a SourceImage is deleted.

A thumbnail is meaningless without its SourceImage, and the viewset only resolves thumbnails through a SourceImage pk — orphaned rows (with source_image=NULL) become unreachable while still occupying DB and the underlying default_storage blob. Prefer CASCADE so deletes propagate, and pair it with cleanup of the stored file (e.g. a pre_delete signal on SourceImageThumbnail that calls default_storage.delete(instance.path)).

🐛 Proposed fix
                 (
                     "source_image",
                     models.ForeignKey(
-                        null=True,
-                        on_delete=django.db.models.deletion.SET_NULL,
+                        on_delete=django.db.models.deletion.CASCADE,
                         related_name="thumbnails",
                         to="main.sourceimage",
                     ),
                 ),

Note: this requires removing null=True from the corresponding field on the SourceImageThumbnail model and likely a follow-up migration if any existing rows have source_image_id IS NULL. Storage-side cleanup on delete is a separate concern and won’t be handled by changing on_delete alone.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(
"source_image",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thumbnails",
to="main.sourceimage",
),
),
(
"source_image",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="thumbnails",
to="main.sourceimage",
),
),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ami/main/migrations/0085_sourceimagethumbnail.py` around lines 25 - 33,
Change the ForeignKey on SourceImageThumbnail named source_image to use
on_delete=django.db.models.deletion.CASCADE and remove null=True from the field
definition in the SourceImageThumbnail model so thumbnails are deleted with
their SourceImage; add a pre_delete signal (or model delete override) for
SourceImageThumbnail that calls
django.core.files.storage.default_storage.delete(instance.path) to remove the
file from storage when a thumbnail is deleted; ensure you create a follow-up
migration to update existing rows that have source_image_id IS NULL (or
delete/associate them) before removing nullability.

],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="sourceimagethumbnail",
index=models.Index(fields=["source_image", "label"], name="main_source_source__b0d4cd_idx"),
),
migrations.AddConstraint(
model_name="sourceimagethumbnail",
constraint=models.UniqueConstraint(
fields=("source_image", "label"), name="unique_source_image_thumbnail_label"
),
),
]
85 changes: 84 additions & 1 deletion ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from django.conf import settings
from django.contrib.auth.models import AbstractUser, AnonymousUser
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError, models, transaction
from django.db.models import Exists, OuterRef, Q
Expand Down Expand Up @@ -44,6 +44,7 @@
from ami.users.models import User
from ami.utils.media import calculate_file_checksum, extract_timestamp
from ami.utils.requests import get_apply_default_filters_flag, get_default_classification_threshold
from ami.utils.s3 import botocore, read_image
from ami.utils.schemas import OrderedEnum

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -2206,6 +2207,63 @@ def get_custom_user_permissions(self, user) -> list[str]:
custom_perms.add(Project.Permissions.RUN_SINGLE_IMAGE_JOB)
return list(custom_perms)

def find_or_generate_thumbnail_for_label(self, label):
try:
thumb = self.thumbnails.get(label=label)
except SourceImageThumbnail.DoesNotExist:
thumb = None
size = settings.THUMBNAILS["SIZES"].get(label)
prefix = settings.THUMBNAILS["STORAGE_PREFIX"]

if (
not thumb
or thumb.width != size["width"]
or thumb.last_modified < self.last_modified
or not default_storage.exists(thumb.path)
):
Comment thread
loppear marked this conversation as resolved.
if self.path and self.deployment and self.deployment.data_source:
config = self.deployment.data_source.config
# Get the file
try:
img = read_image(config=config, key=self.path)
except botocore.exceptions.ClientError as e:
logger.error(f"Could not read image for {self.path}: {e}")
raise ObjectDoesNotExist(f"SourceImage with id {self.pk} media not found") from e
elif self.path:
img = PIL.Image.open(default_storage.open(self.path))
else:
raise ObjectDoesNotExist(f"SourceImage with id {self.pk} media config not found")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Make the thumbnail
orig_width, orig_height = img.size
width = size["width"]
height = size.get("height", None)
if not height:
height = int(orig_height * (width / float(orig_width)))
new_size = (width, height)
img.thumbnail(new_size)

buffer = BytesIO()
img.save(buffer, format="JPEG")
contents = buffer.getvalue()
file_size = len(contents)
Comment on lines +2236 to +2248
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Pillow Image.save JPEG mode RGBA error supported modes

💡 Result:

The error "OSError: cannot write mode RGBA as JPEG" occurs because the JPEG file format does not support an alpha channel (transparency) [1][2][3]. Pillow explicitly prevents saving RGBA images as JPEG to avoid ambiguity regarding how to handle the transparent pixels [4]. To resolve this, you must convert the image to a mode supported by JPEG before saving [2][3]. The standard JPEG format supports the following modes: - L (8-bit grayscale) [3][5] - RGB (24-bit true color) [3][5] - CMYK (32-bit color separation) [3][5] Solution You can convert your image to RGB using the convert method [2][3]. Note that this will discard the alpha channel, and transparent areas will typically turn black or white depending on how the conversion is handled [1][2]. Example code: from PIL import Image # Open the RGBA image img = Image.open("input.png") # Convert to RGB (discards alpha channel) rgb_img = img.convert("RGB") # Save as JPEG rgb_img.save("output.jpg") If you need to preserve transparency, you should save the image in a format that supports it, such as PNG [1][2]. If you specifically need to keep the alpha channel but must use a JPEG-like compression, consider formats like JPEG 2000, which supports RGBA [3][5].

Citations:


JPEG encoding fails for non-RGB source images without conversion.

img.save(buffer, format="JPEG") raises OSError: cannot write mode RGBA as JPEG for images with alpha channels or palettes (RGBA, P, LA, PA, etc.). PNG sources commonly trigger this failure. JPEG format natively supports only L, RGB, and CMYK modes. Convert the image to RGB before saving to ensure compatibility.

Proposed fix
             new_size = (width, height)
             img.thumbnail(new_size)
 
+            if img.mode not in ("RGB", "L"):
+                img = img.convert("RGB")
+
             buffer = BytesIO()
             img.save(buffer, format="JPEG")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ami/main/models.py` around lines 2236 - 2248, The JPEG save fails for non-RGB
source images (e.g., RGBA, P) because img.save(buffer, format="JPEG") cannot
write those modes; before saving the thumbnail, convert the PIL Image to an
appropriate mode (e.g., call img.convert("RGB") when img.mode is not already
JPEG-compatible) so that the subsequent img.save(buffer, format="JPEG")
succeeds; update the code around the thumbnail creation/BytesIO block (the
img.thumbnail(...), buffer = BytesIO(), img.save(...), contents =
buffer.getvalue()) to perform the conversion on the img object prior to saving.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mihow My guess is this an assumption throughout (that source images do not include alpha layer PNGs) and I'm not going to address/guard that here if so.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


# Remove prior thumbnails for this size
for t in self.thumbnails.filter(label=label):
default_storage.delete(t.path)
t.delete()

# Write to storage
buffer.seek(0)
thumbnail_key = f"{prefix}capture_{self.pk}/{label}.jpg"
thumbnail_path = default_storage.save(thumbnail_key, buffer)

# Save to DB
width, height = img.size
thumb = self.thumbnails.create(
path=thumbnail_path, label=label, width=width, height=height, size=file_size
)
return thumb

class Meta:
ordering = ("deployment", "event", "timestamp")

Expand Down Expand Up @@ -2392,6 +2450,31 @@ def sample_captures_by_nth(
yield from qs[::nth]


@final
class SourceImageThumbnail(BaseModel):
"""A thumbnail cache of a SourceImage"""

path = models.CharField(max_length=255, blank=True)
label = models.CharField(max_length=255)
width = models.IntegerField(null=True, blank=True)
height = models.IntegerField(null=True, blank=True)
size = models.BigIntegerField(null=True, blank=True)
last_modified = models.DateTimeField(null=True, blank=True, auto_now_add=True)

source_image = models.ForeignKey(SourceImage, on_delete=models.SET_NULL, null=True, related_name="thumbnails")

Comment thread
loppear marked this conversation as resolved.
class Meta:
constraints = [
models.UniqueConstraint(
fields=["source_image", "label"],
name="unique_source_image_thumbnail_label",
),
]
indexes = [
models.Index(fields=["source_image", "label"]),
]


# @final
# class IdentificationHistory(BaseModel):
# """A history of identifications for an occurrence."""
Expand Down
Loading
Loading