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
+34 -34
View File
@@ -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])
+61 -20
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))
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}')
+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_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 → 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
# 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.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_url, access_token, channel_id go in .env (see bottom of this file).
+62 -74
View File
@@ -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: easts 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__':