Files
sky-cam/moon_detect.py
T
Claude 5fb7ff09a3 Add nightly moon-track timelapse and monthly moon-phase composites
Two new jobs operate on the SUNRISE_CAM, sharing three Python helpers
(moon_phase, moon_detect, moon_composite) and one cached lunar texture:

- moon-track.sh: nightly batch detects the moon in each east frame,
  crops a 480x480 box around it, stitches into an mp4 that holds the
  moon roughly centred while clouds and stars drift past.

- moon-phase-monthly.sh: daily check that runs whichever phase composite
  is due that day. Handles full moon (posts D+3), first quarter (D+2,
  best-effort due to daytime-only geometry from east), and third quarter
  (D+2). Picks the frame closest in time to the exact phase moment that
  meets quality / altitude / illumination thresholds, then composites
  the cached lunar texture into it -- sky/halo/parallactic-angle/timing
  real from east, surface detail borrowed from the reference image.

Honest-by-design: a 38-px white blob from a wide-field IP camera cannot
be enhanced into crater detail by software. The composite makes the
borrowing explicit and constrains everything else (when, where, sky,
orientation) to match what east actually saw.

install.sh now downloads the skyfield ephemeris (de421.bsp) and the
default lunar reference (Wikipedia CC BY-SA full-moon photo) on first
run. Both can be overridden via .env.

https://claude.ai/code/session_015PBVDESC3KLMbq1LpA6qLn
2026-04-30 11:40:11 +00:00

