Netgrimoire/Green-Grimoire/Scripts/VHS-Restoration.md
2026-04-12 09:53:51 -05:00

531 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 (051, 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
`