Compare commits

..
10 Commits
Author SHA1 Message Date
Outis e92f189a44 Merge pull request #73 from outis1one/claude/fix-sunrise-video-timing-hKgfU
sunrise.py: replace suntime with skyfield for accurate sunrise calcul…
2026-05-13 23:25:03 -04:00
Claude 2f15ce47c9 sunrise.py: replace suntime with skyfield for accurate sunrise calculation
The suntime library uses a simplified approximation that degrades at high
latitudes as sunrise approaches very early hours near the summer solstice.
The computed sunrise drifted progressively earlier than the actual event,
shrinking the capture window until today it ended before the sun rose.

Switch to skyfield's sunrise_sunset almanac (DE421 ephemeris), which is
accurate to within a second at any latitude — the same library already
used by moon_phase.py for sun events. The search window is anchored to
local midnight→midnight UTC so the correct calendar-day sunrise is
always returned regardless of timezone offset.

Also promote skyfield to the core pip install in bootstrap.sh (dropping
suntime, which is no longer needed) and tidy the stale "suntime" hints
in 4-seasons.sh and montage-mvt.sh error messages.

https://claude.ai/code/session_01S4oUbU9xNj91cbV5bj5NRn
2026-05-14 03:17:45 +00:00
Outis 5f4391bd9f Merge pull request #72 from outis1one/claude/moon-photography-enhancement-dD9R6
Claude/moon photography enhancement d d9 r6
2026-05-03 19:36:18 -04:00
Claude 6b03ae73a3 gitignore: exclude crescent detection debug images
*_debug_standard.jpg and *_debug_crescent.jpg are generated by
test_crescent_detect.py and should not be tracked.

https://claude.ai/code/session_01HuJ83KvMvshiY6HxJbtMsc
2026-05-03 23:35:13 +00:00
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
Outis 5dda4f6993 Merge pull request #71 from outis1one/claude/moon-photography-enhancement-dD9R6
Add waxing/waning crescent phases; fix phase-shadow double-application
2026-05-03 19:27:20 -04:00
Claude 29a864375b Add waxing/waning crescent phases; fix phase-shadow double-application
moon_detect.py
- detect_moon() accepts saturated_threshold / min_roundness / max_halo_ratio
  so crescent phases (dim, non-circular arc) can be detected with relaxed
  thresholds without changing defaults for full/quarter detection.

moon_phase.py
- crescent_times_in_range(): 2-hour scan returning the UTC moment illumination
  crosses a target fraction (default 28%) on the waxing or waning side.
  Used for scheduling just like phase_events_in_range() for named phases.

moon_composite.py
- Remove _apply_phase_shadow() from composite_full_moon.  NASA SVS Dial-a-Moon
  renders already include the correct terminator, libration and earthshine for
  the exact hour; applying the shadow on top double-darkened the unlit limb on
  every non-full phase.  The function stays available for callers that need it.

moon_phase_monthly.py
- Add waxing-crescent (🌒) and waning-crescent (🌘) to PHASE_SPEC.
  Timing via crescent_times_in_range(); scheduling identical to other phases.
- CRESCENT_* tuning constants (all overridable from sky-cam.conf):
    MOON_CRESCENT_TARGET_ILLUM   default 0.28
    MOON_CRESCENT_MIN_QUALITY    default 0.35
    MOON_CRESCENT_SATURATED_THR  default 180
    MOON_CRESCENT_MIN_ROUNDNESS  default 0.15
    MOON_CRESCENT_MAX_HALO_RATIO default 12.0
- run_phase() and auto_run() branch on spec['crescent'] to use crescent
  timing and detection params.
- Atmospheric background naturally captures twilight colours (dawn for waning
  crescent, dusk/dawn for waxing) from the real east frame.

https://claude.ai/code/session_01HuJ83KvMvshiY6HxJbtMsc
2026-05-03 23:21:44 +00:00
Outis 71a12cb310 Merge pull request #70 from outis1one/claude/moon-photography-enhancement-dD9R6
Claude/moon photography enhancement d d9 r6
2026-05-03 14:33:17 -04:00
Claude b21803151b composite: add unsharp mask after Lanczos resize to restore crater crispness
Lanczos downsampling (NASA 2048 px → 756 px output) anti-aliases fine
crater rims and mare boundaries to a blur of ~1-2 px.  Apply Pillow's
UnsharpMask(radius=2, percent=160, threshold=2) immediately after resize
in both composite_full_moon and render_phase_closeup.  This restores the
perceived sharpness to match what the NASA source actually contains, and
brings crater detail in line with what a 52x optical zoom camera shows on
a clear night from the same location.  No AI or upscaling involved.

https://claude.ai/code/session_01HuJ83KvMvshiY6HxJbtMsc
2026-05-03 18:31:24 +00:00
Claude 15ece6ff3f composite: switch to smooth synthetic atmospheric glow, eliminating foil artefact
The previous approach tried to preserve real sky-cam pixels near the moon and
darken them, but with a bright/hazy sky (thick clouds, full moon glow) the
cloud texture still bled through as the "wrinkled silver foil" look.

New approach in _atmospheric_sky_background():
- Sample the mean atmospheric colour from a ring at 1.15–1.8× moon radius.
- Scale it to a tasteful glow level (×0.35) — real colour, not raw brightness.
- Build a smooth quadratic radial gradient: halo colour at the disk edge,
  deep night-sky (R4 G6 B14) at 3× radius and beyond.

No raw sky-cam pixels appear in the background, so the result is texture-free
regardless of cloud cover or haze. Verified clean against both a normal frame
and a 2.5× brightened worst-case bright-sky simulation.

https://claude.ai/code/session_01HuJ83KvMvshiY6HxJbtMsc
2026-05-03 18:10:27 +00:00
10 changed files with 401 additions and 57 deletions
+2
View File
@@ -1,3 +1,5 @@
__pycache__/ __pycache__/
.env .env
sunrise-sounds/ sunrise-sounds/
*_debug_standard.jpg
*_debug_crescent.jpg
+1 -1
View File
@@ -39,7 +39,7 @@ echo "Processing: $yesterday"
# ── Season / movement info from astronomical calculation ────────────────────── # ── Season / movement info from astronomical calculation ──────────────────────
_season_info="$(python3 "$SCRIPT_DIR/season_info.py" "$yesterday")" || { _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 exit 1
} }
eval "$_season_info" eval "$_season_info"
+2 -2
View File
@@ -22,8 +22,8 @@ echo ""
echo "Done. Next steps:" echo "Done. Next steps:"
echo " 1. Install system packages (if not already present):" echo " 1. Install system packages (if not already present):"
echo " sudo apt install ffmpeg bc fonts-dejavu" echo " sudo apt install ffmpeg bc fonts-dejavu"
echo " pip3 install suntime pytz requests" echo " pip3 install pytz requests skyfield"
echo " pip3 install skyfield Pillow numpy scipy # moon jobs (moon-track, moon-phase-monthly)" echo " pip3 install Pillow numpy scipy # moon jobs (moon-track, moon-phase-monthly)"
echo "" echo ""
echo " 2. Edit $TARGET/sky-cam.conf" echo " 2. Edit $TARGET/sky-cam.conf"
echo " — SCRIPT_DIR full path to the directory you'll run scripts from" echo " — SCRIPT_DIR full path to the directory you'll run scripts from"
+1 -1
View File
@@ -47,7 +47,7 @@ ATTR_FADE="$MONTAGE_ATTR_FADE"
# ── Season / movement info ──────────────────────────────────────────────────── # ── Season / movement info ────────────────────────────────────────────────────
_season_info="$(python3 "$SCRIPT_DIR/season_info.py" ${DATE_ARG:+"$DATE_ARG"})" || { _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 exit 1
} }
eval "$_season_info" eval "$_season_info"
+34 -28
View File
@@ -110,36 +110,40 @@ def _atmospheric_sky_background(
moon_cy: float, moon_cy: float,
moon_diam_px: float, moon_diam_px: float,
) -> Image.Image: ) -> 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 Samples the real halo colour from a ring just outside the moon disk, then
collapse diffuse cloud texture while the bright atmospheric glow survives). builds a smooth radial gradient from that colour at the disk edge to deep
Beyond ~4.5× the radius it fades smoothly to a near-black night colour, so night-sky beyond ~2.5× the radius. No sky-cam pixels are used directly,
the result looks like a genuine dark-sky photograph rather than an so there is no cloud texture or foil artefact regardless of how bright or
over-exposed, heavily upscaled camera frame. hazy the original sky was.
""" """
arr = np.asarray(sky_img).astype(np.float32) arr = np.asarray(sky_img).astype(np.float32)
h, w = arr.shape[:2] h, w = arr.shape[:2]
r = moon_diam_px / 2.0
# 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()
yy, xx = np.ogrid[0:h, 0:w] yy, xx = np.ogrid[0:h, 0:w]
dist = np.sqrt((xx - moon_cx) ** 2 + (yy - moon_cy) ** 2) dist = np.sqrt((xx - moon_cx) ** 2 + (yy - moon_cy) ** 2)
r = moon_diam_px / 2.0 # Sample mean colour of the real halo in a ring from 1.15× to 1.8× radius.
inner_r = 1.3 * r # real-sky halo fully preserved inside here ring = (dist >= 1.15 * r) & (dist < 1.8 * r)
outer_r = 2.2 * r # fully dark outside here — keeps frame corners black 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 # Radial alpha: 1.0 at the moon disk edge (dist_norm=1), 0 at 3× radius.
# bright atmospheric halo (already near-white) survives nearly intact. dist_norm = dist / r
real_darkened = np.power(np.clip(arr / 255.0, 0, 1), 1.6) * 0.65 * 255.0 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 bg_arr = glow_alpha * halo_rgb + (1.0 - glow_alpha) * night
return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8)) return Image.fromarray(np.clip(bg_arr, 0, 255).astype(np.uint8))
def _disk_mask(size: int, feather_px: int = 6) -> Image.Image: 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 = int(round(output_size[1] * moon_height_pct))
target_size += target_size % 2 # even target_size += target_size % 2 # even
ref_resized = ref.resize((target_size, target_size), LANCZOS) 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) ── # Phase shadow is NOT applied here: the NASA SVS Dial-a-Moon source already
illum = moon_phase.illumination(when_utc) # renders the correct terminator, libration and earthshine for the exact
pa = moon_phase.phase_angle(when_utc) # hour. Applying _apply_phase_shadow on top would double-darken the unlit
if illum < 0.995: # limb. (_apply_phase_shadow remains available for callers that supply a
wax = moon_phase.waxing(when_utc) # full-moon reference photo rather than a phase-correct NASA render.)
ref_resized = _apply_phase_shadow(ref_resized, pa, wax)
# ── Parallactic-angle rotation ── # ── Parallactic-angle rotation ──
par = moon_phase.parallactic_angle(when_utc) 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) ref_rot = ref_resized.rotate(-par, resample=BICUBIC, expand=False)
# ── Composite with feathered circular mask ── # ── Composite with feathered circular mask ──
@@ -307,6 +310,9 @@ def render_phase_closeup(
target = int(round(output_size[1] * moon_height_pct)) target = int(round(output_size[1] * moon_height_pct))
target += target % 2 target += target % 2
moon_resized = moon.resize((target, target), LANCZOS) 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 # Rotate by parallactic angle so "up" on the moon matches east's sky
if when_utc is not None: if when_utc is not None:
+21 -9
View File
@@ -78,12 +78,14 @@ def _grayscale_array(image_path: str) -> np.ndarray:
return np.asarray(im) 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) labels, n = ndimage.label(mask)
if n == 0: if n == 0:
return None return None
sizes = ndimage.sum(mask, labels, range(1, n + 1)) 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] order = np.argsort(sizes)[::-1]
for idx in order[:8]: for idx in order[:8]:
label_id = idx + 1 label_id = idx + 1
@@ -97,23 +99,33 @@ def _largest_round_blob(mask: np.ndarray) -> tuple[int, np.ndarray, np.ndarray]
continue continue
radius = diam / 2.0 radius = diam / 2.0
roundness = len(xs) / (math.pi * radius * radius) roundness = len(xs) / (math.pi * radius * radius)
if roundness < MIN_ROUNDNESS: if roundness < min_roundness:
continue continue
return label_id, ys, xs return label_id, ys, xs
return None return None
def detect_moon(image_path: str) -> MoonDetection | None: def detect_moon(
"""Return MoonDetection or None if no acceptable moon is found.""" 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) a = _grayscale_array(image_path)
if OVERLAY_PX > 0: if OVERLAY_PX > 0:
a = a.copy() a = a.copy()
a[-OVERLAY_PX:, :] = 0 a[-OVERLAY_PX:, :] = 0
mask = a >= SATURATED_THRESHOLD mask = a >= saturated_threshold
if not mask.any(): if not mask.any():
return None return None
found = _largest_round_blob(mask) found = _largest_round_blob(mask, min_roundness)
if found is None: if found is None:
return None return None
_, ys, xs = found _, ys, xs = found
@@ -153,12 +165,12 @@ def detect_moon(image_path: str) -> MoonDetection | None:
yh, xh = np.where(halo_labels == halo_id) yh, xh = np.where(halo_labels == halo_id)
halo_radius = max(xh.max() - xh.min(), yh.max() - yh.min()) / 2.0 halo_radius = max(xh.max() - xh.min(), yh.max() - yh.min()) / 2.0
halo_ratio = halo_radius / radius if radius > 0 else 1.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 return None # too much glow → probably thick cloud cover
# Quality score: roundness (0..1), low halo (1 = clear, 0 = thick cloud), # Quality score: roundness (0..1), low halo (1 = clear, 0 = thick cloud),
# isolation factor (1 if very isolated, less if close to other lights). # 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) 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 quality = 0.5 * roundness + 0.35 * halo_clean + 0.15 * iso_factor
+40
View File
@@ -107,6 +107,46 @@ def full_moons_in_range(start: datetime, end: datetime) -> list[datetime]:
return phase_events_in_range(start, end, 2) 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]: 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. """Return UTC datetimes of every occurrence of `phase_index` between start and end.
+65 -9
View File
@@ -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)) 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 = { PHASE_SPEC = {
'full': { 'full': {
@@ -110,6 +118,28 @@ PHASE_SPEC = {
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)), 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_THIRD_QUARTER_ENABLED', '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$') _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 import moon_phase
from moon_detect import detect_moon from moon_detect import detect_moon
is_crescent = spec.get('crescent', False)
if target_utc is None: if target_utc is None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
events = moon_phase.phase_events_in_range( if is_crescent:
now - timedelta(days=45), now, spec['index']) 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: 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 return 1
target_utc = events[-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. # is the one temporally closest to the moon being precisely full/quarter.
dark_frames.sort(key=lambda x: abs(x[1] - target_utc)) 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_frame: str | None = None
best_det = None best_det = None
best_dt: datetime | None = None best_dt: datetime | None = None
for fpath, fdt in dark_frames: for fpath, fdt in dark_frames:
det = detect_moon(fpath) det = detect_moon(fpath, **det_kwargs)
if det is not None and det.quality >= MIN_QUALITY: if det is not None and det.quality >= min_q:
best_frame = fpath best_frame = fpath
best_det = det best_det = det
best_dt = fdt 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).""" """Daily check: run any phase whose post-day equals today (UTC)."""
import moon_phase import moon_phase
today_utc = datetime.now(timezone.utc).date() 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 ran_any = False
rc = 0 rc = 0
for phase, spec in PHASE_SPEC.items(): for phase, spec in PHASE_SPEC.items():
if CONF.get(spec['enabled_key'], 'true').lower() == 'false': if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
print(f'-- {phase}: {spec["enabled_key"]}=false → skip') print(f'-- {phase}: {spec["enabled_key"]}=false → skip')
continue continue
events = moon_phase.phase_events_in_range( if spec.get('crescent'):
datetime.combine(today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc), events = moon_phase.crescent_times_in_range(
datetime.now(timezone.utc), window_start, now,
spec['index']) target_illum=CRESCENT_TARGET_ILLUM,
waxing_side=spec['waxing'],
)
else:
events = moon_phase.phase_events_in_range(
window_start, now, spec['index'])
if not events: if not events:
continue continue
last_event = events[-1] last_event = events[-1]
+51 -7
View File
@@ -1,13 +1,24 @@
#!/usr/bin/env python3 #!/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 pathlib
import re import re
import pytz import pytz
from suntime import Sun
_here = pathlib.Path(__file__).resolve().parent _here = pathlib.Path(__file__).resolve().parent
def _read_conf(path): def _read_conf(path):
conf = {} conf = {}
try: try:
@@ -30,6 +41,7 @@ def _read_conf(path):
pass pass
return conf return conf
conf = _read_conf(_here / 'sky-cam.conf') conf = _read_conf(_here / 'sky-cam.conf')
# .env overrides sky-cam.conf — mirrors the sourcing order at the bottom of 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')) conf.update(_read_conf(_here / '.env'))
@@ -42,9 +54,41 @@ latitude = float(conf['LATITUDE'])
longitude = float(conf['LONGITUDE']) longitude = float(conf['LONGITUDE'])
tz = pytz.timezone(conf['TIMEZONE']) tz = pytz.timezone(conf['TIMEZONE'])
sun = Sun(latitude, longitude) # Search the full local calendar day (midnight→midnight) expressed in UTC.
now = datetime.now(tz) # This bracket is timezone-correct and handles any UTC offset, including large
sunrise_utc = sun.get_sunrise_time(now) # positive offsets (UTC+12/+14) where local midnight falls on the previous UTC day.
sunrise_time = sunrise_utc.astimezone(tz) 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'))
+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())