Apply atmosphere overlay ON TOP of moon, add overcast fallback

The core mental model was wrong: clouds are between the observer and
the moon, so they occlude the disk — they don't sit behind it.

moon_composite.py:
  _make_east_sky_backdrop() → _make_atmosphere_layer()
  render_phase_closeup() pipeline is now:
    1. Black background
    2. NASA moon disk centred (correct phase / libration / shadows)
    3. East frame scaled + blurred → composited OVER the disk at
       atmosphere_opacity (0.0 = clear, 0.68 = heavy overcast)
  Parameters: east_sky_enabled/blur → atmosphere_opacity/blur

moon_phase_monthly.py:
  _atmosphere_opacity_from_quality() maps detection quality to opacity:
    quality >= 0.85 → 0.00 (clear)
    quality   0.70  → 0.20 (light haze)
    quality   0.55  → 0.40 (notable cloud, still detected)
    quality   None  → 0.68 (no detection, heavy overcast)

  Frame scan now tracks two lists:
    qualifying     — frames passing quality + illumination (existing)
    above_horizon  — frames where moon is up but detection failed

  When qualifying is empty and MOON_CLOUDY_POST_ENABLED=true, the
  above-horizon frame closest to the exact phase moment is used with
  opacity 0.45–0.68.  The month is always represented — full moon,
  first quarter, and third quarter each get a post even in cloudy
  months, showing the moon as a faint glow behind cloud.

sky-cam.conf:
  MOON_EAST_SKY_ENABLED/BLUR → MOON_ATMOSPHERE_BLUR (opacity is
  computed automatically from quality, not configured directly)
  New: MOON_CLOUDY_POST_ENABLED=true

