Files
sky-cam/4-seasons.sh
T
Claude ec8eb7aebf 4-seasons: protect night frames from grey filter with brightness floor
A stddev-only filter would wrongly drop near-black night frames.
Now only skips a frame when BOTH stddev < GREY_MIN AND mean brightness
> GREY_DARK_FLOOR (default 30/255).  Night frames are dark so they
always pass; bad-signal grey frames are mid-bright so they are caught.

https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
2026-04-22 16:17:22 +00:00

187 lines
9.6 KiB
Bash
Executable File

#!/bin/bash
# 4-seasons.sh — create one daily clip from yesterday's images, sized to its
# share of the corresponding Vivaldi movement's music duration.
#
# On the last day of a movement period, automatically runs montage-mvt.sh to
# compile the full movement montage.
#
# Run once per day from cron (e.g. 01:00 daily).
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
source "$SCRIPT_DIR/sky-cam.conf"
export TIMEZONE # make it visible to season_info.py subprocess
# Camera name: required first argument (systemd passes it via ExecStart).
CAM_NAME="${1:-}"
if [ -z "$CAM_NAME" ]; then
echo "Usage: $0 <camera-name>"
echo " e.g. $0 east or $0 north"
exit 1
fi
# ── Configuration ──────────────────────────────────────────────────────────────
base_dir="$BASE_DIR"
music_base_dir="$MUSIC_DIR"
[ ! -d "$music_base_dir" ] && music_base_dir="$SCRIPT_DIR/music"
# ── Date setup ────────────────────────────────────────────────────────────────
yesterday=$(date --date="yesterday" +%Y-%m-%d)
echo "Processing: $yesterday"
# ── Season / movement info from astronomical calculation ──────────────────────
_season_info="$(python3 "$SCRIPT_DIR/season_info.py" "$yesterday")" || {
echo "Error: season_info.py failed — check Python dependencies (suntime pytz)"
exit 1
}
eval "$_season_info"
# Provides: SEASON MVT_NUM DAY_OF_MVT DAYS_IN_MVT MVT_START MVT_END
# IS_LAST_DAY ASTRO_YEAR
echo "Season=$SEASON Mvt=$MVT_NUM Year=$ASTRO_YEAR Day=$DAY_OF_MVT/$DAYS_IN_MVT LastDay=$IS_LAST_DAY"
# ── Locate music file and get its duration ────────────────────────────────────
music_file=$(find "$music_base_dir" -type f -iname "*${SEASON}*" \
| grep -iE "Mvt[^0-9]*${MVT_NUM}[^0-9]" | sort | head -n 1)
if [ -z "$music_file" ] || [ ! -f "$music_file" ]; then
echo "ERROR: Music file not found for $SEASON Mvt $MVT_NUM in $music_base_dir"
exit 1
fi
music_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$music_file")
echo "Music: $(basename "$music_file") ($music_duration s)"
# Each daily clip covers (music_duration * loops) / DAYS_IN_MVT seconds.
# Short movements use loops > 1 so each day gets more screen time.
loops_var="MONTAGE_MUSIC_LOOPS_${SEASON}_${MVT_NUM}"
loops="${!loops_var:-${MONTAGE_MUSIC_LOOPS:-1}}"
target_per_clip=$(echo "scale=6; $music_duration * $loops / $DAYS_IN_MVT" | bc)
echo "Target clip duration: $target_per_clip s ($DAYS_IN_MVT days in movement, ${loops}x music)"
# ── Locate yesterday's images ─────────────────────────────────────────────────
image_dir="$base_dir/$CAM_NAME/$yesterday"
if [ ! -d "$image_dir" ]; then
echo "WARNING: Image directory $image_dir not found — skipping $yesterday."
exit 0
fi
total_images=$(find "$image_dir" -type f -name "*.jpg" | wc -l)
if [ "$total_images" -lt 1 ]; then
echo "WARNING: No images in $image_dir — skipping $yesterday."
exit 0
fi
echo "Images found: $total_images"
# ── Per-camera capture interval ───────────────────────────────────────────────
interval_var="CAPTURE_INTERVAL_${CAM_NAME}"
INTERVAL="${!interval_var:-${CAPTURE_INTERVAL:-10}}"
# ── Prepare output directory ──────────────────────────────────────────────────
output_dir="$MOVIES_DIR/$CAM_NAME/$ASTRO_YEAR/$SEASON/Mvt$MVT_NUM"
mkdir -p "$output_dir"
# ── Build sorted image list ───────────────────────────────────────────────────
temp_file=$(mktemp --suffix=.txt)
temp_video="$output_dir/${yesterday}_Mvt${MVT_NUM}-temp.mp4"
cleanup() { rm -f "$temp_file" "$temp_video" 2>/dev/null || true; }
trap cleanup EXIT
mapfile -t all_images < <(find "$image_dir" -type f -name "*.jpg" | sort)
# Validate each frame: skip empty files, corrupt JPEGs, and optionally grey/uniform frames.
# SEASONS_GREY_STDDEV_MIN=0 (default) disables the grey filter.
# Set to e.g. 5 in sky-cam.conf to drop uniform grey frames (bad-signal captures).
GREY_MIN="${SEASONS_GREY_STDDEV_MIN:-0}"
GREY_DARK_FLOOR="${SEASONS_GREY_DARK_FLOOR:-30}" # frames darker than this are never skipped
images=(); bad=0; grey=0
for img in "${all_images[@]}"; do
# Empty or unreadable
if [ ! -r "$img" ] || [ ! -s "$img" ]; then
echo "WARNING: skipping empty/missing frame: $(basename "$img")"
((bad++)) || true; continue
fi
# Corrupt JPEG — ffprobe can't identify any video stream
if ! ffprobe -v quiet -select_streams v:0 \
-show_entries stream=codec_name -of csv=p=0 "$img" 2>/dev/null \
| grep -q .; then
echo "WARNING: skipping corrupt frame: $(basename "$img")"
((bad++)) || true; continue
fi
# Optional: skip uniform-grey bad-signal frames.
# A frame is bad-signal if it is BOTH uniform (low stddev) AND not dark.
# Night frames are dark so they pass even with low stddev.
if [ "$GREY_MIN" != "0" ] && command -v convert &>/dev/null; then
read -r stddev mean < <(convert "$img" -colorspace Gray \
-format "%[fx:int(standard_deviation*255)] %[fx:int(mean*255)]" \
info: 2>/dev/null || echo "255 128")
if [ "${stddev:-255}" -lt "$GREY_MIN" ] && [ "${mean:-0}" -gt "$GREY_DARK_FLOOR" ]; then
((grey++)) || true; continue
fi
fi
images+=("$img")
done
[ "$bad" -gt 0 ] && echo "WARNING: skipped $bad corrupt/empty frame(s) of ${#all_images[@]}"
[ "$grey" -gt 0 ] && echo "INFO: skipped $grey grey/uniform frame(s) (SEASONS_GREY_STDDEV_MIN=${GREY_MIN})"
for img in "${images[@]}"; do
printf "file '%s'\nduration %s\n" "$img" "$INTERVAL"
done > "$temp_file"
printf "file '%s'\n" "${images[-1]}" >> "$temp_file"
# ── Step 1: Build raw video ───────────────────────────────────────────────────
echo "Step 1/2: encoding raw video from ${#images[@]} frames ($(( ${#images[@]} * INTERVAL / 3600 ))h coverage)..."
ffmpeg -loglevel warning \
-f concat -safe 0 -i "$temp_file" \
-c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SEASONS_RAW" -vsync 2 -an \
-y "$temp_video"
raw_duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$temp_video")
echo "Raw duration: $raw_duration s"
# Warn if raw video covers less than 50% of expected image count (big gap or camera outage)
expected_dur=$(echo "scale=0; ${#images[@]} * $INTERVAL" | bc)
coverage_pct=$(echo "scale=0; $raw_duration * 100 / $expected_dur" | bc 2>/dev/null || echo "?")
if [ "$coverage_pct" != "?" ] && [ "$coverage_pct" -lt 50 ]; then
echo "WARNING: raw video is ${coverage_pct}% of expected ${expected_dur}s — significant image gap detected"
"$SCRIPT_DIR/notify.sh" "WARNING: [$CAM_NAME] $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT — only ${coverage_pct}% coverage ($yesterday)" \
"Raw video ${raw_duration}s of expected ${expected_dur}s — check camera logs for gaps" || true
fi
# ── Step 2: Speed-adjust to target clip duration ──────────────────────────────
speed_factor=$(echo "scale=6; $raw_duration / $target_per_clip" | bc)
echo "Step 2/2: speed factor $speed_factor..."
final_file="$output_dir/${CAM_NAME}-${yesterday}_Mvt${MVT_NUM}-Day${DAY_OF_MVT}of${DAYS_IN_MVT}-final.mp4"
if ! ffmpeg -loglevel warning \
-i "$temp_video" \
-vf "setpts=PTS/$speed_factor,fps=25" \
-c:v libx264 -pix_fmt yuv420p -preset "$ENCODE_PRESET" -crf "$CRF_SEASONS_FINAL" \
-t "$target_per_clip" -an \
-y "$final_file"; then
rm -f "$final_file"
"$SCRIPT_DIR/notify.sh" "FAILED: [$CAM_NAME] $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT speed-adjust ($yesterday)" \
"ffmpeg speed-adjust failed — re-run 4-seasons.sh $CAM_NAME while JPGs exist" || true
exit 1
fi
adjusted=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$final_file")
echo "Daily clip: $final_file ($adjusted s)"
# Warn if clip duration deviates more than 0.5 s from target (float drift check)
drift=$(echo "scale=3; $adjusted - $target_per_clip" | bc | sed 's/^-//')
if awk "BEGIN{exit !($drift > 0.5)}"; then
echo "WARNING: clip duration ${adjusted}s differs from target ${target_per_clip}s by ${drift}s"
"$SCRIPT_DIR/notify.sh" "WARNING: [$CAM_NAME] $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT duration drift ($yesterday)" \
"Clip ${adjusted}s vs target ${target_per_clip}s (drift ${drift}s)" || true
fi
"$SCRIPT_DIR/notify.sh" "[$CAM_NAME] $SEASON Mvt$MVT_NUM Day$DAY_OF_MVT/$DAYS_IN_MVT saved ($yesterday)" \
"$(basename "$final_file")${adjusted}s | $final_file" || true
# ── Auto-trigger montage on last day of movement ──────────────────────────────
if [ "$IS_LAST_DAY" = "true" ]; then
echo "Last day of $SEASON Mvt $MVT_NUM — triggering montage compilation..."
# Pass $yesterday so montage-mvt.sh looks up the correct movement
# (running the next morning, "today" would already be the next movement).
"$SCRIPT_DIR/montage-mvt.sh" "$CAM_NAME" "$yesterday"
fi