Files
sky-cam/test_crescent_detect.py
T
Claude 0a48771641 Add test_crescent_detect.py — crescent detection test script
Runs detect_moon() twice on the same frame (standard params, then crescent
params) and saves annotated debug images for each pass so you can see
exactly what each threshold finds.  Optionally renders the full composite
if detection passes and a reference moon image is supplied.

Usage:
  python3 test_crescent_detect.py FRAME.jpg
  python3 test_crescent_detect.py FRAME.jpg --ref MOON.jpg --when 2026-05-08T05:30Z --phase waning-crescent

https://claude.ai/code/session_01HuJ83KvMvshiY6HxJbtMsc
2026-05-03 23:34:50 +00:00

185 lines
7.7 KiB
Python

#!/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())