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
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user