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
116 lines
4.2 KiB
Python
116 lines
4.2 KiB
Python
#!/usr/bin/env python3
|
|
"""test_moon_composite.py — smoke-test atmospheric overlay at three opacity levels.
|
|
|
|
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
|
|
"""
|
|
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import tempfile
|
|
|
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE))
|
|
|
|
EAST_FRAME = HERE / '21-07-00.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':
|
|
"""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)
|
|
|
|
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(218, 214, 200))
|
|
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))
|
|
img = img.filter(ImageFilter.GaussianBlur(radius=size // 80))
|
|
|
|
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))
|
|
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():
|
|
if not EAST_FRAME.exists():
|
|
print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
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:
|
|
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()
|
|
print('done. Three outputs:')
|
|
for filename, opacity, label in CASES:
|
|
print(f' {filename:35s} opacity={opacity:.2f} {label}')
|
|
print()
|
|
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__':
|
|
main()
|