diff --git a/.gitignore b/.gitignore index 00d2826..b654a03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__/ .env sunrise-sounds/ +*_debug_standard.jpg +*_debug_crescent.jpg diff --git a/test_crescent_detect.py b/test_crescent_detect.py new file mode 100644 index 0000000..04d1975 --- /dev/null +++ b/test_crescent_detect.py @@ -0,0 +1,184 @@ +#!/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())