From 9e2852fd737be7d91a27c9abf88a0e3685bf6083 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:03:57 +0000 Subject: [PATCH] Use fixed obs time for NASA fetch and atmosphere check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach: scan a 3-day window, find best moon detection, use that timestamp. Problems: fuzzy composite (multi-day scan could pick a frame far from actual full moon), no clear tie between the NASA render and a specific observable moment. New approach — MOON_OBS_TIME_LOCAL (default 22:30 local): On the night of the exact phase event, look at east frames in a ±MOON_OBS_WINDOW_MIN (default 30 min) window around the configured time. The frame closest to that time determines atmosphere opacity; the same time (rounded to hour) drives the NASA Dial-a-Moon fetch. This gives one definitive moment per phase per month. moon_phase_monthly.py: - PHASE_SPEC stripped to just post_delay + enabled_key (all window/ illumination filtering removed — obs time is the only selector) - run_phase() replaced with obs-time logic; opacity derived from detect_moon quality on that single frame - MOON_FULL_POST_DELAY_DAYS / MOON_QUARTER_POST_DELAY_DAYS default changed to 1 (post morning after the phase, frames already on disk) moon_composite.py: - atmosphere blur default: output_width//10 (192px) → output_width//20 (96px) so cloud shapes survive the blur sky-cam.conf: - MOON_OBS_TIME_LOCAL=22:30, MOON_OBS_WINDOW_MIN=30 - Post delay defaults updated to 1 test_moon_composite.py: - Tries real NASA SVS Dial-a-Moon API first; procedural disc only as fallback if API unreachable - Runs detect_moon on the east frame to compute real opacity - Single output (test_composite_out.jpg) https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR --- moon_composite.py | 7 +- moon_phase_monthly.py | 185 ++++++++++++++++++----------------------- sky-cam.conf | 29 +++++-- test_moon_composite.py | 157 ++++++++++++++++++++++------------ 4 files changed, 208 insertions(+), 170 deletions(-) diff --git a/moon_composite.py b/moon_composite.py index bae2fba..f893d25 100755 --- a/moon_composite.py +++ b/moon_composite.py @@ -253,12 +253,13 @@ def _make_atmosphere_layer( it reads as "clouds between the observer and the moon" — which is physically correct. - blur_radius=0 → auto: output_width // 10, which smooths pixel-level - detail but keeps cloud-scale gradients visible. + blur_radius=0 → auto: output_width // 20 (96 px at 1920 wide). + This smooths pixel-level RTSP noise and OSD text while keeping + recognisable cloud shapes and sky-colour gradients intact. """ src = Image.open(east_frame_path).convert('RGB') layer = src.resize(output_size, LANCZOS) - r = blur_radius if blur_radius > 0 else output_size[0] // 10 + r = blur_radius if blur_radius > 0 else output_size[0] // 20 return layer.filter(ImageFilter.GaussianBlur(radius=r)) diff --git a/moon_phase_monthly.py b/moon_phase_monthly.py index b908cc1..ea37a29 100755 --- a/moon_phase_monthly.py +++ b/moon_phase_monthly.py @@ -88,6 +88,11 @@ REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'f ATMOSPHERE_BLUR = int(CONF.get('MOON_ATMOSPHERE_BLUR', 0)) CLOUDY_POST_ENABLED = CONF.get('MOON_CLOUDY_POST_ENABLED', 'true').lower() != 'false' +_obs_parts = CONF.get('MOON_OBS_TIME_LOCAL', '22:30').split(':') +OBS_TIME_H = int(_obs_parts[0]) +OBS_TIME_M = int(_obs_parts[1]) if len(_obs_parts) > 1 else 0 +OBS_WINDOW_MIN = int(CONF.get('MOON_OBS_WINDOW_MIN', 30)) + def _atmosphere_opacity_from_quality(quality: float | None) -> float: """Map moon detection quality to atmospheric overlay opacity. @@ -115,39 +120,21 @@ PHASE_SPEC = { 'index': 2, 'label': 'Full Moon', 'emoji': '🌕', - # ±4% of target (100%): accept 96–100% illumination - 'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.96)), - 'illum_max': 1.01, - 'waxing': None, - 'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)), - 'window_after': int(CONF.get('MOON_FULL_WINDOW_AFTER_DAYS', 2)), - 'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 3)), + 'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 1)), 'enabled_key': 'MOON_FULL_ENABLED', }, 'first-quarter': { 'index': 1, 'label': 'First Quarter (Waxing Half)', 'emoji': '🌓', - # ±4% of target (50%): accept 46–54% illumination, waxing only - 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)), - 'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)), - 'waxing': True, - 'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)), - 'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)), - 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 2)), + 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)), 'enabled_key': 'MOON_FIRST_QUARTER_ENABLED', }, 'third-quarter': { 'index': 3, 'label': 'Third Quarter (Waning Half)', 'emoji': '🌗', - # ±4% of target (50%): accept 46–54% illumination, waning only - 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)), - 'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)), - 'waxing': False, - 'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)), - 'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)), - 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 2)), + 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)), 'enabled_key': 'MOON_THIRD_QUARTER_ENABLED', }, } @@ -280,99 +267,89 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, cam, dry_run, no_upload, out_path, witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)') - dates = [] - for i in range(-spec['window_before'], spec['window_after'] + 1): - d = (target_local + timedelta(days=i)).date() - dates.append(d.strftime('%Y-%m-%d')) - print(f'scanning dates: {dates}') + # ── Fixed observation time on the night of the phase event ────────────── + # Instead of scanning a multi-day window for the best detection, we look + # at a short window around a configured local time (default 22:30) on the + # night of the exact phase. That single timestamp drives both the NASA + # Dial-a-Moon fetch (rounded to the nearest hour) and the atmosphere check. + obs_naive = datetime( + target_local.year, target_local.month, target_local.day, + OBS_TIME_H, OBS_TIME_M, 0, + ) + if hasattr(tz, 'localize'): + obs_utc = tz.localize(obs_naive).astimezone(timezone.utc) + else: + obs_utc = obs_naive.replace(tzinfo=tz).astimezone(timezone.utc) + print(f'obs time: {obs_utc.isoformat()} ({_format_local(obs_utc)})') - candidates = _candidate_frames(cam, dates) - print(f'frame count in window: {len(candidates)}') - if not candidates: - msg = ( - f'No frames for {cam} in window {dates[0]}..{dates[-1]} ' - f'around {phase} {target_utc.strftime("%Y-%m-%d %H:%MZ")}.' + # Check moon altitude at the observation time + try: + alt_at_obs, _ = moon_phase.altaz(obs_utc) + except Exception as e: + print(f'ERROR: moon_phase.altaz: {e}', file=sys.stderr) + return 2 + print(f'moon altitude at obs time: {alt_at_obs:.1f}°') + + # Gather east frames in the ±OBS_WINDOW_MIN window + obs_start = obs_utc - timedelta(minutes=OBS_WINDOW_MIN) + obs_end = obs_utc + timedelta(minutes=OBS_WINDOW_MIN) + obs_dates: list[str] = [] + d = obs_start.astimezone(tz).date() + while d <= obs_end.astimezone(tz).date(): + obs_dates.append(d.strftime('%Y-%m-%d')) + d += timedelta(days=1) + all_frames = _candidate_frames(cam, obs_dates) + window_frames = [(p, dt) for p, dt in all_frames if obs_start <= dt <= obs_end] + print(f'east frames in ±{OBS_WINDOW_MIN} min window: {len(window_frames)}') + + # Determine atmosphere opacity from the frame closest to obs_utc + east_frame: str | None = None + opacity = 0.0 + + if alt_at_obs < MIN_ALTITUDE: + print('moon below horizon at obs time — posting clean NASA image') + _notify( + f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} ' + f'— moon below horizon at {OBS_TIME_H:02d}:{OBS_TIME_M:02d} local', + f'Adjust MOON_OBS_TIME_LOCAL in sky-cam.conf. Posting clean NASA render.', ) - _notify(f'{spec["emoji"]} {spec["label"]} — no frames available', msg) - print(msg) - return 0 + elif window_frames: + best_f = min(window_frames, key=lambda t: abs(t[1] - obs_utc)) + east_frame, best_dt = best_f + det = detect_moon(east_frame) + quality = det.quality if det is not None else None + opacity = _atmosphere_opacity_from_quality(quality) + offset_min = (best_dt - obs_utc).total_seconds() / 60.0 + print(f'atmosphere frame: {east_frame}') + print(f' quality={quality} opacity={opacity:.2f} ' + f'offset={offset_min:+.1f} min from obs time') + else: + print('no east frames in obs window — posting clean NASA image') - 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) - except Exception as e: - print(f'ERROR: moon_phase.altaz failed: {e}', file=sys.stderr) - return 2 - if alt < MIN_ALTITUDE: - 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']: - continue - if spec['waxing'] is not None: - if moon_phase.waxing(utc_dt) != spec['waxing']: - continue - qualifying.append((path, utc_dt, det, alt, illum)) + # Label atmospheric conditions for the caption + if opacity == 0.0: + atm_label = 'clear sky' + elif opacity < 0.15: + atm_label = 'slight haze' + elif opacity < 0.40: + atm_label = 'cloud cover' + else: + atm_label = 'heavy overcast' - 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 {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.' - ) - _notify(f'{spec["emoji"]} {spec["label"]} — skipped {target_utc.strftime("%B %Y")}', msg) - print(msg) - return 0 - - best = min(qualifying, key=lambda t: abs(t[1] - target_utc)) - 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'opacity={opacity:.2f} delta={delta_min:+.1f} min') + obs_local_str = _format_local(obs_utc) + witness_text = ( + f'sky-cam {cam} — {atm_label} at {obs_local_str} — NASA SVS Dial-a-Moon' + ) if dry_run: + print(f'dry-run: would post with opacity={opacity:.2f} ({atm_label})') return 0 return _render_and_post( - phase, spec, target_utc, when_utc, local_dt, + phase, spec, target_utc, obs_utc, obs_utc.astimezone(tz), cam, dry_run, no_upload, out_path, - 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, + witness_text=witness_text, + east_frame_path=east_frame, atmosphere_opacity=opacity, ) diff --git a/sky-cam.conf b/sky-cam.conf index 8f54113..bdb2a8c 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -491,15 +491,28 @@ MOON_TRACK_FPS=12 # output mp4 framerate MOON_TRACK_CRF=24 # output mp4 CRF MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever -# Full-moon monthly tuning ──────────────────────────────────────────────────── -# How many days after the exact full moon to post. 3 = waits for D-1..D+2 -# nights to be on disk, then runs the morning of D+3. -MOON_FULL_POST_DELAY_DAYS=3 +# Observation time ──────────────────────────────────────────────────────────── +# Local time used to select the east camera frame and the NASA Dial-a-Moon +# render for each phase post. The script looks at east frames within +# ±MOON_OBS_WINDOW_MIN of this time on the night of the exact phase event, +# picks the one closest to the target time, and uses it to: +# 1. Determine atmospheric conditions (clear / hazy / overcast) +# 2. Set the atmosphere overlay opacity on the NASA moon image +# 3. Round to the nearest hour for the NASA API call +# +# 22:30 works well for full moon and first quarter (both visible after dark). +# For third quarter (rises after midnight), consider 02:30 or leave at 22:30 +# and accept that the moon may be below the horizon — the script will warn +# and post a clean NASA render instead. +MOON_OBS_TIME_LOCAL=22:30 +MOON_OBS_WINDOW_MIN=30 # ±minutes around obs time to check east frames -# Quarter (half-moon) tuning ────────────────────────────────────────────────── -# 2 = waits for D-1, D, D+1 nights, runs the morning of D+2. -MOON_QUARTER_POST_DELAY_DAYS=2 -MOON_QUARTER_MIN_ILLUMINATION=0.46 # ±4% of 50%: waxing/waning within 4% of exact quarter +# Post delay ────────────────────────────────────────────────────────────────── +# How many days after the exact phase to run the post. The post delay gives +# time for the obs-night frames to land on disk before the job runs. +MOON_FULL_POST_DELAY_DAYS=1 # post the morning after the full moon +MOON_QUARTER_POST_DELAY_DAYS=1 # post the morning after each quarter +MOON_QUARTER_MIN_ILLUMINATION=0.46 MOON_QUARTER_MAX_ILLUMINATION=0.54 # Frame-acceptance thresholds — a candidate must beat all three to qualify. diff --git a/test_moon_composite.py b/test_moon_composite.py index 197dab4..a8ea69d 100644 --- a/test_moon_composite.py +++ b/test_moon_composite.py @@ -1,41 +1,54 @@ #!/usr/bin/env python3 -"""test_moon_composite.py — smoke-test atmospheric overlay at three opacity levels. +"""test_moon_composite.py — render a phase composite using real NASA data. -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: +Simulates what moon_phase_monthly.py does on the night of a full moon: - 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. + 1. Round the obs time (22:30 local on 2026-04-29) to the nearest hour. + 2. Fetch the NASA SVS Dial-a-Moon render for that UTC hour. + 3. Load the east camera frame (21-07-00.jpg, captured at 21:07 UTC that night). + 4. Detect atmospheric conditions → compute overlay opacity. + 5. Render: NASA moon + east atmosphere overlay → test_composite_out.jpg. Run from the sky-cam directory: python3 test_moon_composite.py + +Requires internet access to reach svs.gsfc.nasa.gov. +If the API is unreachable a procedural moon disc is used as a fallback +so the atmospheric overlay is still visible and testable. """ import os import pathlib import sys import tempfile +from datetime import datetime, timezone 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'), -] +# The east frame is 2026-04-29 21:07 UTC; obs time is 22:30 local → ~21:30 UTC +# (assuming Eastern time UTC-4 in late April). Round to nearest hour → 22:00 UTC. +NASA_FETCH_UTC = datetime(2026, 4, 29, 22, 0, tzinfo=timezone.utc) -def _make_procedural_moon(size: int = 2048) -> 'Image': - """Clean grey disc with simplified lunar maria and limb darkening.""" +def _fetch_nasa(dt: datetime) -> 'pathlib.Path | None': + try: + import moon_dialamoon + print(f'fetching NASA Dial-a-Moon for {dt.strftime("%Y-%m-%dT%H:00Z")} …') + path = moon_dialamoon.fetch_for_time(dt) + print(f' cached at {path}') + return path + except Exception as e: + print(f' NASA fetch failed: {e}') + return None + + +def _make_procedural_moon(size: int = 2048) -> 'pathlib.Path': + """Fallback: clean grey disc with maria and limb darkening.""" from PIL import Image, ImageDraw, ImageFilter import numpy as np @@ -58,7 +71,26 @@ def _make_procedural_moon(size: int = 2048) -> 'Image': 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) + img = Image.fromarray(arr) + + tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) + tmp.close() + img.save(tmp.name) + return pathlib.Path(tmp.name) + + +def _detect_atmosphere(frame_path: str) -> tuple[float | None, float]: + """Run moon_detect and return (quality, opacity).""" + try: + from moon_detect import detect_moon + from moon_phase_monthly import _atmosphere_opacity_from_quality + det = detect_moon(frame_path) + quality = det.quality if det is not None else None + opacity = _atmosphere_opacity_from_quality(quality) + return quality, opacity + except Exception as e: + print(f' detection failed: {e}') + return None, 0.0 def main(): @@ -66,49 +98,64 @@ def main(): print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr) sys.exit(1) - 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) + print('=== moon composite test ===') + print(f'east frame : {EAST_FRAME.name} (2026-04-29 21:07 UTC)') + print(f'NASA time : {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")}') + print() + # 1. Try real NASA image + tmp_to_delete = None + nasa_path = _fetch_nasa(NASA_FETCH_UTC) + if nasa_path is None: + print('falling back to procedural moon disc …') + nasa_path = _make_procedural_moon() + tmp_to_delete = str(nasa_path) + print(f' disc saved to {nasa_path}') + print() + + # 2. Atmosphere from east frame + print(f'checking atmosphere in {EAST_FRAME.name} …') + quality, opacity = _detect_atmosphere(str(EAST_FRAME)) + if quality is not None: + print(f' moon quality: {quality:.3f} → atmosphere opacity: {opacity:.2f}') + else: + print(f' no moon detected (overcast) → opacity: {opacity:.2f}') + print() + + # 3. Render from moon_composite import render_phase_closeup - + caption = ( + f'Full Moon — April 2026 — ' + f'sky-cam east 2026-04-29 22:30 local — ' + f'NASA SVS Dial-a-Moon' + ) + print(f'rendering {OUT_PATH.name} …') try: - 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}') + render_phase_closeup( + nasa_render_path=str(nasa_path), + out_path=str(OUT_PATH), + output_size=(1920, 1080), + moon_height_pct=0.88, + caption=caption, + east_frame_path=str(EAST_FRAME), + atmosphere_opacity=opacity, + atmosphere_blur=0, + ) finally: - os.unlink(tmp_moon) + if tmp_to_delete: + os.unlink(tmp_to_delete) + print(f'done → {OUT_PATH}') print() - print('done. Three outputs:') - for filename, opacity, label in CASES: - print(f' {filename:35s} opacity={opacity:.2f} {label}') - print() - 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') + print(f'opacity={opacity:.2f}:', end=' ') + if opacity == 0.0: + print('clear sky — pure NASA moon on black background') + elif opacity < 0.15: + print('slight haze — moon visible, softly veiled') + elif opacity < 0.40: + print('cloud cover — moon partially obscured') + else: + print('heavy overcast — moon a glow through cloud') if __name__ == '__main__':