Add per-event NASA moon images with east-sky cloud veil

Three changes driven by the same goal: make each monthly moon post
look like east captured it at that exact moment.

Tighter illumination windows (±4% of target phase):
  Full moon:   95% → 96–100%  (was a 5-point band; now hugs exact full)
  Quarters:    40–65% → 46–54%  (was a 25-point band; now ±4% of 50%)
This ensures the NASA Dial-a-Moon render timestamp is pulled within
4 percentage points of the true phase, making the fetched image
genuinely represent that night's moon.  One fresh fetch per event,
~3/month, cached by hour — never reused across months.

Atmospheric cloud veil from east's surrounding sky:
  _extract_cloud_veil() samples the annular sky region just outside
  east's moon disk (2×–5× radius), scales it to the output frame,
  blurs heavily (GaussianBlur r≈output_width/10) so it reads as haze
  rather than an upscaled photo, then blends it over the NASA composite
  at an opacity proportional to sky brightness:
    sky < 5% mean brightness → no veil (clear dark night)
    sky ~10%                 → ~17% veil (thin haze / airglow)
    sky ≥ 20%                → 40% veil (max, MOON_CLOUD_OVERLAY_MAX_OPACITY)
  After the veil pass the NASA moon disk is re-pasted sharply so the
  haze sits naturally behind the crisp lunar surface.
  This is the honest answer to "east can't capture high-res clouds":
  east's real atmospheric fingerprint becomes the veil texture.

