# Annotated-Photo Fallback: Image Sizing and Annotation Accuracy
Review date: 2026-06-14. Scope: one user report — when the **annotated-photo
fallback** triggers (instead of the 3D cutaway), the uploaded image is "too
large to display," and the annotation placement should be confirmed accurate.
This document is findings + next steps for Codex to implement. It does **not**
authorize any deployment or Hugging Face changes on its own; follow the normal
GitHub → HF sync and verification flow in `AGENTS.md`. All file/line references
are to `index.html` unless noted.
---
## TL;DR
1. **The image-sizing bug is real and confirmed in a browser.** The
annotated-photo image renders at its **intrinsic aspect height at full panel
width**, ignoring the viewport height, so tall photos overflow the viewport
by ~2x. `object-fit: contain` never engages because the box it should fit
inside is itself oversized.
2. **Root cause:** nested `display: grid; place-items: center` containers
(`.annotated-stage` → `.annotation-frame`) with auto-sized rows. A child's
`height: 100%` resolves against an *auto* (content-sized) grid row, not the
container's pixel height, so it collapses back to the image's natural size.
3. **The annotation accuracy is mostly broken *as a side effect* of the sizing
bug** — labels are positioned against the oversized, overflowing image box,
so any callout in the lower half of a tall photo lands below the visible
viewport and is clipped. Fixing the sizing fixes most of the misplacement.
A few annotation-accuracy improvements remain that are independent of sizing
(no marker dot at the point, edge-clamp detaches labels, unused `box`).
---
## How this was verified (not just code inspection)
I reproduced the exact `buildAnnotatedPhoto` DOM (`index.html:996-1076`) and the
exact CSS rules (`.fallback-stage`/`.annotated-stage`/`.annotation-frame`/
`.annotation-image`, `index.html:466-511`) in a standalone page, loaded a
**3000 × 4000 portrait** test image into a **900 × 600** viewport pane, and
measured the rendered geometry in a real browser (Chromium via Playwright):
| Scenario | Image rendered (W×H) | Fits 600px-tall pane? |
| --- | --- | --- |
| **Current code (baseline)** | **864 × 1151** | ❌ overflows (image bottom at 1209px) |
| Fix: pin image absolutely to padded box | 864 × 498 | ✅ contained (top 58 → bottom 556) |
| Fix: definite `minmax(0,1fr)` tracks at **both** grid levels | 864 × 498 | ✅ contained |
`864 = 900 − 18px×2` (panel width minus horizontal padding); `1151 ≈ 864 ×
(4000/3000)` — i.e. the image is laid out at panel width and natural aspect,
with **zero** vertical constraint. That is the "too large to display" symptom.
(The repro page was temporary and has been removed; re-create it if you want to
re-measure.)
---
## Root cause (precise)
The fallback markup is three nested boxes:
```
#viewport (absolute, inset:0) <- definite size, good
└─ .fallback-stage.annotated-stage index.html:1011-1012
position:absolute; inset:0 (definite height ✔)
display:grid; place-items:center; padding:58/18/44 index.html:490-495
└─ .annotation-frame index.html:1013-1014
width:100%; height:100% index.html:497-503
display:grid; place-items:center <-- second nested grid
├─ img.annotation-image index.html:1015-1018
│ width:100%; height:100%; object-fit:contain index.html:505-511
└─ .label-layer (absolute, inset:0)
```
Two compounding CSS facts:
- **`place-items: center` sets `align-items: center`, not `stretch`.** Combined
with the default `grid-auto-rows: auto`, the single grid row is **content
sized**, not stretched to the container's definite height.
- **A percentage `height` resolves against the grid *area* (the track), and an
`auto` track is *indefinite*.** So `height: 100%` on a child of a
`place-items: center` grid computes as `auto`.
This happens **twice**:
1. `.annotation-frame { height: 100% }` inside `.annotated-stage` →
the frame's track is auto → frame height becomes auto.
2. `img.annotation-image { height: 100% }` inside `.annotation-frame` →
the image's track is auto → image height becomes auto → the `
` uses its
intrinsic aspect ratio at the available width (864px) → 1151px tall.
Measured proof that the bug is the **inner** grid too: even after forcing
`.annotation-frame` to a correct definite 498px box, the image still rendered
1151px, because its `height:100%` resolves against `.annotation-frame`'s *inner
auto row*, not the frame's element box. Both grid levels must be fixed (or the
nested-grid sizing dropped entirely).
> Note: `.source-image` in the side panel (`index.html:384-390`) does **not**
> have this bug because it has a fixed `height: 180px` — a definite height, so
> `object-fit: contain` works there. The viewport fallback wants to fill
> available space rather than a fixed height, which is why it hit the trap.
---
## Fix options (both verified to contain the image at 864 × 498)
Pick one. **Option A is recommended** — fewest moving parts, no dependence on
fragile nested-grid percentage resolution.
### Option A (recommended): pin the image to the padded content box
Stop relying on `height: 100%` chaining through two grids. Make the image fill a
single absolutely-positioned box and let `object-fit: contain` do the framing.
```css
/* .annotated-stage can keep its background; drop the grid centering */
.annotation-frame {
position: absolute;
inset: 58px 18px 44px; /* same as the old .annotated-stage padding */
/* remove width/height:100% + display:grid + place-items:center */
}
.annotation-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain; /* now fits a definite box -> letterboxed, contained */
}
```
`.label-layer` already is `position:absolute; inset:0` (`index.html:466-471`),
so it keeps lining up with the frame. The `58/18/44` padding currently lives on
`.annotated-stage` (`index.html:493`); move that inset onto `.annotation-frame`
(or keep the padding on the stage and set `.annotation-frame { position:absolute;
inset:0 }`). Either way the frame becomes a **definite** box and the image is
contained.
### Option B: keep the grids but make every track definite
If you prefer to keep `display:grid` centering, the breakage is the auto rows —
give **both** grids definite tracks and let the items stretch:
```css
.annotated-stage {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
place-items: stretch; /* not center: stretch the frame to fill */
padding: 58px 18px 44px;
}
.annotation-frame {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
min-width: 0;
min-height: 0;
width: auto; /* track sizing handles it; 100% no longer needed */
height: auto;
}
.annotation-image { width: 100%; height: 100%; object-fit: contain; }
```
This also measured 864 × 498, contained. It is more brittle (relies on readers
understanding the `minmax(0,1fr)` + `min-height:0` idiom), hence Option A is
preferred.
> Whichever option: this is **CSS-only**. No change to `buildAnnotatedPhoto`'s
> JS, the schema, the backend, or `app.py` is required for the sizing fix.
---
## Annotation accuracy
### A) Most misplacement is a *symptom* of the sizing bug — fixing sizing fixes it
`updatePhotoLabels` (`index.html:1031-1062`) positions each callout from:
- `containedImageRect(image)` (`index.html:1096-1118`) — computes the
letterboxed sub-rect of the image from `image.naturalWidth/Height` and the
`
` bounding box, then
- `label.left = imageRect.left − layerRect.left + point[0] * imageRect.width`
(and the `point[1]` equivalent for top), clamped to the layer.
This math is **correct** *only when the `
` box matches the visible image
area*. With the current sizing bug the `
` box is 864 × 1151 and overflows
the viewport, so:
- `imageRect.width/height` come from the oversized box, so a `point[1] = 0.8`
callout is placed at ~0.8 × 1151 ≈ 920px down — **far below** the 600px
viewport, which is `overflow: hidden` (`.viewport-pane`, `index.html:105`), so
the label is clipped and the user never sees it.
- The clamp uses `layerRect.height` (also 1151), so the "keep on screen" clamp
(`index.html:1044-1047`) clamps to the wrong, oversized bounds.
**Once the image is contained (Option A/B), `containedImageRect` returns the
real letterboxed rect and the existing placement math is accurate.** So the
sizing fix is also the primary annotation-accuracy fix. Re-verify after fixing.
### B) Independent annotation-accuracy improvements (do after the sizing fix)
**User decision (2026-06-14): in scope — implement sizing fix *and* this
annotation-accuracy polish.** Do items #1–#3 below; #4 (1px border) and #5
(coercion clamp) are optional robustness items, implement if cheap.
1. **No marker at the actual point.** The callout box is centered *on* the point
via `.scene-label { transform: translate(-50%, -50%) }` (`index.html:476`),
so the text occludes the component instead of pointing at it, and an
edge-clamped label has nothing tying it back to the real location. Recommend
drawing a small dot/crosshair at the exact `point` (a 1–2px element, not
transformed) and offsetting the text label, optionally with a short leader
line. Numbered prefixes already exist (`(index + 1) + ". "`,
`index.html:1051`) but there's no matching number on the photo, so the number
is currently meaningless spatially.
2. **Edge clamp silently detaches the label from its point.** `Math.min(width −
72, Math.max(72, …))` (`index.html:1040-1047`) keeps the label on screen but,
with no leader/marker (see #1), a clamped label no longer indicates which
component it describes. The marker dot from #1 is the fix — keep the *dot* at
the true (clamped-to-image) point and only clamp the *text*.
3. **`annotation.box` is parsed and validated but never drawn.** The schema and
coercion carry an optional normalized `box: [x, y, w, h]`
(`snap2sim/schema.py:197-202`, `snap2sim/model_io.py:325-327`), but
`buildAnnotatedPhoto` ignores it. When present, drawing the bounding box over
the contained image would be a much stronger, more accurate annotation than a
single point. Optional enhancement.
4. **1px border offset.** `.annotation-image` has a `1px` border
(`index.html:509`) and `box-sizing: border-box` is global (`index.html:38-40`),
so `object-fit: contain` fits the *content* box (inside the border) while
`containedImageRect` measures the *border* box — a 1px placement error.
Negligible; mention only. If you want it exact, account for the border or use
`outline` instead of `border`.
5. **Coercion rejects (drops) out-of-range points instead of clamping.**
`_unit_number_list` returns `None` if any coordinate is outside `[0,1]`
(`snap2sim/model_io.py:311-315`), so a point like `[1.02, 0.5]` discards the
*entire* annotation and the part may then fall through to "unavailable". The
browser already `clamp01`s at render (`index.html:1120-1122`), so consider
clamping (not rejecting) server-side for resilience. Minor robustness item;
the prompt already asks for `[0,1]` (`snap2sim/prompts.py:31-34`).
The point-coordinate contract itself is consistent end-to-end: normalized
`[0,1]`, **origin top-left**, asserted in the prompt (`snap2sim/prompts.py:31-34`)
and consumed as `point[0]→x*width`, `point[1]→y*height` from the image's
top-left (`index.html:1040-1047`). No change needed there.
---
## Security / invariants to preserve
- This is a client-side CSS/JS change to an existing render path; keep the
"no model-authored HTML" rule from `AGENTS.md`/`SECURITY.md`. `label`/`note`
must stay `textContent`, never `innerHTML` (already correct,
`index.html:1051-1058`). A new marker-dot/box element must be built with
`document.createElement` + style, not injected markup.
- Keep using the **local** `currentPreviewUrl` object URL for the photo
(`index.html:1018`, `index.html:1134-1139`); do not round-trip the image back
from the server for display.
- No change to rate limiting, upload caps, or decompression-bomb guards in
`app.py` is required.
---
## Suggested implementation order for Codex
1. **Sizing fix first (Option A).** CSS-only in `index.html`. This is the bug
the user reported and it also corrects most annotation placement.
2. **Re-verify in a real browser** with both a **tall (portrait)** and a **wide
(landscape)** photo that triggers the annotate fallback: the whole image is
visible and contained, no viewport overflow/scroll, and callouts land on the
correct components within the image (especially lower-half points that were
previously clipped). Check the ≤860px stacked layout (`index.html:555-581`).
3. **Then** the annotation-accuracy items (in scope per the user): marker dot at
the true point + offset/leader label (#1, #2), and render `annotation.box`
when present (#3). Items #4/#5 optional.
4. Run the standard local checks per `AGENTS.md` (schema/parser + FastAPI
`TestClient` for `/`, `/analyze_image`, `/generate_scene`), then the normal
PR -> GitHub Actions HF sync -> Space verification.
## Resolved scope
- **Sizing fix + annotation-accuracy polish are both in scope**
(user decision, 2026-06-14): the CSS sizing fix, marker dots at the true
point, offset/leader labels, and rendering `annotation.box` when the model
provides it.
- Final submission note, 2026-06-15: this work shipped before the public
`build-small-hackathon/Snap2Sim` submission.