Composite a basemap under server-side radar images, and stop loop frames stacking (#182) #183

Merged
claude-bot merged 1 commit from fix/radar-basemap-and-loop-disposal into main 2026-08-19 20:45:13 +00:00
Contributor

Fixes #182. Two defects reported against v2.0.0-rc1 while reviewing the Explorer on dev, sharing one root cause, plus a third found while tracing (filed on the issue, not fixed here).

The bugs

No basemap. radar._fetch_noaa_static requests transparent=true, so every stored radar PNG is reflectivity on a transparent background. The dashboard and public pages only look right because Leaflet supplies OSM tiles client-side; the Explorer renders the stored image directly, so echoes float on blank page. Affects every server-side radar image — the #85 alert-time snapshot, #130 periodic frames, the #140 loop, and all notifier attachments.

Loop frames stacking. assemble_gif saved GIFs without a disposal method, so Pillow defaulted to disposal 0 ("do not dispose") and each frame's transparent pixels left the prior frame's echoes on screen. Reproduced through the real code path — three frames with blobs at x-offsets 0/20/40 rendered as [0], [0,20], [0,20,40]. The SPC outlook GIF shares this assembler and was unaffected only because IEM's maps are opaque, which is why it survived review.

The fix

Compositing each frame onto an opaque basemap resolves both — opaque frames cannot accumulate. Esri's ArcGIS export endpoint takes the identical bbox/bboxSR/imageSR/size params as NOAA's radar export, so the basemap is pixel-aligned by construction: no tile stitching, no reprojection, one cached fetch per location.

Key decisions, all recorded on the issue:

  • Assembly-time, not capture-time. The bbox derives entirely from the location's lat/lon and the BBOX_PADDING constant, so a basemap fetched today aligns with a frame captured months ago. This retroactively fixes all 13 months of already-captured history — capture-time compositing could not, and history cannot be recaptured.
  • Layers cached separately, not pre-flattened. The radar composites between the base and the boundaries/places overlay. Flattening them buries the labels under the echoes, precisely where they matter most — measured at ~1.3% of pixels on a real St. Louis frame, and those pixels are the state line, "Cape Girardeau" and "Missouri", all swallowed. test_composite_on_basemap_draws_reference_labels_over_the_radar pins the order.
  • Scoped to the Explorer and notifiers. The dashboard and public radar API endpoints stay bare — they already render under a Leaflet tile layer, so a baked-in basemap would double up. The radar cache key carries the variant so composited and bare images can never collide.
  • disposal=2 regardless, as belt-and-braces against a future transparent-frame caller.
  • Fail-soft everywhere. A fetch error, malformed PNG, Pillow error, or the disable setting all return the original radar bytes rather than raising. This sits on the alert-notification path, so a basemap problem must never cost a notifier its radar image.

The public alert page was checked rather than assumed: it links the same /media/alert-radar/ still and renders it as a plain <img>, not a Leaflet overlay, so compositing there is correct with no double-up.

Verification

The cumulative-overlay regression test was confirmed to genuinely fail without disposal=2 and pass with it, rather than trusted as a smoke test.

  • ruff check app/ tests/ — clean
  • Full suite on the dev server: 994 passed, 13 deselected (Postgres tier), 0 failures

Not in scope

get_radar_image(animated=True) — which every notifier passes — is a no-op: _fetch_radar ignores the flag and always returns a static PNG, while the result is cached as content_type="image/gif". So notifier attachments are PNG bytes labelled GIF and the "animated radar" channel option has never animated. Documented on #182; being handled separately.

Fixes #182. Two defects reported against `v2.0.0-rc1` while reviewing the Explorer on dev, sharing one root cause, plus a third found while tracing (filed on the issue, **not** fixed here). ## The bugs **No basemap.** `radar._fetch_noaa_static` requests `transparent=true`, so every stored radar PNG is reflectivity on a transparent background. The dashboard and public pages only look right because Leaflet supplies OSM tiles client-side; the Explorer renders the stored image directly, so echoes float on blank page. Affects every server-side radar image — the #85 alert-time snapshot, #130 periodic frames, the #140 loop, and all notifier attachments. **Loop frames stacking.** `assemble_gif` saved GIFs without a disposal method, so Pillow defaulted to disposal 0 ("do not dispose") and each frame's transparent pixels left the prior frame's echoes on screen. Reproduced through the real code path — three frames with blobs at x-offsets 0/20/40 rendered as `[0]`, `[0,20]`, `[0,20,40]`. The SPC outlook GIF shares this assembler and was unaffected only because IEM's maps are opaque, which is why it survived review. ## The fix Compositing each frame onto an **opaque** basemap resolves both — opaque frames cannot accumulate. Esri's ArcGIS export endpoint takes the identical `bbox`/`bboxSR`/`imageSR`/`size` params as NOAA's radar export, so the basemap is pixel-aligned by construction: no tile stitching, no reprojection, one cached fetch per location. Key decisions, all recorded on the issue: - **Assembly-time, not capture-time.** The bbox derives entirely from the location's lat/lon and the `BBOX_PADDING` constant, so a basemap fetched today aligns with a frame captured months ago. This retroactively fixes all 13 months of already-captured history — capture-time compositing could not, and history cannot be recaptured. - **Layers cached separately, not pre-flattened.** The radar composites *between* the base and the boundaries/places overlay. Flattening them buries the labels under the echoes, precisely where they matter most — measured at ~1.3% of pixels on a real St. Louis frame, and those pixels are the state line, "Cape Girardeau" and "Missouri", all swallowed. `test_composite_on_basemap_draws_reference_labels_over_the_radar` pins the order. - **Scoped to the Explorer and notifiers.** The dashboard and public radar API endpoints stay bare — they already render under a Leaflet tile layer, so a baked-in basemap would double up. The radar cache key carries the variant so composited and bare images can never collide. - **`disposal=2` regardless**, as belt-and-braces against a future transparent-frame caller. - **Fail-soft everywhere.** A fetch error, malformed PNG, Pillow error, or the disable setting all return the original radar bytes rather than raising. This sits on the alert-notification path, so a basemap problem must never cost a notifier its radar image. The public alert page was checked rather than assumed: it links the same `/media/alert-radar/` still and renders it as a plain `<img>`, not a Leaflet overlay, so compositing there is correct with no double-up. ## Verification The cumulative-overlay regression test was confirmed to genuinely fail without `disposal=2` and pass with it, rather than trusted as a smoke test. - `ruff check app/ tests/` — clean - Full suite on the dev server: **994 passed, 13 deselected** (Postgres tier), 0 failures ## Not in scope `get_radar_image(animated=True)` — which every notifier passes — is a no-op: `_fetch_radar` ignores the flag and always returns a static PNG, while the result is cached as `content_type="image/gif"`. So notifier attachments are PNG bytes labelled GIF and the "animated radar" channel option has never animated. Documented on #182; being handled separately.
Composite a basemap under server-side radar images, and stop loop frames stacking (#182)
All checks were successful
CI / test (pull_request) Successful in 4m11s
50921a43ee
Every radar PNG this app stores was fetched with transparent=true, so it
carried reflectivity and nothing else. The dashboard and public pages only
looked right because Leaflet supplies OSM tiles underneath client-side; the
Explorer renders the stored image directly, so echoes floated on blank page
with no coastline or state line to orient against.

The same transparency made assemble_gif produce a cumulative smear. Pillow
defaults to GIF disposal 0 ("do not dispose"), so each loop frame's
transparent pixels left the previous frame's echoes on screen and frame N
showed frames 1..N stacked together.

Compositing each frame onto an opaque basemap fixes both: opaque frames
cannot accumulate. Esri's ArcGIS export endpoint takes the identical bbox
params as NOAA's radar export, so the basemap is pixel-aligned by
construction — no stitching, no reprojection, one cached fetch per location.

The base and boundaries/places layers are cached separately rather than
pre-flattened, because the radar composites BETWEEN them. Flattening buries
the place labels under the echoes, and it does so exactly where they matter
most: the labels a reader needs are the ones under the storm.

Compositing is applied at assembly time rather than capture time, so all
retained history benefits, not just frames captured from here on. Scoped to
the Explorer and the notifier attachments; the dashboard and public radar
API endpoints stay bare so their baked-in basemap cannot double up with the
Leaflet tile layer. The radar cache key carries the variant so the two can
never collide.

disposal=2 goes in regardless as belt-and-braces, so a future caller passing
transparent frames cannot silently reintroduce the smear.

Basemap failures are fail-soft everywhere: a fetch error, a malformed PNG, a
Pillow error, or the disable setting all return the original radar bytes
rather than raising. This sits on the alert-notification path, so a missing
basemap must never cost a notifier its radar image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch fix/radar-basemap-and-loop-disposal 2026-08-19 20:45:14 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/WeatherBot!183
No description provided.