19 KiB
| 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
- 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
# 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)
- Download the latest Ubuntu release from:
https://github.com/xinntao/Real-ESRGAN/releases
→ look for
realesrgan-ncnn-vulkan-*-ubuntu.zip - Unzip into your working folder
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.
./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 (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:
# 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-animevideov3model 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 loadcorrupt 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
- Run
--no-aifirst on one file to check the cleanup result - If it looks good, run the full pipeline on all files overnight
- 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 `