Compare commits
10
Commits
590e078a65
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e92f189a44 | ||
|
|
2f15ce47c9 | ||
|
|
5f4391bd9f | ||
|
|
6b03ae73a3 | ||
|
|
0a48771641 | ||
|
|
5dda4f6993 | ||
|
|
29a864375b | ||
|
|
71a12cb310 | ||
|
|
b21803151b | ||
|
|
15ece6ff3f |
@@ -1,3 +1,5 @@
|
||||
__pycache__/
|
||||
.env
|
||||
sunrise-sounds/
|
||||
*_debug_standard.jpg
|
||||
*_debug_crescent.jpg
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ echo "Processing: $yesterday"
|
||||
|
||||
# ── Season / movement info from astronomical calculation ──────────────────────
|
||||
_season_info="$(python3 "$SCRIPT_DIR/season_info.py" "$yesterday")" || {
|
||||
echo "Error: season_info.py failed — check Python dependencies (suntime pytz)"
|
||||
echo "Error: season_info.py failed — check Python dependencies (pytz)"
|
||||
exit 1
|
||||
}
|
||||
eval "$_season_info"
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@ echo ""
|
||||
echo "Done. Next steps:"
|
||||
echo " 1. Install system packages (if not already present):"
|
||||
echo " sudo apt install ffmpeg bc fonts-dejavu"
|
||||
echo " pip3 install suntime pytz requests"
|
||||
echo " pip3 install skyfield Pillow numpy scipy # moon jobs (moon-track, moon-phase-monthly)"
|
||||
echo " pip3 install pytz requests skyfield"
|
||||
echo " pip3 install Pillow numpy scipy # moon jobs (moon-track, moon-phase-monthly)"
|
||||
echo ""
|
||||
echo " 2. Edit $TARGET/sky-cam.conf"
|
||||
echo " — SCRIPT_DIR full path to the directory you'll run scripts from"
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ ATTR_FADE="$MONTAGE_ATTR_FADE"
|
||||
|
||||
# ── Season / movement info ────────────────────────────────────────────────────
|
||||
_season_info="$(python3 "$SCRIPT_DIR/season_info.py" ${DATE_ARG:+"$DATE_ARG"})" || {
|
||||
echo "Error: season_info.py failed — check Python dependencies (suntime pytz)"
|
||||
echo "Error: season_info.py failed — check Python dependencies (pytz)"
|
||||
exit 1
|
||||
}
|
||||
eval "$_season_info"
|
||||
|
||||
+34
-28
@@ -110,36 +110,40 @@ def _atmospheric_sky_background(
|
||||
moon_cy: float,
|
||||
moon_diam_px: float,
|
||||
) -> Image.Image:
|
||||
"""Real atmospheric halo on a deep night-sky backdrop.
|
||||
"""Smooth atmospheric glow on a deep night-sky backdrop.
|
||||
|
||||
Inside ~1.8× the moon radius the real sky is preserved (gamma-darkened to
|
||||
collapse diffuse cloud texture while the bright atmospheric glow survives).
|
||||
Beyond ~4.5× the radius it fades smoothly to a near-black night colour, so
|
||||
the result looks like a genuine dark-sky photograph rather than an
|
||||
over-exposed, heavily upscaled camera frame.
|
||||
Samples the real halo colour from a ring just outside the moon disk, then
|
||||
builds a smooth radial gradient from that colour at the disk edge to deep
|
||||
night-sky beyond ~2.5× the radius. No sky-cam pixels are used directly,
|
||||
so there is no cloud texture or foil artefact regardless of how bright or
|
||||
hazy the original sky was.
|
||||
"""
|
||||
arr = np.asarray(sky_img).astype(np.float32)
|
||||
h, w = arr.shape[:2]
|
||||
|
||||
# Deep night-sky colour — very dark desaturated blue
|
||||
night = np.array([4, 6, 14], dtype=np.float32)
|
||||
dark_bg = np.broadcast_to(night, (h, w, 3)).copy()
|
||||
r = moon_diam_px / 2.0
|
||||
|
||||
yy, xx = np.ogrid[0:h, 0:w]
|
||||
dist = np.sqrt((xx - moon_cx) ** 2 + (yy - moon_cy) ** 2)
|
||||
|
||||
r = moon_diam_px / 2.0
|
||||
inner_r = 1.3 * r # real-sky halo fully preserved inside here
|
||||
outer_r = 2.2 * r # fully dark outside here — keeps frame corners black
|
||||
# Sample mean colour of the real halo in a ring from 1.15× to 1.8× radius.
|
||||
ring = (dist >= 1.15 * r) & (dist < 1.8 * r)
|
||||
if ring.any():
|
||||
halo_rgb = arr[ring].mean(axis=0) # real atmospheric colour
|
||||
else:
|
||||
halo_rgb = np.array([80, 85, 95], dtype=np.float32)
|
||||
# Scale to a tasteful glow level (original is often overexposed)
|
||||
halo_rgb = np.clip(halo_rgb * 0.35, 0, 180).astype(np.float32)
|
||||
|
||||
alpha = np.clip((outer_r - dist) / (outer_r - inner_r), 0.0, 1.0)[..., None]
|
||||
# Deep night-sky colour — very dark desaturated blue
|
||||
night = np.array([4, 6, 14], dtype=np.float32)
|
||||
|
||||
# Gamma + scale: compresses diffuse cloud texture toward black while the
|
||||
# bright atmospheric halo (already near-white) survives nearly intact.
|
||||
real_darkened = np.power(np.clip(arr / 255.0, 0, 1), 1.6) * 0.65 * 255.0
|
||||
# Radial alpha: 1.0 at the moon disk edge (dist_norm=1), 0 at 3× radius.
|
||||
dist_norm = dist / r
|
||||
glow_alpha = np.clip(1.0 - (dist_norm - 1.0) / 2.0, 0.0, 1.0) ** 2
|
||||
glow_alpha = glow_alpha[..., None]
|
||||
|
||||
blended = alpha * real_darkened + (1.0 - alpha) * dark_bg
|
||||
return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
|
||||
bg_arr = glow_alpha * halo_rgb + (1.0 - glow_alpha) * night
|
||||
return Image.fromarray(np.clip(bg_arr, 0, 255).astype(np.uint8))
|
||||
|
||||
|
||||
def _disk_mask(size: int, feather_px: int = 6) -> Image.Image:
|
||||
@@ -246,19 +250,18 @@ def composite_full_moon(
|
||||
target_size = int(round(output_size[1] * moon_height_pct))
|
||||
target_size += target_size % 2 # even
|
||||
ref_resized = ref.resize((target_size, target_size), LANCZOS)
|
||||
ref_resized = ref_resized.filter(
|
||||
ImageFilter.UnsharpMask(radius=2, percent=160, threshold=2)
|
||||
)
|
||||
|
||||
# ── Phase shadow (skip when essentially full) ──
|
||||
illum = moon_phase.illumination(when_utc)
|
||||
pa = moon_phase.phase_angle(when_utc)
|
||||
if illum < 0.995:
|
||||
wax = moon_phase.waxing(when_utc)
|
||||
ref_resized = _apply_phase_shadow(ref_resized, pa, wax)
|
||||
# Phase shadow is NOT applied here: the NASA SVS Dial-a-Moon source already
|
||||
# renders the correct terminator, libration and earthshine for the exact
|
||||
# hour. Applying _apply_phase_shadow on top would double-darken the unlit
|
||||
# limb. (_apply_phase_shadow remains available for callers that supply a
|
||||
# full-moon reference photo rather than a phase-correct NASA render.)
|
||||
|
||||
# ── Parallactic-angle rotation ──
|
||||
par = moon_phase.parallactic_angle(when_utc)
|
||||
# PIL rotates counter-clockwise for positive angles; we want celestial
|
||||
# north to end up "up" in the camera image. Negate so the rotation
|
||||
# direction matches image-space y-down convention.
|
||||
ref_rot = ref_resized.rotate(-par, resample=BICUBIC, expand=False)
|
||||
|
||||
# ── Composite with feathered circular mask ──
|
||||
@@ -307,6 +310,9 @@ def render_phase_closeup(
|
||||
target = int(round(output_size[1] * moon_height_pct))
|
||||
target += target % 2
|
||||
moon_resized = moon.resize((target, target), LANCZOS)
|
||||
moon_resized = moon_resized.filter(
|
||||
ImageFilter.UnsharpMask(radius=2, percent=160, threshold=2)
|
||||
)
|
||||
|
||||
# Rotate by parallactic angle so "up" on the moon matches east's sky
|
||||
if when_utc is not None:
|
||||
|
||||
+21
-9
@@ -78,12 +78,14 @@ def _grayscale_array(image_path: str) -> np.ndarray:
|
||||
return np.asarray(im)
|
||||
|
||||
|
||||
def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray] | None:
|
||||
def _largest_round_blob(
|
||||
mask: np.ndarray,
|
||||
min_roundness: float = MIN_ROUNDNESS,
|
||||
) -> tuple[int, np.ndarray, np.ndarray] | None:
|
||||
labels, n = ndimage.label(mask)
|
||||
if n == 0:
|
||||
return None
|
||||
sizes = ndimage.sum(mask, labels, range(1, n + 1))
|
||||
# Sort blobs by size descending; check roundness on the top few
|
||||
order = np.argsort(sizes)[::-1]
|
||||
for idx in order[:8]:
|
||||
label_id = idx + 1
|
||||
@@ -97,23 +99,33 @@ def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray]
|
||||
continue
|
||||
radius = diam / 2.0
|
||||
roundness = len(xs) / (math.pi * radius * radius)
|
||||
if roundness < MIN_ROUNDNESS:
|
||||
if roundness < min_roundness:
|
||||
continue
|
||||
return label_id, ys, xs
|
||||
return None
|
||||
|
||||
|
||||
def detect_moon(image_path: str) -> MoonDetection | None:
|
||||
"""Return MoonDetection or None if no acceptable moon is found."""
|
||||
def detect_moon(
|
||||
image_path: str,
|
||||
saturated_threshold: int = SATURATED_THRESHOLD,
|
||||
min_roundness: float = MIN_ROUNDNESS,
|
||||
max_halo_ratio: float = MAX_HALO_RATIO,
|
||||
) -> MoonDetection | None:
|
||||
"""Return MoonDetection or None if no acceptable moon is found.
|
||||
|
||||
saturated_threshold / min_roundness / max_halo_ratio can be relaxed for
|
||||
crescent phases, which produce a dim, non-circular arc rather than a bright
|
||||
near-perfect disk.
|
||||
"""
|
||||
a = _grayscale_array(image_path)
|
||||
if OVERLAY_PX > 0:
|
||||
a = a.copy()
|
||||
a[-OVERLAY_PX:, :] = 0
|
||||
mask = a >= SATURATED_THRESHOLD
|
||||
mask = a >= saturated_threshold
|
||||
if not mask.any():
|
||||
return None
|
||||
|
||||
found = _largest_round_blob(mask)
|
||||
found = _largest_round_blob(mask, min_roundness)
|
||||
if found is None:
|
||||
return None
|
||||
_, ys, xs = found
|
||||
@@ -153,12 +165,12 @@ def detect_moon(image_path: str) -> MoonDetection | None:
|
||||
yh, xh = np.where(halo_labels == halo_id)
|
||||
halo_radius = max(xh.max() - xh.min(), yh.max() - yh.min()) / 2.0
|
||||
halo_ratio = halo_radius / radius if radius > 0 else 1.0
|
||||
if halo_ratio > MAX_HALO_RATIO:
|
||||
if halo_ratio > max_halo_ratio:
|
||||
return None # too much glow → probably thick cloud cover
|
||||
|
||||
# Quality score: roundness (0..1), low halo (1 = clear, 0 = thick cloud),
|
||||
# isolation factor (1 if very isolated, less if close to other lights).
|
||||
halo_clean = max(0.0, min(1.0, (MAX_HALO_RATIO - halo_ratio) / (MAX_HALO_RATIO - 1.5)))
|
||||
halo_clean = max(0.0, min(1.0, (max_halo_ratio - halo_ratio) / (max_halo_ratio - 1.5)))
|
||||
iso_factor = 1.0 if isolation == float('inf') else min(1.0, isolation / 600.0)
|
||||
quality = 0.5 * roundness + 0.35 * halo_clean + 0.15 * iso_factor
|
||||
|
||||
|
||||
@@ -107,6 +107,46 @@ def full_moons_in_range(start: datetime, end: datetime) -> list[datetime]:
|
||||
return phase_events_in_range(start, end, 2)
|
||||
|
||||
|
||||
def crescent_times_in_range(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
target_illum: float = 0.28,
|
||||
waxing_side: bool = True,
|
||||
) -> list[datetime]:
|
||||
"""Return UTC datetimes when illumination crosses target_illum on the given side.
|
||||
|
||||
Scans in 2-hour steps and records each moment illumination passes through
|
||||
target_illum while waxing (waxing_side=True) or waning (waxing_side=False).
|
||||
Yields one event per lunar cycle, used for scheduling just like
|
||||
phase_events_in_range() is used for quarter/full events.
|
||||
"""
|
||||
_lazy()
|
||||
step = timedelta(hours=2)
|
||||
results: list[datetime] = []
|
||||
t = _to_utc(start)
|
||||
end_dt = _to_utc(end)
|
||||
prev_illum: float | None = None
|
||||
prev_t: datetime | None = None
|
||||
|
||||
while t <= end_dt:
|
||||
illum_val = illumination(t)
|
||||
is_wax = waxing(t)
|
||||
if is_wax == waxing_side:
|
||||
if prev_illum is not None:
|
||||
if (prev_illum - target_illum) * (illum_val - target_illum) < 0:
|
||||
# Linear interpolation to the crossing moment
|
||||
frac = (target_illum - prev_illum) / (illum_val - prev_illum)
|
||||
results.append(prev_t + frac * (t - prev_t))
|
||||
prev_illum = illum_val
|
||||
prev_t = t
|
||||
else:
|
||||
prev_illum = None
|
||||
prev_t = None
|
||||
t += step
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def phase_events_in_range(start: datetime, end: datetime, phase_index: int) -> list[datetime]:
|
||||
"""Return UTC datetimes of every occurrence of `phase_index` between start and end.
|
||||
|
||||
|
||||
+62
-6
@@ -87,6 +87,14 @@ REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'f
|
||||
|
||||
DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30))
|
||||
|
||||
# Crescent detection uses relaxed thresholds: a crescent arc scores low on
|
||||
# roundness and is dimmer than a full disk.
|
||||
CRESCENT_TARGET_ILLUM = float(CONF.get('MOON_CRESCENT_TARGET_ILLUM', 0.28))
|
||||
CRESCENT_MIN_QUALITY = float(CONF.get('MOON_CRESCENT_MIN_QUALITY', 0.35))
|
||||
CRESCENT_SATURATED_THR = int(CONF.get('MOON_CRESCENT_SATURATED_THR', 180))
|
||||
CRESCENT_MIN_ROUNDNESS = float(CONF.get('MOON_CRESCENT_MIN_ROUNDNESS', 0.15))
|
||||
CRESCENT_MAX_HALO_RATIO = float(CONF.get('MOON_CRESCENT_MAX_HALO_RATIO', 12.0))
|
||||
|
||||
|
||||
PHASE_SPEC = {
|
||||
'full': {
|
||||
@@ -110,6 +118,28 @@ PHASE_SPEC = {
|
||||
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
|
||||
'enabled_key': 'MOON_THIRD_QUARTER_ENABLED',
|
||||
},
|
||||
# Crescents have no fixed skyfield phase index — timing is computed via
|
||||
# crescent_times_in_range() at the configured illumination target (~28%).
|
||||
# Detection uses relaxed thresholds since a crescent arc is non-circular
|
||||
# and dimmer than a full disk. The atmospheric background naturally picks
|
||||
# up twilight colours from the real east frame (dawn for waning, dusk/dawn
|
||||
# depending on the month for waxing).
|
||||
'waxing-crescent': {
|
||||
'crescent': True,
|
||||
'waxing': True,
|
||||
'label': 'Waxing Crescent',
|
||||
'emoji': '🌒',
|
||||
'post_delay': int(CONF.get('MOON_CRESCENT_POST_DELAY_DAYS', 1)),
|
||||
'enabled_key': 'MOON_WAXING_CRESCENT_ENABLED',
|
||||
},
|
||||
'waning-crescent': {
|
||||
'crescent': True,
|
||||
'waxing': False,
|
||||
'label': 'Waning Crescent',
|
||||
'emoji': '🌘',
|
||||
'post_delay': int(CONF.get('MOON_CRESCENT_POST_DELAY_DAYS', 1)),
|
||||
'enabled_key': 'MOON_WANING_CRESCENT_ENABLED',
|
||||
},
|
||||
}
|
||||
|
||||
_FRAME_RE = re.compile(r'^(\d{2})-(\d{2})-(\d{2})\.jpg$')
|
||||
@@ -281,12 +311,21 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
import moon_phase
|
||||
from moon_detect import detect_moon
|
||||
|
||||
is_crescent = spec.get('crescent', False)
|
||||
|
||||
if target_utc is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
if is_crescent:
|
||||
events = moon_phase.crescent_times_in_range(
|
||||
now - timedelta(days=35), now,
|
||||
target_illum=CRESCENT_TARGET_ILLUM,
|
||||
waxing_side=spec['waxing'],
|
||||
)
|
||||
else:
|
||||
events = moon_phase.phase_events_in_range(
|
||||
now - timedelta(days=45), now, spec['index'])
|
||||
if not events:
|
||||
print(f'no recent {phase} found in past 45 days', file=sys.stderr)
|
||||
print(f'no recent {phase} found', file=sys.stderr)
|
||||
return 1
|
||||
target_utc = events[-1]
|
||||
|
||||
@@ -336,12 +375,21 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
# is the one temporally closest to the moon being precisely full/quarter.
|
||||
dark_frames.sort(key=lambda x: abs(x[1] - target_utc))
|
||||
|
||||
# Crescent arcs are non-circular and dimmer — use relaxed detection params.
|
||||
det_kwargs = (
|
||||
dict(saturated_threshold=CRESCENT_SATURATED_THR,
|
||||
min_roundness=CRESCENT_MIN_ROUNDNESS,
|
||||
max_halo_ratio=CRESCENT_MAX_HALO_RATIO)
|
||||
if is_crescent else {}
|
||||
)
|
||||
min_q = CRESCENT_MIN_QUALITY if is_crescent else MIN_QUALITY
|
||||
|
||||
best_frame: str | None = None
|
||||
best_det = None
|
||||
best_dt: datetime | None = None
|
||||
for fpath, fdt in dark_frames:
|
||||
det = detect_moon(fpath)
|
||||
if det is not None and det.quality >= MIN_QUALITY:
|
||||
det = detect_moon(fpath, **det_kwargs)
|
||||
if det is not None and det.quality >= min_q:
|
||||
best_frame = fpath
|
||||
best_det = det
|
||||
best_dt = fdt
|
||||
@@ -451,16 +499,24 @@ def auto_run(cam: str, dry_run: bool, no_upload: bool) -> int:
|
||||
"""Daily check: run any phase whose post-day equals today (UTC)."""
|
||||
import moon_phase
|
||||
today_utc = datetime.now(timezone.utc).date()
|
||||
window_start = datetime.combine(
|
||||
today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
ran_any = False
|
||||
rc = 0
|
||||
for phase, spec in PHASE_SPEC.items():
|
||||
if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
|
||||
print(f'-- {phase}: {spec["enabled_key"]}=false → skip')
|
||||
continue
|
||||
if spec.get('crescent'):
|
||||
events = moon_phase.crescent_times_in_range(
|
||||
window_start, now,
|
||||
target_illum=CRESCENT_TARGET_ILLUM,
|
||||
waxing_side=spec['waxing'],
|
||||
)
|
||||
else:
|
||||
events = moon_phase.phase_events_in_range(
|
||||
datetime.combine(today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc),
|
||||
datetime.now(timezone.utc),
|
||||
spec['index'])
|
||||
window_start, now, spec['index'])
|
||||
if not events:
|
||||
continue
|
||||
last_event = events[-1]
|
||||
|
||||
+51
-7
@@ -1,13 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
from datetime import datetime
|
||||
"""sunrise.py — compute today's local sunrise time using skyfield.
|
||||
|
||||
Outputs: YYYY-MM-DD HH:MM:SS (local time, no timezone suffix)
|
||||
Exits non-zero with a message on stderr if the sun does not rise today
|
||||
(polar night or midnight sun).
|
||||
|
||||
Reads LATITUDE, LONGITUDE, TIMEZONE from sky-cam.conf / .env.
|
||||
Requires skyfield and the de421.bsp ephemeris alongside this script
|
||||
(skyfield downloads de421.bsp automatically on first use if absent).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytz
|
||||
from suntime import Sun
|
||||
|
||||
_here = pathlib.Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _read_conf(path):
|
||||
conf = {}
|
||||
try:
|
||||
@@ -30,6 +41,7 @@ def _read_conf(path):
|
||||
pass
|
||||
return conf
|
||||
|
||||
|
||||
conf = _read_conf(_here / 'sky-cam.conf')
|
||||
# .env overrides sky-cam.conf — mirrors the sourcing order at the bottom of sky-cam.conf
|
||||
conf.update(_read_conf(_here / '.env'))
|
||||
@@ -42,9 +54,41 @@ latitude = float(conf['LATITUDE'])
|
||||
longitude = float(conf['LONGITUDE'])
|
||||
tz = pytz.timezone(conf['TIMEZONE'])
|
||||
|
||||
sun = Sun(latitude, longitude)
|
||||
now = datetime.now(tz)
|
||||
sunrise_utc = sun.get_sunrise_time(now)
|
||||
sunrise_time = sunrise_utc.astimezone(tz)
|
||||
# Search the full local calendar day (midnight→midnight) expressed in UTC.
|
||||
# This bracket is timezone-correct and handles any UTC offset, including large
|
||||
# positive offsets (UTC+12/+14) where local midnight falls on the previous UTC day.
|
||||
now_local = datetime.now(tz)
|
||||
midnight_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
t0_utc = midnight_local.astimezone(timezone.utc)
|
||||
t1_utc = t0_utc + timedelta(hours=25) # 25 h covers any timezone's full day
|
||||
|
||||
print(sunrise_time.strftime('%Y-%m-%d %H:%M:%S'))
|
||||
from skyfield.api import Loader, wgs84
|
||||
from skyfield.almanac import find_discrete, sunrise_sunset
|
||||
|
||||
loader = Loader(str(_here), verbose=False)
|
||||
ts = loader.timescale()
|
||||
eph = loader('de421.bsp') # downloaded automatically on first run
|
||||
topos = wgs84.latlon(latitude, longitude)
|
||||
|
||||
t0 = ts.from_datetime(t0_utc)
|
||||
t1 = ts.from_datetime(t1_utc)
|
||||
|
||||
times, events = find_discrete(t0, t1, sunrise_sunset(eph, topos))
|
||||
|
||||
# events: True = sunrise, False = sunset — pick the first sunrise on today's date
|
||||
sunrise_local = None
|
||||
for t, is_rise in zip(times, events):
|
||||
if is_rise:
|
||||
candidate = t.utc_datetime().astimezone(tz)
|
||||
if candidate.date() == now_local.date():
|
||||
sunrise_local = candidate
|
||||
break
|
||||
|
||||
if sunrise_local is None:
|
||||
raise SystemExit(
|
||||
"sunrise.py: no sunrise found for today "
|
||||
f"({now_local.date()} at {latitude:.4f}°, {longitude:.4f}°) — "
|
||||
"polar night or midnight sun?"
|
||||
)
|
||||
|
||||
print(sunrise_local.strftime('%Y-%m-%d %H:%M:%S'))
|
||||
|
||||
@@ -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