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
+61 -20
View File
@@ -85,8 +85,29 @@ OUT_H = int(CONF.get('MOON_OUTPUT_H', CONF.get('MOON_FULL_OUTPUT_H', 1080)))
MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', 0.92))
REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false'
EAST_SKY_ENABLED = CONF.get('MOON_EAST_SKY_ENABLED', 'true').lower() != 'false'
EAST_SKY_BLUR = int(CONF.get('MOON_EAST_SKY_BLUR', 0))
ATMOSPHERE_BLUR = int(CONF.get('MOON_ATMOSPHERE_BLUR', 0))
CLOUDY_POST_ENABLED = CONF.get('MOON_CLOUDY_POST_ENABLED', 'true').lower() != 'false'
def _atmosphere_opacity_from_quality(quality: float | None) -> float:
"""Map moon detection quality to atmospheric overlay opacity.
quality >= 0.85 → 0.00 clear sky, no overlay
quality 0.70 → 0.20 light haze
quality 0.55 → 0.40 noticeable cloud, moon still detected
quality < 0.55 → up to 0.65 (overcast fallback frames)
quality is None → 0.68 no detection at all, heavy overcast
The overlay is applied OVER the NASA moon disk, so higher opacity means
more of the disk is obscured — physically correct for clouds above us.
"""
if quality is None:
return 0.68
if quality >= 0.85:
return 0.0
# Linear: 0.0 at quality=0.85, 0.40 at quality=0.55
raw = (0.85 - quality) / 0.30 * 0.40
return min(0.65, raw)
PHASE_SPEC = {
@@ -277,6 +298,7 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
return 0
qualifying = []
above_horizon = [] # frames where moon is up but quality/illum check failed
for path, utc_dt in candidates:
try:
alt, _ = moon_phase.altaz(utc_dt)
@@ -287,6 +309,7 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
continue
det = detect_moon(path)
if det is None or det.quality < MIN_QUALITY:
above_horizon.append((path, utc_dt, det))
continue
illum = moon_phase.illumination(utc_dt)
if illum < spec['illum_min'] or illum > spec['illum_max']:
@@ -296,21 +319,38 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
continue
qualifying.append((path, utc_dt, det, alt, illum))
print(f'qualifying frames: {len(qualifying)}')
print(f'qualifying frames: {len(qualifying)} above-horizon fallback pool: {len(above_horizon)}')
if not qualifying:
# No frame met quality + illumination thresholds — likely overcast.
# If MOON_CLOUDY_POST_ENABLED, use the above-horizon frame closest to
# the exact phase moment as the atmosphere source and post with a
# heavy cloud overlay so the month is still represented.
if above_horizon and CLOUDY_POST_ENABLED:
best_cloudy = min(above_horizon, key=lambda t: abs(t[1] - target_utc))
path, _when, det = best_cloudy
quality = det.quality if det is not None else None
opacity = _atmosphere_opacity_from_quality(quality)
print(f'overcast fallback: {path} quality={quality} opacity={opacity:.2f}')
witness = f'cloud cover — {target_utc.strftime("%Y-%m-%d")} — NASA SVS Dial-a-Moon'
if dry_run:
return 0
return _render_and_post(
phase, spec, target_utc, target_utc,
target_utc.astimezone(tz),
cam, dry_run, no_upload, out_path,
witness_text=witness,
east_frame_path=path,
atmosphere_opacity=opacity,
)
msg = (
f'No clear-shot {phase} frame in window {dates[0]}..{dates[-1]} '
f'(need quality≥{MIN_QUALITY}, altitude≥{MIN_ALTITUDE}°, '
f'illumination {spec["illum_min"]:.2f}-{spec["illum_max"]:.2f}). '
f'No {phase} frame in window {dates[0]}..{dates[-1]} '
f'(quality≥{MIN_QUALITY}, altitude≥{MIN_ALTITUDE}°, '
f'illumination {spec["illum_min"]:.2f}-{spec["illum_max"]:.2f}) '
f'and no above-horizon frames for cloudy fallback.'
)
if phase == 'first-quarter':
msg += ('First quarter from east is best-effort because the moon is '
'only up during daylight hours — daytime detection often '
'fails. Lower MOON_MIN_QUALITY or accept that some months '
'will skip.')
else:
msg += 'Likely cloudy across the whole window.'
_notify(f'{spec["emoji"]} {spec["label"]} — no clear shot {target_utc.strftime("%B %Y")}', msg)
_notify(f'{spec["emoji"]} {spec["label"]} — skipped {target_utc.strftime("%B %Y")}', msg)
print(msg)
return 0
@@ -318,10 +358,11 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
path, when_utc, det, alt, illum = best
local_dt = when_utc.astimezone(tz)
delta_min = (when_utc - target_utc).total_seconds() / 60.0
opacity = _atmosphere_opacity_from_quality(det.quality)
print(f'picked: {path}')
print(f' when_utc={when_utc.isoformat()} local={local_dt} '
f'altitude={alt:.1f}° illum={illum:.4f} quality={det.quality:.3f} '
f'Δtarget={delta_min:+.1f} min')
f'altitude={alt:.1f} illum={illum:.4f} quality={det.quality:.3f} '
f'opacity={opacity:.2f} delta={delta_min:+.1f} min')
if dry_run:
return 0
@@ -332,13 +373,13 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} '
f'({delta_min:+.0f} min from exact {phase})',
east_frame_path=path,
east_detection=det,
atmosphere_opacity=opacity,
)
def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
dry_run, no_upload, out_path, witness_text,
east_frame_path=None, east_detection=None):
east_frame_path=None, atmosphere_opacity=0.0):
if out_path is None:
out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
out_dir.mkdir(parents=True, exist_ok=True)
@@ -372,8 +413,8 @@ def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
caption=caption,
east_frame_path=east_frame_path,
east_sky_enabled=EAST_SKY_ENABLED,
east_sky_blur=EAST_SKY_BLUR,
atmosphere_opacity=atmosphere_opacity,
atmosphere_blur=ATMOSPHERE_BLUR,
)
print(f'wrote {out_path}')