#!/usr/bin/env python3 """test_crescent_detect.py — test crescent moon detection on a camera frame. Runs detect_moon() twice on the same image: 1. Standard parameters (full/quarter moon defaults) 2. Crescent parameters (relaxed roundness, lower brightness threshold) Saves annotated debug images so you can see exactly what each pass found. Optionally renders the full composite if detection succeeds and a reference moon image (or cached NASA dial-a-moon) is available. Usage: # Basic detection test against any east camera frame: python3 test_crescent_detect.py PATH/TO/FRAME.jpg # Also render the composite (needs ref moon image): python3 test_crescent_detect.py PATH/TO/FRAME.jpg --ref PATH/TO/MOON.jpg # Specify the capture time for parallactic-angle rotation: python3 test_crescent_detect.py PATH/TO/FRAME.jpg --ref PATH/TO/MOON.jpg \\ --when 2026-05-08T05:30:00Z --phase waning-crescent # Quick self-test using the bundled east frame (no crescent, but confirms # the standard detector still works after the crescent changes): python3 test_crescent_detect.py """ from __future__ import annotations import argparse import pathlib import sys from datetime import datetime, timezone HERE = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) # ── Default self-test frame ─────────────────────────────────────────────────── _DEFAULT_FRAME = HERE / '21-07-00.jpg' # ── Crescent thresholds (mirrors moon_phase_monthly.CRESCENT_* defaults) ───── CRESCENT_SATURATED_THR = 180 CRESCENT_MIN_ROUNDNESS = 0.15 CRESCENT_MAX_HALO_RATIO = 12.0 CRESCENT_MIN_QUALITY = 0.35 def _label(det, min_q: float) -> str: if det is None: return 'NOT DETECTED' verdict = 'PASS' if det.quality >= min_q else f'FAIL (quality {det.quality:.3f} < {min_q})' return ( f'{verdict} cx={det.centroid_xy[0]:.0f} cy={det.centroid_xy[1]:.0f} ' f'diam={det.diameter_px:.0f}px roundness={det.roundness:.2f} ' f'halo_ratio={det.halo_ratio:.1f} quality={det.quality:.3f}' ) def run(frame: str, ref_moon: str | None, when_utc: datetime | None, phase: str, out_dir: pathlib.Path) -> int: from moon_detect import detect_moon, _draw_debug import moon_phase_monthly as mpm frame_p = pathlib.Path(frame) if not frame_p.exists(): print(f'ERROR: frame not found: {frame}', file=sys.stderr) return 1 out_dir.mkdir(parents=True, exist_ok=True) stem = frame_p.stem print(f'frame : {frame_p}') print(f'phase : {phase}') print() # ── Pass 1: standard detection ──────────────────────────────────────────── det_std = detect_moon(str(frame_p)) print(f'standard (thr=240 round≥0.55 halo≤6.0) → {_label(det_std, mpm.MIN_QUALITY)}') debug_std = out_dir / f'{stem}_debug_standard.jpg' _draw_debug(str(frame_p), det_std, str(debug_std)) print(f' debug → {debug_std}') print() # ── Pass 2: crescent detection ──────────────────────────────────────────── det_cre = detect_moon( str(frame_p), saturated_threshold=CRESCENT_SATURATED_THR, min_roundness=CRESCENT_MIN_ROUNDNESS, max_halo_ratio=CRESCENT_MAX_HALO_RATIO, ) print(f'crescent (thr=180 round≥0.15 halo≤12.0) → {_label(det_cre, CRESCENT_MIN_QUALITY)}') debug_cre = out_dir / f'{stem}_debug_crescent.jpg' _draw_debug(str(frame_p), det_cre, str(debug_cre)) print(f' debug → {debug_cre}') # ── Optional composite render ───────────────────────────────────────────── det_for_render = det_cre if phase in ('waxing-crescent', 'waning-crescent') else det_std min_q = CRESCENT_MIN_QUALITY if phase in ('waxing-crescent', 'waning-crescent') else mpm.MIN_QUALITY if ref_moon and det_for_render is not None and det_for_render.quality >= min_q: print() if when_utc is None: print('note: --when not supplied; skipping parallactic rotation') when_utc = datetime.now(timezone.utc) # Monkeypatch moon_phase if skyfield is unavailable try: import moon_phase as _mp _mp.illumination(when_utc) # probe — raises if no ephemeris except Exception: print('skyfield/ephemeris not available — using stub moon_phase values') import moon_phase as _mp illum = {'waxing-crescent': 0.28, 'waning-crescent': 0.28, 'full': 1.0, 'first-quarter': 0.50, 'third-quarter': 0.50} _mp.illumination = lambda w: illum.get(phase, 0.50) _mp.phase_angle = lambda w: {'waxing-crescent': 110, 'waning-crescent': 110, 'full': 0, 'first-quarter': 90, 'third-quarter': 90}.get(phase, 90) _mp.waxing = lambda w: phase == 'waxing-crescent' _mp.parallactic_angle = lambda w, **kw: 0.0 from moon_composite import composite_full_moon out_img = out_dir / f'{stem}_{phase}_composite.jpg' composite_full_moon( source_jpg=str(frame_p), detection=det_for_render, when_utc=when_utc, ref_moon_path=ref_moon, out_path=str(out_img), output_size=(1920, 1080), moon_height_pct=0.70, caption=( f'{phase.replace("-", " ").title()} — ' f'{when_utc.strftime("%B %Y")} — sky-cam east — NASA SVS Dial-a-Moon' ), ) print(f'composite → {out_img}') elif ref_moon: print() print('composite skipped — detection did not pass quality threshold') return 0 def main() -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument('frame', nargs='?', default=str(_DEFAULT_FRAME), help='east camera JPEG to test (default: bundled 21-07-00.jpg)') p.add_argument('--ref', metavar='MOON_JPG', help='reference moon image for composite render ' '(NASA dial-a-moon PNG or any moon photo)') p.add_argument('--when', metavar='ISO_UTC', help='capture time, e.g. 2026-05-08T05:30:00Z ' '(used for parallactic rotation; defaults to now)') p.add_argument('--phase', choices=['waxing-crescent', 'waning-crescent', 'full', 'first-quarter', 'third-quarter'], default='waning-crescent', help='which phase to simulate (affects detection thresholds ' 'and composite labelling, default: waning-crescent)') p.add_argument('--out-dir', metavar='DIR', default=str(HERE), help='directory for debug and composite output (default: sky-cam dir)') args = p.parse_args() when = None if args.when: when = datetime.fromisoformat(args.when.replace('Z', '+00:00')) if when.tzinfo is None: when = when.replace(tzinfo=timezone.utc) return run( frame=args.frame, ref_moon=args.ref, when_utc=when, phase=args.phase, out_dir=pathlib.Path(args.out_dir), ) if __name__ == '__main__': sys.exit(main())