Merge pull request #72 from outis1one/claude/moon-photography-enhancement-dD9R6

Claude/moon photography enhancement d d9 r6
This commit is contained in:
Outis
2026-05-03 19:36:18 -04:00
committed by GitHub
2 changed files with 186 additions and 0 deletions
+2
View File
@@ -1,3 +1,5 @@
__pycache__/
.env
sunrise-sounds/
*_debug_standard.jpg
*_debug_crescent.jpg
+184
View File
@@ -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())