Merge pull request #57 from outis1one/clDemoe/digital-zoom-moon-capture-DprtQ
Add nightly moon-track timelapse and monthly moon-phase composites
This commit is contained in:
@@ -5,6 +5,8 @@ Automated sky / timelapse camera scripts that produce:
|
||||
- **Daily sunrise clip** — a speed-adjusted video of the sunrise window, uploaded to Mattermost each morning
|
||||
- **Four Seasons timelapse** — daily clips sized to each Vivaldi movement's music duration, assembled automatically into per-movement montages (with music + attribution overlay) and a full-year video
|
||||
- **Full-day timelapse** — a fixed-fps timelapse of every image captured that day, kept for a configurable retention window
|
||||
- **Nightly moon-track timelapse** — every east night frame where the moon is visible is cropped around the moon and stitched into a short mp4 (the moon roughly held still while clouds and stars drift past)
|
||||
- **Monthly moon-phase close-ups** — one composite per phase (full moon, first quarter, third quarter), framed as if east took it through a 65× telephoto. The sky/halo/parallactic-angle/timing are real-from-east; the lunar surface texture is borrowed from a cached high-res reference (no software can recover detail your camera didn't capture). Posted to Mattermost.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,6 +17,7 @@ Automated sky / timelapse camera scripts that produce:
|
||||
```bash
|
||||
sudo apt install ffmpeg bc fonts-dejavu curl python3-pip
|
||||
pip3 install suntime pytz requests
|
||||
pip3 install skyfield Pillow numpy scipy # moon jobs (moon-track + moon-phase-monthly)
|
||||
```
|
||||
|
||||
| Package | Purpose |
|
||||
@@ -24,6 +27,8 @@ pip3 install suntime pytz requests
|
||||
| `fonts-dejavu` | Text overlays (sunrise time, attribution) |
|
||||
| `python3` + `suntime pytz` | Astronomical sunrise calculation — pure math, no internet, works indefinitely |
|
||||
| `python3` + `requests` | Mattermost upload |
|
||||
| `python3` + `skyfield` | Moon phases + altitude/azimuth + parallactic angle (offline after first ephemeris download) |
|
||||
| `python3` + `Pillow numpy scipy` | Moon detection + phase compositing |
|
||||
|
||||
### 2. Download
|
||||
|
||||
@@ -262,6 +267,48 @@ Replace the date list with whatever range you need. Each run produces one `*-fi
|
||||
./year-end-join.sh 2025 east
|
||||
```
|
||||
|
||||
**Moon jobs**:
|
||||
```bash
|
||||
# Re-run last night's moon-track timelapse for east
|
||||
./moon-track.sh east
|
||||
|
||||
# Back-fill moon-track for a specific past night
|
||||
./moon-track.sh east 2026-04-15
|
||||
|
||||
# Auto mode — runs whichever phase composite is due today (no-ops otherwise)
|
||||
./moon-phase-monthly.sh
|
||||
|
||||
# Force a single phase, picking the most recent occurrence
|
||||
./moon-phase-monthly.sh --phase full
|
||||
./moon-phase-monthly.sh --phase first-quarter
|
||||
./moon-phase-monthly.sh --phase third-quarter
|
||||
|
||||
# Build but skip the Mattermost post
|
||||
./moon-phase-monthly.sh --phase full --no-upload
|
||||
|
||||
# Dry run — find best frame, log it, build nothing
|
||||
./moon-phase-monthly.sh --phase third-quarter --dry-run
|
||||
|
||||
# Back-fill a specific event by exact UTC moment
|
||||
./moon-phase-monthly.sh --phase full --target 2026-04-01T15:51:00Z
|
||||
|
||||
# Inspect any moon-related stats for a frame
|
||||
python3 moon_detect.py BASE_DIR/east/2026-04-29/21-07-00.jpg --debug /tmp/dbg.png
|
||||
python3 moon_phase.py info 2026-04-29T21:07:00Z
|
||||
```
|
||||
|
||||
**Posting schedule**:
|
||||
|
||||
| Job | When the timer fires | When the artifact actually appears |
|
||||
|---|---|---|
|
||||
| Sunrise video | `SCHEDULE_SUNRISE` (default 03:00 local) | A few minutes after sunrise + `SUNRISE_POST_MIN` |
|
||||
| Moon-track timelapse | `SCHEDULE_MOON_TRACK` (default 02:30 local) | ~5 min after the timer, covers the previous night |
|
||||
| 🌕 Full Moon composite | `SCHEDULE_MOON_PHASE` (default 09:30 local) | 3 days after exact full moon (configurable: `MOON_FULL_POST_DELAY_DAYS`) |
|
||||
| 🌓 First Quarter composite | same timer | 2 days after exact first quarter (configurable: `MOON_QUARTER_POST_DELAY_DAYS`) — best-effort, see geometry note below |
|
||||
| 🌗 Third Quarter composite | same timer | 2 days after exact third quarter |
|
||||
|
||||
The moon-phase timer fires every day; the script no-ops on days that aren't a post-day for any phase, so you'll see exactly three posts per lunar cycle in Mattermost (sometimes only two if first quarter detection fails — see geometry note in `sky-cam.conf`).
|
||||
|
||||
**Check capture status**:
|
||||
```bash
|
||||
systemctl --user status sky-cam-capture-east.service
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 ""
|
||||
echo " 2. Edit $TARGET/sky-cam.conf"
|
||||
echo " — SCRIPT_DIR full path to the directory you'll run scripts from"
|
||||
|
||||
+66
@@ -225,6 +225,72 @@ EOF
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Moon jobs: nightly tracker + monthly full-moon composite ────────────────
|
||||
# Both jobs target the SUNRISE_CAM (the east-facing camera). They share three
|
||||
# Python helpers (moon_phase.py, moon_detect.py, moon_composite.py) and one
|
||||
# downloaded asset (the lunar reference texture).
|
||||
if [ "${MOON_TRACK_ENABLED:-true}" = "true" ] || [ "${MOON_FULL_ENABLED:-true}" = "true" ]; then
|
||||
# Pre-download skyfield ephemeris so first run doesn't hit the network at
|
||||
# an inopportune moment. Stored next to the scripts.
|
||||
if [ ! -f "$HERE/de421.bsp" ]; then
|
||||
echo "Downloading skyfield ephemeris (de421.bsp, ~17 MB)..."
|
||||
if curl -fsSL -o "$HERE/de421.bsp" \
|
||||
"https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp"; then
|
||||
echo " ok"
|
||||
else
|
||||
echo " WARNING: ephemeris download failed — moon jobs will retry on first run"
|
||||
rm -f "$HERE/de421.bsp"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Lunar reference texture for moon_phase_monthly.py. Default URL is the
|
||||
# Wikipedia "FullMoon2010" by Gregory H. Revera (CC BY-SA 3.0), 3500×3500.
|
||||
# Override MOON_REFERENCE_URL in .env to use a different source — any
|
||||
# high-res photo of a full moon on a black background works.
|
||||
ref_dir="${MOON_REFERENCE_DIR:-$HERE/moon-ref}"
|
||||
ref_path="${MOON_REFERENCE_PATH:-$ref_dir/full-moon.jpg}"
|
||||
ref_url="${MOON_REFERENCE_URL:-https://upload.wikimedia.org/wikipedia/commons/e/e1/FullMoon2010.jpg}"
|
||||
if [ ! -f "$ref_path" ]; then
|
||||
mkdir -p "$(dirname "$ref_path")"
|
||||
echo "Downloading lunar reference texture..."
|
||||
if curl -fsSL -o "$ref_path" "$ref_url"; then
|
||||
echo " ok → $ref_path"
|
||||
else
|
||||
echo " WARNING: lunar reference download failed"
|
||||
echo " Drop a high-res full-moon JPEG at $ref_path manually,"
|
||||
echo " or set MOON_REFERENCE_URL in .env and re-run install.sh."
|
||||
rm -f "$ref_path"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Nightly moon-track job — runs on the SUNRISE_CAM only.
|
||||
if [ "${MOON_TRACK_ENABLED:-true}" = "true" ]; then
|
||||
write_service "sky-cam-moon-track" \
|
||||
"$SUNRISE_CAM: nightly moon tracking timelapse" \
|
||||
"moon-track.sh $SUNRISE_CAM"
|
||||
write_timer "sky-cam-moon-track" \
|
||||
"$SUNRISE_CAM: nightly moon tracking timelapse" \
|
||||
"${SCHEDULE_MOON_TRACK:-02:30:00}"
|
||||
timers+=("sky-cam-moon-track")
|
||||
fi
|
||||
|
||||
# Moon-phase composite job — runs daily; the Python helper internally checks
|
||||
# full / first-quarter / third-quarter and only acts when today is the
|
||||
# matching post-day for one of them. One timer covers all three phases.
|
||||
if [ "${MOON_FULL_ENABLED:-true}" = "true" ] \
|
||||
|| [ "${MOON_FIRST_QUARTER_ENABLED:-true}" = "true" ] \
|
||||
|| [ "${MOON_THIRD_QUARTER_ENABLED:-true}" = "true" ]; then
|
||||
write_service "sky-cam-moon-phase" \
|
||||
"$SUNRISE_CAM: monthly moon-phase composite (full + quarters)" \
|
||||
"moon-phase-monthly.sh" \
|
||||
$'After=network-online.target\nWants=network-online.target\n'
|
||||
write_timer "sky-cam-moon-phase" \
|
||||
"$SUNRISE_CAM: monthly moon-phase composite" \
|
||||
"${SCHEDULE_MOON_PHASE:-09:30:00}"
|
||||
timers+=("sky-cam-moon-phase")
|
||||
fi
|
||||
|
||||
# ── Per-camera Four Seasons jobs ─────────────────────────────────────────────
|
||||
# Reads CAMERAS and SCHEDULE_SEASONS_<cam> from sky-cam.conf.
|
||||
# Script receives the camera name as $1 so it knows which camera to process.
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# moon-phase-monthly.sh — thin wrapper around moon_phase_monthly.py.
|
||||
#
|
||||
# Run via systemd daily. In auto mode (no flags) the Python helper checks
|
||||
# all three phases (full, first-quarter, third-quarter) and runs the composite
|
||||
# for any whose post-day equals today UTC. If you scheduled the timer for
|
||||
# 09:30 local, you'll see posts hit Mattermost at:
|
||||
#
|
||||
# Full Moon 3 days after exact full moon
|
||||
# First Quarter 2 days after exact first quarter (best-effort — daytime)
|
||||
# Third Quarter 2 days after exact third quarter
|
||||
#
|
||||
# Manual:
|
||||
# ./moon-phase-monthly.sh # auto mode
|
||||
# ./moon-phase-monthly.sh --dry-run # auto, no post
|
||||
# ./moon-phase-monthly.sh --phase full # force most recent full
|
||||
# ./moon-phase-monthly.sh --phase third-quarter --target 2026-04-09T11:51:00Z
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
|
||||
source "$SCRIPT_DIR/sky-cam.conf"
|
||||
export TZ="$TIMEZONE"
|
||||
|
||||
exec python3 "$SCRIPT_DIR/moon_phase_monthly.py" "$@"
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
# moon-track.sh — nightly: detect the moon in each east night frame, crop a
|
||||
# fixed-size box around it, stitch the cropped sequence into an mp4 that holds
|
||||
# the moon roughly centred while clouds, halo and stars drift past.
|
||||
#
|
||||
# Run once per day from systemd around 02:00, after the night the moon was up.
|
||||
# Processes YESTERDAY's frames by default; pass YYYY-MM-DD to back-fill.
|
||||
#
|
||||
# Output: $MOVIES_DIR/<cam>/moon-track/YYYY/YYYY-MM-DD-moon-track.mp4
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
|
||||
source "$SCRIPT_DIR/sky-cam.conf"
|
||||
export TZ="$TIMEZONE"
|
||||
|
||||
CAM_NAME="${1:-${SUNRISE_CAM:-east}}"
|
||||
DATE_ARG="${2:-}"
|
||||
if [ -n "$DATE_ARG" ]; then
|
||||
target_date=$(date --date="$DATE_ARG" +%Y-%m-%d)
|
||||
else
|
||||
target_date=$(date --date="yesterday" +%Y-%m-%d)
|
||||
fi
|
||||
|
||||
if [ "${MOON_TRACK_ENABLED:-true}" != "true" ]; then
|
||||
echo "MOON_TRACK_ENABLED=false — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
image_dir="$BASE_DIR/$CAM_NAME/$target_date"
|
||||
if [ ! -d "$image_dir" ]; then
|
||||
echo "no images for $CAM_NAME on $target_date — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
year="${target_date:0:4}"
|
||||
out_dir="$MOVIES_DIR/$CAM_NAME/moon-track/$year"
|
||||
mkdir -p "$out_dir"
|
||||
final="$out_dir/${target_date}-moon-track.mp4"
|
||||
|
||||
# Crop size around the detected moon, in source pixels. At 4K with a ~38 px
|
||||
# moon, 480 px gives a comfortable surround with halo and any clouds visible.
|
||||
CROP_PX="${MOON_TRACK_CROP_PX:-480}"
|
||||
FPS="${MOON_TRACK_FPS:-12}"
|
||||
CRF="${MOON_TRACK_CRF:-24}"
|
||||
|
||||
work_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
|
||||
export SCRIPT_DIR
|
||||
echo "Scanning $image_dir for night frames with the moon visible..."
|
||||
python3 - "$image_dir" "$work_dir" "$CROP_PX" <<'PY'
|
||||
import os, sys
|
||||
sys.path.insert(0, os.environ['SCRIPT_DIR'])
|
||||
from PIL import Image
|
||||
from moon_detect import detect_moon
|
||||
|
||||
image_dir = sys.argv[1]
|
||||
work_dir = sys.argv[2]
|
||||
crop_px = int(sys.argv[3])
|
||||
half = crop_px // 2
|
||||
|
||||
frames = sorted(f for f in os.listdir(image_dir) if f.endswith('.jpg'))
|
||||
n = len(frames)
|
||||
written = 0
|
||||
for i, name in enumerate(frames):
|
||||
p = os.path.join(image_dir, name)
|
||||
det = detect_moon(p)
|
||||
if det is None:
|
||||
continue
|
||||
cx, cy = det.centroid_xy
|
||||
im = Image.open(p)
|
||||
w, h = im.size
|
||||
left = max(0, min(int(cx - half), w - crop_px))
|
||||
top = max(0, min(int(cy - half), h - crop_px))
|
||||
crop = im.crop((left, top, left + crop_px, top + crop_px))
|
||||
out = os.path.join(work_dir, f'{written:06d}.jpg')
|
||||
crop.save(out, quality=88)
|
||||
written += 1
|
||||
if (i + 1) % 200 == 0 or i + 1 == n:
|
||||
print(f' scanned {i + 1}/{n} frames, kept {written}', flush=True)
|
||||
print(f'moon-detected frames: {written}')
|
||||
PY
|
||||
|
||||
count=$(find "$work_dir" -maxdepth 1 -name '*.jpg' | wc -l)
|
||||
if [ "$count" -lt 10 ]; then
|
||||
echo "only $count moon-visible frames on $target_date — skipping video build"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "encoding $count frames into $final ..."
|
||||
ffmpeg -loglevel warning -y \
|
||||
-framerate "$FPS" \
|
||||
-i "$work_dir/%06d.jpg" \
|
||||
-c:v libx264 -pix_fmt yuv420p -preset "${ENCODE_PRESET:-slow}" \
|
||||
-crf "$CRF" \
|
||||
"$final"
|
||||
|
||||
echo "moon-track: $final ($(du -h "$final" | cut -f1))"
|
||||
|
||||
# Retention sweep
|
||||
RETENTION="${MOON_TRACK_RETENTION_DAYS:-90}"
|
||||
if [ "$RETENTION" -gt 0 ]; then
|
||||
find "$MOVIES_DIR/$CAM_NAME/moon-track" -type f -name '*-moon-track.mp4' \
|
||||
-mtime +"$RETENTION" -delete 2>/dev/null || true
|
||||
fi
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""moon_composite.py — paint a high-res lunar texture into a sky-cam frame.
|
||||
|
||||
Goal: from a frame east captured of a small white blob (~38 px), produce a
|
||||
1920x1080 image that looks like east took it through a 65x telephoto.
|
||||
|
||||
What's real, from east:
|
||||
- Sky color, atmospheric halo, any clouds drifting past
|
||||
- Time, parallactic angle (orientation of "up" on the moon)
|
||||
- Position of the moon in the frame at that instant
|
||||
|
||||
What's borrowed:
|
||||
- The lunar surface texture (one cached high-res reference image)
|
||||
|
||||
Library entry point:
|
||||
|
||||
from moon_composite import composite_full_moon
|
||||
composite_full_moon(
|
||||
source_jpg='/data/east/2026-04-29/21-07-00.jpg',
|
||||
detection=detect_moon(...),
|
||||
when_utc=datetime(2026, 4, 29, 21, 7, 0, tzinfo=timezone.utc),
|
||||
ref_moon_path='/path/to/full-moon.jpg',
|
||||
out_path='/movies/east/full-moons/2026-04-full-moon.jpg',
|
||||
)
|
||||
|
||||
CLI:
|
||||
|
||||
python3 moon_composite.py SOURCE.jpg WHEN_UTC REF_MOON.jpg OUT.jpg
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
# Pillow ≥ 10 renamed resampling constants
|
||||
try:
|
||||
LANCZOS = Image.Resampling.LANCZOS
|
||||
BICUBIC = Image.Resampling.BICUBIC
|
||||
except AttributeError: # Pillow < 10
|
||||
LANCZOS = Image.LANCZOS
|
||||
BICUBIC = Image.BICUBIC
|
||||
|
||||
|
||||
def _square_crop_to_disk(ref: Image.Image, threshold: int = 25) -> Image.Image:
|
||||
"""Tight-crop a reference moon image to the disk's bounding square.
|
||||
|
||||
Most lunar reference photos have the disk on a large black field with
|
||||
significant padding. We threshold on luminance and crop to bbox + small
|
||||
margin so the disk fills our target square evenly.
|
||||
"""
|
||||
g = np.asarray(ref.convert('L'))
|
||||
mask = g >= threshold
|
||||
ys, xs = np.where(mask)
|
||||
if len(xs) == 0:
|
||||
return ref
|
||||
x0, x1 = int(xs.min()), int(xs.max())
|
||||
y0, y1 = int(ys.min()), int(ys.max())
|
||||
cx = (x0 + x1) // 2
|
||||
cy = (y0 + y1) // 2
|
||||
half = max(x1 - x0, y1 - y0) // 2 + 4 # tiny margin
|
||||
left = max(0, cx - half)
|
||||
top = max(0, cy - half)
|
||||
right = min(ref.width, cx + half)
|
||||
bottom = min(ref.height, cy + half)
|
||||
return ref.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def _disk_mask(size: int, feather_px: int = 6) -> Image.Image:
|
||||
"""Soft circular alpha mask the size of the reference moon image."""
|
||||
m = Image.new('L', (size, size), 0)
|
||||
draw = ImageDraw.Draw(m)
|
||||
# Inset slightly so the feather sits inside the disk edge
|
||||
draw.ellipse((feather_px, feather_px, size - feather_px, size - feather_px),
|
||||
fill=255)
|
||||
if feather_px > 0:
|
||||
m = m.filter(ImageFilter.GaussianBlur(radius=feather_px))
|
||||
return m
|
||||
|
||||
|
||||
def _apply_phase_shadow(disk: Image.Image, phase_angle_deg: float,
|
||||
waxing: bool) -> Image.Image:
|
||||
"""Darken the un-lit portion of the disk based on phase.
|
||||
|
||||
phase_angle_deg: 0 = full, 90 = quarter, 180 = new.
|
||||
waxing: True = lit on right, False = lit on left.
|
||||
|
||||
Implementation: the terminator is an ellipse whose semi-minor axis is
|
||||
cos(phase_angle). Pixels on the un-lit side are multiplied by a small
|
||||
factor (not zero, so the un-lit limb stays visible like real earthshine).
|
||||
"""
|
||||
if phase_angle_deg < 1.0:
|
||||
return disk # full enough that shadow would be a single-pixel sliver
|
||||
w, h = disk.size
|
||||
# Build a mask: 1.0 in lit area, 0.04 in un-lit area, soft transition near
|
||||
# the terminator.
|
||||
cx, cy = w / 2.0, h / 2.0
|
||||
r = min(w, h) / 2.0
|
||||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
# Normalise to disk coords (-1..1)
|
||||
nx = (xx - cx) / r
|
||||
ny = (yy - cy) / r
|
||||
# Distance from disk centre (we still want to clip to the disk)
|
||||
in_disk = (nx * nx + ny * ny) <= 1.0
|
||||
# Terminator equation: x_norm = cos(phase) on the appropriate side.
|
||||
# For waxing moon, the lit portion is right of the terminator (nx > x_t).
|
||||
cos_p = math.cos(math.radians(phase_angle_deg))
|
||||
# When the moon is more than half lit (cos_p > 0), terminator is on the
|
||||
# un-lit side and the lit portion is broader. When less than half
|
||||
# (cos_p < 0), terminator is on the lit side.
|
||||
# Distance from terminator (positive = lit side)
|
||||
if waxing:
|
||||
d = nx - (-cos_p)
|
||||
else:
|
||||
d = -(nx - cos_p)
|
||||
# Smooth step around the terminator (~2% of radius)
|
||||
soft_px = max(1.5 / r, 0.01)
|
||||
lit = np.clip(0.5 + d / (2 * soft_px), 0.04, 1.0)
|
||||
lit = np.where(in_disk, lit, 1.0) # leave outside-disk untouched
|
||||
arr = np.asarray(disk).astype(np.float32)
|
||||
arr = arr * lit[..., None]
|
||||
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), disk.mode)
|
||||
|
||||
|
||||
def composite_full_moon(
|
||||
source_jpg: str,
|
||||
detection, # MoonDetection from moon_detect
|
||||
when_utc: datetime,
|
||||
ref_moon_path: str,
|
||||
out_path: str,
|
||||
output_size: tuple[int, int] = (1920, 1080),
|
||||
moon_height_pct: float = 0.70,
|
||||
caption: str | None = None,
|
||||
):
|
||||
"""Build the full-moon close-up composite and write it to out_path."""
|
||||
# Lazy import — avoids loading skyfield when caller doesn't need it
|
||||
import moon_phase
|
||||
|
||||
src = Image.open(source_jpg).convert('RGB')
|
||||
cx, cy = detection.centroid_xy
|
||||
src_diam = detection.diameter_px
|
||||
|
||||
# ── Crop east around the moon, sized so the moon fills moon_height_pct ──
|
||||
crop_h = int(round(src_diam / moon_height_pct))
|
||||
crop_w = int(round(crop_h * output_size[0] / output_size[1]))
|
||||
sw, sh = src.size
|
||||
crop_w = min(crop_w, sw)
|
||||
crop_h = min(crop_h, sh)
|
||||
left = int(round(cx - crop_w / 2))
|
||||
top = int(round(cy - crop_h / 2))
|
||||
left = max(0, min(left, sw - crop_w))
|
||||
top = max(0, min(top, sh - crop_h))
|
||||
crop = src.crop((left, top, left + crop_w, top + crop_h))
|
||||
bg = crop.resize(output_size, LANCZOS)
|
||||
|
||||
# ── Where is the moon's center within the upscaled background? ──
|
||||
moon_x_in_crop = cx - left
|
||||
moon_y_in_crop = cy - top
|
||||
scale = output_size[1] / crop_h
|
||||
out_moon_cx = moon_x_in_crop * scale
|
||||
out_moon_cy = moon_y_in_crop * scale
|
||||
|
||||
# ── Load reference texture, tight-crop to disk ──
|
||||
ref = Image.open(ref_moon_path).convert('RGB')
|
||||
ref = _square_crop_to_disk(ref)
|
||||
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)
|
||||
|
||||
# ── 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)
|
||||
|
||||
# ── 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 ──
|
||||
feather = max(4, target_size // 200)
|
||||
mask = _disk_mask(target_size, feather_px=feather)
|
||||
|
||||
paste_x = int(round(out_moon_cx - target_size / 2))
|
||||
paste_y = int(round(out_moon_cy - target_size / 2))
|
||||
# Clamp so the disk stays fully on canvas (recenter if needed)
|
||||
paste_x = max(0, min(paste_x, output_size[0] - target_size))
|
||||
paste_y = max(0, min(paste_y, output_size[1] - target_size))
|
||||
bg.paste(ref_rot, (paste_x, paste_y), mask)
|
||||
|
||||
# ── Caption ──
|
||||
if caption:
|
||||
draw = ImageDraw.Draw(bg)
|
||||
# Drop shadow for legibility
|
||||
draw.text((24, output_size[1] - 44), caption, fill=(0, 0, 0))
|
||||
draw.text((22, output_size[1] - 46), caption, fill=(220, 220, 220))
|
||||
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
bg.save(out_path, quality=92)
|
||||
return out_path
|
||||
|
||||
|
||||
def _cli():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('source')
|
||||
p.add_argument('when_utc', help='ISO 8601 UTC, e.g. 2026-04-29T21:07:00Z')
|
||||
p.add_argument('ref_moon')
|
||||
p.add_argument('out')
|
||||
p.add_argument('--caption', default=None)
|
||||
p.add_argument('--moon-pct', type=float, default=0.70)
|
||||
args = p.parse_args()
|
||||
|
||||
from moon_detect import detect_moon
|
||||
det = detect_moon(args.source)
|
||||
if det is None:
|
||||
print('ERROR: no moon detected in source', file=sys.stderr)
|
||||
return 2
|
||||
when = datetime.fromisoformat(args.when_utc.replace('Z', '+00:00'))
|
||||
composite_full_moon(
|
||||
args.source, det, when, args.ref_moon, args.out,
|
||||
moon_height_pct=args.moon_pct, caption=args.caption,
|
||||
)
|
||||
print(f'wrote {args.out}')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(_cli())
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""moon_detect.py — find the moon disk in a sky-cam frame.
|
||||
|
||||
Returns centroid, radius, and a quality score derived from:
|
||||
- roundness (area / (pi * r^2))
|
||||
- isolation (no comparable bright blob within IGNORE_RADIUS_PX)
|
||||
- sky (low halo extent → clearer sky around the moon)
|
||||
|
||||
Used as a library:
|
||||
|
||||
from moon_detect import detect_moon
|
||||
result = detect_moon('/path/to/frame.jpg')
|
||||
if result is not None:
|
||||
cx, cy = result['centroid']
|
||||
r = result['radius']
|
||||
score = result['quality']
|
||||
|
||||
CLI (handy for tuning):
|
||||
|
||||
python3 moon_detect.py /path/to/frame.jpg
|
||||
python3 moon_detect.py /path/to/frame.jpg --debug debug.png
|
||||
|
||||
The detector ignores the bottom OVERLAY_PX rows because capture frames carry a
|
||||
burnt-in timestamp that contains saturated pixels.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy import ndimage
|
||||
|
||||
# --- Tuning constants --------------------------------------------------------
|
||||
SATURATED_THRESHOLD = 240 # 0..255 — pixels at or above this count as "moon disk"
|
||||
HALO_THRESHOLD = 100 # 0..255 — pixels above this count toward halo extent
|
||||
OVERLAY_PX = 200 # bottom rows to ignore (timestamp overlay)
|
||||
MIN_DIAMETER_PX = 10 # blob smaller than this is noise
|
||||
MAX_DIAMETER_PX = 120 # blob bigger than this is probably the sun, not the moon
|
||||
MIN_ROUNDNESS = 0.55 # area / (pi * r^2) — perfect circle = 1
|
||||
ISOLATION_PX = 200 # other comparable blob this close → reject
|
||||
# Halo extent — typical moon halo is ~3.5x disk radius; >5x means thick clouds
|
||||
MAX_HALO_RATIO = 6.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoonDetection:
|
||||
centroid_xy: tuple[float, float]
|
||||
radius_px: float
|
||||
diameter_px: float
|
||||
blob_pixels: int
|
||||
roundness: float
|
||||
halo_radius_px: float
|
||||
halo_ratio: float
|
||||
isolation_px: float
|
||||
quality: float
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {
|
||||
'centroid': list(self.centroid_xy),
|
||||
'radius': self.radius_px,
|
||||
'diameter': self.diameter_px,
|
||||
'blob_pixels': self.blob_pixels,
|
||||
'roundness': self.roundness,
|
||||
'halo_radius': self.halo_radius_px,
|
||||
'halo_ratio': self.halo_ratio,
|
||||
'isolation': self.isolation_px,
|
||||
'quality': self.quality,
|
||||
}
|
||||
|
||||
|
||||
def _grayscale_array(image_path: str) -> np.ndarray:
|
||||
im = Image.open(image_path).convert('L')
|
||||
return np.asarray(im)
|
||||
|
||||
|
||||
def _largest_round_blob(mask: np.ndarray) -> 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
|
||||
ys, xs = np.where(labels == label_id)
|
||||
if len(xs) < 4:
|
||||
continue
|
||||
bbw = xs.max() - xs.min() + 1
|
||||
bbh = ys.max() - ys.min() + 1
|
||||
diam = max(bbw, bbh)
|
||||
if diam < MIN_DIAMETER_PX or diam > MAX_DIAMETER_PX:
|
||||
continue
|
||||
radius = diam / 2.0
|
||||
roundness = len(xs) / (math.pi * radius * radius)
|
||||
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."""
|
||||
a = _grayscale_array(image_path)
|
||||
if OVERLAY_PX > 0:
|
||||
a = a.copy()
|
||||
a[-OVERLAY_PX:, :] = 0
|
||||
mask = a >= SATURATED_THRESHOLD
|
||||
if not mask.any():
|
||||
return None
|
||||
|
||||
found = _largest_round_blob(mask)
|
||||
if found is None:
|
||||
return None
|
||||
_, ys, xs = found
|
||||
|
||||
cx = float(xs.mean())
|
||||
cy = float(ys.mean())
|
||||
blob_pixels = int(len(xs))
|
||||
bbw = xs.max() - xs.min() + 1
|
||||
bbh = ys.max() - ys.min() + 1
|
||||
diameter = float(max(bbw, bbh))
|
||||
radius = diameter / 2.0
|
||||
roundness = blob_pixels / (math.pi * radius * radius)
|
||||
|
||||
# Isolation: any other saturated blob nearby of comparable size?
|
||||
labels_full, n_full = ndimage.label(mask)
|
||||
own_label = labels_full[int(round(cy)), int(round(cx))]
|
||||
isolation = float('inf')
|
||||
for label_id in range(1, n_full + 1):
|
||||
if label_id == own_label:
|
||||
continue
|
||||
ys2, xs2 = np.where(labels_full == label_id)
|
||||
if len(xs2) < blob_pixels * 0.3:
|
||||
continue
|
||||
d = math.hypot(xs2.mean() - cx, ys2.mean() - cy)
|
||||
if d < isolation:
|
||||
isolation = d
|
||||
if isolation < ISOLATION_PX:
|
||||
return None
|
||||
|
||||
# Halo: connected component above HALO_THRESHOLD that contains the centroid
|
||||
halo_mask = a >= HALO_THRESHOLD
|
||||
halo_labels, _ = ndimage.label(halo_mask)
|
||||
halo_id = halo_labels[int(round(cy)), int(round(cx))]
|
||||
if halo_id == 0:
|
||||
halo_radius = radius
|
||||
else:
|
||||
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:
|
||||
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)))
|
||||
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
|
||||
|
||||
return MoonDetection(
|
||||
centroid_xy=(cx, cy),
|
||||
radius_px=radius,
|
||||
diameter_px=diameter,
|
||||
blob_pixels=blob_pixels,
|
||||
roundness=roundness,
|
||||
halo_radius_px=halo_radius,
|
||||
halo_ratio=halo_ratio,
|
||||
isolation_px=isolation if isolation != float('inf') else -1.0,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
|
||||
def _draw_debug(image_path: str, detection: MoonDetection | None, out_path: str):
|
||||
im = Image.open(image_path).convert('RGB')
|
||||
draw = ImageDraw.Draw(im)
|
||||
if detection is None:
|
||||
draw.text((20, 20), 'NO MOON DETECTED', fill='red')
|
||||
else:
|
||||
cx, cy = detection.centroid_xy
|
||||
r = detection.radius_px
|
||||
hr = detection.halo_radius_px
|
||||
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline='yellow', width=4)
|
||||
draw.ellipse((cx - hr, cy - hr, cx + hr, cy + hr), outline='cyan', width=2)
|
||||
draw.text((20, 20), f'q={detection.quality:.2f} r={r:.1f} halo={hr:.1f}',
|
||||
fill='yellow')
|
||||
im.save(out_path)
|
||||
|
||||
|
||||
def _cli():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('image')
|
||||
p.add_argument('--debug', help='write annotated PNG to this path')
|
||||
p.add_argument('--json', action='store_true', help='emit JSON for scripting')
|
||||
args = p.parse_args()
|
||||
|
||||
det = detect_moon(args.image)
|
||||
if args.json:
|
||||
print(json.dumps(det.asdict() if det else None))
|
||||
elif det is None:
|
||||
print('no moon detected')
|
||||
else:
|
||||
d = det.asdict()
|
||||
for k, v in d.items():
|
||||
print(f'{k}={v}')
|
||||
if args.debug:
|
||||
_draw_debug(args.image, det, args.debug)
|
||||
print(f'debug image written: {args.debug}', file=sys.stderr)
|
||||
return 0 if det else 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(_cli())
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""moon_phase.py — moon ephemeris lookups for sky-cam.
|
||||
|
||||
Provides:
|
||||
- full_moons_in_range(start, end) list of UTC datetimes of full moons
|
||||
- nearest_full_moon(when) UTC datetime of full moon nearest 'when'
|
||||
- illumination(when) 0.0..1.0 fraction lit
|
||||
- phase_angle(when) degrees, 0=full, 180=new
|
||||
- waxing(when) True if moon is waxing
|
||||
- altaz(when, lat, lon) (altitude_deg, azimuth_deg)
|
||||
- parallactic_angle(when, lat, lon) degrees, rotation to put celestial north up
|
||||
|
||||
Used as a library by other scripts and as a CLI:
|
||||
|
||||
python3 moon_phase.py next-full # next full moon UTC + local
|
||||
python3 moon_phase.py altaz <ISO-UTC> # alt/az from configured location
|
||||
python3 moon_phase.py info <ISO-UTC> # everything for one timestamp
|
||||
|
||||
All times assume UTC unless tagged otherwise. Local timezone comes from
|
||||
sky-cam.conf TIMEZONE for display only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
_here = pathlib.Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _read_conf(path):
|
||||
conf = {}
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
k, v = line.split('=', 1)
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
v = re.sub(r'\s+#.*$', '', v)
|
||||
v = v.strip('"').strip("'")
|
||||
m = re.match(r'^\$\{[^}]+:-([^}]*)\}$', v)
|
||||
if m:
|
||||
v = m.group(1).strip('"').strip("'")
|
||||
conf[k] = v
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return conf
|
||||
|
||||
|
||||
_conf = _read_conf(_here / 'sky-cam.conf')
|
||||
_conf.update(_read_conf(_here / '.env'))
|
||||
|
||||
LATITUDE = float(_conf.get('LATITUDE') or 0.0)
|
||||
LONGITUDE = float(_conf.get('LONGITUDE') or 0.0)
|
||||
TIMEZONE = _conf.get('TIMEZONE', 'UTC')
|
||||
|
||||
# Cached ephemeris — bootstrap.sh pre-downloads de421.bsp into SCRIPT_DIR so
|
||||
# this never needs to hit the network at run time.
|
||||
_EPH_PATH = _here / 'de421.bsp'
|
||||
|
||||
_ts = None
|
||||
_eph = None
|
||||
_observer = None
|
||||
|
||||
|
||||
def _lazy():
|
||||
"""Defer skyfield import + ephemeris load until first use.
|
||||
|
||||
Lets the module be imported by tests and by --help paths even when
|
||||
skyfield is missing or the ephemeris hasn't been downloaded yet.
|
||||
"""
|
||||
global _ts, _eph, _observer
|
||||
if _ts is not None:
|
||||
return
|
||||
from skyfield.api import Loader, wgs84
|
||||
loader = Loader(str(_here), verbose=False)
|
||||
_ts = loader.timescale()
|
||||
if _EPH_PATH.exists():
|
||||
_eph = loader('de421.bsp')
|
||||
else:
|
||||
_eph = loader('de421.bsp') # downloads on first run
|
||||
_observer = _eph['earth'] + wgs84.latlon(LATITUDE, LONGITUDE)
|
||||
|
||||
|
||||
def _to_utc(when: datetime) -> datetime:
|
||||
if when.tzinfo is None:
|
||||
return when.replace(tzinfo=timezone.utc)
|
||||
return when.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _t(when: datetime):
|
||||
_lazy()
|
||||
w = _to_utc(when)
|
||||
return _ts.from_datetime(w)
|
||||
|
||||
|
||||
def full_moons_in_range(start: datetime, end: datetime) -> list[datetime]:
|
||||
"""Return UTC datetimes of every full moon between start and end (inclusive)."""
|
||||
return phase_events_in_range(start, end, 2)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
skyfield phase indices: 0 = new, 1 = first quarter, 2 = full, 3 = last quarter.
|
||||
"""
|
||||
_lazy()
|
||||
from skyfield.almanac import find_discrete, moon_phases
|
||||
t0 = _t(start)
|
||||
t1 = _t(end)
|
||||
times, phases = find_discrete(t0, t1, moon_phases(_eph))
|
||||
return [t.utc_datetime() for t, p in zip(times, phases) if p == phase_index]
|
||||
|
||||
|
||||
def nearest_full_moon(when: datetime) -> datetime:
|
||||
"""Full moon UTC nearest 'when' — searches a 45-day window centred on it."""
|
||||
w = _to_utc(when)
|
||||
candidates = full_moons_in_range(w - timedelta(days=45), w + timedelta(days=45))
|
||||
return min(candidates, key=lambda d: abs(d - w))
|
||||
|
||||
|
||||
def illumination(when: datetime) -> float:
|
||||
"""Fraction of the moon's disk that is illuminated (0..1)."""
|
||||
_lazy()
|
||||
from skyfield.almanac import fraction_illuminated
|
||||
return float(fraction_illuminated(_eph, 'moon', _t(when)))
|
||||
|
||||
|
||||
def phase_angle(when: datetime) -> float:
|
||||
"""Sun-Moon-Earth phase angle in degrees: 0=full, 90=quarter, 180=new."""
|
||||
_lazy()
|
||||
earth = _eph['earth']
|
||||
sun = _eph['sun']
|
||||
moon = _eph['moon']
|
||||
t = _t(when)
|
||||
e = earth.at(t)
|
||||
s_from_moon = (sun - moon).at(t)
|
||||
e_from_moon = (e.position.au - moon.at(t).position.au)
|
||||
# angle between sun→moon→earth
|
||||
a = s_from_moon.position.au
|
||||
b = e_from_moon
|
||||
cosang = (a[0]*b[0] + a[1]*b[1] + a[2]*b[2]) / (
|
||||
math.sqrt(a[0]**2 + a[1]**2 + a[2]**2)
|
||||
* math.sqrt(b[0]**2 + b[1]**2 + b[2]**2)
|
||||
)
|
||||
cosang = max(-1.0, min(1.0, cosang))
|
||||
return math.degrees(math.acos(cosang))
|
||||
|
||||
|
||||
def waxing(when: datetime) -> bool:
|
||||
"""True if moon is waxing (illumination growing)."""
|
||||
now = illumination(when)
|
||||
later = illumination(when + timedelta(hours=6))
|
||||
return later > now
|
||||
|
||||
|
||||
def altaz(when: datetime, lat: float | None = None, lon: float | None = None):
|
||||
"""Apparent altitude/azimuth in degrees as seen from (lat, lon).
|
||||
|
||||
Falls back to configured LATITUDE/LONGITUDE if not specified.
|
||||
"""
|
||||
_lazy()
|
||||
from skyfield.api import wgs84
|
||||
if lat is None and lon is None:
|
||||
obs = _observer
|
||||
else:
|
||||
obs = _eph['earth'] + wgs84.latlon(
|
||||
LATITUDE if lat is None else lat,
|
||||
LONGITUDE if lon is None else lon,
|
||||
)
|
||||
t = _t(when)
|
||||
alt, az, _ = obs.at(t).observe(_eph['moon']).apparent().altaz()
|
||||
return float(alt.degrees), float(az.degrees)
|
||||
|
||||
|
||||
def parallactic_angle(when: datetime, lat: float | None = None, lon: float | None = None) -> float:
|
||||
"""Parallactic angle in degrees — rotates a moon image so celestial north is up.
|
||||
|
||||
sin(q) = sin(H) * cos(lat) / cos(alt)
|
||||
where H is the hour angle and alt is the altitude.
|
||||
|
||||
Returned value is the angle to rotate the lunar texture clockwise (in the
|
||||
image sense, y-down) so celestial north points up in the camera frame.
|
||||
"""
|
||||
_lazy()
|
||||
from skyfield.api import wgs84
|
||||
if lat is None:
|
||||
lat = LATITUDE
|
||||
if lon is None:
|
||||
lon = LONGITUDE
|
||||
obs = _eph['earth'] + wgs84.latlon(lat, lon)
|
||||
t = _t(when)
|
||||
apparent = obs.at(t).observe(_eph['moon']).apparent()
|
||||
alt, _, _ = apparent.altaz()
|
||||
ra, dec, _ = apparent.radec(epoch='date')
|
||||
# local sidereal time at observer's longitude → hour angle
|
||||
lst = t.gast * 15.0 + lon # gast in hours → degrees, plus longitude
|
||||
H = math.radians(lst - ra._degrees)
|
||||
phi = math.radians(lat)
|
||||
delta = math.radians(dec.degrees)
|
||||
# Standard parallactic angle formula
|
||||
q = math.atan2(math.sin(H), math.tan(phi) * math.cos(delta) - math.sin(delta) * math.cos(H))
|
||||
return math.degrees(q)
|
||||
|
||||
|
||||
def _format_local(dt_utc: datetime) -> str:
|
||||
try:
|
||||
import pytz
|
||||
tz = pytz.timezone(TIMEZONE)
|
||||
return dt_utc.astimezone(tz).strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
except Exception:
|
||||
return dt_utc.strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||
|
||||
|
||||
def _cli():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__.strip())
|
||||
return 1
|
||||
cmd = sys.argv[1]
|
||||
if cmd == 'next-full':
|
||||
now = datetime.now(timezone.utc)
|
||||
fm = full_moons_in_range(now, now + timedelta(days=45))
|
||||
if not fm:
|
||||
print('No full moon found in the next 45 days?', file=sys.stderr)
|
||||
return 1
|
||||
next_fm = fm[0]
|
||||
print(f'next_full_moon_utc={next_fm.strftime("%Y-%m-%dT%H:%M:%SZ")}')
|
||||
print(f'next_full_moon_local={_format_local(next_fm)}')
|
||||
return 0
|
||||
if cmd == 'nearest-full':
|
||||
when = datetime.fromisoformat(sys.argv[2].replace('Z', '+00:00'))
|
||||
fm = nearest_full_moon(when)
|
||||
print(f'nearest_full_moon_utc={fm.strftime("%Y-%m-%dT%H:%M:%SZ")}')
|
||||
return 0
|
||||
if cmd == 'altaz':
|
||||
when = datetime.fromisoformat(sys.argv[2].replace('Z', '+00:00'))
|
||||
alt, az = altaz(when)
|
||||
print(f'altitude_deg={alt:.3f}')
|
||||
print(f'azimuth_deg={az:.3f}')
|
||||
return 0
|
||||
if cmd == 'info':
|
||||
when = datetime.fromisoformat(sys.argv[2].replace('Z', '+00:00'))
|
||||
alt, az = altaz(when)
|
||||
print(f'utc={when.strftime("%Y-%m-%dT%H:%M:%SZ")}')
|
||||
print(f'local={_format_local(when)}')
|
||||
print(f'illumination={illumination(when):.4f}')
|
||||
print(f'phase_angle_deg={phase_angle(when):.2f}')
|
||||
print(f'waxing={waxing(when)}')
|
||||
print(f'altitude_deg={alt:.3f}')
|
||||
print(f'azimuth_deg={az:.3f}')
|
||||
print(f'parallactic_angle_deg={parallactic_angle(when):.3f}')
|
||||
return 0
|
||||
print(f'unknown command: {cmd}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(_cli())
|
||||
Executable
+412
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""moon_phase_monthly.py — build the monthly moon-phase composite.
|
||||
|
||||
Handles three phases, controlled by --phase:
|
||||
|
||||
full ~100% lit, posted MOON_FULL_POST_DELAY_DAYS after exact full
|
||||
first-quarter ~50% lit waxing (right half lit in northern hemisphere)
|
||||
third-quarter ~50% lit waning (left half lit in northern hemisphere)
|
||||
|
||||
Algorithm (per phase):
|
||||
1. Find the most recent occurrence of the target phase (or honour --target).
|
||||
2. Scan east frames across the collection window (D-Δb .. D+Δa).
|
||||
3. Filter to frames where:
|
||||
quality >= MOON_MIN_QUALITY
|
||||
altitude_deg >= MOON_MIN_ALTITUDE_DEG
|
||||
illumination is within the phase's illumination band
|
||||
waxing-state matches the phase target
|
||||
4. Pick the qualifying frame closest in time to exact phase UTC.
|
||||
5. Composite the cached lunar texture into it (real sky + halo from east,
|
||||
borrowed surface detail from the cached reference).
|
||||
6. Optionally upload to Mattermost.
|
||||
|
||||
Geometry note — first-quarter from east is HARD: at first quarter the moon is
|
||||
up from noon to midnight, but east only sees the eastern sky, so it captures
|
||||
the moon during DAYTIME only (with a bright sky background). The detector is
|
||||
brightness-based and may often fail to find a daytime moon. Set
|
||||
MOON_FIRST_QUARTER_ENABLED=false in sky-cam.conf if you'd rather not chase it.
|
||||
Full moon and third-quarter both rise after dark and stay in east's view —
|
||||
those should land cleanly most months.
|
||||
|
||||
Usage:
|
||||
moon_phase_monthly.py # auto: run any phase whose post-day = today
|
||||
moon_phase_monthly.py --phase full # force a single phase
|
||||
moon_phase_monthly.py --phase third-quarter --target 2026-04-09T11:51:00Z --dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
_here = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_here))
|
||||
|
||||
|
||||
def _read_conf(path):
|
||||
conf = {}
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
k, v = line.split('=', 1)
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
v = re.sub(r'\s+#.*$', '', v)
|
||||
v = v.strip('"').strip("'")
|
||||
m = re.match(r'^\$\{[^}]+:-([^}]*)\}$', v)
|
||||
if m:
|
||||
v = m.group(1).strip('"').strip("'")
|
||||
conf[k] = v
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return conf
|
||||
|
||||
|
||||
CONF = _read_conf(_here / 'sky-cam.conf')
|
||||
CONF.update(_read_conf(_here / '.env'))
|
||||
|
||||
BASE_DIR = CONF.get('BASE_DIR') or str(_here / 'data')
|
||||
MOVIES_DIR = CONF.get('MOVIES_DIR') or f'{BASE_DIR}/movies'
|
||||
SUNRISE_CAM = CONF.get('SUNRISE_CAM', 'east')
|
||||
TIMEZONE = CONF.get('TIMEZONE', 'UTC')
|
||||
|
||||
MIN_QUALITY = float(CONF.get('MOON_MIN_QUALITY', CONF.get('MOON_FULL_MIN_QUALITY', 0.55)))
|
||||
MIN_ALTITUDE = float(CONF.get('MOON_MIN_ALTITUDE_DEG', CONF.get('MOON_FULL_MIN_ALTITUDE_DEG', 15.0)))
|
||||
OUT_W = int(CONF.get('MOON_OUTPUT_W', CONF.get('MOON_FULL_OUTPUT_W', 1920)))
|
||||
OUT_H = int(CONF.get('MOON_OUTPUT_H', CONF.get('MOON_FULL_OUTPUT_H', 1080)))
|
||||
MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', CONF.get('MOON_FULL_HEIGHT_PCT', 0.70)))
|
||||
REF_PATH = CONF.get('MOON_REFERENCE_PATH') or str(_here / 'moon-ref' / 'full-moon.jpg')
|
||||
|
||||
|
||||
PHASE_SPEC = {
|
||||
'full': {
|
||||
'index': 2,
|
||||
'label': 'Full Moon',
|
||||
'emoji': '🌕',
|
||||
'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.95)),
|
||||
'illum_max': 1.01,
|
||||
'waxing': None,
|
||||
'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)),
|
||||
'window_after': int(CONF.get('MOON_FULL_WINDOW_AFTER_DAYS', 2)),
|
||||
'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 3)),
|
||||
'enabled_key': 'MOON_FULL_ENABLED',
|
||||
},
|
||||
'first-quarter': {
|
||||
'index': 1,
|
||||
'label': 'First Quarter (Waxing Half)',
|
||||
'emoji': '🌓',
|
||||
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)),
|
||||
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)),
|
||||
'waxing': True,
|
||||
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
|
||||
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)),
|
||||
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 2)),
|
||||
'enabled_key': 'MOON_FIRST_QUARTER_ENABLED',
|
||||
},
|
||||
'third-quarter': {
|
||||
'index': 3,
|
||||
'label': 'Third Quarter (Waning Half)',
|
||||
'emoji': '🌗',
|
||||
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.40)),
|
||||
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.65)),
|
||||
'waxing': False,
|
||||
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
|
||||
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)),
|
||||
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 2)),
|
||||
'enabled_key': 'MOON_THIRD_QUARTER_ENABLED',
|
||||
},
|
||||
}
|
||||
|
||||
_FRAME_RE = re.compile(r'^(\d{2})-(\d{2})-(\d{2})\.jpg$')
|
||||
|
||||
|
||||
def _local_tz():
|
||||
try:
|
||||
import pytz
|
||||
return pytz.timezone(TIMEZONE)
|
||||
except Exception:
|
||||
return timezone.utc
|
||||
|
||||
|
||||
def _frame_local_dt(date_str: str, fname: str):
|
||||
m = _FRAME_RE.match(fname)
|
||||
if not m:
|
||||
return None
|
||||
h, mn, s = (int(x) for x in m.groups())
|
||||
y, mo, d = (int(x) for x in date_str.split('-'))
|
||||
naive = datetime(y, mo, d, h, mn, s)
|
||||
tz = _local_tz()
|
||||
if hasattr(tz, 'localize'):
|
||||
return tz.localize(naive)
|
||||
return naive.replace(tzinfo=tz)
|
||||
|
||||
|
||||
def _candidate_frames(cam: str, dates: list[str]) -> list[tuple[str, datetime]]:
|
||||
out = []
|
||||
for d in dates:
|
||||
fdir = pathlib.Path(BASE_DIR) / cam / d
|
||||
if not fdir.is_dir():
|
||||
continue
|
||||
for fname in sorted(os.listdir(fdir)):
|
||||
if not fname.endswith('.jpg'):
|
||||
continue
|
||||
local_dt = _frame_local_dt(d, fname)
|
||||
if local_dt is None:
|
||||
continue
|
||||
out.append((str(fdir / fname), local_dt.astimezone(timezone.utc)))
|
||||
return out
|
||||
|
||||
|
||||
def _format_local(dt_utc: datetime) -> str:
|
||||
return dt_utc.astimezone(_local_tz()).strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
|
||||
|
||||
def _notify(title: str, body: str):
|
||||
try:
|
||||
subprocess.run([str(_here / 'notify.sh'), title, body], check=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _post_to_mattermost(image_path: str, message: str) -> bool:
|
||||
import requests
|
||||
base = CONF.get('mattermost_url', '').rstrip('/')
|
||||
token = CONF.get('access_token', '')
|
||||
channel_id = CONF.get('channel_id', '')
|
||||
if not all([base, token, channel_id]):
|
||||
print('mattermost credentials missing — skipping upload', file=sys.stderr)
|
||||
return False
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
with open(image_path, 'rb') as f:
|
||||
r = requests.post(
|
||||
f'{base}/api/v4/files',
|
||||
headers=headers,
|
||||
files={'files': f},
|
||||
data={'channel_id': channel_id},
|
||||
)
|
||||
if r.status_code != 201:
|
||||
print(f'mattermost upload failed: {r.status_code} {r.text}', file=sys.stderr)
|
||||
return False
|
||||
file_id = r.json()['file_infos'][0]['id']
|
||||
r = requests.post(
|
||||
f'{base}/api/v4/posts',
|
||||
headers=headers,
|
||||
json={'channel_id': channel_id, 'message': message, 'file_ids': [file_id]},
|
||||
)
|
||||
if r.status_code != 201:
|
||||
print(f'mattermost post failed: {r.status_code} {r.text}', file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _output_subdir(phase: str) -> str:
|
||||
return {
|
||||
'full': 'full-moons',
|
||||
'first-quarter': 'first-quarter',
|
||||
'third-quarter': 'third-quarter',
|
||||
}[phase]
|
||||
|
||||
|
||||
def _output_filename(phase: str, target_utc: datetime) -> str:
|
||||
slug = {'full': 'full', 'first-quarter': 'first-quarter', 'third-quarter': 'third-quarter'}[phase]
|
||||
return f"{target_utc.strftime('%Y-%m')}-{slug}.jpg"
|
||||
|
||||
|
||||
def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
dry_run: bool, no_upload: bool, out_path: str | None) -> int:
|
||||
spec = PHASE_SPEC[phase]
|
||||
if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
|
||||
print(f'{spec["enabled_key"]}=false — skipping {phase}')
|
||||
return 0
|
||||
|
||||
import moon_phase
|
||||
from moon_detect import detect_moon
|
||||
|
||||
if target_utc is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
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)
|
||||
return 1
|
||||
target_utc = events[-1]
|
||||
|
||||
print(f'target {phase}: {target_utc.isoformat()} ({_format_local(target_utc)})')
|
||||
|
||||
tz = _local_tz()
|
||||
target_local = target_utc.astimezone(tz)
|
||||
dates = []
|
||||
for i in range(-spec['window_before'], spec['window_after'] + 1):
|
||||
d = (target_local + timedelta(days=i)).date()
|
||||
dates.append(d.strftime('%Y-%m-%d'))
|
||||
print(f'scanning dates: {dates}')
|
||||
|
||||
candidates = _candidate_frames(cam, dates)
|
||||
print(f'frame count in window: {len(candidates)}')
|
||||
if not candidates:
|
||||
msg = (
|
||||
f'No frames for {cam} in window {dates[0]}..{dates[-1]} '
|
||||
f'around {phase} {target_utc.strftime("%Y-%m-%d %H:%MZ")}.'
|
||||
)
|
||||
_notify(f'{spec["emoji"]} {spec["label"]} — no frames available', msg)
|
||||
print(msg)
|
||||
return 0
|
||||
|
||||
qualifying = []
|
||||
for path, utc_dt in candidates:
|
||||
try:
|
||||
alt, _ = moon_phase.altaz(utc_dt)
|
||||
except Exception as e:
|
||||
print(f'ERROR: moon_phase.altaz failed: {e}', file=sys.stderr)
|
||||
return 2
|
||||
if alt < MIN_ALTITUDE:
|
||||
continue
|
||||
det = detect_moon(path)
|
||||
if det is None or det.quality < MIN_QUALITY:
|
||||
continue
|
||||
illum = moon_phase.illumination(utc_dt)
|
||||
if illum < spec['illum_min'] or illum > spec['illum_max']:
|
||||
continue
|
||||
if spec['waxing'] is not None:
|
||||
if moon_phase.waxing(utc_dt) != spec['waxing']:
|
||||
continue
|
||||
qualifying.append((path, utc_dt, det, alt, illum))
|
||||
|
||||
print(f'qualifying frames: {len(qualifying)}')
|
||||
if not qualifying:
|
||||
msg = (
|
||||
f'No clear-shot {phase} frame in window {dates[0]}..{dates[-1]} '
|
||||
f'(need quality≥{MIN_QUALITY}, altitude≥{MIN_ALTITUDE}°, '
|
||||
f'illumination {spec["illum_min"]:.2f}-{spec["illum_max"]:.2f}). '
|
||||
)
|
||||
if phase == 'first-quarter':
|
||||
msg += ('First quarter from east is best-effort because the moon is '
|
||||
'only up during daylight hours — daytime detection often '
|
||||
'fails. Lower MOON_MIN_QUALITY or accept that some months '
|
||||
'will skip.')
|
||||
else:
|
||||
msg += 'Likely cloudy across the whole window.'
|
||||
_notify(f'{spec["emoji"]} {spec["label"]} — no clear shot {target_utc.strftime("%B %Y")}', msg)
|
||||
print(msg)
|
||||
return 0
|
||||
|
||||
best = min(qualifying, key=lambda t: abs(t[1] - target_utc))
|
||||
path, when_utc, det, alt, illum = best
|
||||
delta_min = (when_utc - target_utc).total_seconds() / 60.0
|
||||
local_dt = when_utc.astimezone(tz)
|
||||
print(f'picked: {path}')
|
||||
print(f' when_utc={when_utc.isoformat()} local={local_dt} '
|
||||
f'altitude={alt:.1f}° illum={illum:.4f} quality={det.quality:.3f} '
|
||||
f'Δtarget={delta_min:+.1f} min')
|
||||
|
||||
if dry_run:
|
||||
return 0
|
||||
|
||||
if not pathlib.Path(REF_PATH).is_file():
|
||||
msg = (f'Lunar reference image missing at {REF_PATH}. Re-run install.sh '
|
||||
f'or drop a high-res full moon JPEG there manually.')
|
||||
_notify(f'{spec["emoji"]} {spec["label"]} — reference missing', msg)
|
||||
print(msg, file=sys.stderr)
|
||||
return 3
|
||||
|
||||
if out_path is None:
|
||||
out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = str(out_dir / _output_filename(phase, target_utc))
|
||||
|
||||
from moon_composite import composite_full_moon
|
||||
caption = (
|
||||
f"{spec['label']} — {target_utc.strftime('%B %Y')} — "
|
||||
f"sky-cam {cam} {local_dt.strftime('%Y-%m-%d %H:%M:%S %Z')}"
|
||||
)
|
||||
composite_full_moon(
|
||||
path, det, when_utc, REF_PATH, out_path,
|
||||
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
|
||||
caption=caption,
|
||||
)
|
||||
print(f'wrote {out_path}')
|
||||
|
||||
if no_upload:
|
||||
_notify(
|
||||
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} (built, not posted)',
|
||||
f'{out_path} — picked {local_dt}, Δtarget {delta_min:+.0f} min',
|
||||
)
|
||||
return 0
|
||||
|
||||
posted = _post_to_mattermost(
|
||||
out_path,
|
||||
f"{spec['emoji']} {spec['label']} — {target_utc.strftime('%B %Y')}\n"
|
||||
f"Captured by sky-cam {cam} at {local_dt.strftime('%Y-%m-%d %H:%M:%S %Z')} "
|
||||
f"({delta_min:+.0f} min from exact {phase}).",
|
||||
)
|
||||
if posted:
|
||||
_notify(
|
||||
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} posted',
|
||||
f'Picked {local_dt} — Δtarget {delta_min:+.0f} min — {out_path}',
|
||||
)
|
||||
else:
|
||||
_notify(
|
||||
f'FAILED: {spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} upload',
|
||||
f'Composite built at {out_path} but Mattermost upload failed.',
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
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()
|
||||
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
|
||||
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'])
|
||||
if not events:
|
||||
continue
|
||||
last_event = events[-1]
|
||||
days_since = (today_utc - last_event.date()).days
|
||||
if days_since == spec['post_delay']:
|
||||
print(f'== running {phase} (last event {last_event.date()}, +{spec["post_delay"]} days = today) ==')
|
||||
sub = run_phase(phase, last_event, cam, dry_run, no_upload, None)
|
||||
rc = rc or sub
|
||||
ran_any = True
|
||||
else:
|
||||
print(f'-- {phase}: last {last_event.date()}, days_since={days_since}, post_delay={spec["post_delay"]} → skip')
|
||||
if not ran_any:
|
||||
print('no phase scheduled for today')
|
||||
return rc
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--phase', choices=list(PHASE_SPEC.keys()),
|
||||
help='Run a single phase regardless of schedule')
|
||||
p.add_argument('--target', help='Override phase event UTC, ISO 8601 (requires --phase)')
|
||||
p.add_argument('--cam', default=SUNRISE_CAM)
|
||||
p.add_argument('--dry-run', action='store_true')
|
||||
p.add_argument('--no-upload', action='store_true')
|
||||
p.add_argument('--out', help='Override output path (requires --phase)')
|
||||
args = p.parse_args()
|
||||
|
||||
if args.phase:
|
||||
target = None
|
||||
if args.target:
|
||||
target = datetime.fromisoformat(args.target.replace('Z', '+00:00'))
|
||||
return run_phase(args.phase, target, args.cam, args.dry_run, args.no_upload, args.out)
|
||||
return auto_run(args.cam, args.dry_run, args.no_upload)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+109
@@ -47,6 +47,18 @@
|
||||
# on the last day of a movement, auto-triggers
|
||||
# montage-mvt.sh
|
||||
#
|
||||
# moon-track.sh — nightly at SCHEDULE_MOON_TRACK (SUNRISE_CAM only)
|
||||
# detects the moon in each east night frame, crops
|
||||
# a tracked sequence, stitches into an mp4
|
||||
#
|
||||
# moon-phase-monthly.sh — daily at SCHEDULE_MOON_PHASE (SUNRISE_CAM only)
|
||||
# no-ops except on phase post-days, when it picks
|
||||
# the best east frame from the collection window,
|
||||
# composites a high-res lunar texture into it,
|
||||
# and posts to Mattermost. Handles full moon,
|
||||
# first quarter (waxing half), and third quarter
|
||||
# (waning half).
|
||||
#
|
||||
#
|
||||
# install.sh — run once at setup, and again if this file changes
|
||||
# generates and installs systemd units from conf
|
||||
@@ -135,6 +147,25 @@
|
||||
# ← run manually only; no script calls it
|
||||
# → safe to rename with no other changes needed
|
||||
#
|
||||
# moon-track.sh
|
||||
# ← called by systemd sky-cam-moon-track.timer (SCHEDULE_MOON_TRACK)
|
||||
# → if renamed: update install.sh (the moon-track write_service block)
|
||||
# calls → moon_detect.py (Python lib via heredoc), ffmpeg
|
||||
#
|
||||
# moon-phase-monthly.sh
|
||||
# ← called by systemd sky-cam-moon-phase.timer (SCHEDULE_MOON_PHASE)
|
||||
# → if renamed: update install.sh (the moon-phase write_service block)
|
||||
# calls → moon_phase_monthly.py
|
||||
#
|
||||
# moon_phase_monthly.py
|
||||
# ← called by moon-phase-monthly.sh
|
||||
# → if renamed: update moon-phase-monthly.sh (the exec line at the bottom)
|
||||
# calls → moon_phase.py, moon_detect.py, moon_composite.py, notify.sh
|
||||
#
|
||||
# moon_phase.py / moon_detect.py / moon_composite.py
|
||||
# ← Python libraries used by moon-track.sh and moon_phase_monthly.py
|
||||
# → if renamed: update moon-track.sh, moon_phase_monthly.py, README
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
# ── Install location ──────────────────────────────────────────────────────────
|
||||
@@ -235,6 +266,10 @@ SUNRISE_CAM=east # which camera faces east (gets the sunrise v
|
||||
#
|
||||
SCHEDULE_SUNRISE=03:00:00
|
||||
|
||||
# Moon jobs (see "Moon jobs" section further down)
|
||||
SCHEDULE_MOON_TRACK=02:30:00 # nightly tracker — runs after midnight, before seasons
|
||||
SCHEDULE_MOON_PHASE=09:30:00 # daily check; no-ops except on phase post-days
|
||||
|
||||
# Four Seasons daily clip — one per camera, staggered 30 min apart.
|
||||
# Processes yesterday's images; on the last day of a movement auto-triggers
|
||||
# montage-mvt.sh. Space cameras at least 30 min apart to avoid disk contention.
|
||||
@@ -397,6 +432,80 @@ AMBIENT_RETENTION_DAYS=30 # global default; 0 = keep forever
|
||||
#AMBIENT_RETENTION_DAYS_north=30
|
||||
#AMBIENT_RETENTION_DAYS_south=60 # keep south longer for nature sound library
|
||||
|
||||
# ── Moon jobs (nightly tracker + monthly phase close-ups) ────────────────────
|
||||
# All moon jobs operate on the SUNRISE_CAM (the east-facing camera that already
|
||||
# has a clear view of the eastern sky). They share three Python helpers
|
||||
# (moon_phase.py, moon_detect.py, moon_composite.py) and one cached lunar
|
||||
# reference texture downloaded by install.sh.
|
||||
#
|
||||
# moon-track.sh nightly batch — detects the moon in each frame,
|
||||
# crops a 480×480 box around it, stitches the
|
||||
# tracked sequence into an mp4. Output:
|
||||
# $MOVIES_DIR/<cam>/moon-track/YYYY/YYYY-MM-DD-moon-track.mp4
|
||||
#
|
||||
# moon-phase-monthly.sh daily check; runs whichever phase composite is
|
||||
# due today. Three phases handled:
|
||||
# 🌕 Full Moon posts D + MOON_FULL_POST_DELAY_DAYS
|
||||
# 🌓 First Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS
|
||||
# 🌗 Third Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS
|
||||
# Each picks the frame closest in time to the
|
||||
# exact phase moment that meets quality / altitude
|
||||
# / illumination thresholds, composites the
|
||||
# cached lunar texture into it (sky/halo/angle
|
||||
# real from east, surface detail borrowed),
|
||||
# uploads to Mattermost. Output:
|
||||
# $MOVIES_DIR/<cam>/full-moons/YYYY-MM-full.jpg
|
||||
# $MOVIES_DIR/<cam>/first-quarter/YYYY-MM-first-quarter.jpg
|
||||
# $MOVIES_DIR/<cam>/third-quarter/YYYY-MM-third-quarter.jpg
|
||||
#
|
||||
# Honest-by-design: the surface texture is not from your camera (no software
|
||||
# can recover detail your camera didn't capture). The framing — sky color,
|
||||
# halo, parallactic angle, exact moment of capture — is all real-from-east.
|
||||
#
|
||||
# Geometry caveat: east only sees the eastern sky. At first quarter the moon
|
||||
# is up only from noon to midnight, transiting south at sunset, so east only
|
||||
# catches it during DAYTIME. The brightness-based detector often fails on
|
||||
# daytime moon shots — first quarter is best-effort. Full moon and third
|
||||
# quarter both rise after dark and stay in east's view; those should land
|
||||
# cleanly most months.
|
||||
#
|
||||
# Toggles (any can be disabled independently):
|
||||
MOON_TRACK_ENABLED=true
|
||||
MOON_FULL_ENABLED=true
|
||||
MOON_FIRST_QUARTER_ENABLED=true # set false if daytime-detection misses are noisy
|
||||
MOON_THIRD_QUARTER_ENABLED=true
|
||||
|
||||
# Nightly tracker tuning ──────────────────────────────────────────────────────
|
||||
MOON_TRACK_CROP_PX=480 # pixels — box size around the moon (source coords)
|
||||
MOON_TRACK_FPS=12 # output mp4 framerate
|
||||
MOON_TRACK_CRF=24 # output mp4 CRF
|
||||
MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever
|
||||
|
||||
# Full-moon monthly tuning ────────────────────────────────────────────────────
|
||||
# How many days after the exact full moon to post. 3 = waits for D-1..D+2
|
||||
# nights to be on disk, then runs the morning of D+3.
|
||||
MOON_FULL_POST_DELAY_DAYS=3
|
||||
|
||||
# Frame-acceptance thresholds — a candidate must beat all three to qualify.
|
||||
# Lower = more permissive (accept hazier nights / lower moon). If you find
|
||||
# the script never finds a clear shot, loosen these.
|
||||
MOON_FULL_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation)
|
||||
MOON_FULL_MIN_ALTITUDE_DEG=15 # below this the moon is in trees / on the horizon
|
||||
MOON_FULL_MIN_ILLUMINATION=0.95 # 0..1 — ~95% lit covers ±2 days from exact full
|
||||
|
||||
# Output frame. Default 1920×1080 to match the sunrise videos.
|
||||
MOON_FULL_OUTPUT_W=1920
|
||||
MOON_FULL_OUTPUT_H=1080
|
||||
MOON_FULL_HEIGHT_PCT=0.70 # moon disk fills this fraction of frame height
|
||||
|
||||
# Lunar reference texture — downloaded once by install.sh, reused forever.
|
||||
# Override MOON_REFERENCE_URL in .env to use a different image. Any high-res
|
||||
# photo of a full moon on a black background works. Default is the Wikipedia
|
||||
# "FullMoon2010" by Gregory H. Revera (CC BY-SA 3.0), 3500×3500.
|
||||
#MOON_REFERENCE_URL=https://upload.wikimedia.org/wikipedia/commons/e/e1/FullMoon2010.jpg
|
||||
#MOON_REFERENCE_DIR="$SCRIPT_DIR/moon-ref"
|
||||
#MOON_REFERENCE_PATH="$MOON_REFERENCE_DIR/full-moon.jpg"
|
||||
|
||||
# ── Mattermost — daily sunrise upload ─────────────────────────────────────────
|
||||
# mattermost_url, access_token, channel_id go in .env (see bottom of this file).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user