diff --git a/wcs-frame-match.sh b/wcs-frame-match.sh new file mode 100755 index 0000000..6540a20 --- /dev/null +++ b/wcs-frame-match.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +set -euo pipefail + +# wcs-frame-match.sh +# Extract a frame from MP4 at a given timestamp and compare it to a reference image. +# If it matches within a threshold, prefix the MP4 filename (e.g., WCS_...). +# Dependencies: ffmpeg, ImageMagick (IM6/IM7). NOTE: SSIM typically requires ImageMagick 7 (magick); IM6 often lacks SSIM. + +# Defaults +TIMESTAMP="1.0" +TIMESTAMP_LIST="" +METRIC="ssim" # ssim | rmse | psnr +THRESHOLD="" +PREFIX="WCS_" +DRYRUN="false" +KEEP_TEMP="false" +RECURSIVE="false" +ALIGN="scale" # scale | pad | none +DEBUG="false" +AUTO_SKIP_BLACK="false" +BLACK_PIC_TH="0.98" +BLACK_MIN_D="0.05" # seconds +ROI_GEOM="" # e.g., 800x200+50+100 + +usage() { + cat <<'EOF' +Usage: wcs-frame-match.sh -s REF.png [options] [files.mp4...] + -s Path to reference image (PNG/JPG etc.) [required] + -t Timestamp in seconds (float OK), default: 1.0 + You can also provide a comma-separated list via --timestamps or --timestamp, e.g. 0.0,0.5,1.0 + -m Metric: ssim | rmse | psnr (default: ssim) [NOTE: SSIM usually needs ImageMagick 7] + -T Threshold/tolerance: + * ssim: 0..1 (higher is better). e.g., 0.95 + * rmse: 0..1 (lower is better). e.g., 0.01 + * psnr: dB (higher is better). e.g., 35 + If not set: ssim=0.95, rmse=0.01, psnr=35 + -p Prefix to add on match (default: WCS_) + -r Recursive: process all *.mp4 under current directory + -a Frame sizing vs reference: scale | pad | none (default: scale) + -n Dry run (no actual rename) + -k Keep temporary files + -d Debug (print raw ImageMagick output and details) + -B Auto-skip initial black segment using ffmpeg blackdetect (default: off) + --black-pic-th FLOAT (default: 0.98) + --black-min-d SEC (default: 0.05) + -R ROI crop geometry on aligned images, e.g., 800x200+50+100 (focus only this region) + -h Help + +Examples: + wcs-frame-match.sh -s ref.png video1.mp4 + wcs-frame-match.sh -s ref.jpg -t 0.8 -m ssim -T 0.93 film.mp4 + wcs-frame-match.sh -s ref.png -m rmse -T 0.005 -r -n + wcs-frame-match.sh -s ref.png -m psnr -T 35 -a pad *.mp4 + wcs-frame-match.sh -s ref.png -B --timestamps 0,0.5,1 -R 800x200+0+700 -n *.mp4 +EOF +} + +# ---- Arg parsing +REF_IMAGE="" +USER_SET_THRESHOLD="false" +# Support long flags via getopts "-:" trick +while getopts ":s:t:m:T:p:rkna:hdBR:R:-:" opt; do + case "$opt" in + s) REF_IMAGE="$OPTARG" ;; + t) TIMESTAMP="$OPTARG" ;; + m) METRIC="$(echo "$OPTARG" | tr '[:upper:]' '[:lower:]')" ;; + T) THRESHOLD="$OPTARG"; USER_SET_THRESHOLD="true" ;; + p) PREFIX="$OPTARG" ;; + r) RECURSIVE="true" ;; + n) DRYRUN="true" ;; + k) KEEP_TEMP="true" ;; + a) ALIGN="$OPTARG" ;; + d) DEBUG="true" ;; + B) AUTO_SKIP_BLACK="true" ;; + R) ROI_GEOM="$OPTARG" ;; + -) + case "$OPTARG" in + debug) DEBUG="true" ;; + timestamps=*) TIMESTAMP_LIST="${OPTARG#timestamps=}" ;; + timestamps) + # support space-separated: --timestamps 0,0.5,1 + val="${!OPTIND}"; OPTIND=$((OPTIND+1)); TIMESTAMP_LIST="$val" ;; + timestamp=*) TIMESTAMP_LIST="${OPTARG#timestamp=}" ;; + timestamp) + # support space-separated: --timestamp 0,0.5,1 OR single value + val="${!OPTIND}"; OPTIND=$((OPTIND+1)); TIMESTAMP_LIST="$val" ;; + black-pic-th=*) BLACK_PIC_TH="${OPTARG#black-pic-th=}" ;; + black-pic-th) + val="${!OPTIND}"; OPTIND=$((OPTIND+1)); BLACK_PIC_TH="$val" ;; + black-min-d=*) BLACK_MIN_D="${OPTARG#black-min-d=}" ;; + black-min-d) + val="${!OPTIND}"; OPTIND=$((OPTIND+1)); BLACK_MIN_D="$val" ;; + *) echo "Unknown long flag --$OPTARG" >&2; usage; exit 1 ;; + esac + ;; + h) usage; exit 0 ;; + \?) echo "Invalid flag: -$OPTARG" >&2; usage; exit 1 ;; + :) echo "Flag -$OPTARG requires a value." >&2; usage; exit 1 ;; + esac +done +shift $((OPTIND-1)) + +# ---- Basic checks +if [[ -z "$REF_IMAGE" ]]; then + echo "Error: reference image must be provided with -s." >&2 + usage; exit 1 +fi +if [[ ! -f "$REF_IMAGE" ]]; then + echo "Error: reference image not found: $REF_IMAGE" >&2 + exit 1 +fi + +case "$METRIC" in + ssim|rmse|psnr) ;; + *) echo "Error: metric must be one of: ssim | rmse | psnr" >&2; exit 1 ;; +esac + +# Set default threshold only if user didn't provide one +if [[ "$USER_SET_THRESHOLD" != "true" ]]; then + case "$METRIC" in + ssim) THRESHOLD="0.95" ;; + rmse) THRESHOLD="0.01" ;; + psnr) THRESHOLD="35" ;; + esac +fi + +# Locate ImageMagick cmds (prefer IM7 'magick compare' for SSIM support) +if command -v magick >/dev/null 2>&1; then + COMPARE_CMD=(magick compare) + IDENTIFY=(magick identify) + CONVERT=(magick convert) +elif command -v compare >/dev/null 2>&1 && command -v identify >/dev/null 2>&1 && command -v convert >/dev/null 2>&1; then + COMPARE_CMD=(compare) + IDENTIFY=(identify) + CONVERT=(convert) +else + echo "Error: Could not find ImageMagick (compare/identify/convert or magick) in PATH." >&2 + exit 1 +fi + +# Create temp dir early (needed by ensure_metric_supported) +TMPDIR="$(mktemp -d -t wcsframe-XXXXXXXX)" +cleanup() { + if [[ "$KEEP_TEMP" != "true" ]]; then + rm -rf "$TMPDIR" + else + echo "Temporary files kept in: $TMPDIR" + fi +} +trap cleanup EXIT + +# Verify metric support for current ImageMagick +ensure_metric_supported() { + local metric_upper t1 t2 raw status + metric_upper="$(echo "$METRIC" | tr '[:lower:]' '[:upper:]')" + t1="$(mktemp -p "$TMPDIR" imtestA_XXXX.png)" + t2="$(mktemp -p "$TMPDIR" imtestB_XXXX.png)" + ${CONVERT[@]} -size 2x2 xc:black "$t1" + ${CONVERT[@]} -size 2x2 xc:white "$t2" + set +e + raw=$(${COMPARE_CMD[@]} -metric "$metric_upper" "$t1" "$t2" null: 2>&1) + status=$? + set -e + if echo "$raw" | grep -qi "unrecognized metric type"; then + echo "[WARNING] Your ImageMagick variant does not support metric '$metric_upper'." >&2 + if [[ "$METRIC" == "ssim" ]]; then + echo "[WARNING] Falling back to -m rmse (use -m psnr if you prefer)." >&2 + METRIC="rmse" + # update default threshold for new metric if user didn't set one + if [[ "$USER_SET_THRESHOLD" != "true" ]]; then THRESHOLD="0.01"; fi + else + echo "[ERROR] Choose a supported metric: -m rmse or -m psnr, or install ImageMagick 7 for SSIM." >&2 + fi + fi +} + +ensure_metric_supported + +# Read reference dimensions +REF_W="$(${IDENTIFY[@]} -format "%w" "$REF_IMAGE")" +REF_H="$(${IDENTIFY[@]} -format "%h" "$REF_IMAGE")" + +# Precompute optional ROI on reference (after alignment size) +REF_ALIGNED="$TMPDIR/ref_aligned.png" +${CONVERT[@]} "$REF_IMAGE" -resize "${REF_W}x${REF_H}!" -colorspace sRGB "$REF_ALIGNED" +if [[ -n "$ROI_GEOM" ]]; then + REF_CROP="$TMPDIR/ref_crop.png" + ${CONVERT[@]} "$REF_ALIGNED" -crop "$ROI_GEOM" +repage "$REF_CROP" +else + REF_CROP="$REF_ALIGNED" +fi + +# Collect files +FILES=() +if [[ "$RECURSIVE" == "true" ]]; then + while IFS= read -r -d '' f; do FILES+=("$f"); done < <(find . -type f -iname "*.mp4" -print0) +fi +if [[ $# -gt 0 ]]; then + for f in "$@"; do FILES+=("$f"); done +fi + +if [[ ${#FILES[@]} -eq 0 ]]; then + echo "No MP4 files to process. Provide files or use -r." >&2 + exit 1 +fi + +# Strict metric value parsing +parse_metric_value() { + local metric="$1"; shift + local text="$*" + local val="" + case "$metric" in + ssim|rmse) + # Only accept values in [0..1] (or exactly 1.0) + val="$(echo "$text" | grep -Eo '\b(0(\.[0-9]+)?|1(\.0+)?)\b' | head -n1)" + ;; + psnr) + # First float in 0..100 (reasonable range) + val="$(echo "$text" | grep -Eo '[0-9]+(\.[0-9]+)?' | awk '$1>=0 && $1<=100 {print; exit}')" + ;; + esac + echo "$val" +} + +# Resize/alignment (with colorspace normalization) +resize_or_align() { + local in="$1" + local out="$2" + case "$ALIGN" in + scale) + ${CONVERT[@]} "$in" -resize "${REF_W}x${REF_H}!" -colorspace sRGB "$out" + ;; + pad) + ${CONVERT[@]} "$in" -resize "${REF_W}x${REF_H}" -background black -gravity center -extent "${REF_W}x${REF_H}" -colorspace sRGB "$out" + ;; + none) + ${CONVERT[@]} "$in" -colorspace sRGB "$out" + ;; + *) echo "Invalid ALIGN: $ALIGN" >&2; exit 1 ;; + esac +} + +apply_roi_if_any() { + local in="$1"; local out="$2" + if [[ -n "$ROI_GEOM" ]]; then + ${CONVERT[@]} "$in" -crop "$ROI_GEOM" +repage "$out" + else + cp -f "$in" "$out" + fi +} + +# Detect initial black segment end using ffmpeg blackdetect +# Returns end time in seconds (float) or 0 if none found +black_end_time() { + local mp4="$1" + local out + out=$(ffmpeg -hide_banner -nostats -t 5 -i "$mp4" -vf "blackdetect=d=${BLACK_MIN_D}:pic_th=${BLACK_PIC_TH}" -an -f null - 2>&1 || true) + echo "$out" | awk -F'black_end:' '/black_end/ {gsub(/ .*/, "", $2); print $2; exit}' | awk '{print ($1+0)}' +} + +# sample a given timestamp and compare; echoes "score matchflag"; returns 0 always +compare_at_time() { + local mp4="$1"; local ts="$2" + local frame_png="$TMPDIR/frame.png" + local frame_aligned="$TMPDIR/frame_aligned.png" + local frame_roi="$TMPDIR/frame_roi.png" + + ffmpeg -loglevel error -ss "$ts" -i "$mp4" -frames:v 1 -q:v 2 -y "$frame_png" + resize_or_align "$frame_png" "$frame_aligned" + apply_roi_if_any "$frame_aligned" "$frame_roi" + + local metric_upper raw status val match="false" + metric_upper="$(echo "$METRIC" | tr '[:lower:]' '[:upper:]')" + set +e + raw=$(${COMPARE_CMD[@]} -metric "$metric_upper" "$REF_CROP" "$frame_roi" null: 2>&1) + status=$? + set -e + [[ "$DEBUG" == "true" ]] && echo "[DEBUG] t=$ts status=$status raw='$raw'" >&2 + + val="$(parse_metric_value "$METRIC" "$raw")" + if [[ -z "$val" ]]; then + echo "nan false"; return + fi + case "$METRIC" in + ssim|psnr) + awk -v v="$val" -v th="$THRESHOLD" 'BEGIN{exit !(v >= th)}' && match="true" || true ;; + rmse) + awk -v v="$val" -v th="$THRESHOLD" 'BEGIN{exit !(v <= th)}' && match="true" || true ;; + esac + echo "$val $match" +} + +process_file() { + local mp4="$1" + if [[ ! -f "$mp4" ]]; then + echo "Skipping (not found): $mp4"; return + fi + local base="$(basename "$mp4")" + if [[ "$base" == "$PREFIX"* ]]; then + echo "Already prefixed, skipping: $mp4"; return + fi + + # Determine timestamps to test + local ts_list=() + if [[ -n "$TIMESTAMP_LIST" ]]; then + IFS=',' read -r -a ts_list <<< "$TIMESTAMP_LIST" + else + ts_list=("$TIMESTAMP") + fi + + # Optional: skip initial black + local bend=0 + if [[ "$AUTO_SKIP_BLACK" == "true" ]]; then + bend="$(black_end_time "$mp4")" + bend=${bend:-0} + [[ -z "$bend" ]] && bend=0 + [[ "$DEBUG" == "true" ]] && echo "[DEBUG] black_end=$bend for $mp4" + fi + + local best_score="" + local best_ts="" + local matched="false" + + for ts in "${ts_list[@]}"; do + # adjust timestamp if it falls within initial black region + local eff_ts="$ts" + if [[ "$AUTO_SKIP_BLACK" == "true" ]]; then + awk -v a="$eff_ts" -v b="$bend" 'BEGIN{exit !(a < b)}' && eff_ts=$(awk -v b="$bend" 'BEGIN{printf "%.3f", b+0.010}') || true + fi + read -r score ismatch < <(compare_at_time "$mp4" "$eff_ts") + [[ "$score" == "nan" ]] && continue + if [[ "$matched" == "false" && "$ismatch" == "true" ]]; then + matched="true"; best_score="$score"; best_ts="$eff_ts"; break + fi + # track best score even if not matched + if [[ -z "$best_score" ]]; then best_score="$score"; best_ts="$eff_ts"; else + case "$METRIC" in + ssim|psnr) awk -v x="$score" -v y="$best_score" 'BEGIN{exit !(x>y)}' && { best_score="$score"; best_ts="$eff_ts"; } || true ;; + rmse) awk -v x="$score" -v y="$best_score" 'BEGIN{exit !(x best %s at t=%s (threshold %s) => %s +" \ + "$mp4" "$METRIC=$best_score" "$best_ts" "$THRESHOLD" "$verdict" + + if [[ "$matched" == "true" ]]; then + local dir newpath + dir="$(dirname "$mp4")"; newpath="${dir}/${PREFIX}${base}" + if [[ "$DRYRUN" == "true" ]]; then + echo "[DRYRUN] mv -- \"$mp4\" \"$newpath\"" + else + mv -- "$mp4" "$newpath" + fi + fi +} + +# Collect either recursive or provided files +if [[ ${#FILES[@]} -eq 0 ]]; then + : # already handled earlier +fi + +# Run +for f in "${FILES[@]}"; do + process_file "$f" +done