Simplify moon phase: binary go/no-go, parallactic angle rotation, no atmosphere overlay

- moon_composite.py: remove _make_atmosphere_layer() and all atmosphere
  parameters from render_phase_closeup(); add when_utc parameter and apply
  parallactic angle rotation so the NASA disk is oriented to match east's sky

- moon_phase_monthly.py: change obs time default to 22:00 (aligns with NASA
  hourly renders); scan east frames in 22:00-23:00 window; binary go/no-go
  (quality >= MIN_QUALITY = post, no clear shot = skip entirely); remove
  _atmosphere_opacity_from_quality(), ATMOSPHERE_BLUR, CLOUDY_POST_ENABLED

- sky-cam.conf: update MOON_OBS_TIME_LOCAL to 22:00, remove
  MOON_OBS_WINDOW_MIN, remove the atmospheric overlay and cloudy fallback
  sections and their config variables

- test_moon_composite.py: single clean test — fetch NASA for 22:00 UTC on
  2026-04-29, verify east frame quality, render with parallactic angle; no
  procedural fallback

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
Claude
2026-05-01 22:27:34 +00:00
parent 9e2852fd73
commit a6989a9ea8
4 changed files with 98 additions and 268 deletions
+11 -46
View File
@@ -240,29 +240,6 @@ def composite_full_moon(
return out_path return out_path
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 to atmospheric haze.
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 → 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] // 20
return layer.filter(ImageFilter.GaussianBlur(radius=r))
def render_phase_closeup( def render_phase_closeup(
nasa_render_path: str, nasa_render_path: str,
out_path: str, out_path: str,
@@ -270,28 +247,16 @@ def render_phase_closeup(
moon_height_pct: float = 0.92, moon_height_pct: float = 0.92,
caption: str | None = None, caption: str | None = None,
background: tuple[int, int, int] = (0, 0, 0), background: tuple[int, int, int] = (0, 0, 0),
east_frame_path: str | None = None, when_utc: datetime | None = None,
atmosphere_opacity: float = 0.0,
atmosphere_blur: int = 0,
): ):
"""Full-screen close-up rendering using a NASA SVS Dial-a-Moon image. """Full-screen close-up rendering using a NASA SVS Dial-a-Moon image.
Rendering pipeline: Renders the NASA moon disk centred on a black background, rotated by
1. Black background (outer space). the parallactic angle so its orientation matches what east's camera sees
2. NASA moon disk — correct phase, libration, crater shadows — centred from its geographic location at the given UTC time.
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.
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
""" """
# ── Background + NASA moon ──────────────────────────────────────────── import moon_phase
bg = Image.new('RGB', output_size, background) bg = Image.new('RGB', output_size, background)
moon = Image.open(nasa_render_path).convert('RGB') moon = Image.open(nasa_render_path).convert('RGB')
moon = _square_crop_to_disk(moon) moon = _square_crop_to_disk(moon)
@@ -300,6 +265,11 @@ def render_phase_closeup(
target += target % 2 target += target % 2
moon_resized = moon.resize((target, target), LANCZOS) moon_resized = moon.resize((target, target), LANCZOS)
# Rotate by parallactic angle so "up" on the moon matches east's sky
if when_utc is not None:
par = moon_phase.parallactic_angle(when_utc)
moon_resized = moon_resized.rotate(-par, resample=BICUBIC, expand=False)
feather = max(3, target // 240) feather = max(3, target // 240)
mask = _disk_mask(target, feather_px=feather) mask = _disk_mask(target, feather_px=feather)
@@ -307,11 +277,6 @@ def render_phase_closeup(
py = (output_size[1] - target) // 2 py = (output_size[1] - target) // 2
bg.paste(moon_resized, (px, py), mask) 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: if caption:
draw = ImageDraw.Draw(bg) draw = ImageDraw.Draw(bg)
_draw_caption(draw, caption, (22, output_size[1] - 48), output_size[0]) _draw_caption(draw, caption, (22, output_size[1] - 48), output_size[0])
+34 -70
View File
@@ -85,34 +85,9 @@ 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)) MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', 0.92))
REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false' REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false'
ATMOSPHERE_BLUR = int(CONF.get('MOON_ATMOSPHERE_BLUR', 0)) _obs_parts = CONF.get('MOON_OBS_TIME_LOCAL', '22:00').split(':')
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_H = int(_obs_parts[0])
OBS_TIME_M = int(_obs_parts[1]) if len(_obs_parts) > 1 else 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.
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 = { PHASE_SPEC = {
@@ -290,73 +265,64 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
return 2 return 2
print(f'moon altitude at obs time: {alt_at_obs:.1f}°') print(f'moon altitude at obs time: {alt_at_obs:.1f}°')
# Gather east frames in the ±OBS_WINDOW_MIN window # Scan east frames in the 22:0023:00 window for any clear moon detection
obs_start = obs_utc - timedelta(minutes=OBS_WINDOW_MIN) obs_end = obs_utc + timedelta(hours=1)
obs_end = obs_utc + timedelta(minutes=OBS_WINDOW_MIN)
obs_dates: list[str] = [] obs_dates: list[str] = []
d = obs_start.astimezone(tz).date() d = obs_utc.astimezone(tz).date()
while d <= obs_end.astimezone(tz).date(): while d <= obs_end.astimezone(tz).date():
obs_dates.append(d.strftime('%Y-%m-%d')) obs_dates.append(d.strftime('%Y-%m-%d'))
d += timedelta(days=1) d += timedelta(days=1)
all_frames = _candidate_frames(cam, obs_dates) all_frames = _candidate_frames(cam, obs_dates)
window_frames = [(p, dt) for p, dt in all_frames if obs_start <= dt <= obs_end] window_frames = [(p, dt) for p, dt in all_frames if obs_utc <= dt <= obs_end]
print(f'east frames in ±{OBS_WINDOW_MIN} min window: {len(window_frames)}') print(f'east frames in 22:00-23:00 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: if alt_at_obs < MIN_ALTITUDE:
print('moon below horizon at obs time — posting clean NASA image') print(f'moon below horizon at obs time ({alt_at_obs:.1f}deg) — skipping')
_notify( _notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} ' 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'— 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.', f'Adjust MOON_OBS_TIME_LOCAL in sky-cam.conf.',
) )
elif window_frames: return 0
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')
# Label atmospheric conditions for the caption # Binary go/no-go: any frame in the window with quality >= MIN_QUALITY?
if opacity == 0.0: clear_frame: str | None = None
atm_label = 'clear sky' for fpath, fdt in window_frames:
elif opacity < 0.15: det = detect_moon(fpath)
atm_label = 'slight haze' if det is not None and det.quality >= MIN_QUALITY:
elif opacity < 0.40: clear_frame = fpath
atm_label = 'cloud cover' offset_min = (fdt - obs_utc).total_seconds() / 60.0
else: print(f'clear moon detected: {fpath}')
atm_label = 'heavy overcast' print(f' quality={det.quality:.3f} offset=+{offset_min:.1f} min')
break
else:
q = det.quality if det is not None else None
print(f' {fpath}: quality={q} — not clear enough')
if clear_frame is None:
print('no clear moon detection in window — skipping (overcast or moon absent)')
_notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped',
f'No clear moon detection in the 22:00-23:00 window.',
)
return 0
obs_local_str = _format_local(obs_utc) obs_local_str = _format_local(obs_utc)
witness_text = ( witness_text = f'sky-cam {cam} — clear at {obs_local_str} — NASA SVS Dial-a-Moon'
f'sky-cam {cam}{atm_label} at {obs_local_str} — NASA SVS Dial-a-Moon'
)
if dry_run: if dry_run:
print(f'dry-run: would post with opacity={opacity:.2f} ({atm_label})') print(f'dry-run: would post using NASA image for {obs_utc.isoformat()}')
return 0 return 0
return _render_and_post( return _render_and_post(
phase, spec, target_utc, obs_utc, obs_utc.astimezone(tz), phase, spec, target_utc, obs_utc, obs_utc.astimezone(tz),
cam, dry_run, no_upload, out_path, cam, dry_run, no_upload, out_path,
witness_text=witness_text, witness_text=witness_text,
east_frame_path=east_frame,
atmosphere_opacity=opacity,
) )
def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam, def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
dry_run, no_upload, out_path, witness_text, dry_run, no_upload, out_path, witness_text):
east_frame_path=None, atmosphere_opacity=0.0):
if out_path is None: if out_path is None:
out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase) out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
@@ -389,9 +355,7 @@ def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
str(nasa_path), out_path, str(nasa_path), out_path,
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT, output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
caption=caption, caption=caption,
east_frame_path=east_frame_path, when_utc=when_utc,
atmosphere_opacity=atmosphere_opacity,
atmosphere_blur=ATMOSPHERE_BLUR,
) )
print(f'wrote {out_path}') print(f'wrote {out_path}')
+11 -40
View File
@@ -492,20 +492,18 @@ MOON_TRACK_CRF=24 # output mp4 CRF
MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever
# Observation time ──────────────────────────────────────────────────────────── # Observation time ────────────────────────────────────────────────────────────
# Local time used to select the east camera frame and the NASA Dial-a-Moon # Local time that defines the observation window: MOON_OBS_TIME_LOCAL to
# render for each phase post. The script looks at east frames within # MOON_OBS_TIME_LOCAL+1h. East frames in this window are checked for a clear
# ±MOON_OBS_WINDOW_MIN of this time on the night of the exact phase event, # moon detection (quality >= MOON_MIN_QUALITY). If any frame qualifies, the
# picks the one closest to the target time, and uses it to: # NASA Dial-a-Moon image for MOON_OBS_TIME_LOCAL UTC is fetched and posted.
# 1. Determine atmospheric conditions (clear / hazy / overcast) # If no frame shows a clear moon, the month is skipped entirely.
# 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). # 22:00 aligns directly with NASA's hourly renders. Full moon and third
# For third quarter (rises after midnight), consider 02:30 or leave at 22:30 # quarter both rise after dark and are visible at this hour. First quarter
# and accept that the moon may be below the horizon — the script will warn # is up only until ~midnight from a new-moon start, so it may not be visible
# and post a clean NASA render instead. # at 22:00 depending on the exact date — set MOON_FIRST_QUARTER_ENABLED=false
MOON_OBS_TIME_LOCAL=22:30 # if first-quarter posts are consistently missed.
MOON_OBS_WINDOW_MIN=30 # ±minutes around obs time to check east frames MOON_OBS_TIME_LOCAL=22:00
# Post delay ────────────────────────────────────────────────────────────────── # Post delay ──────────────────────────────────────────────────────────────────
# How many days after the exact phase to run the post. The post delay gives # How many days after the exact phase to run the post. The post delay gives
@@ -537,33 +535,6 @@ MOON_HEIGHT_PCT=0.92 # moon disk fills this fraction of frame heig
MOON_DIALAMOON_TARGET_PX=2048 # cached PNG longest side; downsampled on save MOON_DIALAMOON_TARGET_PX=2048 # cached PNG longest side; downsampled on save
MOON_DIALAMOON_TIMEOUT_SEC=30 MOON_DIALAMOON_TIMEOUT_SEC=30
# Atmospheric overlay ─────────────────────────────────────────────────────────
# East's camera frame is scaled to output size, blurred, and composited OVER
# the NASA moon disk. This is physically correct: clouds sit between the
# observer and the moon, so they occlude the disk rather than appear behind it.
#
# Opacity is derived from the moon detection quality in that frame:
# quality ≥ 0.85 → 0% (clear sky — pure NASA render)
# quality 0.70 → 20% (light haze)
# quality 0.55 → 40% (notable cloud, moon still detected)
# quality < 0.55 → 4065% (overcast fallback — see MOON_CLOUDY_POST_ENABLED)
# no detection → 68% (heavy overcast)
#
# blur radius: 0 = auto (output_width / 10); set in px to override.
#MOON_ATMOSPHERE_BLUR=0
#
# Cloudy-month fallback ───────────────────────────────────────────────────────
# If no frame in the collection window passes the quality + illumination
# thresholds, the script normally skips posting that month. With
# MOON_CLOUDY_POST_ENABLED=true it instead finds the above-horizon frame
# closest to the exact phase moment, applies a heavy atmospheric overlay
# (opacity 0.450.68), and posts the month anyway — the moon shows as a faint
# glow behind cloud rather than being absent entirely.
#
# This applies to all three phases: full, first-quarter, third-quarter.
# Caption will read "cloud cover — YYYY-MM-DD — NASA SVS Dial-a-Moon".
MOON_CLOUDY_POST_ENABLED=true
# ── Mattermost — daily sunrise upload ───────────────────────────────────────── # ── Mattermost — daily sunrise upload ─────────────────────────────────────────
# mattermost_url, access_token, channel_id go in .env (see bottom of this file). # mattermost_url, access_token, channel_id go in .env (see bottom of this file).
+42 -112
View File
@@ -3,25 +3,20 @@
Simulates what moon_phase_monthly.py does on the night of a full moon: Simulates what moon_phase_monthly.py does on the night of a full moon:
1. Round the obs time (22:30 local on 2026-04-29) to the nearest hour. 1. Fetch the NASA SVS Dial-a-Moon render for 2026-04-29T22:00Z.
2. Fetch the NASA SVS Dial-a-Moon render for that UTC hour. 2. Load the east camera frame (21-07-00.jpg) and check for a clear moon.
3. Load the east camera frame (21-07-00.jpg, captured at 21:07 UTC that night). 3. If clear: render — NASA moon with parallactic angle rotation → test_composite_out.jpg.
4. Detect atmospheric conditions → compute overlay opacity. 4. If not clear: exit with a message (no fallback image).
5. Render: NASA moon + east atmosphere overlay → test_composite_out.jpg.
Run from the sky-cam directory: Run from the sky-cam directory:
python3 test_moon_composite.py python3 test_moon_composite.py
Requires internet access to reach svs.gsfc.nasa.gov. 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 pathlib
import sys import sys
import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
HERE = pathlib.Path(__file__).resolve().parent HERE = pathlib.Path(__file__).resolve().parent
@@ -30,67 +25,10 @@ sys.path.insert(0, str(HERE))
EAST_FRAME = HERE / '21-07-00.jpg' EAST_FRAME = HERE / '21-07-00.jpg'
OUT_PATH = HERE / 'test_composite_out.jpg' OUT_PATH = HERE / 'test_composite_out.jpg'
# The east frame is 2026-04-29 21:07 UTC; obs time is 22:30 local → ~21:30 UTC # Obs time: 22:00 UTC on 2026-04-29 — aligns directly with NASA hourly renders.
# (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) NASA_FETCH_UTC = datetime(2026, 4, 29, 22, 0, tzinfo=timezone.utc)
MIN_QUALITY = 0.55
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
img = Image.new('RGB', (size, size), (0, 0, 0))
draw = ImageDraw.Draw(img)
cx = cy = size // 2
r = int(size * 0.47)
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(218, 214, 200))
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))
img = img.filter(ImageFilter.GaussianBlur(radius=size // 80))
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))
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')
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(): def main():
@@ -103,59 +41,51 @@ def main():
print(f'NASA time : {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")}') print(f'NASA time : {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")}')
print() print()
# 1. Try real NASA image # 1. Check east frame for clear moon detection
tmp_to_delete = None print(f'checking moon in {EAST_FRAME.name} ...')
nasa_path = _fetch_nasa(NASA_FETCH_UTC) try:
if nasa_path is None: from moon_detect import detect_moon
print('falling back to procedural moon disc …') det = detect_moon(str(EAST_FRAME))
nasa_path = _make_procedural_moon() quality = det.quality if det is not None else None
tmp_to_delete = str(nasa_path) except Exception as e:
print(f' disc saved to {nasa_path}') print(f' detection failed: {e}', file=sys.stderr)
sys.exit(2)
if quality is None or quality < MIN_QUALITY:
print(f' quality={quality} — no clear moon detection — skipping (no fallback)')
sys.exit(0)
print(f' quality={quality:.3f} — clear shot confirmed')
print() print()
# 2. Atmosphere from east frame # 2. Fetch NASA Dial-a-Moon
print(f'checking atmosphere in {EAST_FRAME.name} ') print(f'fetching NASA Dial-a-Moon for {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")} ...')
quality, opacity = _detect_atmosphere(str(EAST_FRAME)) try:
if quality is not None: import moon_dialamoon
print(f' moon quality: {quality:.3f} → atmosphere opacity: {opacity:.2f}') nasa_path = moon_dialamoon.fetch_for_time(NASA_FETCH_UTC)
else: print(f' cached at {nasa_path}')
print(f' no moon detected (overcast) → opacity: {opacity:.2f}') except Exception as e:
print(f' NASA fetch failed: {e}', file=sys.stderr)
sys.exit(3)
print() print()
# 3. Render # 3. Render with parallactic angle rotation
from moon_composite import render_phase_closeup from moon_composite import render_phase_closeup
caption = ( caption = (
f'Full Moon — April 2026 — ' f'Full Moon — April 2026 — '
f'sky-cam east 2026-04-29 22:30 local' f'sky-cam east 2026-04-29 22:00 UTC'
f'NASA SVS Dial-a-Moon' f'NASA SVS Dial-a-Moon'
) )
print(f'rendering {OUT_PATH.name} ') print(f'rendering {OUT_PATH.name} ...')
try: render_phase_closeup(
render_phase_closeup( nasa_render_path=str(nasa_path),
nasa_render_path=str(nasa_path), out_path=str(OUT_PATH),
out_path=str(OUT_PATH), output_size=(1920, 1080),
output_size=(1920, 1080), moon_height_pct=0.88,
moon_height_pct=0.88, caption=caption,
caption=caption, when_utc=NASA_FETCH_UTC,
east_frame_path=str(EAST_FRAME), )
atmosphere_opacity=opacity, print(f'done -> {OUT_PATH}')
atmosphere_blur=0,
)
finally:
if tmp_to_delete:
os.unlink(tmp_to_delete)
print(f'done → {OUT_PATH}')
print()
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__': if __name__ == '__main__':