Replace annular cloud veil with full-frame east sky backdrop

The annular-ring veil approach sampled too small a region (38 px moon
in 3840×2160 gives a tiny annulus) and was invisible in practice.
More fundamentally, using the whole east sky is what "relayed to where
it was taken" actually means.

New approach — _make_east_sky_backdrop():
  Scale the full east camera frame to output size, apply GaussianBlur
  r = output_width // 6 (≈ 320 px at 1920 wide).  The blur erases RTSP
  artefacts, OSD overlays, and the wide-angle look while preserving real
  sky colour, cloud / haze gradients, and the dark ground silhouette.
  The NASA moon disk is pasted sharp on top.  The result reads as east
  photographed the moon through a telephoto with its actual sky that night.

  Clear dark sky → nearly black backdrop (same feel as before).
  Thin cloud cover → soft grey-blue haze behind the sharp moon.
  Heavy overcast → the moon detection would not qualify, so this case
    never reaches rendering.

Config rename: MOON_CLOUD_OVERLAY_* → MOON_EAST_SKY_ENABLED / _BLUR.
moon_phase_monthly.py: CLOUD_OVERLAY_* → EAST_SKY_*.
render_phase_closeup(): cloud_overlay_* params → east_sky_*.

test_moon_composite.py: replace full_moon_closeup.jpg (timestamped
photograph) with a procedural grey disc generated via PIL + numpy so the
test does not look like it is reusing an existing image.

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
Claude
2026-05-01 18:17:54 +00:00
parent 5244fbb89b
commit 1425a6e5f4
4 changed files with 151 additions and 182 deletions
+35 -77
View File
@@ -240,61 +240,27 @@ def composite_full_moon(
return out_path
def _extract_cloud_veil(
def _make_east_sky_backdrop(
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.
) -> Image.Image:
"""Scale the full east frame to output_size and blur it heavily.
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.
The blur removes wide-angle camera detail (pixel noise, RTSP compression
artefacts, OSD text) while preserving the real atmospheric colours, any
cloud patterns, and the dark ground silhouette at the bottom of frame.
The result reads as "this is the sky east saw that night" rather than a
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')
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
backdrop = src.resize(output_size, LANCZOS)
r = blur_radius if blur_radius > 0 else output_size[0] // 6
return backdrop.filter(ImageFilter.GaussianBlur(radius=r))
def render_phase_closeup(
@@ -305,25 +271,32 @@ def render_phase_closeup(
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,
east_sky_enabled: bool = True,
east_sky_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 size it to fill the
output frame on a black background and add a caption.
The dial-a-moon render has the correct phase, libration and crater shadows
for the requested timestamp. We size it to fill the output frame 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.
When east_frame_path is provided the east camera's frame for that night is
scaled to output_size and blurred heavily (GaussianBlur r ≈ output_width/6)
to produce an atmospheric backdrop — east's real night sky colour, any
cloud or haze patterns, and the dark ground silhouette at the bottom of
frame all show through the blur as soft gradients. The NASA moon disk is
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 = _square_crop_to_disk(moon)
@@ -338,21 +311,6 @@ 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_caption(draw, caption, (22, output_size[1] - 48), output_size[0])