Skip to content

Fix misplaced bounding boxes on portrait captures and add large image thumbnail size#1374

Open
mihow wants to merge 3 commits into
mainfrom
fix/session-detail-thumbnail-orientation
Open

Fix misplaced bounding boxes on portrait captures and add large image thumbnail size#1374
mihow wants to merge 3 commits into
mainfrom
fix/session-detail-thumbnail-orientation

Conversation

@mihow

@mihow mihow commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Captures taken in portrait orientation (for example from a phone-based station) showed their detection bounding boxes in the wrong place in the session detail view — shifted and rotated 90° relative to the insects they belong to. This PR makes the session detail view render captures from a generated thumbnail instead of the original file. What users get today: correctly placed boxes on portrait captures, and faster capture browsing than scrubbing through full-size originals. A new 2560px "large" thumbnail size keeps zooming reasonably sharp.

Before (production) After (this branch, same image and detections)
Boxes misplaced on a portrait capture Boxes aligned on the insects

List of Changes

  1. Bounding boxes now line up correctly on portrait captures in the session detail view — the view renders a generated "large" thumbnail instead of the original image file.
  2. A new "large" (2560px wide) thumbnail size is available from the thumbnails API for every capture, generated lazily on first request like the existing sizes.
  3. Browsing captures in the detail view is lighter once thumbnails are warm — the browser fetches the 2560px derivative instead of a multi-megabyte original. The "view source file" link still opens the full-resolution original.
  4. Captures with no stored width/height no longer risk wrongly scaled boxes: the overlay is skipped instead of scaled against the rendered image's dimensions.
  5. Two new backend tests pin the behavior: thumbnails must strip EXIF metadata and keep the source's raw pixel dimensions, and the "large" label must generate and redirect like the existing sizes.

Detailed Description

Photos shot in portrait are typically stored as landscape pixels plus an EXIF Orientation tag. Everything in our pipeline — detection coordinates, stored image dimensions, crops — lives in that raw (unrotated) pixel space and is mutually consistent. Browsers, however, force-apply the EXIF rotation when displaying the original file, and for cross-origin images they ignore the image-orientation: none CSS override unless the storage bucket serves CORS headers (verified empirically). So the detail view displayed a rotated image while the box overlay math worked in raw space.

This regressed in #1339, which switched the view's image source from the medium thumbnail to the original URL to improve zoom quality. Generated thumbnails are re-encoded without EXIF metadata, so they always render in the same pixel space as the boxes — a new test makes that invariant explicit instead of a side effect, and a comment in the generator guards against a future "preserve metadata" change silently breaking every overlay.

All thumbnail plumbing (viewset label validation, thumbnail_urls(), serializers) iterates settings.THUMBNAILS["SIZES"] dynamically, so adding the "large" size required no code changes beyond the setting itself. PIL's thumbnail() never upscales: sources narrower than 2560px get an original-resolution re-encode, which is still EXIF-stripped and therefore still correct.

What happens to captures that already have thumbnails

Adding a size is purely additive. There is nothing to invalidate, backfill, or migrate before or after deploying this.

Thumbnails are stored as one row per capture and label (SourceImageThumbnail, with a unique constraint on that pair), so each size is independent of the others. This change only adds a large entry; the small (240px) and medium (1024px) specs are untouched. thumbnail_is_valid() compares a stored row's width against the configured spec width, so existing rows remain valid and are never re-encoded.

For an existing capture there is simply no large row yet, which reads as invalid. thumbnail_urls() therefore emits the route URL (/captures/thumbnails/<pk>/?label=large) for that label rather than a direct storage URL, and the first request generates the derivative lazily: fetch the original, encode a 2560px JPEG, store it, write the row, then redirect to storage. Every later request for that capture is warm and served straight from storage, with the backend out of the loop.

The same mechanism covers invalidation in general. Because the stored width is the requested spec width, changing an existing size in settings (for example medium from 1024 to 1200) makes every row for that label read as invalid and regenerate on next request; a modified source image is handled the same way through last_modified. There is no cache-busting step to run and no management command to schedule — thumbnail generation only ever happens lazily, through the thumbnail viewset.

