Netgrimoire/Netgrimoire/Nucking-Futz/Scripts/vhs_restoration.md
2026-04-01 02:54:27 +00:00

19 KiB
Raw Blame History

title description published date tags editor dateCreated
Video Restoration Script Restore VHS Video Captures true 2026-03-06T03:48:12.713Z markdown 2026-03-06T03:48:05.841Z

VHS Video Restoration — User Guide

A pipeline script for cleaning up and upscaling old VHS captures on Ubuntu 24.04. Runs in two modes: a fast FFmpeg-only cleanup pass, and a full AI upscale using Real-ESRGAN.


Requirements

  • Ubuntu 24.04
  • FFmpegsudo apt install ffmpeg
  • bcsudo apt install bc
  • Real-ESRGAN (optional, for AI upscaling — see setup below)

File Setup

Place everything in a working folder with this structure:

~/your-folder/
├── vhs_restore.sh
├── realesrgan-ncnn-vulkan        ← AI upscaler binary (optional)
├── models/                       ← Real-ESRGAN model files
├── input/                        ← Put your source videos here
├── output/                       ← Restored videos appear here
└── work/                         ← Temporary scratch files (auto-created)

Supported input formats: .mpg, .mpeg, .mp4, .avi, .mov, .mkv, .wmv, .m4v, .ts


First-Time Setup

# Make the script executable
chmod +x vhs_restore.sh

