From 29a864375b98cca0f88eb723bc233fcd8774d8fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 23:21:44 +0000 Subject: [PATCH] Add waxing/waning crescent phases; fix phase-shadow double-application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moon_detect.py - detect_moon() accepts saturated_threshold / min_roundness / max_halo_ratio so crescent phases (dim, non-circular arc) can be detected with relaxed thresholds without changing defaults for full/quarter detection. moon_phase.py - crescent_times_in_range(): 2-hour scan returning the UTC moment illumination crosses a target fraction (default 28%) on the waxing or waning side. Used for scheduling just like phase_events_in_range() for named phases. moon_composite.py - Remove _apply_phase_shadow() from composite_full_moon. NASA SVS Dial-a-Moon renders already include the correct terminator, libration and earthshine for the exact hour; applying the shadow on top double-darkened the unlit limb on every non-full phase. The function stays available for callers that need it. moon_phase_monthly.py - Add waxing-crescent (🌒) and waning-crescent (🌘) to PHASE_SPEC. Timing via crescent_times_in_range(); scheduling identical to other phases. - CRESCENT_* tuning constants (all overridable from sky-cam.conf): MOON_CRESCENT_TARGET_ILLUM default 0.28 MOON_CRESCENT_MIN_QUALITY default 0.35 MOON_CRESCENT_SATURATED_THR default 180 MOON_CRESCENT_MIN_ROUNDNESS default 0.15 MOON_CRESCENT_MAX_HALO_RATIO default 12.0 - run_phase() and auto_run() branch on spec['crescent'] to use crescent timing and detection params. - Atmospheric background naturally captures twilight colours (dawn for waning crescent, dusk/dawn for waxing) from the real east frame. https://claude.ai/code/session_01HuJ83KvMvshiY6HxJbtMsc --- moon_composite.py | 11 +++---- moon_detect.py | 30 ++++++++++++------ moon_phase.py | 40 +++++++++++++++++++++++ moon_phase_monthly.py | 74 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 131 insertions(+), 24 deletions(-) diff --git a/moon_composite.py b/moon_composite.py index 0c0631f..943ab85 100755 --- a/moon_composite.py +++ b/moon_composite.py @@ -254,12 +254,11 @@ def composite_full_moon( ImageFilter.UnsharpMask(radius=2, percent=160, threshold=2) ) - # ── Phase shadow (skip when essentially full) ── - illum = moon_phase.illumination(when_utc) - pa = moon_phase.phase_angle(when_utc) - if illum < 0.995: - wax = moon_phase.waxing(when_utc) - ref_resized = _apply_phase_shadow(ref_resized, pa, wax) + # Phase shadow is NOT applied here: the NASA SVS Dial-a-Moon source already + # renders the correct terminator, libration and earthshine for the exact + # hour. Applying _apply_phase_shadow on top would double-darken the unlit + # limb. (_apply_phase_shadow remains available for callers that supply a + # full-moon reference photo rather than a phase-correct NASA render.) # ── Parallactic-angle rotation ── par = moon_phase.parallactic_angle(when_utc) diff --git a/moon_detect.py b/moon_detect.py index 0d1d1e1..803cf54 100755 --- a/moon_detect.py +++ b/moon_detect.py @@ -78,12 +78,14 @@ def _grayscale_array(image_path: str) -> np.ndarray: return np.asarray(im) -def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray] | None: +def _largest_round_blob( + mask: np.ndarray, + min_roundness: float = MIN_ROUNDNESS, +) -> 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 @@ -97,23 +99,33 @@ def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray] continue radius = diam / 2.0 roundness = len(xs) / (math.pi * radius * radius) - if roundness < MIN_ROUNDNESS: + 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.""" +def detect_moon( + image_path: str, + saturated_threshold: int = SATURATED_THRESHOLD, + min_roundness: float = MIN_ROUNDNESS, + max_halo_ratio: float = MAX_HALO_RATIO, +) -> MoonDetection | None: + """Return MoonDetection or None if no acceptable moon is found. + + saturated_threshold / min_roundness / max_halo_ratio can be relaxed for + crescent phases, which produce a dim, non-circular arc rather than a bright + near-perfect disk. + """ a = _grayscale_array(image_path) if OVERLAY_PX > 0: a = a.copy() a[-OVERLAY_PX:, :] = 0 - mask = a >= SATURATED_THRESHOLD + mask = a >= saturated_threshold if not mask.any(): return None - found = _largest_round_blob(mask) + found = _largest_round_blob(mask, min_roundness) if found is None: return None _, ys, xs = found @@ -153,12 +165,12 @@ def detect_moon(image_path: str) -> MoonDetection | None: 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: + 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))) + 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 diff --git a/moon_phase.py b/moon_phase.py index 2f15187..29cb9c2 100755 --- a/moon_phase.py +++ b/moon_phase.py @@ -107,6 +107,46 @@ def full_moons_in_range(start: datetime, end: datetime) -> list[datetime]: return phase_events_in_range(start, end, 2) +def crescent_times_in_range( + start: datetime, + end: datetime, + target_illum: float = 0.28, + waxing_side: bool = True, +) -> list[datetime]: + """Return UTC datetimes when illumination crosses target_illum on the given side. + + Scans in 2-hour steps and records each moment illumination passes through + target_illum while waxing (waxing_side=True) or waning (waxing_side=False). + Yields one event per lunar cycle, used for scheduling just like + phase_events_in_range() is used for quarter/full events. + """ + _lazy() + step = timedelta(hours=2) + results: list[datetime] = [] + t = _to_utc(start) + end_dt = _to_utc(end) + prev_illum: float | None = None + prev_t: datetime | None = None + + while t <= end_dt: + illum_val = illumination(t) + is_wax = waxing(t) + if is_wax == waxing_side: + if prev_illum is not None: + if (prev_illum - target_illum) * (illum_val - target_illum) < 0: + # Linear interpolation to the crossing moment + frac = (target_illum - prev_illum) / (illum_val - prev_illum) + results.append(prev_t + frac * (t - prev_t)) + prev_illum = illum_val + prev_t = t + else: + prev_illum = None + prev_t = None + t += step + + return results + + def phase_events_in_range(start: datetime, end: datetime, phase_index: int) -> list[datetime]: """Return UTC datetimes of every occurrence of `phase_index` between start and end. diff --git a/moon_phase_monthly.py b/moon_phase_monthly.py index 6f886e9..7151f74 100755 --- a/moon_phase_monthly.py +++ b/moon_phase_monthly.py @@ -87,6 +87,14 @@ REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'f DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30)) +# Crescent detection uses relaxed thresholds: a crescent arc scores low on +# roundness and is dimmer than a full disk. +CRESCENT_TARGET_ILLUM = float(CONF.get('MOON_CRESCENT_TARGET_ILLUM', 0.28)) +CRESCENT_MIN_QUALITY = float(CONF.get('MOON_CRESCENT_MIN_QUALITY', 0.35)) +CRESCENT_SATURATED_THR = int(CONF.get('MOON_CRESCENT_SATURATED_THR', 180)) +CRESCENT_MIN_ROUNDNESS = float(CONF.get('MOON_CRESCENT_MIN_ROUNDNESS', 0.15)) +CRESCENT_MAX_HALO_RATIO = float(CONF.get('MOON_CRESCENT_MAX_HALO_RATIO', 12.0)) + PHASE_SPEC = { 'full': { @@ -110,6 +118,28 @@ PHASE_SPEC = { 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)), 'enabled_key': 'MOON_THIRD_QUARTER_ENABLED', }, + # Crescents have no fixed skyfield phase index — timing is computed via + # crescent_times_in_range() at the configured illumination target (~28%). + # Detection uses relaxed thresholds since a crescent arc is non-circular + # and dimmer than a full disk. The atmospheric background naturally picks + # up twilight colours from the real east frame (dawn for waning, dusk/dawn + # depending on the month for waxing). + 'waxing-crescent': { + 'crescent': True, + 'waxing': True, + 'label': 'Waxing Crescent', + 'emoji': '🌒', + 'post_delay': int(CONF.get('MOON_CRESCENT_POST_DELAY_DAYS', 1)), + 'enabled_key': 'MOON_WAXING_CRESCENT_ENABLED', + }, + 'waning-crescent': { + 'crescent': True, + 'waxing': False, + 'label': 'Waning Crescent', + 'emoji': '🌘', + 'post_delay': int(CONF.get('MOON_CRESCENT_POST_DELAY_DAYS', 1)), + 'enabled_key': 'MOON_WANING_CRESCENT_ENABLED', + }, } _FRAME_RE = re.compile(r'^(\d{2})-(\d{2})-(\d{2})\.jpg$') @@ -281,12 +311,21 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, import moon_phase from moon_detect import detect_moon + is_crescent = spec.get('crescent', False) + if target_utc is None: now = datetime.now(timezone.utc) - events = moon_phase.phase_events_in_range( - now - timedelta(days=45), now, spec['index']) + if is_crescent: + events = moon_phase.crescent_times_in_range( + now - timedelta(days=35), now, + target_illum=CRESCENT_TARGET_ILLUM, + waxing_side=spec['waxing'], + ) + else: + events = moon_phase.phase_events_in_range( + now - timedelta(days=45), now, spec['index']) if not events: - print(f'no recent {phase} found in past 45 days', file=sys.stderr) + print(f'no recent {phase} found', file=sys.stderr) return 1 target_utc = events[-1] @@ -336,12 +375,21 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, # is the one temporally closest to the moon being precisely full/quarter. dark_frames.sort(key=lambda x: abs(x[1] - target_utc)) + # Crescent arcs are non-circular and dimmer — use relaxed detection params. + det_kwargs = ( + dict(saturated_threshold=CRESCENT_SATURATED_THR, + min_roundness=CRESCENT_MIN_ROUNDNESS, + max_halo_ratio=CRESCENT_MAX_HALO_RATIO) + if is_crescent else {} + ) + min_q = CRESCENT_MIN_QUALITY if is_crescent else MIN_QUALITY + best_frame: str | None = None best_det = None best_dt: datetime | None = None for fpath, fdt in dark_frames: - det = detect_moon(fpath) - if det is not None and det.quality >= MIN_QUALITY: + det = detect_moon(fpath, **det_kwargs) + if det is not None and det.quality >= min_q: best_frame = fpath best_det = det best_dt = fdt @@ -451,16 +499,24 @@ def auto_run(cam: str, dry_run: bool, no_upload: bool) -> int: """Daily check: run any phase whose post-day equals today (UTC).""" import moon_phase today_utc = datetime.now(timezone.utc).date() + window_start = datetime.combine( + today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc) + now = datetime.now(timezone.utc) ran_any = False rc = 0 for phase, spec in PHASE_SPEC.items(): if CONF.get(spec['enabled_key'], 'true').lower() == 'false': print(f'-- {phase}: {spec["enabled_key"]}=false → skip') continue - events = moon_phase.phase_events_in_range( - datetime.combine(today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc), - datetime.now(timezone.utc), - spec['index']) + if spec.get('crescent'): + events = moon_phase.crescent_times_in_range( + window_start, now, + target_illum=CRESCENT_TARGET_ILLUM, + waxing_side=spec['waxing'], + ) + else: + events = moon_phase.phase_events_in_range( + window_start, now, spec['index']) if not events: continue last_event = events[-1]