---
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 `<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 [[ "$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
`







