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:
+4
-3
@@ -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))
|
||||
|
||||
|
||||
|
||||
+75
-98
@@ -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}')
|
||||
|
||||
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")}.'
|
||||
# ── 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,
|
||||
)
|
||||
_notify(f'{spec["emoji"]} {spec["label"]} — no frames available', msg)
|
||||
print(msg)
|
||||
return 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)})')
|
||||
|
||||
qualifying = []
|
||||
above_horizon = [] # frames where moon is up but quality/illum check failed
|
||||
for path, utc_dt in candidates:
|
||||
# Check moon altitude at the observation time
|
||||
try:
|
||||
alt, _ = moon_phase.altaz(utc_dt)
|
||||
alt_at_obs, _ = moon_phase.altaz(obs_utc)
|
||||
except Exception as e:
|
||||
print(f'ERROR: moon_phase.altaz failed: {e}', file=sys.stderr)
|
||||
print(f'ERROR: moon_phase.altaz: {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))
|
||||
print(f'moon altitude at obs time: {alt_at_obs:.1f}°')
|
||||
|
||||
print(f'qualifying frames: {len(qualifying)} above-horizon fallback pool: {len(above_horizon)}')
|
||||
# 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)}')
|
||||
|
||||
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
|
||||
# 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.',
|
||||
)
|
||||
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)
|
||||
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,
|
||||
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
|
||||
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'
|
||||
|
||||
obs_local_str = _format_local(obs_utc)
|
||||
witness_text = (
|
||||
f'sky-cam {cam} — {atm_label} at {obs_local_str} — NASA SVS Dial-a-Moon'
|
||||
)
|
||||
|
||||
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:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
+21
-8
@@ -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.
|
||||
|
||||
+92
-45
@@ -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,27 +98,42 @@ 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
|
||||
|
||||
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}]'
|
||||
f'sky-cam east 2026-04-29 22:30 local — '
|
||||
f'NASA SVS Dial-a-Moon'
|
||||
)
|
||||
print(f'rendering {filename} (opacity={opacity:.2f}) {label} …')
|
||||
print(f'rendering {OUT_PATH.name} …')
|
||||
try:
|
||||
render_phase_closeup(
|
||||
nasa_render_path=tmp_moon,
|
||||
out_path=str(out),
|
||||
nasa_render_path=str(nasa_path),
|
||||
out_path=str(OUT_PATH),
|
||||
output_size=(1920, 1080),
|
||||
moon_height_pct=0.88,
|
||||
caption=caption,
|
||||
@@ -94,21 +141,21 @@ def main():
|
||||
atmosphere_opacity=opacity,
|
||||
atmosphere_blur=0,
|
||||
)
|
||||
print(f' -> {out}')
|
||||
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__':
|
||||
|
||||
Reference in New Issue
Block a user