test_moon_composite.py:
  Generates three outputs at opacity 0.00 / 0.20 / 0.65 so the
  full opacity range is visible in one test run.

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
Claude
2026-05-01 21:51:42 +00:00
parent 1425a6e5f4
commit c09b872f52
4 changed files with 180 additions and 143 deletions
+33 -33
View File
@@ -240,27 +240,26 @@ def composite_full_moon(
return out_path return out_path
def _make_east_sky_backdrop( def _make_atmosphere_layer(
east_frame_path: str, east_frame_path: str,
output_size: tuple[int, int], output_size: tuple[int, int],
blur_radius: int = 0, blur_radius: int = 0,
) -> Image.Image: ) -> Image.Image:
"""Scale the full east frame to output_size and blur it heavily. """Scale the full east frame to output_size and blur to atmospheric haze.
The blur removes wide-angle camera detail (pixel noise, RTSP compression The blur removes wide-angle camera detail (RTSP artefacts, OSD text,
artefacts, OSD text) while preserving the real atmospheric colours, any pixel noise) while preserving real sky colour and large-scale cloud
cloud patterns, and the dark ground silhouette at the bottom of frame. structure. The resulting layer is applied OVER the NASA moon disk so
The result reads as "this is the sky east saw that night" rather than a it reads as "clouds between the observer and the moon" — which is
stretched wide-angle photo. physically correct.
blur_radius=0 chooses automatically: output_width // 6, which gives a blur_radius=0 → auto: output_width // 10, which smooths pixel-level
soft impressionistic backdrop while still letting cloud shapes show detail but keeps cloud-scale gradients visible.
through as gentle colour gradients.
""" """
src = Image.open(east_frame_path).convert('RGB') src = Image.open(east_frame_path).convert('RGB')
backdrop = src.resize(output_size, LANCZOS) layer = src.resize(output_size, LANCZOS)
r = blur_radius if blur_radius > 0 else output_size[0] // 6 r = blur_radius if blur_radius > 0 else output_size[0] // 10
return backdrop.filter(ImageFilter.GaussianBlur(radius=r)) return layer.filter(ImageFilter.GaussianBlur(radius=r))
def render_phase_closeup( def render_phase_closeup(
@@ -271,32 +270,28 @@ def render_phase_closeup(
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, east_frame_path: str | None = None,
east_sky_enabled: bool = True, atmosphere_opacity: float = 0.0,
east_sky_blur: int = 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.
The dial-a-moon render has the correct phase, libration and crater shadows Rendering pipeline:
for the requested timestamp. We size it to fill the output frame and add 1. Black background (outer space).
a caption. 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.
When east_frame_path is provided the east camera's frame for that night is atmosphere_opacity controls what the viewer sees through east's sky:
scaled to output_size and blurred heavily (GaussianBlur r ≈ output_width/6) 0.00 — perfectly clear: pure NASA render, no overlay
to produce an atmospheric backdrop — east's real night sky colour, any 0.10 — slight haze: moon is sharp but slightly softened
cloud or haze patterns, and the dark ground silhouette at the bottom of 0.35 — noticeable cloud cover: moon partially obscured
frame all show through the blur as soft gradients. The NASA moon disk is 0.65 — heavy overcast: moon a faint glow through thick cloud
then pasted sharp on top of that backdrop.
This is what "relayed to where it was taken" looks like: the sky behind
the NASA moon is east's actual sky from that hour. Set
east_sky_enabled=False (or leave east_frame_path=None) to use a plain
black background instead.
""" """
if east_sky_enabled and east_frame_path: # ── Background + NASA moon ────────────────────────────────────────────
bg = _make_east_sky_backdrop(east_frame_path, output_size, east_sky_blur)
else:
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)
@@ -311,6 +306,11 @@ 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])
+62 -21
View File
@@ -85,8 +85,29 @@ 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'
EAST_SKY_ENABLED = CONF.get('MOON_EAST_SKY_ENABLED', 'true').lower() != 'false' ATMOSPHERE_BLUR = int(CONF.get('MOON_ATMOSPHERE_BLUR', 0))
EAST_SKY_BLUR = int(CONF.get('MOON_EAST_SKY_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)
PHASE_SPEC = { PHASE_SPEC = {
@@ -277,6 +298,7 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
return 0 return 0
qualifying = [] qualifying = []
above_horizon = [] # frames where moon is up but quality/illum check failed
for path, utc_dt in candidates: for path, utc_dt in candidates:
try: try:
alt, _ = moon_phase.altaz(utc_dt) alt, _ = moon_phase.altaz(utc_dt)
@@ -287,6 +309,7 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
continue continue
det = detect_moon(path) det = detect_moon(path)
if det is None or det.quality < MIN_QUALITY: if det is None or det.quality < MIN_QUALITY:
above_horizon.append((path, utc_dt, det))
continue continue
illum = moon_phase.illumination(utc_dt) illum = moon_phase.illumination(utc_dt)
if illum < spec['illum_min'] or illum > spec['illum_max']: if illum < spec['illum_min'] or illum > spec['illum_max']:
@@ -296,21 +319,38 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
continue continue
qualifying.append((path, utc_dt, det, alt, illum)) qualifying.append((path, utc_dt, det, alt, illum))
print(f'qualifying frames: {len(qualifying)}') print(f'qualifying frames: {len(qualifying)} above-horizon fallback pool: {len(above_horizon)}')
if not qualifying: if not qualifying:
msg = ( # No frame met quality + illumination thresholds — likely overcast.
f'No clear-shot {phase} frame in window {dates[0]}..{dates[-1]} ' # If MOON_CLOUDY_POST_ENABLED, use the above-horizon frame closest to
f'(need quality≥{MIN_QUALITY}, altitude≥{MIN_ALTITUDE}°, ' # the exact phase moment as the atmosphere source and post with a
f'illumination {spec["illum_min"]:.2f}-{spec["illum_max"]:.2f}). ' # 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,
) )
if phase == 'first-quarter':
msg += ('First quarter from east is best-effort because the moon is ' msg = (
'only up during daylight hours — daytime detection often ' f'No {phase} frame in window {dates[0]}..{dates[-1]} '
'fails. Lower MOON_MIN_QUALITY or accept that some months ' f'(quality≥{MIN_QUALITY}, altitude≥{MIN_ALTITUDE}°, '
'will skip.') f'illumination {spec["illum_min"]:.2f}-{spec["illum_max"]:.2f}) '
else: f'and no above-horizon frames for cloudy fallback.'
msg += 'Likely cloudy across the whole window.' )
_notify(f'{spec["emoji"]} {spec["label"]}no clear shot {target_utc.strftime("%B %Y")}', msg) _notify(f'{spec["emoji"]} {spec["label"]}skipped {target_utc.strftime("%B %Y")}', msg)
print(msg) print(msg)
return 0 return 0
@@ -318,10 +358,11 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
path, when_utc, det, alt, illum = best path, when_utc, det, alt, illum = best
local_dt = when_utc.astimezone(tz) local_dt = when_utc.astimezone(tz)
delta_min = (when_utc - target_utc).total_seconds() / 60.0 delta_min = (when_utc - target_utc).total_seconds() / 60.0
opacity = _atmosphere_opacity_from_quality(det.quality)
print(f'picked: {path}') print(f'picked: {path}')
print(f' when_utc={when_utc.isoformat()} local={local_dt} ' print(f' when_utc={when_utc.isoformat()} local={local_dt} '
f'altitude={alt:.1f}° illum={illum:.4f} quality={det.quality:.3f} ' f'altitude={alt:.1f} illum={illum:.4f} quality={det.quality:.3f} '
f'Δtarget={delta_min:+.1f} min') f'opacity={opacity:.2f} delta={delta_min:+.1f} min')
if dry_run: if dry_run:
return 0 return 0
@@ -332,13 +373,13 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} ' witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} '
f'({delta_min:+.0f} min from exact {phase})', f'({delta_min:+.0f} min from exact {phase})',
east_frame_path=path, east_frame_path=path,
east_detection=det, 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, east_detection=None): 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)
@@ -372,8 +413,8 @@ def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
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, east_frame_path=east_frame_path,
east_sky_enabled=EAST_SKY_ENABLED, atmosphere_opacity=atmosphere_opacity,
east_sky_blur=EAST_SKY_BLUR, atmosphere_blur=ATMOSPHERE_BLUR,
) )
print(f'wrote {out_path}') print(f'wrote {out_path}')
+23 -15
View File
@@ -524,24 +524,32 @@ 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
# East sky backdrop ─────────────────────────────────────────────────────────── # Atmospheric overlay ─────────────────────────────────────────────────────────
# When east verifies a moon sighting, its full camera frame is scaled to # East's camera frame is scaled to output size, blurred, and composited OVER
# output size and blurred heavily (GaussianBlur r ≈ output_width / 6) to # the NASA moon disk. This is physically correct: clouds sit between the
# produce an atmospheric backdrop behind the NASA moon disk. # observer and the moon, so they occlude the disk rather than appear behind it.
# #
# The blur removes RTSP artefacts, OSD text, and the wide-angle look while # Opacity is derived from the moon detection quality in that frame:
# preserving the real sky colour, any cloud or haze gradients, and the dark # quality ≥ 0.85 → 0% (clear sky — pure NASA render)
# tree/ground silhouette at the bottom of frame. The NASA moon is pasted # quality 0.70 → 20% (light haze)
# sharp on top — the result looks like east shot it through a telephoto, with # quality 0.55 → 40% (notable cloud, moon still detected)
# its actual sky that night as the background. # quality < 0.55 → 4065% (overcast fallback — see MOON_CLOUDY_POST_ENABLED)
# no detection → 68% (heavy overcast)
# #
# This is the honest solution to "east doesn't capture high-res clouds": east's # blur radius: 0 = auto (output_width / 10); set in px to override.
# real atmospheric fingerprint (dark and clear, softly hazy, or cloud-diffused) #MOON_ATMOSPHERE_BLUR=0
# becomes the background without pretending to photograph detail that isn't there.
# #
# Set false to use a plain black background instead (original behaviour). # Cloudy-month fallback ───────────────────────────────────────────────────────
MOON_EAST_SKY_ENABLED=true # If no frame in the collection window passes the quality + illumination
#MOON_EAST_SKY_BLUR=0 # blur radius in px; 0 = auto (output_width / 6) # 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).
+49 -61
View File
@@ -1,72 +1,64 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""test_moon_composite.py — smoke-test the east-sky-backdrop composite. """test_moon_composite.py — smoke-test atmospheric overlay at three opacity levels.
Uses the sample east frame (21-07-00.jpg) already in the repo and a Generates three output images from the same east camera frame (21-07-00.jpg)
procedurally generated moon disc (no camera timestamp, no copyright) to to show how the atmospheric overlay looks across the quality spectrum:
demonstrate the east-sky backdrop without needing a live NASA API call.
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.
Run from the sky-cam directory: Run from the sky-cam directory:
python3 test_moon_composite.py python3 test_moon_composite.py
Output: test_composite_out.jpg
The east frame (2026-04-29 21:06:45) shows the moon through visible thin
cloud cover; that sky — blurred to atmospheric haze — becomes the backdrop
behind the procedural moon disc. In production the disc is replaced by the
NASA SVS Dial-a-Moon render for the exact UTC hour east captured the moon.
""" """
import os
import pathlib import pathlib
import sys import sys
from datetime import datetime, timezone import tempfile
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 = [
('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'),
]
def _make_procedural_moon(size: int = 2048) -> 'Image': def _make_procedural_moon(size: int = 2048) -> 'Image':
"""Generate a clean grey disc that stands in for a NASA Dial-a-Moon render. """Clean grey disc with simplified lunar maria and limb darkening."""
Draws a base disc, a few darker ellipses for lunar maria, and a subtle
limb-darkening gradient. No timestamp, no copyright, no camera artefacts.
"""
from PIL import Image, ImageDraw, ImageFilter from PIL import Image, ImageDraw, ImageFilter
import numpy as np
img = Image.new('RGB', (size, size), (0, 0, 0)) img = Image.new('RGB', (size, size), (0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
cx = cy = size // 2 cx = cy = size // 2
r = int(size * 0.47) r = int(size * 0.47)
# Base disc — warm grey, slightly off-white like a real moon
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(218, 214, 200)) draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(218, 214, 200))
# A few darker patches suggesting the major maria
draw.ellipse([cx - r//3, cy - r//3, cx + r//6, cy + r//5], fill=(170, 167, 154)) 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//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//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)) draw.ellipse([cx - r//2, cy - r//10, cx - r//5, cy + r//4], fill=(185, 182, 169))
# Smooth the hard edges so it blends naturally
img = img.filter(ImageFilter.GaussianBlur(radius=size // 80)) img = img.filter(ImageFilter.GaussianBlur(radius=size // 80))
# Subtle limb darkening: blend a radial dark vignette at the disc edge
vignette = Image.new('L', (size, size), 0) vignette = Image.new('L', (size, size), 0)
vd = ImageDraw.Draw(vignette) vd = ImageDraw.Draw(vignette)
vd.ellipse([cx - r, cy - r, cx + r, cy + r], fill=255) vd.ellipse([cx - r, cy - r, cx + r, cy + r], fill=255)
vignette = vignette.filter(ImageFilter.GaussianBlur(radius=size // 30)) vignette = vignette.filter(ImageFilter.GaussianBlur(radius=size // 30))
import numpy as np
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
# Darken towards the limb (where vig is small → near edge) arr = np.clip(arr * (0.75 + 0.25 * vig)[..., None], 0, 255).astype('uint8')
darken = 0.75 + 0.25 * vig # 0.75 at edge, 1.0 at centre return Image.fromarray(arr)
arr = np.clip(arr * darken[..., None], 0, 255).astype('uint8')
img = Image.fromarray(arr)
return img
def main(): def main():
@@ -74,53 +66,49 @@ 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)
# Save the procedural disc to a temp file so render_phase_closeup can open it
import tempfile, os
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tf:
tmp_moon = tf.name
try:
print('=== moon composite smoke-test ===')
print(f'east frame : {EAST_FRAME.name}')
print(f'moon disc : procedural (NASA Dial-a-Moon stand-in)')
print(f'output : {OUT_PATH.name}')
print()
print('generating procedural moon disc …') print('generating procedural moon disc …')
moon_img = _make_procedural_moon(2048) moon_img = _make_procedural_moon(2048)
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp_moon = tmp.name
tmp.close()
moon_img.save(tmp_moon) moon_img.save(tmp_moon)
from moon_composite import render_phase_closeup from moon_composite import render_phase_closeup
caption = (
'Full Moon — April 2026 — '
'sky-cam east witnessed at 2026-04-29 21:07:00 UTC '
'(+7 min from exact full) — render: NASA SVS Dial-a-Moon'
)
print('rendering composite with east sky backdrop …') 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( render_phase_closeup(
nasa_render_path=tmp_moon, nasa_render_path=tmp_moon,
out_path=str(OUT_PATH), out_path=str(out),
output_size=(1920, 1080), output_size=(1920, 1080),
moon_height_pct=0.88, moon_height_pct=0.88,
caption=caption, caption=caption,
east_frame_path=str(EAST_FRAME), east_frame_path=str(EAST_FRAME),
east_sky_enabled=True, atmosphere_opacity=opacity,
east_sky_blur=0, # auto: output_width / 6 = 320 px atmosphere_blur=0,
) )
print(f' -> {out}')
finally: finally:
os.unlink(tmp_moon) os.unlink(tmp_moon)
print(f'\ndone → {OUT_PATH}')
print() print()
print('What you should see:') print('done. Three outputs:')
print(' • Background: easts April 29 night sky, blurred to soft atmospheric haze') for filename, opacity, label in CASES:
print(' (thin cloud cover visible as a grey-blue gradient, treeline at bottom)') print(f' {filename:35s} opacity={opacity:.2f} {label}')
print(' • Moon disc filling ~88% of the 1920×1080 frame, pasted sharp on top')
print(' • Caption at bottom-left with phase / date / witness text')
print() print()
print('In production the procedural disc is replaced by the NASA SVS Dial-a-Moon') print('What you should see in each:')
print('render for that exact UTC hour — same layout, real crater detail.') 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')
if __name__ == '__main__': if __name__ == '__main__':