diff --git a/ami/main/models.py b/ami/main/models.py index 3662b4107..370f7e323 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -2519,6 +2519,8 @@ def find_or_generate_thumbnail_for_label(self, label): img.thumbnail(new_size) buffer = BytesIO() + # No ``exif=`` argument: detection boxes are overlaid on thumbnails in raw + # pixel coordinates, so the EXIF Orientation tag must not propagate. img.save(buffer, format="JPEG", progressive=True, optimize=True, quality=82) contents = buffer.getvalue() file_size = len(contents) diff --git a/ami/main/tests.py b/ami/main/tests.py index a0d0253a1..996db345a 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -262,6 +262,46 @@ def test_thumbnail_new_with_size(self): self.assertEqual(thumb.height, 768) self.assertEqual(response.headers["Location"], f"/media/{thumb.path}") + def test_thumbnail_new_with_large_size(self): + response = self.client.get(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/?label=large") + self.assertEqual(response.status_code, 302) + thumb = self.first_capture.thumbnails.get(label="large") + # ``width`` stores the requested spec width for the regen gate, not the + # encoder output. The test fixture is 1024px wide and PIL's thumbnail() + # never upscales, so the stored file keeps its original dimensions. + self.assertEqual(thumb.width, 2560) + self.assertEqual(thumb.height, 768) + self.assertEqual(response.headers["Location"], f"/media/{thumb.path}") + + def test_thumbnail_strips_exif_orientation(self): + """Thumbnails must stay in the source file's raw pixel space so detection + boxes (stored in raw coordinates) align when overlaid in the UI: neither + rotate the pixels nor propagate the EXIF Orientation tag to the output. + """ + from django.core.files.storage import default_storage + + from ami.utils import s3 + + ORIENTATION_TAG = 274 # EXIF tag id for Orientation + source = Image.new("RGB", (320, 240), (10, 120, 30)) + exif = source.getexif() + exif[ORIENTATION_TAG] = 6 # stored landscape, rotate 90° CW to view + buffer = BytesIO() + source.save(buffer, format="JPEG", exif=exif) + + assert self.deployment.data_source is not None + s3.write_file(self.deployment.data_source.config, self.first_capture.path, buffer.getvalue()) + + thumb = self.first_capture.find_or_generate_thumbnail_for_label("large") + + with default_storage.open(thumb.path) as f: + output = Image.open(f) + output.load() + + # Pixels stay in raw (landscape) space and the orientation tag is gone. + self.assertEqual(output.size, (320, 240)) + self.assertIsNone(output.getexif().get(ORIENTATION_TAG)) + def test_thumbnail_blank_path_row_regenerates(self): """A row with an empty ``path`` (failed or interrupted generation) must trigger regeneration, not redirect to the storage root. diff --git a/config/settings/base.py b/config/settings/base.py index d90899e91..01883d0eb 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -589,5 +589,8 @@ def _celery_result_backend_url(redis_url): JOB_LOG_PERSIST_ENABLED = env.bool("JOB_LOG_PERSIST_ENABLED", default=True) # type: ignore[no-untyped-call] -# Sizes for Source Image Thumbnails -THUMBNAILS = {"STORAGE_PREFIX": "thumbnails/", "SIZES": {"small": {"width": 240}, "medium": {"width": 1024}}} +# Sizes for Source Image Thumbnails ("large" backs the zoomable session detail view) +THUMBNAILS = { + "STORAGE_PREFIX": "thumbnails/", + "SIZES": {"small": {"width": 240}, "medium": {"width": 1024}, "large": {"width": 2560}}, +} diff --git a/ui/src/data-services/models/capture.ts b/ui/src/data-services/models/capture.ts index 4d6d7b303..c87189957 100644 --- a/ui/src/data-services/models/capture.ts +++ b/ui/src/data-services/models/capture.ts @@ -183,6 +183,16 @@ export class Capture { return this._capture.url } + // EXIF-free large thumbnail for the zoomable session detail view; detection + // boxes align with its pixels, unlike the EXIF-rotated original file. + get thumbnailLarge(): string { + if (this._capture.thumbnails?.large) { + return this._capture.thumbnails.large + } + + return this._capture.url + } + get src(): string { return this._capture.url } diff --git a/ui/src/pages/session-details/capture/capture.tsx b/ui/src/pages/session-details/capture/capture.tsx index 6e2848ee7..a4dc631bb 100644 --- a/ui/src/pages/session-details/capture/capture.tsx +++ b/ui/src/pages/session-details/capture/capture.tsx @@ -93,23 +93,22 @@ export const Capture = ({ const boxWidth = boxRight - boxLeft const boxHeight = boxBottom - boxTop - const _width = width ?? naturalSize?.width - const _height = height ?? naturalSize?.height - - if (!_width || !_height) { + // Boxes are in the original image's pixel space and the rendered image + // may be a downscaled thumbnail, so only stored dimensions can scale them. + if (!width || !height) { return result } result[detection.id] = { - width: `${(boxWidth / _width) * 100}%`, - height: `${(boxHeight / _height) * 100}%`, - top: `${(boxTop / _height) * 100}%`, - left: `${(boxLeft / _width) * 100}%`, + width: `${(boxWidth / width) * 100}%`, + height: `${(boxHeight / height) * 100}%`, + top: `${(boxTop / height) * 100}%`, + left: `${(boxLeft / width) * 100}%`, } return result }, {}), - [width, height, naturalSize, detections] + [width, height, detections] ) const ratio = useMemo(() => { diff --git a/ui/src/pages/session-details/session-details.tsx b/ui/src/pages/session-details/session-details.tsx index a55dd1ca2..09d51d2bf 100644 --- a/ui/src/pages/session-details/session-details.tsx +++ b/ui/src/pages/session-details/session-details.tsx @@ -169,7 +169,7 @@ const Content = ({ session }: { session: SessionDetails }) => { detections={activeCapture?.detections ?? []} height={activeCapture?.height ?? session.firstCapture.height} showDetections={settings.showDetections} - src={activeCapture?.url} + src={activeCapture?.thumbnailLarge} transformRef={transformRef} width={activeCapture?.width ?? session.firstCapture.width} />