Known caveats

  • Zoom sharpness is capped at 2560px for all captures, portrait or not. Add zoom and pan support in session detail view #1339 introduced full-resolution zoom; this PR trades that for correctness and lighter loading. Progressively load higher-resolution images when zooming in the session detail view #1373 proposes restoring full resolution progressively at high zoom levels.
  • Cold generation shifts load to the backend. The first view of each capture at the new size makes Django fetch the original and encode a 2560px JPEG in-request; rapidly scrubbing through a large uncached session concentrates that work on web workers (same pattern discussed in Lazy thumbnail generation can saturate the web worker pool during cold gallery loads #1346). Once generated, the serializer emits direct storage URLs and the backend is not involved. This exposure is considerably smaller once the progressive loader in Progressively load sharper capture images while zooming in the session detail view #1375 is in place: that change starts the view on the medium thumbnail and only requests large when someone actually zooms in, so the 2560px derivative gets generated for captures people inspect rather than every capture they scroll past. The two are intended to merge and deploy together for that reason. Pre-generating thumbnails per session is still a possible follow-up if it bites in practice.
  • Each capture gains one more stored derivative. A 2560px JPEG is added to storage the first time a capture is viewed at that size. It is materially smaller than the original it derives from, but it is worth being aware of for large projects.
  • The portrait situation itself is unchanged elsewhere: detection crops and any future view that overlays coordinates on the original file are still in raw pixel space. This PR fixes the session detail view; the platform-wide orientation question (normalize at ingest vs. expose orientation metadata) remains open.

Follow-ups

How to test

  1. Open a session with portrait captures (reproduction case: session 12918 in the Ogden St. project shows the misplaced boxes on the current deployment).
  2. With this branch, the same view shows boxes aligned with the insects, and the image request goes through /captures/thumbnails/<id>/?label=large on first load (direct storage URL once warm). Verified end-to-end on a local stack with the production portrait image and its nine real detections — screenshots above.
  3. Backend: python manage.py test ami.main.tests.TestImageThumbnailViews (29 tests, includes the two new ones).

🤖 Generated with Claude Code

Portrait photos are typically stored as landscape pixels plus an EXIF
Orientation tag. Browsers force-apply that rotation to cross-origin
images, while detections, image dimensions, and crops all live in the
raw (unrotated) pixel space - so the session detail view drew bounding
boxes 90 degrees out of place on portrait captures. This regressed in
PR #1339, which switched the view from the medium thumbnail to the
original image URL for zoom quality.

Thumbnails are re-encoded without EXIF metadata, so they always render
in the same pixel space as the boxes. Add a "large" (2560px) thumbnail
size for zoom quality and point the session detail view at it, falling
back to the original URL when thumbnails are unavailable.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 20, 2026 22:21
@netlify

netlify Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-ssec ready!

Name Link
🔨 Latest commit 1a15510
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/6a5eb2d529390c0007f4ecab
😎 Deploy Preview https://deploy-preview-1374--antenna-ssec.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-preview ready!

Name Link
🔨 Latest commit 1a15510
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/6a5eb2d59ea72600083f6e34
😎 Deploy Preview https://deploy-preview-1374--antenna-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 59 (🔴 down 6 from production)
Accessibility: 81 (🔴 down 8 from production)
Best Practices: 92 (🔴 down 8 from production)
SEO: 92 (no change from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a 2560-pixel large thumbnail, validates generation and EXIF removal, and uses the thumbnail in session detail views with dimension-based detection-box scaling.

Changes

Large thumbnail support

Layer / File(s) Summary
Large thumbnail generation
config/settings/base.py, ami/main/models.py, ami/main/tests.py
Adds the large thumbnail configuration, documents EXIF handling, and tests redirect behavior, stored dimensions, generated file location, raw pixel dimensions, and Orientation EXIF removal.
Session detail rendering and overlay scaling
ui/src/data-services/models/capture.ts, ui/src/pages/session-details/session-details.tsx, ui/src/pages/session-details/capture/capture.tsx
Selects the large thumbnail with a URL fallback, renders it in session details, and scales detection boxes only when capture dimensions are available.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SessionDetailsPage
  participant CaptureModel
  participant CaptureComponent
  SessionDetailsPage->>CaptureModel: Read thumbnailLarge
  CaptureModel-->>SessionDetailsPage: Return large thumbnail URL or capture URL
  SessionDetailsPage->>CaptureComponent: Pass image source and capture dimensions
  CaptureComponent->>CaptureComponent: Scale boxes from capture dimensions
Loading

Possibly related issues

  • RolnickLab/antenna issue 1373: Related to the large-thumbnail rendering and bounding-box alignment changes.

Possibly related PRs

Suggested reviewers: annavik

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main changes: portrait bounding-box fix and new large thumbnail size.
Description check ✅ Passed The PR description covers summary, change list, detailed rationale, screenshots, testing, and caveats; only optional template sections are missing.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-detail-thumbnail-orientation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
ami/main/tests.py (1)

297-297: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer unittest assertions for consistency.

Consider using self.assertIsNotNone() instead of a bare assert to maintain consistency with the rest of the assertions in this test case.

💡 Proposed change
-        assert self.deployment.data_source is not None
+        self.assertIsNotNone(self.deployment.data_source)
🤖 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/tests.py` at line 297, Replace the bare assertion on
self.deployment.data_source with the test case’s unittest-style
self.assertIsNotNone() assertion, preserving the same validation and message if
one is already used nearby.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@ami/main/tests.py`:
- Line 297: Replace the bare assertion on self.deployment.data_source with the
test case’s unittest-style self.assertIsNotNone() assertion, preserving the same
validation and message if one is already used nearby.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 81bd9779-c2fb-46d3-8ecd-44330829d772

📥 Commits

Reviewing files that changed from the base of the PR and between 0693e13 and 40bcea2.

📒 Files selected for processing (4)
  • ami/main/tests.py
  • config/settings/base.py
  • ui/src/data-services/models/capture.ts
  • ui/src/pages/session-details/session-details.tsx

Copilot AI left a comment

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.

Pull request overview

This PR fixes misaligned detection bounding boxes on portrait captures in the session detail view by switching the UI to render a generated EXIF-stripped thumbnail (new “large” size) instead of the original image, ensuring the displayed pixels stay in the same raw coordinate space as stored detection boxes.

Changes:

  • Session detail view now uses a “large” thumbnail URL as the capture image source (avoids EXIF orientation rendering differences in browsers).
  • Added a new large thumbnail size (2560px) to backend thumbnail settings.
  • Added backend tests to pin “large” thumbnail generation behavior and ensure EXIF orientation is stripped without rotating pixels.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
ui/src/pages/session-details/session-details.tsx Swaps the rendered capture source to the EXIF-stripped large thumbnail for correct box overlay alignment.
ui/src/data-services/models/capture.ts Adds a thumbnailLarge accessor to prefer the large thumbnail URL (with fallback to original URL).
config/settings/base.py Registers the new large (2560px) thumbnail size in THUMBNAILS["SIZES"].
ami/main/tests.py Adds coverage for label=large generation and for stripping EXIF orientation while preserving raw pixel dimensions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

mihow and others added 2 commits July 20, 2026 16:42
Box coordinates are stored in the original image's pixel space, and the
session detail view now renders a downscaled thumbnail, so the previous
fallback to the rendered image's natural size would draw boxes at the
wrong scale for captures with no stored width/height. Skip drawing
instead. Also document in the thumbnail generator that EXIF must not
propagate to the output, since box alignment depends on it.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@mihow
mihow force-pushed the fix/session-detail-thumbnail-orientation branch from 672de61 to 1a15510 Compare July 20, 2026 23:44

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
ui/src/pages/session-details/capture/capture.tsx (1)

89-115: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider moving the dimension check outside the reduce loop.

Since the absence of width or height applies to the entire capture, you can short-circuit before iterating over the detections array. This avoids evaluating the dimension check for every single detection.

♻️ Proposed refactor
-  const boxStyles = useMemo(
-    () =>
-      detections.reduce((result: { [key: string]: BoxStyle }, detection) => {
-        const [boxLeft, boxTop, boxRight, boxBottom] = detection.bbox
-        const boxWidth = boxRight - boxLeft
-        const boxHeight = boxBottom - boxTop
-
-        // Box coordinates are in the original image's pixel space, so they can
-        // only be scaled against the capture's stored dimensions. The rendered
-        // image may be a downscaled thumbnail, so its natural size is not a
-        // valid substitute — without stored dimensions, skip drawing boxes
-        // rather than drawing them at the wrong scale.
-        if (!width || !height) {
-          return result
-        }
-
-        result[detection.id] = {
-          width: `${(boxWidth / width) * 100}%`,
-          height: `${(boxHeight / height) * 100}%`,
-          top: `${(boxTop / height) * 100}%`,
-          left: `${(boxLeft / width) * 100}%`,
-        }
-
-        return result
-      }, {}),
-    [width, height, detections]
-  )
+  const boxStyles = useMemo(() => {
+    // Box coordinates are in the original image's pixel space, so they can
+    // only be scaled against the capture's stored dimensions. The rendered
+    // image may be a downscaled thumbnail, so its natural size is not a
+    // valid substitute — without stored dimensions, skip drawing boxes
+    // rather than drawing them at the wrong scale.
+    if (!width || !height) {
+      return {}
+    }
+
+    return detections.reduce((result: { [key: string]: BoxStyle }, detection) => {
+      const [boxLeft, boxTop, boxRight, boxBottom] = detection.bbox
+      const boxWidth = boxRight - boxLeft
+      const boxHeight = boxBottom - boxTop
+
+      result[detection.id] = {
+        width: `${(boxWidth / width) * 100}%`,
+        height: `${(boxHeight / height) * 100}%`,
+        top: `${(boxTop / height) * 100}%`,
+        left: `${(boxLeft / width) * 100}%`,
+      }
+
+      return result
+    }, {})
+  }, [width, height, detections])
🤖 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 `@ui/src/pages/session-details/capture/capture.tsx` around lines 89 - 115, Move
the width/height guard outside the detections.reduce call in the boxStyles
useMemo callback, returning an empty style map immediately when either dimension
is unavailable; otherwise run the existing reduction and preserve the current
box scaling behavior.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@ui/src/pages/session-details/capture/capture.tsx`:
- Around line 89-115: Move the width/height guard outside the detections.reduce
call in the boxStyles useMemo callback, returning an empty style map immediately
when either dimension is unavailable; otherwise run the existing reduction and
preserve the current box scaling behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 58e3e922-e89f-4904-8819-4fe6a7740c2e

📥 Commits

Reviewing files that changed from the base of the PR and between 40bcea2 and 3405963.

📒 Files selected for processing (2)
  • ami/main/models.py
  • ui/src/pages/session-details/capture/capture.tsx

@mihow mihow changed the title Fix misplaced bounding boxes on portrait captures in the session detail view Fix misplaced bounding boxes on portrait captures and add large image thumbnail size Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants