Compare commits
10
Commits
590e078a65
...
e92f189a44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e92f189a44 | ||
|
|
2f15ce47c9 | ||
|
|
5f4391bd9f | ||
|
|
6b03ae73a3 | ||
|
|
0a48771641 | ||
|
|
5dda4f6993 | ||
|
|
29a864375b | ||
|
|
71a12cb310 | ||
|
|
b21803151b | ||
|
|
15ece6ff3f |
@@ -1,3 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
.env
|
.env
|
||||||
sunrise-sounds/
|
sunrise-sounds/
|
||||||
|
*_debug_standard.jpg
|
||||||
|
*_debug_crescent.jpg
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'))
|
||||||
|
|||||||
@@ -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