New sky-cam.conf keys:
  MOON_CLOUD_OVERLAY_ENABLED=true
  MOON_CLOUD_OVERLAY_MAX_OPACITY=0.40
  #MOON_CLOUD_OVERLAY_BLUR=0  (0 = auto)

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
Claude
2026-05-01 17:07:16 +00:00
parent ebba2b3ae3
commit 3ededd68b8
3 changed files with 131 additions and 13 deletions
+86 -4
View File
@@ -209,6 +209,63 @@ def composite_full_moon(
return out_path return out_path
def _extract_cloud_veil(
east_frame_path: str,
cx: float,
cy: float,
moon_radius_px: float,
output_size: tuple[int, int],
blur_radius: int = 0,
max_opacity: float = 0.40,
) -> tuple[Image.Image, float] | None:
"""Extract sky texture around the moon from east frame as an atmospheric veil.
Samples an annular region just outside the moon disk (2x5x radius),
scales it to output_size, then blurs heavily so it reads as atmospheric
haze rather than an upscaled photo. Opacity is proportional to how
bright the surrounding sky is — dark clear sky returns None, thin cloud
returns a partial veil, bright overcast returns max_opacity.
Returns (image, opacity) or None if the sky is too dark to matter.
"""
src = Image.open(east_frame_path).convert('RGB')
arr = np.asarray(src).astype(np.float32)
src_h, src_w = arr.shape[:2]
inner_r = moon_radius_px * 2.0
outer_r = min(moon_radius_px * 5.0, min(src_h, src_w) * 0.40)
if outer_r <= inner_r:
return None
yy, xx = np.mgrid[0:src_h, 0:src_w]
dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
annulus = (dist >= inner_r) & (dist <= outer_r)
if not annulus.any():
return None
mean_brightness = float(arr[annulus].mean()) / 255.0
if mean_brightness < 0.05:
return None # clear dark sky — nothing to veil
# Opacity scales from 0 at 5% brightness to max_opacity at ~20% brightness.
opacity = min(max_opacity, (mean_brightness - 0.05) * (max_opacity / 0.15))
if opacity <= 0:
return None
x0 = max(0, int(cx - outer_r))
x1 = min(src_w, int(cx + outer_r))
y0 = max(0, int(cy - outer_r))
y1 = min(src_h, int(cy + outer_r))
patch = src.crop((x0, y0, x1, y1))
cloud = patch.resize(output_size, LANCZOS)
# Blur radius: large enough to erase camera detail, keep only haze shape
r = blur_radius if blur_radius > 0 else max(8, output_size[0] // 10)
cloud = cloud.filter(ImageFilter.GaussianBlur(radius=r))
return cloud, opacity
def render_phase_closeup( def render_phase_closeup(
nasa_render_path: str, nasa_render_path: str,
out_path: str, out_path: str,
@@ -216,14 +273,24 @@ def render_phase_closeup(
moon_height_pct: float = 0.92, moon_height_pct: float = 0.92,
caption: str | None = None, caption: str | None = None,
background: tuple[int, int, int] = (0, 0, 0), background: tuple[int, int, int] = (0, 0, 0),
east_frame_path: str | None = None,
east_detection=None,
cloud_overlay_enabled: bool = True,
cloud_overlay_max_opacity: float = 0.40,
cloud_overlay_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 already has the correct phase, libration and The dial-a-moon render already has the correct phase, libration and
crater shadows for the requested timestamp, so we simply size it to fill crater shadows for the requested timestamp, so we size it to fill the
the output frame on a black background and add a caption. No east output frame on a black background and add a caption.
compositing — the moon dominates the frame the way a 65× telephoto shot
would. When east_frame_path and east_detection are provided the function also
extracts the sky around east's moon detection and blends it as a
subtle atmospheric veil over the composite. This lets thin cloud or
haze from east's actual observation show through — the opacity is
proportional to how bright the surrounding sky was. Set
cloud_overlay_enabled=False to always skip this step.
""" """
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')
@@ -240,6 +307,21 @@ 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 veil from east's surrounding sky ──────────────────────
if cloud_overlay_enabled and east_frame_path and east_detection is not None:
cx, cy = east_detection.centroid_xy
radius_px = east_detection.diameter_px / 2
veil = _extract_cloud_veil(
east_frame_path, cx, cy, radius_px, output_size,
blur_radius=cloud_overlay_blur,
max_opacity=cloud_overlay_max_opacity,
)
if veil is not None:
cloud_img, opacity = veil
bg = Image.blend(bg, cloud_img, alpha=opacity)
# Re-paste the moon sharply on top so haze sits behind disk edge
bg.paste(moon_resized, (px, py), mask)
if caption: if caption:
draw = ImageDraw.Draw(bg) draw = ImageDraw.Draw(bg)
draw.text((24, output_size[1] - 44), caption, fill=(0, 0, 0)) draw.text((24, output_size[1] - 44), caption, fill=(0, 0, 0))
+21 -6
View File
@@ -85,13 +85,18 @@ 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'
CLOUD_OVERLAY_ENABLED = CONF.get('MOON_CLOUD_OVERLAY_ENABLED', 'true').lower() != 'false'
CLOUD_OVERLAY_MAX_OPACITY = float(CONF.get('MOON_CLOUD_OVERLAY_MAX_OPACITY', 0.40))
CLOUD_OVERLAY_BLUR = int(CONF.get('MOON_CLOUD_OVERLAY_BLUR', 0))
PHASE_SPEC = { PHASE_SPEC = {
'full': { 'full': {
'index': 2, 'index': 2,
'label': 'Full Moon', 'label': 'Full Moon',
'emoji': '🌕', 'emoji': '🌕',
'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.95)), # ±4% of target (100%): accept 96100% illumination
'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.96)),
'illum_max': 1.01, 'illum_max': 1.01,
'waxing': None, 'waxing': None,
'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)), 'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)),
@@ -103,8 +108,9 @@ PHASE_SPEC = {
'index': 1, 'index': 1,
'label': 'First Quarter (Waxing Half)', 'label': 'First Quarter (Waxing Half)',
'emoji': '🌓', 'emoji': '🌓',
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)), # ±4% of target (50%): accept 4654% illumination, waxing only
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)), 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)),
'waxing': True, 'waxing': True,
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)), 'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)), 'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)),
@@ -115,8 +121,9 @@ PHASE_SPEC = {
'index': 3, 'index': 3,
'label': 'Third Quarter (Waning Half)', 'label': 'Third Quarter (Waning Half)',
'emoji': '🌗', 'emoji': '🌗',
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)), # ±4% of target (50%): accept 4654% illumination, waning only
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)), 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)),
'waxing': False, 'waxing': False,
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)), 'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)), 'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)),
@@ -325,11 +332,14 @@ 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=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_detection=det,
) )
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):
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)
@@ -362,6 +372,11 @@ def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
str(nasa_path), out_path, str(nasa_path), out_path,
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT, output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
caption=caption, caption=caption,
east_frame_path=east_frame_path,
east_detection=east_detection,
cloud_overlay_enabled=CLOUD_OVERLAY_ENABLED,
cloud_overlay_max_opacity=CLOUD_OVERLAY_MAX_OPACITY,
cloud_overlay_blur=CLOUD_OVERLAY_BLUR,
) )
print(f'wrote {out_path}') print(f'wrote {out_path}')
+24 -3
View File
@@ -499,15 +499,15 @@ MOON_FULL_POST_DELAY_DAYS=3
# Quarter (half-moon) tuning ────────────────────────────────────────────────── # Quarter (half-moon) tuning ──────────────────────────────────────────────────
# 2 = waits for D-1, D, D+1 nights, runs the morning of D+2. # 2 = waits for D-1, D, D+1 nights, runs the morning of D+2.
MOON_QUARTER_POST_DELAY_DAYS=2 MOON_QUARTER_POST_DELAY_DAYS=2
MOON_QUARTER_MIN_ILLUMINATION=0.40 # ~50% ± 10% covers the day around exact quarter MOON_QUARTER_MIN_ILLUMINATION=0.46 # ±4% of 50%: waxing/waning within 4% of exact quarter
MOON_QUARTER_MAX_ILLUMINATION=0.65 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.
# Lower = more permissive (accept hazier nights / lower moon). If you find # Lower = more permissive (accept hazier nights / lower moon). If you find
# the script never finds a clear shot, loosen these. # the script never finds a clear shot, loosen these.
MOON_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation) MOON_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation)
MOON_MIN_ALTITUDE_DEG=15 # below this the moon is in trees / on the horizon MOON_MIN_ALTITUDE_DEG=15 # below this the moon is in trees / on the horizon
MOON_FULL_MIN_ILLUMINATION=0.95 # 0..1 — ~95% lit covers ±2 days from exact full MOON_FULL_MIN_ILLUMINATION=0.96 # ±4% of 100%: accept 96100% illumination for full moon
# Output frame. Default 1920×1080 to match the sunrise videos. # Output frame. Default 1920×1080 to match the sunrise videos.
MOON_OUTPUT_W=1920 MOON_OUTPUT_W=1920
@@ -524,6 +524,27 @@ MOON_HEIGHT_PCT=0.92 # moon disk fills this fraction of frame heig
MOON_DIALAMOON_TARGET_PX=2048 # cached PNG longest side; downsampled on save MOON_DIALAMOON_TARGET_PX=2048 # cached PNG longest side; downsampled on save
MOON_DIALAMOON_TIMEOUT_SEC=30 MOON_DIALAMOON_TIMEOUT_SEC=30
# Atmospheric cloud veil ──────────────────────────────────────────────────────
# When east verifies a moon sighting, the surrounding sky region (annulus
# just outside the moon disk) is sampled and scaled to fill the output frame,
# then blurred heavily so it reads as atmospheric haze rather than an upscaled
# photo. The result is blended over the NASA composite at an opacity
# proportional to how bright that sky patch was — dark clear sky adds nothing,
# thin clouds add a gentle veil, bright overcast reaches MOON_CLOUD_OVERLAY_MAX_OPACITY.
#
# This is the honest solution to "east doesn't capture high-res clouds": we use
# east's actual sky as an atmospheric fingerprint rather than pretending to
# photograph cloud detail that isn't there.
#
# Opacity scale:
# mean sky brightness < 5% → no veil (clear dark sky)
# mean sky brightness 10% → ~17% veil (thin haze / airglow)
# mean sky brightness ≥ 20% → MOON_CLOUD_OVERLAY_MAX_OPACITY (40%)
#
MOON_CLOUD_OVERLAY_ENABLED=true
MOON_CLOUD_OVERLAY_MAX_OPACITY=0.40 # 0.01.0; never fully obscures the NASA render
#MOON_CLOUD_OVERLAY_BLUR=0 # blur radius in px; 0 = auto (output_width / 10)
# ── 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).