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:
+61
-20
@@ -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}')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user