Add per-event NASA moon images with east-sky cloud veil
Three changes driven by the same goal: make each monthly moon post
look like east captured it at that exact moment.
Tighter illumination windows (±4% of target phase):
Full moon: 95% → 96–100% (was a 5-point band; now hugs exact full)
Quarters: 40–65% → 46–54% (was a 25-point band; now ±4% of 50%)
This ensures the NASA Dial-a-Moon render timestamp is pulled within
4 percentage points of the true phase, making the fetched image
genuinely represent that night's moon. One fresh fetch per event,
~3/month, cached by hour — never reused across months.
Atmospheric cloud veil from east's surrounding sky:
_extract_cloud_veil() samples the annular sky region just outside
east's moon disk (2×–5× radius), scales it to the output frame,
blurs heavily (GaussianBlur r≈output_width/10) so it reads as haze
rather than an upscaled photo, then blends it over the NASA composite
at an opacity proportional to sky brightness:
sky < 5% mean brightness → no veil (clear dark night)
sky ~10% → ~17% veil (thin haze / airglow)
sky ≥ 20% → 40% veil (max, MOON_CLOUD_OVERLAY_MAX_OPACITY)
After the veil pass the NASA moon disk is re-pasted sharply so the
haze sits naturally behind the crisp lunar surface.
This is the honest answer to "east can't capture high-res clouds":
east's real atmospheric fingerprint becomes the veil texture.
New sky-cam.conf keys:
MOON_CLOUD_OVERLAY_ENABLED=true
MOON_CLOUD_OVERLAY_MAX_OPACITY=0.40
#MOON_CLOUD_OVERLAY_BLUR=0 (0 = auto)
https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
+86
-4
@@ -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 (2x–5x 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
@@ -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 96–100% 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 46–54% 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 46–54% 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
@@ -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 96–100% 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.0–1.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).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user