# Create the input folder and add your videos
mkdir input
cp /path/to/your/videos/*.mpg input/

Installing Real-ESRGAN (one-time, for AI upscaling)

  1. Download the latest Ubuntu release from: https://github.com/xinntao/Real-ESRGAN/releases → look for realesrgan-ncnn-vulkan-*-ubuntu.zip
  2. Unzip into your working folder
  3. chmod +x realesrgan-ncnn-vulkan

Running the Script

Fast — processes in a few minutes per file. No AI upscaling.

./vhs_restore.sh --no-ai

Full pipeline with AI upscaling

Slow on CPU (plan for several hours per hour of footage). Produces the best results.

./vhs_restore.sh

All options

Flag Description Default
-i DIR Input directory ./input
-o DIR Output directory ./output
-w DIR Scratch/work directory ./work
-b PATH Path to Real-ESRGAN binary ./realesrgan-ncnn-vulkan
-s 2 or -s 4 Upscale factor 2
-q 16 Output quality (051, lower = better) 16
--no-ai Skip AI upscaling, FFmpeg only off
--keep Keep extracted PNG frames after processing off
-h Show help

Examples:

# Process files from a custom folder
./vhs_restore.sh -i ~/Videos/VHS -o ~/Videos/Restored

# 4x upscale with slightly smaller output file
./vhs_restore.sh -s 4 -q 18

# FFmpeg cleanup only, custom folders
./vhs_restore.sh -i ~/Videos/VHS -o ~/Videos/Restored --no-ai

What the Script Does

Stage 1 — FFmpeg cleanup (always runs):

  • Deinterlaces the video (yadif) — removes the horizontal combing artifacts common in VHS captures
  • Denoises (hqdn3d=2:1:2:2) — gentle noise reduction that avoids motion blocking
  • Sharpens edges (unsharp) — recovers detail softened by the denoise step
  • Colour corrects — boosts washed-out VHS colour, adjusts contrast and gamma, corrects the green/yellow cast common in aged tape

Stage 2 — Frame extraction (AI mode only):

  • Extracts every frame as a PNG into a temporary folder

Stage 3 — Real-ESRGAN upscaling (AI mode only):

  • Runs the realesr-animevideov3 model on each frame
  • Default: 2× upscale (e.g. 640×480 → 1280×960)

Reassembly:

  • Rebuilds the video from upscaled frames with the original audio

Live Progress

The script shows live FFmpeg output. Watch for:

  • speed=3.5x — processing at 3.5× realtime (good)
  • speed=0.5x — slow, likely a very heavy filter load
  • corrupt decoded frame — normal for damaged VHS files, FFmpeg will push through

Troubleshooting

Script hangs with no output Run with --no-ai first to confirm FFmpeg is working, then check that your Real-ESRGAN binary is executable (chmod +x realesrgan-ncnn-vulkan).

Output looks blocky during motion The denoise values may still be too high for your footage. Edit the script and reduce hqdn3d=2:1:2:2 to hqdn3d=1:1:1:1, or remove hqdn3d entirely — Real-ESRGAN handles noise well on its own.

Colour looks over-saturated Reduce saturation=1.8 in the filter chain to saturation=1.4 or 1.2.

Real-ESRGAN not found Ensure the binary is in the same folder as the script and is executable. Or pass the path explicitly: ./vhs_restore.sh -b /path/to/realesrgan-ncnn-vulkan

Error logs All FFmpeg and Real-ESRGAN logs are saved to /tmp/ for diagnosis:

  • /tmp/ffmpeg_stage1.log
  • /tmp/ffmpeg_extract.log
  • /tmp/realesrgan.log
  • /tmp/ffmpeg_reassemble.log

Workflow Recommendation

  1. Run --no-ai first on one file to check the cleanup result
  2. If it looks good, run the full pipeline on all files overnight
  3. For heavily damaged footage, consider also running CodeFormer (face restoration) on top of the output — particularly effective if the video contains people

Output

Restored files are saved to ./output/ as <original_name>_restored.mp4 encoded as H.264 with AAC audio.

vhs_restore.sh Script

`#!/usr/bin/env bash

=============================================================================

vhs_restore.sh — Automated VHS Video Restoration Pipeline

Stages: Deinterlace → Denoise → Colour correct → AI Upscale → Reassemble

Changes from v1:

- Gentle hqdn3d (2:1:2:2) to prevent motion blocking/pixelation

- Aggressive colour correction for washed-out VHS footage

- Live FFmpeg progress shown in terminal (no silent hanging)

- Logs still saved to /tmp/ for error diagnosis

=============================================================================

set -euo pipefail

── Colour output helpers ────────────────────────────────────────────────────

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' info() { echo -e "${CYAN}[INFO]${NC} $"; } success() { echo -e "${GREEN}[OK]${NC} $"; } warn() { echo -e "${YELLOW}[WARN]${NC} $"; } error() { echo -e "${RED}[ERROR]${NC} $" >&2; } header() { echo -e "\n${BOLD}${CYAN}══ $* ══${NC}"; }

── Default configuration ────────────────────────────────────────────────────

INPUT_DIR="./input" # Folder containing your source VHS videos OUTPUT_DIR="./output" # Final restored videos land here WORK_DIR="./work" # Scratch space (frames, temp files) REALESRGAN_BIN="./realesrgan-ncnn-vulkan" # Path to Real-ESRGAN binary REALESRGAN_MODEL="realesr-animevideov3" # Best model for home video UPSCALE_FACTOR=2 # 2x or 4x (4x is very slow on CPU) OUTPUT_WIDTH=1920 # Target width used in --no-ai mode OUTPUT_HEIGHT=1080 # Target height used in --no-ai mode CRF=16 # Output quality 0-51, lower = better PRESET="slow" # FFmpeg encode preset SKIP_UPSCALE=false # --no-ai flag sets this true KEEP_FRAMES=false # --keep flag sets this true

── Parse CLI flags ──────────────────────────────────────────────────────────

usage() { cat <<EOF Usage: $(basename "$0") [options]

Options: -i DIR Input directory (default: ./input) -o DIR Output directory (default: ./output) -w DIR Work/scratch dir (default: ./work) -b PATH Path to realesrgan-ncnn-vulkan binary -s FACTOR Upscale factor: 2 or 4 (default: 2) -q CRF Output quality 0-51, lower=better (default: 16) --no-ai Skip Real-ESRGAN; FFmpeg cleanup only (fast) --keep Keep extracted frames after processing -h Show this help

Examples: $(basename "$0") -i ~/Videos/VHS -o ~/Videos/Restored $(basename "$0") -i ~/Videos/VHS --no-ai # Quick cleanup only $(basename "$0") -i ~/Videos/VHS -s 4 -q 18 # 4x upscale EOF exit 0 }

while $# -gt 0 ; do case "$1" in -i) INPUT_DIR="$2"; shift 2 ;; -o) OUTPUT_DIR="$2"; shift 2 ;; -w) WORK_DIR="$2"; shift 2 ;; -b) REALESRGAN_BIN="$2"; shift 2 ;; -s) UPSCALE_FACTOR="$2"; shift 2 ;; -q) CRF="$2"; shift 2 ;; --no-ai) SKIP_UPSCALE=true; shift ;; --keep) KEEP_FRAMES=true; shift ;; -h|--help) usage ;; *) error "Unknown option: $1"; usage ;; esac done

── Dependency checks ────────────────────────────────────────────────────────

header "Checking dependencies"

check_cmd() { if command -v "$1" &>/dev/null; then success "$1 found" else error "$1 not found. Install with: $2" exit 1 fi }

check_cmd ffmpeg "sudo apt install ffmpeg" check_cmd ffprobe "sudo apt install ffmpeg" check_cmd bc "sudo apt install bc"

if ; then if ! -x "$REALESRGAN_BIN" ; then warn "Real-ESRGAN binary not found at: $REALESRGAN_BIN" echo echo -e "${YELLOW}To install Real-ESRGAN:${NC}" echo " 1. Download: https://github.com/xinntao/Real-ESRGAN/releases" echo " -> realesrgan-ncnn-vulkan-*-ubuntu.zip" echo " 2. Unzip into this directory" echo " 3. chmod +x realesrgan-ncnn-vulkan" echo " 4. Re-run this script" echo echo "Or run with --no-ai for FFmpeg-only cleanup (no upscaling)." exit 1 fi success "Real-ESRGAN found" fi

── Locate input files ───────────────────────────────────────────────────────

header "Scanning input directory: $INPUT_DIR"

if ! -d "$INPUT_DIR" ; then error "Input directory not found: $INPUT_DIR" exit 1 fi

mapfile -t VIDEO_FILES < <(find "$INPUT_DIR" -maxdepth 1
-type f ( -iname ".mp4" -o -iname ".avi" -o -iname ".mov"
-o -iname "
.mkv" -o -iname ".mpg" -o -iname ".mpeg"
-o -iname ".wmv" -o -iname ".m4v" -o -iname "*.ts" )
| sort)

if ${#VIDEO_FILES[@]} -eq 0 ; then error "No video files found in $INPUT_DIR" exit 1 fi

info "Found ${#VIDEO_FILES[@]} video file(s):" for f in "${VIDEO_FILES[@]}"; do echo " * $(basename "$f")"; done

── Helpers ──────────────────────────────────────────────────────────────────

probe() { ffprobe -v error -select_streams v:0
-show_entries "stream=$2" -of csv=p=0 "$1" 2>/dev/null | head -1 }

human_time() { local s="${1%.*}" printf '%dh %dm %ds' $((s/3600)) $(( (s%3600)/60 )) $((s%60)) }

── Create directories ───────────────────────────────────────────────────────

mkdir -p "$OUTPUT_DIR" "$WORK_DIR"

── Overall stats ────────────────────────────────────────────────────────────

TOTAL_FILES=${#VIDEO_FILES[@]} PROCESSED=0 FAILED=0 PIPELINE_START=$(date +%s)

════════════════════════════════════════════════════════════════════════════

MAIN LOOP

════════════════════════════════════════════════════════════════════════════

for INPUT_FILE in "${VIDEO_FILES[@]}"; do

BASENAME=$(basename "$INPUT_FILE") STEM="${BASENAME%.*}" CLEANED="$WORK_DIR/${STEM}_cleaned.mp4" FRAMES_IN="$WORK_DIR/${STEM}_frames_in" FRAMES_OUT="$WORK_DIR/${STEM}_frames_out" FINAL_OUTPUT="$OUTPUT_DIR/${STEM}_restored.mp4"

header "Processing: $BASENAME ($((PROCESSED+1))/$TOTAL_FILES)" FILE_START=$(date +%s)

── Probe source ──────────────────────────────────────────────────────────

FPS=$(probe "$INPUT_FILE" "r_frame_rate") FPS_DEC=$(echo "scale=3; $FPS" | bc 2>/dev/null || echo "25") WIDTH=$(probe "$INPUT_FILE" "width") HEIGHT=$(probe "$INPUT_FILE" "height") FIELD_ORDER=$(probe "$INPUT_FILE" "field_order") DURATION=$(ffprobe -v error -show_entries format=duration
-of csv=p=0 "$INPUT_FILE" 2>/dev/null | head -1)

info "Source: ${WIDTH}x${HEIGHT} ${FPS_DEC}fps $(human_time "${DURATION%.*}") field_order=${FIELD_ORDER:-unknown}"

Always deinterlace for VHS -- safe even if not flagged as interlaced

if [[ "FIELD_ORDER" =~ ^(tt|tb|bt|bb) ]]; then DEINTERLACE_FILTER="yadif=mode=1," info "Interlacing detected — applying yadif deinterlacer" else DEINTERLACE_FILTER="yadif=mode=1," warn "Interlacing not confirmed by probe — applying yadif anyway (safe for VHS)" fi

── Stage 1: FFmpeg cleanup ───────────────────────────────────────────────

header "Stage 1/3 — FFmpeg cleanup & colour correction" info "Watch fps= and speed= for live progress." info "Corrupt frame warnings are normal for old VHS captures." echo

if ; then SCALE_FILTER="scale=${OUTPUT_WIDTH}:${OUTPUT_HEIGHT}:flags=lanczos," else SCALE_FILTER="" fi

Filter chain notes:

hqdn3d=2:1:2:2 -- gentle denoise; low temporal values (3rd/4th)

prevent the motion blocking seen with higher values

unsharp -- moderate sharpening to recover edge detail

eq -- aggressive colour boost for washed-out VHS

colorbalance -- corrects the green/yellow cast common in aged VHS

VFILTER="${DEINTERLACE_FILTER}
hqdn3d=2:1:2:2,
unsharp=3:3:0.5:3:3:0.3,
eq=contrast=1.2:brightness=0.05:saturation=1.8:gamma=1.1,
colorbalance=rs=0.1:gs=0.0:bs=-0.1,
${SCALE_FILTER}
format=yuv420p"

if ! ffmpeg -y -i "$INPUT_FILE"
-vf "$VFILTER"
-c:v libx264 -crf 18 -preset medium
-c:a aac -b:a 192k -ac 2
-stats
"$CLEANED" 2>&1 | tee /tmp/ffmpeg_stage1.log |
grep --line-buffered -E "(frame=|speed=|error|Error|Invalid)"; then error "FFmpeg stage 1 failed. Full log: /tmp/ffmpeg_stage1.log" FAILED=$((FAILED+1)) continue fi

echo success "Stage 1 complete -> $(du -sh "$CLEANED" | cut -f1)"

if ; then cp "$CLEANED" "$FINAL_OUTPUT" success "Output (no AI): $FINAL_OUTPUT" PROCESSED=$((PROCESSED+1)) && rm -f "$CLEANED" continue fi

── Stage 2: Extract frames ───────────────────────────────────────────────

header "Stage 2/3 — Extracting frames for AI upscaling" mkdir -p "$FRAMES_IN" "$FRAMES_OUT"

FRAME_COUNT=$(ffprobe -v error -count_packets
-select_streams v:0 -show_entries stream=nb_read_packets
-of csv=p=0 "$CLEANED" 2>/dev/null | head -1) FRAME_COUNT=${FRAME_COUNT:-0} info "Extracting ~${FRAME_COUNT} frames..."

if ! ffmpeg -y -i "$CLEANED"
-vsync 0 -stats
"$FRAMES_IN/frame%08d.png" 2>&1 | tee /tmp/ffmpeg_extract.log |
grep --line-buffered -E "(frame=|speed=|error|Error)"; then error "Frame extraction failed. Full log: /tmp/ffmpeg_extract.log" FAILED=$((FAILED+1)) continue fi

ACTUAL_FRAMES=$(find "$FRAMES_IN" -name "*.png" | wc -l) echo success "Extracted $ACTUAL_FRAMES frames"

── Stage 3: Real-ESRGAN ──────────────────────────────────────────────────

header "Stage 3/3 — Real-ESRGAN AI upscaling (${UPSCALE_FACTOR}x)" warn "Slow on CPU — est. $(echo "scale=0; $ACTUAL_FRAMES * 10 / 60" | bc)-$(echo "scale=0; $ACTUAL_FRAMES * 30 / 60" | bc) minutes" info "Upscaled frames will appear in: $FRAMES_OUT" echo

UPSCALE_START=$(date +%s) if ! "$REALESRGAN_BIN"
-i "$FRAMES_IN"
-o "$FRAMES_OUT"
-n "$REALESRGAN_MODEL"
-s "$UPSCALE_FACTOR"
-f png 2>&1 | tee /tmp/realesrgan.log; then error "Real-ESRGAN failed. Full log: /tmp/realesrgan.log" FAILED=$((FAILED+1)) continue fi

UPSCALE_END=$(date +%s) UPSCALE_ELAPSED=$((UPSCALE_END - UPSCALE_START)) success "AI upscaling complete in $(human_time $UPSCALE_ELAPSED)"

── Reassemble ────────────────────────────────────────────────────────────

REASSEMBLE_FPS=$(ffprobe -v error -select_streams v:0
-show_entries stream=r_frame_rate
-of csv=p=0 "$CLEANED" 2>/dev/null | head -1)

info "Reassembling video from upscaled frames..." echo

if ! ffmpeg -y
-framerate "$REASSEMBLE_FPS"
-i "$FRAMES_OUT/frame%08d.png"
-i "$CLEANED"
-map 0:v -map 1:a
-c:v libx264 -crf "$CRF" -preset "$PRESET"
-c:a copy
-movflags +faststart
-stats
"$FINAL_OUTPUT" 2>&1 | tee /tmp/ffmpeg_reassemble.log |
grep --line-buffered -E "(frame=|speed=|error|Error)"; then error "Reassembly failed. Full log: /tmp/ffmpeg_reassemble.log" FAILED=$((FAILED+1)) continue fi

── Cleanup ───────────────────────────────────────────────────────────────

if ; then rm -rf "$FRAMES_IN" "$FRAMES_OUT" "$CLEANED" info "Scratch files cleaned up" else info "Frames kept in: $FRAMES_IN / $FRAMES_OUT" fi

FILE_END=$(date +%s) FILE_ELAPSED=$((FILE_END - FILE_START)) PROCESSED=$((PROCESSED+1))

OUT_SIZE=$(du -sh "$FINAL_OUTPUT" | cut -f1) echo success "Done: $FINAL_OUTPUT" info " File size : $OUT_SIZE" info " Time taken: $(human_time $FILE_ELAPSED)"

done

════════════════════════════════════════════════════════════════════════════

Final summary

════════════════════════════════════════════════════════════════════════════

PIPELINE_END=$(date +%s) PIPELINE_ELAPSED=$((PIPELINE_END - PIPELINE_START))

header "Pipeline Complete" echo -e " ${GREEN}Processed : $PROCESSED / $TOTAL_FILES${NC}" $FAILED -gt 0 && echo -e " ${RED}Failed : $FAILED${NC}" echo -e " Total time: $(human_time $PIPELINE_ELAPSED)" echo -e " Output dir: $OUTPUT_DIR" echo

if $PROCESSED -gt 0 ; then echo "Restored files:" find "$OUTPUT_DIR" -name "*_restored.mp4" | while read -r f; do SIZE=$(du -sh "$f" | cut -f1) echo " * $(basename "$f") ($SIZE)" done fi `