--- title: Video Restoration Script description: Restore VHS Video Captures published: true date: 2026-03-06T03:48:12.713Z tags: editor: markdown dateCreated: 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** - **FFmpeg** — `sudo apt install ffmpeg` - **bc** — `sudo 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 ```bash # 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 ### Quick cleanup only (recommended first pass) Fast — processes in a few minutes per file. No AI upscaling. ```bash ./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. ```bash ./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 (0–51, lower = better) | `16` | | `--no-ai` | Skip AI upscaling, FFmpeg only | off | | `--keep` | Keep extracted PNG frames after processing | off | | `-h` | Show help | | **Examples:** ```bash # 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 `_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 </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 [[ "$SKIP_UPSCALE" == false ]]; 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 [[ "$SKIP_UPSCALE" == true ]]; 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 [[ "$SKIP_UPSCALE" == true ]]; then cp "$CLEANED" "$FINAL_OUTPUT" success "Output (no AI): $FINAL_OUTPUT" PROCESSED=$((PROCESSED+1)) [[ "$KEEP_FRAMES" == false ]] && 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 [[ "$KEEP_FRAMES" == false ]]; 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 `