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
2 changes: 2 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
}
10 changes: 10 additions & 0 deletions ui/src/data-services/models/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
17 changes: 8 additions & 9 deletions ui/src/pages/session-details/capture/capture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/pages/session-details/session-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
Loading