#!/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