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
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(
nasa_render_path: str,
out_path: str,
@@ -216,14 +273,24 @@ def render_phase_closeup(
moon_height_pct: float = 0.92,
caption: str | None = None,
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.
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
the output frame on a black background and add a caption. No east
compositing — the moon dominates the frame the way a 65× telephoto shot
would.
crater shadows for the requested timestamp, so we size it to fill the
output frame on a black background and add a caption.
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)
moon = Image.open(nasa_render_path).convert('RGB')
@@ -240,6 +307,21 @@ def render_phase_closeup(
py = (output_size[1] - target) // 2
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:
draw = ImageDraw.Draw(bg)
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))
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 = {
'full': {
'index': 2,
'label': 'Full Moon',
'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,
'waxing': None,
'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)),
@@ -103,8 +108,9 @@ PHASE_SPEC = {
'index': 1,
'label': 'First Quarter (Waxing Half)',
'emoji': '🌓',
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)),
# ±4% of target (50%): accept 4654% illumination, waxing only
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)),
'waxing': True,
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)),
@@ -115,8 +121,9 @@ PHASE_SPEC = {
'index': 3,
'label': 'Third Quarter (Waning Half)',
'emoji': '🌗',
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)),
# ±4% of target (50%): accept 4654% illumination, waning only
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)),
'waxing': False,
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_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,
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,
)
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:
out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
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,
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
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}')
+24 -3
View File
@@ -499,15 +499,15 @@ MOON_FULL_POST_DELAY_DAYS=3
# Quarter (half-moon) tuning ──────────────────────────────────────────────────
# 2 = waits for D-1, D, D+1 nights, runs the morning of D+2.
MOON_QUARTER_POST_DELAY_DAYS=2
MOON_QUARTER_MIN_ILLUMINATION=0.40 # ~50% ± 10% covers the day around exact quarter
MOON_QUARTER_MAX_ILLUMINATION=0.65
MOON_QUARTER_MIN_ILLUMINATION=0.46 # ±4% of 50%: waxing/waning within 4% of exact quarter
MOON_QUARTER_MAX_ILLUMINATION=0.54
# Frame-acceptance thresholds — a candidate must beat all three to qualify.
# Lower = more permissive (accept hazier nights / lower moon). If you find
# the script never finds a clear shot, loosen these.
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_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.
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_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_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()