Apply atmosphere overlay ON TOP of moon, add overcast fallback

The core mental model was wrong: clouds are between the observer and
the moon, so they occlude the disk — they don't sit behind it.

moon_composite.py:
  _make_east_sky_backdrop() → _make_atmosphere_layer()
  render_phase_closeup() pipeline is now:
    1. Black background
    2. NASA moon disk centred (correct phase / libration / shadows)
    3. East frame scaled + blurred → composited OVER the disk at
       atmosphere_opacity (0.0 = clear, 0.68 = heavy overcast)
  Parameters: east_sky_enabled/blur → atmosphere_opacity/blur

moon_phase_monthly.py:
  _atmosphere_opacity_from_quality() maps detection quality to opacity:
    quality >= 0.85 → 0.00 (clear)
    quality   0.70  → 0.20 (light haze)
    quality   0.55  → 0.40 (notable cloud, still detected)
    quality   None  → 0.68 (no detection, heavy overcast)

  Frame scan now tracks two lists:
    qualifying     — frames passing quality + illumination (existing)
    above_horizon  — frames where moon is up but detection failed

  When qualifying is empty and MOON_CLOUDY_POST_ENABLED=true, the
  above-horizon frame closest to the exact phase moment is used with
  opacity 0.45–0.68.  The month is always represented — full moon,
  first quarter, and third quarter each get a post even in cloudy
  months, showing the moon as a faint glow behind cloud.

sky-cam.conf:
  MOON_EAST_SKY_ENABLED/BLUR → MOON_ATMOSPHERE_BLUR (opacity is
  computed automatically from quality, not configured directly)
  New: MOON_CLOUDY_POST_ENABLED=true

test_moon_composite.py:
  Generates three outputs at opacity 0.00 / 0.20 / 0.65 so the
  full opacity range is visible in one test run.

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
Claude
2026-05-01 21:51:42 +00:00
parent 1425a6e5f4
commit c09b872f52
4 changed files with 180 additions and 143 deletions
+34 -34
View File
@@ -240,27 +240,26 @@ def composite_full_moon(
return out_path
def _make_east_sky_backdrop(
def _make_atmosphere_layer(
east_frame_path: str,
output_size: tuple[int, int],
blur_radius: int = 0,
) -> Image.Image:
"""Scale the full east frame to output_size and blur it heavily.
"""Scale the full east frame to output_size and blur to atmospheric haze.
The blur removes wide-angle camera detail (pixel noise, RTSP compression
artefacts, OSD text) while preserving the real atmospheric colours, any
cloud patterns, and the dark ground silhouette at the bottom of frame.
The result reads as "this is the sky east saw that night" rather than a
stretched wide-angle photo.
The blur removes wide-angle camera detail (RTSP artefacts, OSD text,
pixel noise) while preserving real sky colour and large-scale cloud
structure. The resulting layer is applied OVER the NASA moon disk so
it reads as "clouds between the observer and the moon" — which is
physically correct.
blur_radius=0 chooses automatically: output_width // 6, which gives a
soft impressionistic backdrop while still letting cloud shapes show
through as gentle colour gradients.
blur_radius=0 → auto: output_width // 10, which smooths pixel-level
detail but keeps cloud-scale gradients visible.
"""
src = Image.open(east_frame_path).convert('RGB')
backdrop = src.resize(output_size, LANCZOS)
r = blur_radius if blur_radius > 0 else output_size[0] // 6
return backdrop.filter(ImageFilter.GaussianBlur(radius=r))
layer = src.resize(output_size, LANCZOS)
r = blur_radius if blur_radius > 0 else output_size[0] // 10
return layer.filter(ImageFilter.GaussianBlur(radius=r))
def render_phase_closeup(
@@ -271,32 +270,28 @@ def render_phase_closeup(
caption: str | None = None,
background: tuple[int, int, int] = (0, 0, 0),
east_frame_path: str | None = None,
east_sky_enabled: bool = True,
east_sky_blur: int = 0,
atmosphere_opacity: float = 0.0,
atmosphere_blur: int = 0,
):
"""Full-screen close-up rendering using a NASA SVS Dial-a-Moon image.
The dial-a-moon render has the correct phase, libration and crater shadows
for the requested timestamp. We size it to fill the output frame and add
a caption.
Rendering pipeline:
1. Black background (outer space).
2. NASA moon disk — correct phase, libration, crater shadows — centred
and scaled to moon_height_pct of frame height.
3. Atmospheric layer (optional): east's camera frame, scaled to output
size and blurred, composited OVER the moon at atmosphere_opacity.
This is physically correct — clouds are between the observer and the
moon so they occlude the disk, not sit behind it.
When east_frame_path is provided the east camera's frame for that night is
scaled to output_size and blurred heavily (GaussianBlur r ≈ output_width/6)
to produce an atmospheric backdrop — east's real night sky colour, any
cloud or haze patterns, and the dark ground silhouette at the bottom of
frame all show through the blur as soft gradients. The NASA moon disk is
then pasted sharp on top of that backdrop.
This is what "relayed to where it was taken" looks like: the sky behind
the NASA moon is east's actual sky from that hour. Set
east_sky_enabled=False (or leave east_frame_path=None) to use a plain
black background instead.
atmosphere_opacity controls what the viewer sees through east's sky:
0.00 — perfectly clear: pure NASA render, no overlay
0.10 — slight haze: moon is sharp but slightly softened
0.35 — noticeable cloud cover: moon partially obscured
0.65 — heavy overcast: moon a faint glow through thick cloud
"""
if east_sky_enabled and east_frame_path:
bg = _make_east_sky_backdrop(east_frame_path, output_size, east_sky_blur)
else:
bg = Image.new('RGB', output_size, background)
# ── Background + NASA moon ────────────────────────────────────────────
bg = Image.new('RGB', output_size, background)
moon = Image.open(nasa_render_path).convert('RGB')
moon = _square_crop_to_disk(moon)
@@ -311,6 +306,11 @@ def render_phase_closeup(
py = (output_size[1] - target) // 2
bg.paste(moon_resized, (px, py), mask)
# ── Atmospheric layer from east frame — applied OVER the moon ─────────
if east_frame_path and atmosphere_opacity > 0.0:
atm = _make_atmosphere_layer(east_frame_path, output_size, atmosphere_blur)
bg = Image.blend(bg, atm, alpha=atmosphere_opacity)
if caption:
draw = ImageDraw.Draw(bg)
_draw_caption(draw, caption, (22, output_size[1] - 48), output_size[0])