From c09b872f5215e9e6c1e9cf3ee856e4625b25deeb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:51:42 +0000 Subject: [PATCH] Apply atmosphere overlay ON TOP of moon, add overcast fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- moon_composite.py | 68 ++++++++++----------- moon_phase_monthly.py | 81 ++++++++++++++++++------ sky-cam.conf | 38 +++++++----- test_moon_composite.py | 136 +++++++++++++++++++---------------------- 4 files changed, 180 insertions(+), 143 deletions(-) diff --git a/moon_composite.py b/moon_composite.py index 195b72e..bae2fba 100755 --- a/moon_composite.py +++ b/moon_composite.py @@ -240,27 +240,26 @@ def composite_full_moon( return out_path -def _make_east_sky_backdrop( +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 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 - artefacts, OSD text) while preserving the real atmospheric colours, any - cloud patterns, and the dark ground silhouette at the bottom of frame. - The result reads as "this is the sky east saw that night" rather than a - stretched wide-angle photo. + 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 chooses automatically: output_width // 6, which gives a - soft impressionistic backdrop while still letting cloud shapes show - through as gentle colour gradients. + 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') - backdrop = src.resize(output_size, LANCZOS) - r = blur_radius if blur_radius > 0 else output_size[0] // 6 - return backdrop.filter(ImageFilter.GaussianBlur(radius=r)) + 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( @@ -271,32 +270,28 @@ def render_phase_closeup( caption: str | None = None, background: tuple[int, int, int] = (0, 0, 0), east_frame_path: str | None = None, - east_sky_enabled: bool = True, - east_sky_blur: int = 0, + atmosphere_opacity: float = 0.0, + atmosphere_blur: int = 0, ): """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 - for the requested timestamp. We size it to fill the output frame and add - a caption. + 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. - When east_frame_path is provided the east camera's frame for that night is - scaled to output_size and blurred heavily (GaussianBlur r ≈ output_width/6) - to produce an atmospheric backdrop — east's real night sky colour, any - cloud or haze patterns, and the dark ground silhouette at the bottom of - frame all show through the blur as soft gradients. The NASA moon disk is - 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. + 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 """ - if east_sky_enabled and east_frame_path: - bg = _make_east_sky_backdrop(east_frame_path, output_size, east_sky_blur) - else: - bg = Image.new('RGB', output_size, background) - + # ── Background + NASA moon ──────────────────────────────────────────── + bg = Image.new('RGB', output_size, background) moon = Image.open(nasa_render_path).convert('RGB') moon = _square_crop_to_disk(moon) @@ -311,6 +306,11 @@ 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]) diff --git a/moon_phase_monthly.py b/moon_phase_monthly.py index d980b13..b908cc1 100755 --- a/moon_phase_monthly.py +++ b/moon_phase_monthly.py @@ -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)) REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false' -EAST_SKY_ENABLED = CONF.get('MOON_EAST_SKY_ENABLED', 'true').lower() != 'false' -EAST_SKY_BLUR = int(CONF.get('MOON_EAST_SKY_BLUR', 0)) +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) PHASE_SPEC = { @@ -277,6 +298,7 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, 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) @@ -287,6 +309,7 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, 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']: @@ -296,21 +319,38 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, continue 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: + # 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, + ) + msg = ( - f'No clear-shot {phase} frame in window {dates[0]}..{dates[-1]} ' - f'(need quality≥{MIN_QUALITY}, altitude≥{MIN_ALTITUDE}°, ' - f'illumination {spec["illum_min"]:.2f}-{spec["illum_max"]:.2f}). ' + 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.' ) - if phase == 'first-quarter': - msg += ('First quarter from east is best-effort because the moon is ' - 'only up during daylight hours — daytime detection often ' - 'fails. Lower MOON_MIN_QUALITY or accept that some months ' - 'will skip.') - else: - 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) return 0 @@ -318,10 +358,11 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, 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'Δtarget={delta_min:+.1f} min') + f'altitude={alt:.1f} illum={illum:.4f} quality={det.quality:.3f} ' + f'opacity={opacity:.2f} delta={delta_min:+.1f} min') if dry_run: 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")} ' f'({delta_min:+.0f} min from exact {phase})', east_frame_path=path, - east_detection=det, + atmosphere_opacity=opacity, ) 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, east_detection=None): + east_frame_path=None, atmosphere_opacity=0.0): if out_path is None: out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase) 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, caption=caption, east_frame_path=east_frame_path, - east_sky_enabled=EAST_SKY_ENABLED, - east_sky_blur=EAST_SKY_BLUR, + atmosphere_opacity=atmosphere_opacity, + atmosphere_blur=ATMOSPHERE_BLUR, ) print(f'wrote {out_path}') diff --git a/sky-cam.conf b/sky-cam.conf index f8dd4be..8f54113 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -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_TIMEOUT_SEC=30 -# East sky backdrop ─────────────────────────────────────────────────────────── -# When east verifies a moon sighting, its full camera frame is scaled to -# output size and blurred heavily (GaussianBlur r ≈ output_width / 6) to -# produce an atmospheric backdrop behind the NASA moon disk. +# 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. # -# The blur removes RTSP artefacts, OSD text, and the wide-angle look while -# preserving the real sky colour, any cloud or haze gradients, and the dark -# tree/ground silhouette at the bottom of frame. The NASA moon is pasted -# sharp on top — the result looks like east shot it through a telephoto, with -# its actual sky that night as the background. +# 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) # -# This is the honest solution to "east doesn't capture high-res clouds": east's -# real atmospheric fingerprint (dark and clear, softly hazy, or cloud-diffused) -# becomes the background without pretending to photograph detail that isn't there. +# blur radius: 0 = auto (output_width / 10); set in px to override. +#MOON_ATMOSPHERE_BLUR=0 # -# Set false to use a plain black background instead (original behaviour). -MOON_EAST_SKY_ENABLED=true -#MOON_EAST_SKY_BLUR=0 # blur radius in px; 0 = auto (output_width / 6) +# 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). diff --git a/test_moon_composite.py b/test_moon_composite.py index f9798b2..197dab4 100644 --- a/test_moon_composite.py +++ b/test_moon_composite.py @@ -1,72 +1,64 @@ #!/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 -procedurally generated moon disc (no camera timestamp, no copyright) to -demonstrate the east-sky backdrop without needing a live NASA API call. +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: + + 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: 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 sys -from datetime import datetime, timezone +import tempfile 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'), +] def _make_procedural_moon(size: int = 2048) -> 'Image': - """Generate a clean grey disc that stands in for a NASA Dial-a-Moon render. - - Draws a base disc, a few darker ellipses for lunar maria, and a subtle - limb-darkening gradient. No timestamp, no copyright, no camera artefacts. - """ + """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) - # 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)) - - # 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//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)) - - # Smooth the hard edges so it blends naturally 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) vd = ImageDraw.Draw(vignette) vd.ellipse([cx - r, cy - r, cx + r, cy + r], fill=255) vignette = vignette.filter(ImageFilter.GaussianBlur(radius=size // 30)) - import numpy as np - arr = np.asarray(img).astype(float) - vig = np.asarray(vignette).astype(float) / 255.0 - # Darken towards the limb (where vig is small → near edge) - darken = 0.75 + 0.25 * vig # 0.75 at edge, 1.0 at centre - arr = np.clip(arr * darken[..., None], 0, 255).astype('uint8') - img = Image.fromarray(arr) - - return img + 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) def main(): @@ -74,53 +66,49 @@ def main(): print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr) 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 + 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 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 …') - moon_img = _make_procedural_moon(2048) - moon_img.save(tmp_moon) - - 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 …') - render_phase_closeup( - nasa_render_path=tmp_moon, - out_path=str(OUT_PATH), - output_size=(1920, 1080), - moon_height_pct=0.88, - caption=caption, - east_frame_path=str(EAST_FRAME), - east_sky_enabled=True, - east_sky_blur=0, # auto: output_width / 6 = 320 px - ) + 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) - print(f'\ndone → {OUT_PATH}') print() - print('What you should see:') - print(' • Background: east’s April 29 night sky, blurred to soft atmospheric haze') - print(' (thin cloud cover visible as a grey-blue gradient, treeline at bottom)') - 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('done. Three outputs:') + for filename, opacity, label in CASES: + print(f' {filename:35s} opacity={opacity:.2f} {label}') print() - print('In production the procedural disc is replaced by the NASA SVS Dial-a-Moon') - print('render for that exact UTC hour — same layout, real crater detail.') + 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') if __name__ == '__main__':