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

ClDemoe/add moon image locations a cd gh
This commit is contained in:
Outis
2026-05-01 14:05:39 -04:00
committed by GitHub
4 changed files with 242 additions and 13 deletions
+86 -4
View File
@@ -209,6 +209,63 @@ def composite_full_moon(
return out_path 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 (2x5x 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( def render_phase_closeup(
nasa_render_path: str, nasa_render_path: str,
out_path: str, out_path: str,
@@ -216,14 +273,24 @@ def render_phase_closeup(
moon_height_pct: float = 0.92, moon_height_pct: float = 0.92,
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_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. """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 already has the correct phase, libration and
crater shadows for the requested timestamp, so we simply size it to fill crater shadows for the requested timestamp, so we size it to fill the
the output frame on a black background and add a caption. No east output frame on a black background and add a caption.
compositing — the moon dominates the frame the way a 65× telephoto shot
would. 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) bg = Image.new('RGB', output_size, background)
moon = Image.open(nasa_render_path).convert('RGB') moon = Image.open(nasa_render_path).convert('RGB')
@@ -240,6 +307,21 @@ 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.text((24, output_size[1] - 44), caption, fill=(0, 0, 0)) draw.text((24, output_size[1] - 44), caption, fill=(0, 0, 0))
+21 -6
View File
@@ -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)) 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'
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 = { PHASE_SPEC = {
'full': { 'full': {
'index': 2, 'index': 2,
'label': 'Full Moon', 'label': 'Full Moon',
'emoji': '🌕', 'emoji': '🌕',
'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.95)), # ±4% of target (100%): accept 96100% illumination
'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.96)),
'illum_max': 1.01, 'illum_max': 1.01,
'waxing': None, 'waxing': None,
'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)), 'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)),
@@ -103,8 +108,9 @@ PHASE_SPEC = {
'index': 1, 'index': 1,
'label': 'First Quarter (Waxing Half)', 'label': 'First Quarter (Waxing Half)',
'emoji': '🌓', 'emoji': '🌓',
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)), # ±4% of target (50%): accept 4654% illumination, waxing only
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)), 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)),
'waxing': True, 'waxing': True,
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)), 'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)), 'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)),
@@ -115,8 +121,9 @@ PHASE_SPEC = {
'index': 3, 'index': 3,
'label': 'Third Quarter (Waning Half)', 'label': 'Third Quarter (Waning Half)',
'emoji': '🌗', 'emoji': '🌗',
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)), # ±4% of target (50%): accept 4654% illumination, waning only
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)), 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)),
'waxing': False, 'waxing': False,
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)), 'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_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, cam, dry_run, no_upload, out_path,
witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} ' witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} '
f'({delta_min:+.0f} min from exact {phase})', 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, 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: if out_path is None:
out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase) out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
out_dir.mkdir(parents=True, exist_ok=True) 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, str(nasa_path), out_path,
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_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}') print(f'wrote {out_path}')
+24 -3
View File
@@ -499,15 +499,15 @@ MOON_FULL_POST_DELAY_DAYS=3
# Quarter (half-moon) tuning ────────────────────────────────────────────────── # Quarter (half-moon) tuning ──────────────────────────────────────────────────
# 2 = waits for D-1, D, D+1 nights, runs the morning of D+2. # 2 = waits for D-1, D, D+1 nights, runs the morning of D+2.
MOON_QUARTER_POST_DELAY_DAYS=2 MOON_QUARTER_POST_DELAY_DAYS=2
MOON_QUARTER_MIN_ILLUMINATION=0.40 # ~50% ± 10% covers the day around exact quarter MOON_QUARTER_MIN_ILLUMINATION=0.46 # ±4% of 50%: waxing/waning within 4% of exact quarter
MOON_QUARTER_MAX_ILLUMINATION=0.65 MOON_QUARTER_MAX_ILLUMINATION=0.54
# Frame-acceptance thresholds — a candidate must beat all three to qualify. # Frame-acceptance thresholds — a candidate must beat all three to qualify.
# Lower = more permissive (accept hazier nights / lower moon). If you find # Lower = more permissive (accept hazier nights / lower moon). If you find
# the script never finds a clear shot, loosen these. # the script never finds a clear shot, loosen these.
MOON_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation) 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_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 96100% illumination for full moon
# Output frame. Default 1920×1080 to match the sunrise videos. # Output frame. Default 1920×1080 to match the sunrise videos.
MOON_OUTPUT_W=1920 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_TARGET_PX=2048 # cached PNG longest side; downsampled on save
MOON_DIALAMOON_TIMEOUT_SEC=30 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.01.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 — 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).
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""test_moon_composite.py — smoke-test the cloud-veil composite using local files.
Uses the sample east frame (21-07-00.jpg) and the reference moon photo
(full_moon_closeup.jpg) already in the repo to produce a composite without
needing a live NASA API call or actual moon detection.
Run from the sky-cam directory:
python3 test_moon_composite.py
Output: test_composite_out.jpg in the same directory.
The east frame (2026-04-29 21:06:45) shows the moon with visible thin cloud
cover across the sky, so the cloud-veil layer should be clearly active.
"""
import pathlib
import sys
from datetime import datetime, timezone
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
EAST_FRAME = HERE / "21-07-00.jpg"
NASA_RENDER = HERE / "full_moon_closeup.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):
"""Return a detection-like object with centroid_xy and diameter_px."""
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:
centroid_xy = (cx, cy)
diameter_px = MOON_DIAM_PX
quality = 0.82 # plausible for a hazy but visible moon
return _Det()
def main():
for p in (EAST_FRAME, NASA_RENDER):
if not p.exists():
print(f"ERROR: missing {p}", file=sys.stderr)
sys.exit(1)
print("=== moon composite smoke-test ===")
print(f"east frame : {EAST_FRAME.name}")
print(f"NASA render : {NASA_RENDER.name}")
print(f"output : {OUT_PATH.name}")
print()
det = _make_fake_detection(EAST_FRAME)
# Timestamp matching the east frame filename
when_utc = datetime(2026, 4, 29, 21, 7, 0, tzinfo=timezone.utc)
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("What you should see:")
print(" • Full moon disk filling ~92% of the 1920×1080 frame")
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__":
main()