Merge pull request #66 from outis1one/claude/add-moon-image-locations-ACdGH
Claude/add moon image locations a cd gh
This commit is contained in:
+11
-45
@@ -240,28 +240,6 @@ def composite_full_moon(
|
||||
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 // 10, which smooths pixel-level
|
||||
detail but keeps cloud-scale gradients visible.
|
||||
"""
|
||||
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
|
||||
return layer.filter(ImageFilter.GaussianBlur(radius=r))
|
||||
|
||||
|
||||
def render_phase_closeup(
|
||||
nasa_render_path: str,
|
||||
out_path: str,
|
||||
@@ -269,28 +247,16 @@ def render_phase_closeup(
|
||||
moon_height_pct: float = 0.92,
|
||||
caption: str | None = None,
|
||||
background: tuple[int, int, int] = (0, 0, 0),
|
||||
east_frame_path: str | None = None,
|
||||
atmosphere_opacity: float = 0.0,
|
||||
atmosphere_blur: int = 0,
|
||||
when_utc: datetime | None = None,
|
||||
):
|
||||
"""Full-screen close-up rendering using a NASA SVS Dial-a-Moon image.
|
||||
|
||||
Rendering pipeline:
|
||||
1. Black background (outer space).
|
||||
2. NASA moon disk — correct phase, libration, crater shadows — centred
|
||||
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
|
||||
Renders the NASA moon disk centred on a black background, rotated by
|
||||
the parallactic angle so its orientation matches what east's camera sees
|
||||
from its geographic location at the given UTC time.
|
||||
"""
|
||||
# ── Background + NASA moon ────────────────────────────────────────────
|
||||
import moon_phase
|
||||
|
||||
bg = Image.new('RGB', output_size, background)
|
||||
moon = Image.open(nasa_render_path).convert('RGB')
|
||||
moon = _square_crop_to_disk(moon)
|
||||
@@ -299,6 +265,11 @@ def render_phase_closeup(
|
||||
target += target % 2
|
||||
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)
|
||||
mask = _disk_mask(target, feather_px=feather)
|
||||
|
||||
@@ -306,11 +277,6 @@ def render_phase_closeup(
|
||||
py = (output_size[1] - target) // 2
|
||||
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:
|
||||
draw = ImageDraw.Draw(bg)
|
||||
_draw_caption(draw, caption, (22, output_size[1] - 48), output_size[0])
|
||||
|
||||
@@ -179,6 +179,39 @@ def altaz(when: datetime, lat: float | None = None, lon: float | None = None):
|
||||
return float(alt.degrees), float(az.degrees)
|
||||
|
||||
|
||||
def sun_events_in_range(
|
||||
search_start: datetime,
|
||||
search_end: datetime,
|
||||
) -> list[tuple[datetime, bool]]:
|
||||
"""Return every sunrise/sunset transition between search_start and search_end.
|
||||
|
||||
Each item is (utc_datetime, is_rise): is_rise=True for sunrise, False for sunset.
|
||||
Uses skyfield's almanac — accurate to within a minute at the configured location.
|
||||
"""
|
||||
_lazy()
|
||||
from skyfield.almanac import find_discrete, sunrise_sunset
|
||||
t0 = _t(search_start)
|
||||
t1 = _t(search_end)
|
||||
times, values = find_discrete(t0, t1, sunrise_sunset(_eph, _observer))
|
||||
return [(t.utc_datetime(), bool(v)) for t, v in zip(times, values)]
|
||||
|
||||
|
||||
def sun_altaz(when: datetime, lat: float | None = None, lon: float | None = None):
|
||||
"""Apparent altitude/azimuth of the sun in degrees as seen from (lat, lon)."""
|
||||
_lazy()
|
||||
from skyfield.api import wgs84
|
||||
if lat is None and lon is None:
|
||||
obs = _observer
|
||||
else:
|
||||
obs = _eph['earth'] + wgs84.latlon(
|
||||
LATITUDE if lat is None else lat,
|
||||
LONGITUDE if lon is None else lon,
|
||||
)
|
||||
t = _t(when)
|
||||
alt, az, _ = obs.at(t).observe(_eph['sun']).apparent().altaz()
|
||||
return float(alt.degrees), float(az.degrees)
|
||||
|
||||
|
||||
def parallactic_angle(when: datetime, lat: float | None = None, lon: float | None = None) -> float:
|
||||
"""Parallactic angle in degrees — rotates a moon image so celestial north is up.
|
||||
|
||||
|
||||
+129
-141
@@ -85,29 +85,7 @@ 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'
|
||||
|
||||
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)
|
||||
DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30))
|
||||
|
||||
|
||||
PHASE_SPEC = {
|
||||
@@ -115,39 +93,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',
|
||||
},
|
||||
}
|
||||
@@ -247,6 +207,70 @@ def _output_filename(phase: str, target_utc: datetime) -> str:
|
||||
return f"{target_utc.strftime('%Y-%m')}-{slug}.jpg"
|
||||
|
||||
|
||||
def _dark_moon_intervals(
|
||||
search_start: datetime,
|
||||
search_end: datetime,
|
||||
moon_phase_mod,
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
"""Return contiguous blocks where the sky is dark and the moon is visible.
|
||||
|
||||
'Dark' means after (sunset + DARK_START_MIN) and before the following
|
||||
sunrise, based on actual computed sunset/sunrise times. Within each night
|
||||
block we sample every 10 min and keep only the sub-intervals where the
|
||||
moon is above MIN_ALTITUDE_DEG. Falls back to sampling sun altitude
|
||||
directly if skyfield can't find sunrise/sunset events (e.g. polar summer).
|
||||
"""
|
||||
# Get actual sunset/sunrise events; widen the window a bit to catch events
|
||||
# that fall right at the boundary.
|
||||
events = moon_phase_mod.sun_events_in_range(
|
||||
search_start - timedelta(hours=2),
|
||||
search_end + timedelta(hours=2),
|
||||
)
|
||||
|
||||
# Build night periods from real sunset/sunrise times.
|
||||
night_periods: list[tuple[datetime, datetime]] = []
|
||||
for i, (evt_t, is_rise) in enumerate(events):
|
||||
if is_rise:
|
||||
continue # only care about sunsets here
|
||||
dark_start = evt_t + timedelta(minutes=DARK_START_MIN)
|
||||
# The night ends at the next sunrise (or search_end if none found).
|
||||
next_rise = next((t for t, r in events[i + 1:] if r), None)
|
||||
dark_end = next_rise if next_rise is not None else search_end
|
||||
if dark_start < dark_end:
|
||||
night_periods.append((dark_start, dark_end))
|
||||
|
||||
# Polar fallback: no sunset/sunrise events found.
|
||||
if not night_periods:
|
||||
night_periods = [(search_start, search_end)]
|
||||
|
||||
# Within each night, sample every 10 min to find where moon is high enough.
|
||||
step = timedelta(minutes=10)
|
||||
intervals: list[tuple[datetime, datetime]] = []
|
||||
for night_start, night_end in night_periods:
|
||||
t = max(night_start, search_start)
|
||||
end = min(night_end, search_end)
|
||||
if t >= end:
|
||||
continue
|
||||
seg_start = None
|
||||
while t <= end:
|
||||
try:
|
||||
moon_alt, _ = moon_phase_mod.altaz(t)
|
||||
except Exception:
|
||||
t += step
|
||||
continue
|
||||
ok = moon_alt >= MIN_ALTITUDE
|
||||
if ok and seg_start is None:
|
||||
seg_start = t
|
||||
elif not ok and seg_start is not None:
|
||||
intervals.append((seg_start, t))
|
||||
seg_start = None
|
||||
t += step
|
||||
if seg_start is not None:
|
||||
intervals.append((seg_start, end))
|
||||
|
||||
return intervals
|
||||
|
||||
|
||||
def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
dry_run: bool, no_upload: bool, out_path: str | None) -> int:
|
||||
spec = PHASE_SPEC[phase]
|
||||
@@ -269,128 +293,93 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
print(f'target {phase}: {target_utc.isoformat()} ({_format_local(target_utc)})')
|
||||
|
||||
tz = _local_tz()
|
||||
target_local = target_utc.astimezone(tz)
|
||||
|
||||
# If east-verification is disabled the user wants a post regardless of
|
||||
# whether east could see the moon that night. Fetch dial-a-moon for the
|
||||
# exact phase moment, render full-screen, post. Skips all east scanning.
|
||||
if not REQUIRE_EAST_VERIFY:
|
||||
print('MOON_REQUIRE_EAST_VERIFY=false — skipping east scan, using exact phase UTC')
|
||||
return _render_and_post(phase, spec, target_utc, target_utc, target_local,
|
||||
return _render_and_post(phase, spec, target_utc, target_utc,
|
||||
cam, dry_run, no_upload, out_path,
|
||||
witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)')
|
||||
witness_text='east verification disabled')
|
||||
|
||||
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}')
|
||||
# Find every interval within ±24h of the phase moment where the sky is
|
||||
# dark (sun below civil twilight) and the moon is above the horizon.
|
||||
search_start = target_utc - timedelta(hours=24)
|
||||
search_end = target_utc + timedelta(hours=24)
|
||||
intervals = _dark_moon_intervals(search_start, search_end, moon_phase)
|
||||
|
||||
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")}.'
|
||||
if not intervals:
|
||||
print('no dark-sky + moon window in ±24h of phase — skipping')
|
||||
_notify(
|
||||
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped',
|
||||
'No dark-sky + moon-above-horizon window found near the phase moment.',
|
||||
)
|
||||
_notify(f'{spec["emoji"]} {spec["label"]} — no frames available', msg)
|
||||
print(msg)
|
||||
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)
|
||||
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))
|
||||
total_h = sum((e - s).total_seconds() / 3600 for s, e in intervals)
|
||||
print(f'dark+moon window: {len(intervals)} segment(s), {total_h:.1f}h total')
|
||||
for s, e in intervals:
|
||||
print(f' {_format_local(s)} -> {_format_local(e)}')
|
||||
|
||||
print(f'qualifying frames: {len(qualifying)} above-horizon fallback pool: {len(above_horizon)}')
|
||||
# Collect east frames that fall inside those intervals
|
||||
search_dates: list[str] = []
|
||||
d = search_start.astimezone(tz).date()
|
||||
while d <= search_end.astimezone(tz).date():
|
||||
search_dates.append(d.strftime('%Y-%m-%d'))
|
||||
d += timedelta(days=1)
|
||||
all_frames = _candidate_frames(cam, search_dates)
|
||||
dark_frames = [
|
||||
(p, dt) for p, dt in all_frames
|
||||
if any(s <= dt <= e for s, e in intervals)
|
||||
]
|
||||
print(f'east frames in dark+moon window: {len(dark_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
|
||||
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,
|
||||
)
|
||||
# Sort by proximity to exact phase moment so the first clear frame we find
|
||||
# is the one temporally closest to the moon being precisely full/quarter.
|
||||
dark_frames.sort(key=lambda x: abs(x[1] - target_utc))
|
||||
|
||||
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.'
|
||||
best_frame: str | None = None
|
||||
best_dt: datetime | None = None
|
||||
for fpath, fdt in dark_frames:
|
||||
det = detect_moon(fpath)
|
||||
if det is not None and det.quality >= MIN_QUALITY:
|
||||
best_frame = fpath
|
||||
best_dt = fdt
|
||||
offset_min = int((fdt - target_utc).total_seconds() / 60)
|
||||
print(f'best frame: {pathlib.Path(fpath).name} quality={det.quality:.3f} '
|
||||
f'time={_format_local(fdt)} offset={offset_min:+d} min from exact phase')
|
||||
break
|
||||
|
||||
if best_frame is None:
|
||||
checked = len(dark_frames)
|
||||
print(f'no clear moon in {checked} dark-window frame(s) — skipping (overcast)')
|
||||
_notify(
|
||||
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped',
|
||||
f'No clear moon detection in {checked} frame(s) during the dark+moon window.',
|
||||
)
|
||||
_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')
|
||||
illum_pct = round(moon_phase.illumination(best_dt) * 100)
|
||||
print(f'illumination at detection: {illum_pct}%')
|
||||
|
||||
if dry_run:
|
||||
print(f'dry-run: would post NASA image for {best_dt.isoformat()} ({illum_pct}% lit)')
|
||||
return 0
|
||||
|
||||
return _render_and_post(
|
||||
phase, spec, target_utc, when_utc, local_dt,
|
||||
phase, spec, target_utc, best_dt,
|
||||
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,
|
||||
atmosphere_opacity=opacity,
|
||||
witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(best_dt)}',
|
||||
)
|
||||
|
||||
|
||||
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, atmosphere_opacity=0.0):
|
||||
def _render_and_post(phase, spec, target_utc, when_utc, cam,
|
||||
dry_run, no_upload, out_path, witness_text):
|
||||
if out_path is None:
|
||||
out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = str(out_dir / _output_filename(phase, target_utc))
|
||||
|
||||
# Fetch the NASA SVS Dial-a-Moon render for the hour east captured the
|
||||
# moon (or the exact phase moment if east-verification is off). The
|
||||
# render carries the correct phase, libration and crater shadows for
|
||||
# that UTC moment — the strongest possible match for what east "saw,"
|
||||
# and free of the white-blob limitation.
|
||||
import moon_dialamoon
|
||||
import moon_phase as _mp
|
||||
try:
|
||||
nasa_path = moon_dialamoon.fetch_for_time(when_utc)
|
||||
except Exception as e:
|
||||
@@ -403,18 +392,17 @@ def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
|
||||
return 4
|
||||
print(f'dial-a-moon: {nasa_path}')
|
||||
|
||||
illum_pct = round(_mp.illumination(when_utc) * 100)
|
||||
from moon_composite import render_phase_closeup
|
||||
caption = (
|
||||
f"{spec['label']} — {target_utc.strftime('%B %Y')} — "
|
||||
f"sky-cam {cam} {witness_text} — render: NASA SVS Dial-a-Moon"
|
||||
f"{spec['label']} — {illum_pct}% lit — {target_utc.strftime('%B %Y')} — "
|
||||
f"{witness_text} — NASA SVS Dial-a-Moon"
|
||||
)
|
||||
render_phase_closeup(
|
||||
str(nasa_path), out_path,
|
||||
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
|
||||
caption=caption,
|
||||
east_frame_path=east_frame_path,
|
||||
atmosphere_opacity=atmosphere_opacity,
|
||||
atmosphere_blur=ATMOSPHERE_BLUR,
|
||||
when_utc=when_utc,
|
||||
)
|
||||
print(f'wrote {out_path}')
|
||||
|
||||
|
||||
+30
-35
@@ -491,15 +491,37 @@ 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
|
||||
# Dark-sky observation window ─────────────────────────────────────────────────
|
||||
# The script searches ±24h around the exact phase moment using actual computed
|
||||
# sunset and sunrise times for the configured location. The dark window for
|
||||
# each night starts at (sunset + MOON_DARK_START_MIN) and ends at sunrise.
|
||||
# Within that window, only frames where the moon is above MOON_MIN_ALTITUDE_DEG
|
||||
# are considered.
|
||||
#
|
||||
# The east camera faces east, so the moon is visible to it from moonrise through
|
||||
# roughly south — typically from dusk through midnight for a full moon.
|
||||
# MOON_DARK_START_MIN=30 means the script starts looking 30 min after the sun
|
||||
# sets, when the sky is dark enough for a clean moon shot but east can still
|
||||
# catch the moon low on the eastern horizon.
|
||||
#
|
||||
# Of all qualifying frames, the one temporally closest to the exact phase moment
|
||||
# is used (not the first one). This picks the frame when the moon was most
|
||||
# precisely full / at quarter. Its timestamp drives the NASA Dial-a-Moon fetch
|
||||
# and the parallactic angle rotation; the caption shows illumination% at that
|
||||
# moment. If no clear frame is found, the month is skipped entirely.
|
||||
#
|
||||
# If the phase falls early in the day (e.g. 01:30 local), the ±24h window
|
||||
# covers the previous evening through the following dusk — both nights where
|
||||
# the moon is essentially full. If the phase falls just before midnight
|
||||
# (e.g. 23:55), both the same evening and the next morning are included.
|
||||
MOON_DARK_START_MIN=30
|
||||
|
||||
# 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.
|
||||
@@ -524,33 +546,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_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 → 40–65% (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.45–0.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_url, access_token, channel_id go in .env (see bottom of this file).
|
||||
|
||||
|
||||
+58
-81
@@ -1,64 +1,34 @@
|
||||
#!/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. Fetch the NASA SVS Dial-a-Moon render for 2026-04-29T22:00Z.
|
||||
2. Load the east camera frame (21-07-00.jpg) and check for a clear moon.
|
||||
3. If clear: render — NASA moon with parallactic angle rotation → test_composite_out.jpg.
|
||||
4. If not clear: exit with a message (no fallback image).
|
||||
|
||||
Run from the sky-cam directory:
|
||||
|
||||
python3 test_moon_composite.py
|
||||
|
||||
Requires internet access to reach svs.gsfc.nasa.gov.
|
||||
"""
|
||||
|
||||
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'),
|
||||
]
|
||||
# Obs time: 22:00 UTC on 2026-04-29 — aligns directly with NASA hourly renders.
|
||||
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."""
|
||||
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')
|
||||
return Image.fromarray(arr)
|
||||
MIN_QUALITY = 0.55
|
||||
|
||||
|
||||
def main():
|
||||
@@ -66,49 +36,56 @@ 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)
|
||||
|
||||
from moon_composite import render_phase_closeup
|
||||
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. Check east frame for clear moon detection
|
||||
print(f'checking moon in {EAST_FRAME.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}')
|
||||
finally:
|
||||
os.unlink(tmp_moon)
|
||||
from moon_detect import detect_moon
|
||||
det = detect_moon(str(EAST_FRAME))
|
||||
quality = det.quality if det is not None else None
|
||||
except Exception as e:
|
||||
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('done. Three outputs:')
|
||||
for filename, opacity, label in CASES:
|
||||
print(f' {filename:35s} opacity={opacity:.2f} {label}')
|
||||
|
||||
# 2. Fetch NASA Dial-a-Moon
|
||||
print(f'fetching NASA Dial-a-Moon for {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")} ...')
|
||||
try:
|
||||
import moon_dialamoon
|
||||
nasa_path = moon_dialamoon.fetch_for_time(NASA_FETCH_UTC)
|
||||
print(f' cached at {nasa_path}')
|
||||
except Exception as e:
|
||||
print(f' NASA fetch failed: {e}', file=sys.stderr)
|
||||
sys.exit(3)
|
||||
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')
|
||||
|
||||
# 3. Render with parallactic angle rotation
|
||||
from moon_composite import render_phase_closeup
|
||||
caption = (
|
||||
f'Full Moon — April 2026 — '
|
||||
f'sky-cam east 2026-04-29 22:00 UTC — '
|
||||
f'NASA SVS Dial-a-Moon'
|
||||
)
|
||||
print(f'rendering {OUT_PATH.name} ...')
|
||||
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,
|
||||
when_utc=NASA_FETCH_UTC,
|
||||
)
|
||||
print(f'done -> {OUT_PATH}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user