diff --git a/moon_composite.py b/moon_composite.py index ef7cdd3..2325668 100755 --- a/moon_composite.py +++ b/moon_composite.py @@ -209,6 +209,63 @@ def composite_full_moon( 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 (2x–5x 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( nasa_render_path: str, out_path: str, @@ -216,14 +273,24 @@ 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, + 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. 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 - the output frame on a black background and add a caption. No east - compositing — the moon dominates the frame the way a 65× telephoto shot - would. + crater shadows for the requested timestamp, so we size it to fill the + output frame on a black background and add a caption. + + 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) moon = Image.open(nasa_render_path).convert('RGB') @@ -240,6 +307,21 @@ def render_phase_closeup( py = (output_size[1] - target) // 2 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: draw = ImageDraw.Draw(bg) draw.text((24, output_size[1] - 44), caption, fill=(0, 0, 0)) diff --git a/moon_phase_monthly.py b/moon_phase_monthly.py index 47dfcb0..e101420 100755 --- a/moon_phase_monthly.py +++ b/moon_phase_monthly.py @@ -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)) 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 = { 'full': { 'index': 2, 'label': 'Full Moon', 'emoji': '🌕', - 'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.95)), + # ±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)), @@ -103,8 +108,9 @@ PHASE_SPEC = { 'index': 1, 'label': 'First Quarter (Waxing Half)', 'emoji': '🌓', - 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)), - 'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)), + # ±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)), @@ -115,8 +121,9 @@ PHASE_SPEC = { 'index': 3, 'label': 'Third Quarter (Waning Half)', 'emoji': '🌗', - 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)), - 'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)), + # ±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)), @@ -325,11 +332,14 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, 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, + east_detection=det, ) 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: out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase) 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, output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT, 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}') diff --git a/sky-cam.conf b/sky-cam.conf index db03658..c315a73 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -499,15 +499,15 @@ MOON_FULL_POST_DELAY_DAYS=3 # 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.40 # ~50% ± 10% covers the day around exact quarter -MOON_QUARTER_MAX_ILLUMINATION=0.65 +MOON_QUARTER_MIN_ILLUMINATION=0.46 # ±4% of 50%: waxing/waning within 4% of exact quarter +MOON_QUARTER_MAX_ILLUMINATION=0.54 # Frame-acceptance thresholds — a candidate must beat all three to qualify. # Lower = more permissive (accept hazier nights / lower moon). If you find # the script never finds a clear shot, loosen these. 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_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 96–100% illumination for full moon # Output frame. Default 1920×1080 to match the sunrise videos. 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_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.0–1.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_url, access_token, channel_id go in .env (see bottom of this file).