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
+62 -74
View File
@@ -1,72 +1,64 @@
#!/usr/bin/env python3
"""test_moon_composite.py — smoke-test the east-sky-backdrop composite.
"""test_moon_composite.py — smoke-test atmospheric overlay at three opacity levels.
Uses the sample east frame (21-07-00.jpg) already in the repo and a
procedurally generated moon disc (no camera timestamp, no copyright) to
demonstrate the east-sky backdrop without needing a live NASA API call.
Generates three output images from the same east camera frame (21-07-00.jpg)
to show how the atmospheric overlay looks across the quality spectrum:
test_composite_clear.jpg — opacity 0.00 (quality ≥ 0.85, clear sky)
test_composite_hazy.jpg — opacity 0.20 (quality ~ 0.70, light haze)
test_composite_cloudy.jpg — opacity 0.65 (overcast fallback)
The moon disk in each image is procedurally generated (clean grey sphere,
no camera timestamp). In production it is replaced by the NASA SVS
Dial-a-Moon render for the exact UTC hour east captured the moon.
Run from the sky-cam directory:
python3 test_moon_composite.py
Output: test_composite_out.jpg
The east frame (2026-04-29 21:06:45) shows the moon through visible thin
cloud cover; that sky — blurred to atmospheric haze — becomes the backdrop
behind the procedural moon disc. In production the disc is replaced by the
NASA SVS Dial-a-Moon render for the exact UTC hour east captured the moon.
"""
import os
import pathlib
import sys
from datetime import datetime, timezone
import tempfile
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
EAST_FRAME = HERE / '21-07-00.jpg'
OUT_PATH = HERE / 'test_composite_out.jpg'
CASES = [
('test_composite_clear.jpg', 0.00, 'clear sky (quality >= 0.85)'),
('test_composite_hazy.jpg', 0.20, 'light haze (quality ~ 0.70)'),
('test_composite_cloudy.jpg', 0.65, 'heavy overcast fallback'),
]
def _make_procedural_moon(size: int = 2048) -> 'Image':
"""Generate a clean grey disc that stands in for a NASA Dial-a-Moon render.
Draws a base disc, a few darker ellipses for lunar maria, and a subtle
limb-darkening gradient. No timestamp, no copyright, no camera artefacts.
"""
"""Clean grey disc with simplified lunar maria and limb darkening."""
from PIL import Image, ImageDraw, ImageFilter
import numpy as np
img = Image.new('RGB', (size, size), (0, 0, 0))
draw = ImageDraw.Draw(img)
cx = cy = size // 2
r = int(size * 0.47)
# Base disc — warm grey, slightly off-white like a real moon
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(218, 214, 200))
# A few darker patches suggesting the major maria
draw.ellipse([cx - r//3, cy - r//3, cx + r//6, cy + r//5], fill=(170, 167, 154))
draw.ellipse([cx + r//8, cy - r//5, cx + r//3, cy + r//8], fill=(182, 179, 166))
draw.ellipse([cx - r//4, cy + r//6, cx + r//8, cy + r//3], fill=(175, 172, 159))
draw.ellipse([cx - r//2, cy - r//10, cx - r//5, cy + r//4], fill=(185, 182, 169))
# Smooth the hard edges so it blends naturally
img = img.filter(ImageFilter.GaussianBlur(radius=size // 80))
# Subtle limb darkening: blend a radial dark vignette at the disc edge
vignette = Image.new('L', (size, size), 0)
vd = ImageDraw.Draw(vignette)
vd.ellipse([cx - r, cy - r, cx + r, cy + r], fill=255)
vignette = vignette.filter(ImageFilter.GaussianBlur(radius=size // 30))
import numpy as np
arr = np.asarray(img).astype(float)
vig = np.asarray(vignette).astype(float) / 255.0
# Darken towards the limb (where vig is small → near edge)
darken = 0.75 + 0.25 * vig # 0.75 at edge, 1.0 at centre
arr = np.clip(arr * darken[..., None], 0, 255).astype('uint8')
img = Image.fromarray(arr)
return img
arr = np.asarray(img).astype(float)
vig = np.asarray(vignette).astype(float) / 255.0
arr = np.clip(arr * (0.75 + 0.25 * vig)[..., None], 0, 255).astype('uint8')
return Image.fromarray(arr)
def main():
@@ -74,53 +66,49 @@ def main():
print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr)
sys.exit(1)
# Save the procedural disc to a temp file so render_phase_closeup can open it
import tempfile, os
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tf:
tmp_moon = tf.name
print('generating procedural moon disc …')
moon_img = _make_procedural_moon(2048)
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp_moon = tmp.name
tmp.close()
moon_img.save(tmp_moon)
from moon_composite import render_phase_closeup
try:
print('=== moon composite smoke-test ===')
print(f'east frame : {EAST_FRAME.name}')
print(f'moon disc : procedural (NASA Dial-a-Moon stand-in)')
print(f'output : {OUT_PATH.name}')
print()
print('generating procedural moon disc …')
moon_img = _make_procedural_moon(2048)
moon_img.save(tmp_moon)
from moon_composite import render_phase_closeup
caption = (
'Full Moon — April 2026 — '
'sky-cam east witnessed at 2026-04-29 21:07:00 UTC '
'(+7 min from exact full) — render: NASA SVS Dial-a-Moon'
)
print('rendering composite with east sky backdrop …')
render_phase_closeup(
nasa_render_path=tmp_moon,
out_path=str(OUT_PATH),
output_size=(1920, 1080),
moon_height_pct=0.88,
caption=caption,
east_frame_path=str(EAST_FRAME),
east_sky_enabled=True,
east_sky_blur=0, # auto: output_width / 6 = 320 px
)
for filename, opacity, label in CASES:
out = HERE / filename
caption = (
f'Full Moon — April 2026 — '
f'sky-cam east 2026-04-29 21:07 UTC — '
f'NASA SVS Dial-a-Moon [{label}]'
)
print(f'rendering {filename} (opacity={opacity:.2f}) {label}')
render_phase_closeup(
nasa_render_path=tmp_moon,
out_path=str(out),
output_size=(1920, 1080),
moon_height_pct=0.88,
caption=caption,
east_frame_path=str(EAST_FRAME),
atmosphere_opacity=opacity,
atmosphere_blur=0,
)
print(f' -> {out}')
finally:
os.unlink(tmp_moon)
print(f'\ndone → {OUT_PATH}')
print()
print('What you should see:')
print(' • Background: easts April 29 night sky, blurred to soft atmospheric haze')
print(' (thin cloud cover visible as a grey-blue gradient, treeline at bottom)')
print(' • Moon disc filling ~88% of the 1920×1080 frame, pasted sharp on top')
print(' • Caption at bottom-left with phase / date / witness text')
print('done. Three outputs:')
for filename, opacity, label in CASES:
print(f' {filename:35s} opacity={opacity:.2f} {label}')
print()
print('In production the procedural disc is replaced by the NASA SVS Dial-a-Moon')
print('render for that exact UTC hour — same layout, real crater detail.')
print('What you should see in each:')
print(' clear — NASA moon disk sharp and unobscured on black background')
print(' hazy — same moon but with a soft grey-blue veil over the disk')
print(' (from the thin cloud visible in 21-07-00.jpg)')
print(' cloudy — moon mostly hidden; visible as a bright glow through')
print(' the cloud texture from east\'s April 29 frame')
if __name__ == '__main__':