Merge pull request #64 from outis1one/clDemoe/add-moon-image-locations-ACdGH

Replace annular cloud veil with full-frame east sky backdrop
This commit is contained in:
Outis
2026-05-01 14:19:51 -04:00
committed by GitHub
4 changed files with 151 additions and 182 deletions
+35 -77
View File
@@ -240,61 +240,27 @@ def composite_full_moon(
return out_path return out_path
def _extract_cloud_veil( def _make_east_sky_backdrop(
east_frame_path: str, east_frame_path: str,
cx: float,
cy: float,
moon_radius_px: float,
output_size: tuple[int, int], output_size: tuple[int, int],
blur_radius: int = 0, blur_radius: int = 0,
max_opacity: float = 0.40, ) -> Image.Image:
) -> tuple[Image.Image, float] | None: """Scale the full east frame to output_size and blur it heavily.
"""Extract sky texture around the moon from east frame as an atmospheric veil.
Samples an annular region just outside the moon disk (2x5x radius), The blur removes wide-angle camera detail (pixel noise, RTSP compression
scales it to output_size, then blurs heavily so it reads as atmospheric artefacts, OSD text) while preserving the real atmospheric colours, any
haze rather than an upscaled photo. Opacity is proportional to how cloud patterns, and the dark ground silhouette at the bottom of frame.
bright the surrounding sky is — dark clear sky returns None, thin cloud The result reads as "this is the sky east saw that night" rather than a
returns a partial veil, bright overcast returns max_opacity. stretched wide-angle photo.
Returns (image, opacity) or None if the sky is too dark to matter. 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.
""" """
src = Image.open(east_frame_path).convert('RGB') src = Image.open(east_frame_path).convert('RGB')
arr = np.asarray(src).astype(np.float32) backdrop = src.resize(output_size, LANCZOS)
src_h, src_w = arr.shape[:2] r = blur_radius if blur_radius > 0 else output_size[0] // 6
return backdrop.filter(ImageFilter.GaussianBlur(radius=r))
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(
@@ -305,25 +271,32 @@ def render_phase_closeup(
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_frame_path: str | None = None,
east_detection=None, east_sky_enabled: bool = True,
cloud_overlay_enabled: bool = True, east_sky_blur: int = 0,
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 has the correct phase, libration and crater shadows
crater shadows for the requested timestamp, so we size it to fill the for the requested timestamp. We size it to fill the output frame and add
output frame on a black background and add a caption. a caption.
When east_frame_path and east_detection are provided the function also When east_frame_path is provided the east camera's frame for that night is
extracts the sky around east's moon detection and blends it as a scaled to output_size and blurred heavily (GaussianBlur r ≈ output_width/6)
subtle atmospheric veil over the composite. This lets thin cloud or to produce an atmospheric backdrop — east's real night sky colour, any
haze from east's actual observation show through — the opacity is cloud or haze patterns, and the dark ground silhouette at the bottom of
proportional to how bright the surrounding sky was. Set frame all show through the blur as soft gradients. The NASA moon disk is
cloud_overlay_enabled=False to always skip this step. 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.
""" """
bg = Image.new('RGB', output_size, background) 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)
moon = Image.open(nasa_render_path).convert('RGB') moon = Image.open(nasa_render_path).convert('RGB')
moon = _square_crop_to_disk(moon) moon = _square_crop_to_disk(moon)
@@ -338,21 +311,6 @@ 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_caption(draw, caption, (22, output_size[1] - 48), output_size[0]) _draw_caption(draw, caption, (22, output_size[1] - 48), output_size[0])
+4 -7
View File
@@ -85,9 +85,8 @@ 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' EAST_SKY_ENABLED = CONF.get('MOON_EAST_SKY_ENABLED', 'true').lower() != 'false'
CLOUD_OVERLAY_MAX_OPACITY = float(CONF.get('MOON_CLOUD_OVERLAY_MAX_OPACITY', 0.40)) EAST_SKY_BLUR = int(CONF.get('MOON_EAST_SKY_BLUR', 0))
CLOUD_OVERLAY_BLUR = int(CONF.get('MOON_CLOUD_OVERLAY_BLUR', 0))
PHASE_SPEC = { PHASE_SPEC = {
@@ -373,10 +372,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, output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
caption=caption, caption=caption,
east_frame_path=east_frame_path, east_frame_path=east_frame_path,
east_detection=east_detection, east_sky_enabled=EAST_SKY_ENABLED,
cloud_overlay_enabled=CLOUD_OVERLAY_ENABLED, east_sky_blur=EAST_SKY_BLUR,
cloud_overlay_max_opacity=CLOUD_OVERLAY_MAX_OPACITY,
cloud_overlay_blur=CLOUD_OVERLAY_BLUR,
) )
print(f'wrote {out_path}') print(f'wrote {out_path}')
+15 -17
View File
@@ -524,26 +524,24 @@ 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 ────────────────────────────────────────────────────── # East sky backdrop ───────────────────────────────────────────────────────────
# When east verifies a moon sighting, the surrounding sky region (annulus # When east verifies a moon sighting, its full camera frame is scaled to
# just outside the moon disk) is sampled and scaled to fill the output frame, # output size and blurred heavily (GaussianBlur r ≈ output_width / 6) to
# then blurred heavily so it reads as atmospheric haze rather than an upscaled # produce an atmospheric backdrop behind the NASA moon disk.
# 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 # The blur removes RTSP artefacts, OSD text, and the wide-angle look while
# east's actual sky as an atmospheric fingerprint rather than pretending to # preserving the real sky colour, any cloud or haze gradients, and the dark
# photograph cloud detail that isn't there. # 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 scale: # This is the honest solution to "east doesn't capture high-res clouds": east's
# mean sky brightness < 5% → no veil (clear dark sky) # real atmospheric fingerprint (dark and clear, softly hazy, or cloud-diffused)
# mean sky brightness 10% → ~17% veil (thin haze / airglow) # becomes the background without pretending to photograph detail that isn't there.
# mean sky brightness ≥ 20% → MOON_CLOUD_OVERLAY_MAX_OPACITY (40%)
# #
MOON_CLOUD_OVERLAY_ENABLED=true # Set false to use a plain black background instead (original behaviour).
MOON_CLOUD_OVERLAY_MAX_OPACITY=0.40 # 0.01.0; never fully obscures the NASA render MOON_EAST_SKY_ENABLED=true
#MOON_CLOUD_OVERLAY_BLUR=0 # blur radius in px; 0 = auto (output_width / 10) #MOON_EAST_SKY_BLUR=0 # blur radius in px; 0 = auto (output_width / 6)
# ── 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).
+97 -81
View File
@@ -1,18 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""test_moon_composite.py — smoke-test the cloud-veil composite using local files. """test_moon_composite.py — smoke-test the east-sky-backdrop composite.
Uses the sample east frame (21-07-00.jpg) and the reference moon photo Uses the sample east frame (21-07-00.jpg) already in the repo and a
(full_moon_closeup.jpg) already in the repo to produce a composite without procedurally generated moon disc (no camera timestamp, no copyright) to
needing a live NASA API call or actual moon detection. demonstrate the east-sky backdrop without needing a live NASA API call.
Run from the sky-cam directory: Run from the sky-cam directory:
python3 test_moon_composite.py python3 test_moon_composite.py
Output: test_composite_out.jpg in the same directory. Output: test_composite_out.jpg
The east frame (2026-04-29 21:06:45) shows the moon with visible thin cloud The east frame (2026-04-29 21:06:45) shows the moon through visible thin
cover across the sky, so the cloud-veil layer should be clearly active. 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 pathlib import pathlib
@@ -22,90 +24,104 @@ from datetime import datetime, timezone
HERE = pathlib.Path(__file__).resolve().parent HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE))
EAST_FRAME = HERE / "21-07-00.jpg" EAST_FRAME = HERE / '21-07-00.jpg'
NASA_RENDER = HERE / "full_moon_closeup.jpg" OUT_PATH = HERE / 'test_composite_out.jpg'
OUT_PATH = HERE / "test_composite_out.jpg"
# Approximate moon centroid in the east frame, estimated visually.
# The moon appears at roughly 68% from left, 31% from top of the 1920×1080 frame.
# moon_detect.py would compute these exactly at runtime.
MOON_CX_FRAC = 0.68
MOON_CY_FRAC = 0.31
# Apparent diameter in source pixels — roughly 38 px for a typical wide-field
# IP camera at full-moon. Adjust if your camera gives a larger blob.
MOON_DIAM_PX = 38
def _make_fake_detection(frame_path): def _make_procedural_moon(size: int = 2048) -> 'Image':
"""Return a detection-like object with centroid_xy and diameter_px.""" """Generate a clean grey disc that stands in for a NASA Dial-a-Moon render.
from PIL import Image
im = Image.open(frame_path)
w, h = im.size
cx = w * MOON_CX_FRAC
cy = h * MOON_CY_FRAC
print(f" frame size : {w}×{h}")
print(f" moon centroid: ({cx:.0f}, {cy:.0f})")
print(f" moon diameter: {MOON_DIAM_PX} px")
class _Det: Draws a base disc, a few darker ellipses for lunar maria, and a subtle
centroid_xy = (cx, cy) limb-darkening gradient. No timestamp, no copyright, no camera artefacts.
diameter_px = MOON_DIAM_PX """
quality = 0.82 # plausible for a hazy but visible moon from PIL import Image, ImageDraw, ImageFilter
img = Image.new('RGB', (size, size), (0, 0, 0))
draw = ImageDraw.Draw(img)
return _Det() 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
def main(): def main():
for p in (EAST_FRAME, NASA_RENDER): if not EAST_FRAME.exists():
if not p.exists(): print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr)
print(f"ERROR: missing {p}", file=sys.stderr) sys.exit(1)
sys.exit(1)
print("=== moon composite smoke-test ===") # Save the procedural disc to a temp file so render_phase_closeup can open it
print(f"east frame : {EAST_FRAME.name}") import tempfile, os
print(f"NASA render : {NASA_RENDER.name}") with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tf:
print(f"output : {OUT_PATH.name}") tmp_moon = tf.name
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
)
finally:
os.unlink(tmp_moon)
print(f'\ndone → {OUT_PATH}')
print() print()
print('What you should see:')
det = _make_fake_detection(EAST_FRAME) 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)')
# Timestamp matching the east frame filename print(' • Moon disc filling ~88% of the 1920×1080 frame, pasted sharp on top')
when_utc = datetime(2026, 4, 29, 21, 7, 0, tzinfo=timezone.utc) print(' • Caption at bottom-left with phase / date / witness text')
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 [SIMULATED]"
)
print("rendering composite …")
render_phase_closeup(
nasa_render_path=str(NASA_RENDER),
out_path=str(OUT_PATH),
output_size=(1920, 1080),
moon_height_pct=0.92,
caption=caption,
east_frame_path=str(EAST_FRAME),
east_detection=det,
cloud_overlay_enabled=True,
cloud_overlay_max_opacity=0.40,
cloud_overlay_blur=0, # auto
)
print(f"\ndone → {OUT_PATH}")
print() print()
print("What you should see:") print('In production the procedural disc is replaced by the NASA SVS Dial-a-Moon')
print(" • Full moon disk filling ~92% of the 1920×1080 frame") print('render for that exact UTC hour — same layout, real crater detail.')
print(" • Thin grey-blue cloud veil over the composite — sourced from")
print(" the sky ring around the moon in 21-07-00.jpg, blurred to haze")
print(" • Moon disk re-pasted sharp on top of the veil layer")
print(" • Caption at bottom-left with phase / date / witness text")
print()
print("Sky brightness in the annular ring drives veil opacity.")
print("The hazy sky visible in 21-07-00.jpg should produce a visible but")
print("partial veil (estimated ~2030% opacity for that frame).")
if __name__ == "__main__": if __name__ == '__main__':
main() main()