218 lines
7.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""moon_detect.py — find the moon disk in a sky-cam frame.
Returns centroid, radius, and a quality score derived from:
- roundness (area / (pi * r^2))
- isolation (no comparable bright blob within IGNORE_RADIUS_PX)
- sky (low halo extent → clearer sky around the moon)
Used as a library:
from moon_detect import detect_moon
result = detect_moon('/path/to/frame.jpg')
if result is not None:
cx, cy = result['centroid']
r = result['radius']
score = result['quality']
CLI (handy for tuning):
python3 moon_detect.py /path/to/frame.jpg
python3 moon_detect.py /path/to/frame.jpg --debug debug.png
The detector ignores the bottom OVERLAY_PX rows because capture frames carry a
burnt-in timestamp that contains saturated pixels.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from dataclasses import dataclass
import numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage
# --- Tuning constants --------------------------------------------------------
SATURATED_THRESHOLD = 240 # 0..255 — pixels at or above this count as "moon disk"
HALO_THRESHOLD = 100 # 0..255 — pixels above this count toward halo extent
OVERLAY_PX = 200 # bottom rows to ignore (timestamp overlay)
MIN_DIAMETER_PX = 10 # blob smaller than this is noise
MAX_DIAMETER_PX = 120 # blob bigger than this is probably the sun, not the moon
MIN_ROUNDNESS = 0.55 # area / (pi * r^2) — perfect circle = 1
ISOLATION_PX = 200 # other comparable blob this close → reject
# Halo extent — typical moon halo is ~3.5x disk radius; >5x means thick clouds
MAX_HALO_RATIO = 6.0
@dataclass
class MoonDetection:
centroid_xy: tuple[float, float]
radius_px: float
diameter_px: float
blob_pixels: int
roundness: float
halo_radius_px: float
halo_ratio: float
isolation_px: float
quality: float
def asdict(self) -> dict:
return {
'centroid': list(self.centroid_xy),
'radius': self.radius_px,
'diameter': self.diameter_px,
'blob_pixels': self.blob_pixels,
'roundness': self.roundness,
'halo_radius': self.halo_radius_px,
'halo_ratio': self.halo_ratio,
'isolation': self.isolation_px,
'quality': self.quality,
}
def _grayscale_array(image_path: str) -> np.ndarray:
im = Image.open(image_path).convert('L')
return np.asarray(im)
def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray] | None:
labels, n = ndimage.label(mask)
if n == 0:
return None
sizes = ndimage.sum(mask, labels, range(1, n + 1))
# Sort blobs by size descending; check roundness on the top few
order = np.argsort(sizes)[::-1]
for idx in order[:8]:
label_id = idx + 1
ys, xs = np.where(labels == label_id)
if len(xs) < 4:
continue
bbw = xs.max() - xs.min() + 1
bbh = ys.max() - ys.min() + 1
diam = max(bbw, bbh)
if diam < MIN_DIAMETER_PX or diam > MAX_DIAMETER_PX:
continue
radius = diam / 2.0
roundness = len(xs) / (math.pi * radius * radius)
if roundness < MIN_ROUNDNESS:
continue
return label_id, ys, xs
return None
def detect_moon(image_path: str) -> MoonDetection | None:
"""Return MoonDetection or None if no acceptable moon is found."""
a = _grayscale_array(image_path)
if OVERLAY_PX > 0:
a = a.copy()
a[-OVERLAY_PX:, :] = 0
mask = a >= SATURATED_THRESHOLD
if not mask.any():
return None
found = _largest_round_blob(mask)
if found is None:
return None
_, ys, xs = found
cx = float(xs.mean())
cy = float(ys.mean())
blob_pixels = int(len(xs))
bbw = xs.max() - xs.min() + 1
bbh = ys.max() - ys.min() + 1
diameter = float(max(bbw, bbh))
radius = diameter / 2.0
roundness = blob_pixels / (math.pi * radius * radius)
# Isolation: any other saturated blob nearby of comparable size?
labels_full, n_full = ndimage.label(mask)
own_label = labels_full[int(round(cy)), int(round(cx))]
isolation = float('inf')
for label_id in range(1, n_full + 1):
if label_id == own_label:
continue
ys2, xs2 = np.where(labels_full == label_id)
if len(xs2) < blob_pixels * 0.3:
continue
d = math.hypot(xs2.mean() - cx, ys2.mean() - cy)
if d < isolation:
isolation = d
if isolation < ISOLATION_PX:
return None
# Halo: connected component above HALO_THRESHOLD that contains the centroid
halo_mask = a >= HALO_THRESHOLD
halo_labels, _ = ndimage.label(halo_mask)
halo_id = halo_labels[int(round(cy)), int(round(cx))]
if halo_id == 0:
halo_radius = radius
else:
yh, xh = np.where(halo_labels == halo_id)
halo_radius = max(xh.max() - xh.min(), yh.max() - yh.min()) / 2.0
halo_ratio = halo_radius / radius if radius > 0 else 1.0
if halo_ratio > MAX_HALO_RATIO:
return None # too much glow → probably thick cloud cover
# Quality score: roundness (0..1), low halo (1 = clear, 0 = thick cloud),
# isolation factor (1 if very isolated, less if close to other lights).
halo_clean = max(0.0, min(1.0, (MAX_HALO_RATIO - halo_ratio) / (MAX_HALO_RATIO - 1.5)))
iso_factor = 1.0 if isolation == float('inf') else min(1.0, isolation / 600.0)
quality = 0.5 * roundness + 0.35 * halo_clean + 0.15 * iso_factor
return MoonDetection(
centroid_xy=(cx, cy),
radius_px=radius,
diameter_px=diameter,
blob_pixels=blob_pixels,
roundness=roundness,
halo_radius_px=halo_radius,
halo_ratio=halo_ratio,
isolation_px=isolation if isolation != float('inf') else -1.0,
quality=quality,
)
def _draw_debug(image_path: str, detection: MoonDetection | None, out_path: str):
im = Image.open(image_path).convert('RGB')
draw = ImageDraw.Draw(im)
if detection is None:
draw.text((20, 20), 'NO MOON DETECTED', fill='red')
else:
cx, cy = detection.centroid_xy
r = detection.radius_px
hr = detection.halo_radius_px
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline='yellow', width=4)
draw.ellipse((cx - hr, cy - hr, cx + hr, cy + hr), outline='cyan', width=2)
draw.text((20, 20), f'q={detection.quality:.2f} r={r:.1f} halo={hr:.1f}',
fill='yellow')
im.save(out_path)
def _cli():
p = argparse.ArgumentParser()
p.add_argument('image')
p.add_argument('--debug', help='write annotated PNG to this path')
p.add_argument('--json', action='store_true', help='emit JSON for scripting')
args = p.parse_args()
det = detect_moon(args.image)
if args.json:
print(json.dumps(det.asdict() if det else None))
elif det is None:
print('no moon detected')
else:
d = det.asdict()
for k, v in d.items():
print(f'{k}={v}')
if args.debug:
_draw_debug(args.image, det, args.debug)
print(f'debug image written: {args.debug}', file=sys.stderr)
return 0 if det else 2
if __name__ == '__main__':
sys.exit(_cli())