Use fixed obs time for NASA fetch and atmosphere check

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
This commit is contained in:
Claude
2026-05-01 22:03:57 +00:00
parent c09b872f52
commit 9e2852fd73
4 changed files with 208 additions and 170 deletions
+4 -3
View File
@@ -253,12 +253,13 @@ def _make_atmosphere_layer(
it reads as "clouds between the observer and the moon" — which is it reads as "clouds between the observer and the moon" — which is
physically correct. physically correct.
blur_radius=0 → auto: output_width // 10, which smooths pixel-level blur_radius=0 → auto: output_width // 20 (96 px at 1920 wide).
detail but keeps cloud-scale gradients visible. 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') src = Image.open(east_frame_path).convert('RGB')
layer = src.resize(output_size, LANCZOS) 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)) return layer.filter(ImageFilter.GaussianBlur(radius=r))
+81 -104
View File
@@ -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)) ATMOSPHERE_BLUR = int(CONF.get('MOON_ATMOSPHERE_BLUR', 0))
CLOUDY_POST_ENABLED = CONF.get('MOON_CLOUDY_POST_ENABLED', 'true').lower() != 'false' 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: def _atmosphere_opacity_from_quality(quality: float | None) -> float:
"""Map moon detection quality to atmospheric overlay opacity. """Map moon detection quality to atmospheric overlay opacity.
@@ -115,39 +120,21 @@ PHASE_SPEC = {
'index': 2, 'index': 2,
'label': 'Full Moon', 'label': 'Full Moon',
'emoji': '🌕', 'emoji': '🌕',
# ±4% of target (100%): accept 96100% illumination 'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 1)),
'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)),
'enabled_key': 'MOON_FULL_ENABLED', 'enabled_key': 'MOON_FULL_ENABLED',
}, },
'first-quarter': { 'first-quarter': {
'index': 1, 'index': 1,
'label': 'First Quarter (Waxing Half)', 'label': 'First Quarter (Waxing Half)',
'emoji': '🌓', 'emoji': '🌓',
# ±4% of target (50%): accept 4654% illumination, waxing only 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
'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)),
'enabled_key': 'MOON_FIRST_QUARTER_ENABLED', 'enabled_key': 'MOON_FIRST_QUARTER_ENABLED',
}, },
'third-quarter': { 'third-quarter': {
'index': 3, 'index': 3,
'label': 'Third Quarter (Waning Half)', 'label': 'Third Quarter (Waning Half)',
'emoji': '🌗', 'emoji': '🌗',
# ±4% of target (50%): accept 4654% illumination, waning only 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
'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)),
'enabled_key': 'MOON_THIRD_QUARTER_ENABLED', '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, cam, dry_run, no_upload, out_path,
witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)') witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)')
dates = [] # ── Fixed observation time on the night of the phase event ──────────────
for i in range(-spec['window_before'], spec['window_after'] + 1): # Instead of scanning a multi-day window for the best detection, we look
d = (target_local + timedelta(days=i)).date() # at a short window around a configured local time (default 22:30) on the
dates.append(d.strftime('%Y-%m-%d')) # night of the exact phase. That single timestamp drives both the NASA
print(f'scanning dates: {dates}') # 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) # Check moon altitude at the observation time
print(f'frame count in window: {len(candidates)}') try:
if not candidates: alt_at_obs, _ = moon_phase.altaz(obs_utc)
msg = ( except Exception as e:
f'No frames for {cam} in window {dates[0]}..{dates[-1]} ' print(f'ERROR: moon_phase.altaz: {e}', file=sys.stderr)
f'around {phase} {target_utc.strftime("%Y-%m-%d %H:%MZ")}.' 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) elif window_frames:
print(msg) best_f = min(window_frames, key=lambda t: abs(t[1] - obs_utc))
return 0 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 = [] # Label atmospheric conditions for the caption
above_horizon = [] # frames where moon is up but quality/illum check failed if opacity == 0.0:
for path, utc_dt in candidates: atm_label = 'clear sky'
try: elif opacity < 0.15:
alt, _ = moon_phase.altaz(utc_dt) atm_label = 'slight haze'
except Exception as e: elif opacity < 0.40:
print(f'ERROR: moon_phase.altaz failed: {e}', file=sys.stderr) atm_label = 'cloud cover'
return 2 else:
if alt < MIN_ALTITUDE: atm_label = 'heavy overcast'
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))
print(f'qualifying frames: {len(qualifying)} above-horizon fallback pool: {len(above_horizon)}') obs_local_str = _format_local(obs_utc)
witness_text = (
if not qualifying: f'sky-cam {cam}{atm_label} at {obs_local_str} — NASA SVS Dial-a-Moon'
# 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')
if dry_run: if dry_run:
print(f'dry-run: would post with opacity={opacity:.2f} ({atm_label})')
return 0 return 0
return _render_and_post( 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, cam, dry_run, no_upload, out_path,
witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} ' witness_text=witness_text,
f'({delta_min:+.0f} min from exact {phase})', east_frame_path=east_frame,
east_frame_path=path,
atmosphere_opacity=opacity, atmosphere_opacity=opacity,
) )
+21 -8
View File
@@ -491,15 +491,28 @@ MOON_TRACK_FPS=12 # output mp4 framerate
MOON_TRACK_CRF=24 # output mp4 CRF 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
# Full-moon monthly tuning ──────────────────────────────────────────────────── # Observation time ────────────────────────────────────────────────────────────
# How many days after the exact full moon to post. 3 = waits for D-1..D+2 # Local time used to select the east camera frame and the NASA Dial-a-Moon
# nights to be on disk, then runs the morning of D+3. # render for each phase post. The script looks at east frames within
MOON_FULL_POST_DELAY_DAYS=3 # ±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 ────────────────────────────────────────────────── # Post delay ──────────────────────────────────────────────────────────────────
# 2 = waits for D-1, D, D+1 nights, runs the morning of D+2. # How many days after the exact phase to run the post. The post delay gives
MOON_QUARTER_POST_DELAY_DAYS=2 # time for the obs-night frames to land on disk before the job runs.
MOON_QUARTER_MIN_ILLUMINATION=0.46 # ±4% of 50%: waxing/waning within 4% of exact quarter 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 MOON_QUARTER_MAX_ILLUMINATION=0.54
# Frame-acceptance thresholds — a candidate must beat all three to qualify. # Frame-acceptance thresholds — a candidate must beat all three to qualify.
+102 -55
View File
@@ -1,41 +1,54 @@
#!/usr/bin/env python3 #!/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) Simulates what moon_phase_monthly.py does on the night of a full moon:
to show how the atmospheric overlay looks across the quality spectrum:
test_composite_clear.jpg — opacity 0.00 (quality ≥ 0.85, clear sky) 1. Round the obs time (22:30 local on 2026-04-29) to the nearest hour.
test_composite_hazy.jpg — opacity 0.20 (quality ~ 0.70, light haze) 2. Fetch the NASA SVS Dial-a-Moon render for that UTC hour.
test_composite_cloudy.jpg — opacity 0.65 (overcast fallback) 3. Load the east camera frame (21-07-00.jpg, captured at 21:07 UTC that night).
4. Detect atmospheric conditions → compute overlay opacity.
The moon disk in each image is procedurally generated (clean grey sphere, 5. Render: NASA moon + east atmosphere overlay → test_composite_out.jpg.
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: 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.
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 os
import pathlib import pathlib
import sys import sys
import tempfile import tempfile
from datetime import datetime, timezone
HERE = pathlib.Path(__file__).resolve().parent HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE)) 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'
CASES = [ # The east frame is 2026-04-29 21:07 UTC; obs time is 22:30 local → ~21:30 UTC
('test_composite_clear.jpg', 0.00, 'clear sky (quality >= 0.85)'), # (assuming Eastern time UTC-4 in late April). Round to nearest hour → 22:00 UTC.
('test_composite_hazy.jpg', 0.20, 'light haze (quality ~ 0.70)'), NASA_FETCH_UTC = datetime(2026, 4, 29, 22, 0, tzinfo=timezone.utc)
('test_composite_cloudy.jpg', 0.65, 'heavy overcast fallback'),
]
def _make_procedural_moon(size: int = 2048) -> 'Image': def _fetch_nasa(dt: datetime) -> 'pathlib.Path | None':
"""Clean grey disc with simplified lunar maria and limb darkening.""" 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 from PIL import Image, ImageDraw, ImageFilter
import numpy as np import numpy as np
@@ -58,7 +71,26 @@ def _make_procedural_moon(size: int = 2048) -> 'Image':
arr = np.asarray(img).astype(float) arr = np.asarray(img).astype(float)
vig = np.asarray(vignette).astype(float) / 255.0 vig = np.asarray(vignette).astype(float) / 255.0
arr = np.clip(arr * (0.75 + 0.25 * vig)[..., None], 0, 255).astype('uint8') 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(): def main():
@@ -66,49 +98,64 @@ def main():
print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr) print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr)
sys.exit(1) sys.exit(1)
print('generating procedural moon disc …') print('=== moon composite test ===')
moon_img = _make_procedural_moon(2048) print(f'east frame : {EAST_FRAME.name} (2026-04-29 21:07 UTC)')
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) print(f'NASA time : {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")}')
tmp_moon = tmp.name print()
tmp.close()
moon_img.save(tmp_moon)
# 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 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: try:
for filename, opacity, label in CASES: render_phase_closeup(
out = HERE / filename nasa_render_path=str(nasa_path),
caption = ( out_path=str(OUT_PATH),
f'Full Moon — April 2026 — ' output_size=(1920, 1080),
f'sky-cam east 2026-04-29 21:07 UTC — ' moon_height_pct=0.88,
f'NASA SVS Dial-a-Moon [{label}]' caption=caption,
) east_frame_path=str(EAST_FRAME),
print(f'rendering {filename} (opacity={opacity:.2f}) {label}') atmosphere_opacity=opacity,
render_phase_closeup( atmosphere_blur=0,
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: finally:
os.unlink(tmp_moon) if tmp_to_delete:
os.unlink(tmp_to_delete)
print(f'done → {OUT_PATH}')
print() print()
print('done. Three outputs:') print(f'opacity={opacity:.2f}:', end=' ')
for filename, opacity, label in CASES: if opacity == 0.0:
print(f' {filename:35s} opacity={opacity:.2f} {label}') print('clear sky — pure NASA moon on black background')
print() elif opacity < 0.15:
print('What you should see in each:') print('slight haze — moon visible, softly veiled')
print(' clear — NASA moon disk sharp and unobscured on black background') elif opacity < 0.40:
print(' hazy — same moon but with a soft grey-blue veil over the disk') print('cloud cover — moon partially obscured')
print(' (from the thin cloud visible in 21-07-00.jpg)') else:
print(' cloudy — moon mostly hidden; visible as a bright glow through') print('heavy overcast — moon a glow through cloud')
print(' the cloud texture from east\'s April 29 frame')
if __name__ == '__main__': if __name__ == '__main__':