Commit Β·
b1a5003
1
Parent(s): 19dea1e
Initial pipeline upload
Browse filesCrop-inpaint-composite pipeline with LaMa (fast) and VACE-14B (quality) modes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- .gitignore +46 -0
- README.md +44 -6
- app.py +611 -0
- packages.txt +1 -0
- pipeline/__init__.py +3 -0
- pipeline/composite.py +218 -0
- pipeline/crop.py +447 -0
- pipeline/lama.py +150 -0
- pipeline/vace.py +59 -0
- pipeline/video.py +445 -0
- requirements.txt +23 -0
.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
dist/
|
| 9 |
+
*.egg-info/
|
| 10 |
+
.eggs/
|
| 11 |
+
|
| 12 |
+
# Virtual environments
|
| 13 |
+
venv/
|
| 14 |
+
.venv/
|
| 15 |
+
env/
|
| 16 |
+
.env
|
| 17 |
+
|
| 18 |
+
# OS
|
| 19 |
+
.DS_Store
|
| 20 |
+
Thumbs.db
|
| 21 |
+
|
| 22 |
+
# IDE / tooling
|
| 23 |
+
.idea/
|
| 24 |
+
.vscode/
|
| 25 |
+
.claude/
|
| 26 |
+
*.swp
|
| 27 |
+
*.swo
|
| 28 |
+
|
| 29 |
+
# HF / model caches
|
| 30 |
+
.cache/
|
| 31 |
+
hub/
|
| 32 |
+
*.ckpt
|
| 33 |
+
*.pt
|
| 34 |
+
*.bin
|
| 35 |
+
*.safetensors
|
| 36 |
+
|
| 37 |
+
# Local test artifacts
|
| 38 |
+
*.mp4
|
| 39 |
+
*.mov
|
| 40 |
+
*.png
|
| 41 |
+
*.jpg
|
| 42 |
+
*.jpeg
|
| 43 |
+
!**/assets/**
|
| 44 |
+
|
| 45 |
+
# Logs
|
| 46 |
+
*.log
|
README.md
CHANGED
|
@@ -1,12 +1,50 @@
|
|
| 1 |
---
|
| 2 |
-
title: Watermark Remover
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Video Watermark Remover
|
| 3 |
+
emoji: π¦
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "4.44.0"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Video Watermark Remover
|
| 14 |
+
|
| 15 |
+
Self-hosted Hugging Face Space for removing static, opaque watermarks from video footage.
|
| 16 |
+
|
| 17 |
+
## Modes
|
| 18 |
+
|
| 19 |
+
| Mode | Model | Speed | Best for |
|
| 20 |
+
|------|-------|-------|----------|
|
| 21 |
+
| **Fast** | LaMa (per-frame) | Seconds | Sky, water, foliage β low-frequency backgrounds |
|
| 22 |
+
| **Quality** | Wan2.1-VACE-14B | ~2 min | Structured or textured backgrounds |
|
| 23 |
+
|
| 24 |
+
## How it works
|
| 25 |
+
|
| 26 |
+
1. Upload a video clip (β€15 seconds, 1080p)
|
| 27 |
+
2. On the extracted first frame, **draw around the watermark** using the brush tool
|
| 28 |
+
3. Choose **Fast** or **Quality** mode
|
| 29 |
+
4. Hit **Remove Watermark** β the output is composited back at full 1080p
|
| 30 |
+
|
| 31 |
+
### Crop-inpaint-composite
|
| 32 |
+
|
| 33 |
+
The pipeline never runs the model on the full 1920Γ1080 frame. Instead:
|
| 34 |
+
- Your drawn region determines a tight crop (expanded to a VACE-compatible resolution with surrounding context)
|
| 35 |
+
- Only that crop (~7Γ fewer pixels) is processed by the model
|
| 36 |
+
- The result is feather-blended back into the original frame
|
| 37 |
+
- All other pixels are **byte-identical** to the source
|
| 38 |
+
|
| 39 |
+
V-Log color metadata is preserved via FFmpeg flag passthrough.
|
| 40 |
+
|
| 41 |
+
## Platform
|
| 42 |
+
|
| 43 |
+
- ZeroGPU PRO (H200 MIG slice, 70 GB VRAM)
|
| 44 |
+
- PyTorch 2.7.1 + diffusers
|
| 45 |
+
|
| 46 |
+
## License
|
| 47 |
+
|
| 48 |
+
- Pipeline code: Apache 2.0
|
| 49 |
+
- LaMa: Apache 2.0
|
| 50 |
+
- Wan2.1-VACE-14B: [Wan-AI license](https://huggingface.co/Wan-AI/Wan2.1-VACE-14B-diffusers)
|
app.py
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py
|
| 3 |
+
------
|
| 4 |
+
Gradio UI for the Video Watermark Remover Space (ZeroGPU PRO).
|
| 5 |
+
|
| 6 |
+
Flow:
|
| 7 |
+
1. Upload video β extract first frame β display in ImageEditor for mask drawing
|
| 8 |
+
2. User brushes over the watermark
|
| 9 |
+
3. Preview Crop β shows crop region + mask overlay
|
| 10 |
+
4. Mode: Fast (LaMa) | Quality (VACE-14B)
|
| 11 |
+
5. Remove Watermark β runs pipeline β output video
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import shutil
|
| 18 |
+
import tempfile
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import gradio as gr
|
| 22 |
+
import numpy as np
|
| 23 |
+
from PIL import Image, ImageDraw
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# ZeroGPU spaces shim β import succeeds locally too
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
try:
|
| 29 |
+
import spaces # type: ignore
|
| 30 |
+
HAS_SPACES = True
|
| 31 |
+
except ImportError:
|
| 32 |
+
HAS_SPACES = False
|
| 33 |
+
class spaces: # type: ignore
|
| 34 |
+
@staticmethod
|
| 35 |
+
def GPU(duration=60):
|
| 36 |
+
def decorator(fn):
|
| 37 |
+
return fn
|
| 38 |
+
return decorator
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Pipeline imports
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
from pipeline.crop import CropRegion, build_inpaint_mask, compute_crop_region, mask_to_bbox
|
| 44 |
+
from pipeline.video import (
|
| 45 |
+
VideoMeta, VideoWorkspace,
|
| 46 |
+
attach_audio, extract_first_frame, extract_frames, frames_to_video, probe,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# CSS β dark premium theme
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
CSS = """
|
| 53 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 54 |
+
|
| 55 |
+
* { box-sizing: border-box; }
|
| 56 |
+
|
| 57 |
+
body, .gradio-container {
|
| 58 |
+
font-family: 'Inter', sans-serif !important;
|
| 59 |
+
background: #0d1117 !important;
|
| 60 |
+
color: #e6edf3 !important;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.gradio-container {
|
| 64 |
+
max-width: 1200px !important;
|
| 65 |
+
margin: 0 auto !important;
|
| 66 |
+
padding: 24px !important;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* Header */
|
| 70 |
+
.header-block {
|
| 71 |
+
text-align: center;
|
| 72 |
+
padding: 32px 0 24px;
|
| 73 |
+
}
|
| 74 |
+
.header-block h1 {
|
| 75 |
+
font-size: 2rem;
|
| 76 |
+
font-weight: 700;
|
| 77 |
+
background: linear-gradient(135deg, #2dd4bf, #06b6d4);
|
| 78 |
+
-webkit-background-clip: text;
|
| 79 |
+
-webkit-text-fill-color: transparent;
|
| 80 |
+
margin-bottom: 8px;
|
| 81 |
+
}
|
| 82 |
+
.header-block p {
|
| 83 |
+
color: #8b949e;
|
| 84 |
+
font-size: 0.95rem;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* Cards */
|
| 88 |
+
.card {
|
| 89 |
+
background: #161b22;
|
| 90 |
+
border: 1px solid #30363d;
|
| 91 |
+
border-radius: 12px;
|
| 92 |
+
padding: 20px;
|
| 93 |
+
margin-bottom: 16px;
|
| 94 |
+
}
|
| 95 |
+
.card-title {
|
| 96 |
+
font-size: 0.8rem;
|
| 97 |
+
font-weight: 600;
|
| 98 |
+
text-transform: uppercase;
|
| 99 |
+
letter-spacing: 0.08em;
|
| 100 |
+
color: #8b949e;
|
| 101 |
+
margin-bottom: 12px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* Buttons */
|
| 105 |
+
.btn-primary {
|
| 106 |
+
background: linear-gradient(135deg, #0f766e, #0e7490) !important;
|
| 107 |
+
border: none !important;
|
| 108 |
+
color: white !important;
|
| 109 |
+
font-weight: 600 !important;
|
| 110 |
+
border-radius: 8px !important;
|
| 111 |
+
padding: 10px 24px !important;
|
| 112 |
+
transition: opacity 0.2s !important;
|
| 113 |
+
}
|
| 114 |
+
.btn-primary:hover { opacity: 0.85 !important; }
|
| 115 |
+
|
| 116 |
+
.btn-secondary {
|
| 117 |
+
background: #21262d !important;
|
| 118 |
+
border: 1px solid #30363d !important;
|
| 119 |
+
color: #e6edf3 !important;
|
| 120 |
+
font-weight: 500 !important;
|
| 121 |
+
border-radius: 8px !important;
|
| 122 |
+
transition: border-color 0.2s !important;
|
| 123 |
+
}
|
| 124 |
+
.btn-secondary:hover { border-color: #2dd4bf !important; }
|
| 125 |
+
|
| 126 |
+
/* Status */
|
| 127 |
+
.status-box textarea {
|
| 128 |
+
background: #0d1117 !important;
|
| 129 |
+
border: 1px solid #30363d !important;
|
| 130 |
+
color: #8b949e !important;
|
| 131 |
+
font-family: 'JetBrains Mono', monospace !important;
|
| 132 |
+
font-size: 0.82rem !important;
|
| 133 |
+
border-radius: 8px !important;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* Mode radio */
|
| 137 |
+
.mode-radio label { font-weight: 500 !important; }
|
| 138 |
+
|
| 139 |
+
/* Steps badge */
|
| 140 |
+
.step-badge {
|
| 141 |
+
display: inline-flex;
|
| 142 |
+
align-items: center;
|
| 143 |
+
justify-content: center;
|
| 144 |
+
width: 24px;
|
| 145 |
+
height: 24px;
|
| 146 |
+
border-radius: 50%;
|
| 147 |
+
background: #0f766e;
|
| 148 |
+
color: white;
|
| 149 |
+
font-size: 0.75rem;
|
| 150 |
+
font-weight: 700;
|
| 151 |
+
margin-right: 8px;
|
| 152 |
+
}
|
| 153 |
+
"""
|
| 154 |
+
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
# Helpers
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
|
| 159 |
+
def _get_mask_from_editor(editor_value: dict | None) -> np.ndarray | None:
|
| 160 |
+
"""Extract a uint8 mask (HΓW) from gr.ImageEditor output.
|
| 161 |
+
|
| 162 |
+
Unions all layers so drawings across multiple Gradio layers are preserved.
|
| 163 |
+
The returned array is at *display* resolution (the editor canvas size),
|
| 164 |
+
not necessarily source frame resolution. Always rescale with
|
| 165 |
+
_rescale_mask_to_frame() before using mask coordinates against VideoMeta.
|
| 166 |
+
"""
|
| 167 |
+
if editor_value is None:
|
| 168 |
+
return None
|
| 169 |
+
raw_layers = [l for l in (editor_value.get("layers") or []) if l is not None]
|
| 170 |
+
if not raw_layers:
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
combined: np.ndarray | None = None
|
| 174 |
+
for raw in raw_layers:
|
| 175 |
+
arr = np.array(raw)
|
| 176 |
+
if arr.ndim == 3 and arr.shape[2] == 4:
|
| 177 |
+
channel = arr[:, :, 3] # RGBA β alpha
|
| 178 |
+
elif arr.ndim == 3:
|
| 179 |
+
channel = arr.max(axis=2) # RGB β luminance max
|
| 180 |
+
else:
|
| 181 |
+
channel = arr.astype(np.uint8) # already single-channel
|
| 182 |
+
combined = channel if combined is None else np.maximum(combined, channel)
|
| 183 |
+
|
| 184 |
+
return combined
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _rescale_mask_to_frame(
|
| 188 |
+
raw_mask: np.ndarray,
|
| 189 |
+
target_w: int,
|
| 190 |
+
target_h: int,
|
| 191 |
+
) -> np.ndarray:
|
| 192 |
+
"""Rescale a mask from editor/display resolution to full source-frame resolution.
|
| 193 |
+
|
| 194 |
+
gr.ImageEditor renders at a fixed CSS height, so the returned layer may be
|
| 195 |
+
e.g. 854Γ480 for a 1920Γ1080 source. Using display-resolution coordinates
|
| 196 |
+
against full-frame VideoMeta dimensions would place the crop in the wrong
|
| 197 |
+
position.
|
| 198 |
+
|
| 199 |
+
Also enforces uint8 binary (0 / 255) output regardless of the layer dtype
|
| 200 |
+
returned by Gradio β guards against future API changes where the layer
|
| 201 |
+
becomes float32 (0β1) rather than uint8 (0β255).
|
| 202 |
+
"""
|
| 203 |
+
if raw_mask.shape[0] == target_h and raw_mask.shape[1] == target_w:
|
| 204 |
+
result = raw_mask
|
| 205 |
+
else:
|
| 206 |
+
result = np.array(
|
| 207 |
+
Image.fromarray(raw_mask).resize((target_w, target_h), Image.NEAREST)
|
| 208 |
+
)
|
| 209 |
+
# Normalise to binary uint8 regardless of incoming dtype
|
| 210 |
+
return (result > 0).astype(np.uint8) * 255
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _create_crop_preview(
|
| 214 |
+
first_frame: np.ndarray,
|
| 215 |
+
crop_region: CropRegion,
|
| 216 |
+
inpaint_mask: np.ndarray,
|
| 217 |
+
) -> np.ndarray:
|
| 218 |
+
"""Overlay crop rectangle and mask on the first frame for preview."""
|
| 219 |
+
img = Image.fromarray(first_frame).convert("RGBA")
|
| 220 |
+
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
| 221 |
+
draw = ImageDraw.Draw(overlay)
|
| 222 |
+
|
| 223 |
+
cr = crop_region
|
| 224 |
+
|
| 225 |
+
# Semi-transparent teal fill for crop region
|
| 226 |
+
draw.rectangle(
|
| 227 |
+
[cr.frame_x, cr.frame_y, cr.frame_x + cr.frame_w, cr.frame_y + cr.frame_h],
|
| 228 |
+
fill=(13, 148, 136, 40),
|
| 229 |
+
outline=(45, 212, 191, 200),
|
| 230 |
+
width=2,
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# Inpaint mask overlay (red)
|
| 234 |
+
if inpaint_mask is not None:
|
| 235 |
+
mask_rgba = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
| 236 |
+
mask_full = np.zeros((img.height, img.width), dtype=np.uint8)
|
| 237 |
+
y1 = cr.frame_y
|
| 238 |
+
y2 = cr.frame_y + cr.frame_h
|
| 239 |
+
x1 = cr.frame_x
|
| 240 |
+
x2 = cr.frame_x + cr.frame_w
|
| 241 |
+
mask_full[y1:y2, x1:x2] = inpaint_mask
|
| 242 |
+
red_channel = np.zeros((*mask_full.shape, 4), dtype=np.uint8)
|
| 243 |
+
red_channel[mask_full > 0] = [239, 68, 68, 140]
|
| 244 |
+
mask_rgba = Image.fromarray(red_channel, mode="RGBA")
|
| 245 |
+
overlay = Image.alpha_composite(overlay, mask_rgba)
|
| 246 |
+
|
| 247 |
+
composite = Image.alpha_composite(img, overlay).convert("RGB")
|
| 248 |
+
return np.array(composite)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _meta_to_dict(meta: VideoMeta) -> dict:
|
| 252 |
+
return {
|
| 253 |
+
"width": meta.width,
|
| 254 |
+
"height": meta.height,
|
| 255 |
+
"fps": meta.fps,
|
| 256 |
+
"frame_count": meta.frame_count,
|
| 257 |
+
"duration_s": meta.duration_s,
|
| 258 |
+
"color_primaries": meta.color_primaries,
|
| 259 |
+
"color_trc": meta.color_trc,
|
| 260 |
+
"colorspace": meta.colorspace,
|
| 261 |
+
"color_range": meta.color_range,
|
| 262 |
+
"codec_name": meta.codec_name,
|
| 263 |
+
"bit_depth": meta.bit_depth,
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _dict_to_meta(d: dict) -> VideoMeta:
|
| 268 |
+
return VideoMeta(**d)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ---------------------------------------------------------------------------
|
| 272 |
+
# Callbacks
|
| 273 |
+
# ---------------------------------------------------------------------------
|
| 274 |
+
|
| 275 |
+
def on_video_upload(video_path: str | None):
|
| 276 |
+
"""Extract first frame and populate the ImageEditor."""
|
| 277 |
+
if not video_path:
|
| 278 |
+
return gr.update(), gr.update(), None, "Upload a video to begin."
|
| 279 |
+
|
| 280 |
+
try:
|
| 281 |
+
meta = probe(video_path)
|
| 282 |
+
|
| 283 |
+
# ββ Input validation β guard against disk exhaustion on ZeroGPU ββ
|
| 284 |
+
MAX_DURATION_S = 16.0
|
| 285 |
+
MAX_PIXELS = 1920 * 1080
|
| 286 |
+
if meta.duration_s > MAX_DURATION_S:
|
| 287 |
+
return (
|
| 288 |
+
gr.update(), gr.update(), None,
|
| 289 |
+
f"β Clip too long ({meta.duration_s:.1f}s). Max {MAX_DURATION_S:.0f} seconds.",
|
| 290 |
+
)
|
| 291 |
+
if meta.width * meta.height > MAX_PIXELS:
|
| 292 |
+
return (
|
| 293 |
+
gr.update(), gr.update(), None,
|
| 294 |
+
f"β Resolution too high ({meta.width}Γ{meta.height}). Max 1920Γ1080.",
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
# Extract first frame β mkstemp so the fd is closed before FFmpeg writes
|
| 298 |
+
fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="wm_frame_")
|
| 299 |
+
os.close(fd)
|
| 300 |
+
try:
|
| 301 |
+
extract_first_frame(video_path, tmp_path)
|
| 302 |
+
first_frame = np.array(Image.open(tmp_path).convert("RGB"))
|
| 303 |
+
finally:
|
| 304 |
+
try:
|
| 305 |
+
os.unlink(tmp_path)
|
| 306 |
+
except OSError:
|
| 307 |
+
pass
|
| 308 |
+
|
| 309 |
+
meta_str = (
|
| 310 |
+
f"{meta.width}Γ{meta.height} Β· {meta.fps:.3g} fps Β· "
|
| 311 |
+
f"{meta.duration_s:.1f}s Β· {meta.frame_count} frames"
|
| 312 |
+
)
|
| 313 |
+
if meta.color_trc:
|
| 314 |
+
meta_str += f" Β· {meta.color_trc}"
|
| 315 |
+
|
| 316 |
+
editor_val = {
|
| 317 |
+
"background": first_frame,
|
| 318 |
+
"layers": [],
|
| 319 |
+
"composite": None,
|
| 320 |
+
}
|
| 321 |
+
return (
|
| 322 |
+
gr.update(value=editor_val),
|
| 323 |
+
gr.update(value=None),
|
| 324 |
+
_meta_to_dict(meta),
|
| 325 |
+
f"β Loaded β {meta_str}\n\nNow draw over the watermark with the brush tool.",
|
| 326 |
+
)
|
| 327 |
+
except Exception as e:
|
| 328 |
+
return gr.update(), gr.update(), None, f"β Error: {e}"
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def on_preview_crop(editor_value: dict | None, meta_state: dict | None, context_px: int):
|
| 332 |
+
"""Compute crop region from mask and render a preview overlay."""
|
| 333 |
+
if meta_state is None:
|
| 334 |
+
return gr.update(), "Upload a video first."
|
| 335 |
+
if editor_value is None:
|
| 336 |
+
return gr.update(), "Upload a video first."
|
| 337 |
+
|
| 338 |
+
raw_mask = _get_mask_from_editor(editor_value)
|
| 339 |
+
if raw_mask is None or raw_mask.max() == 0:
|
| 340 |
+
return gr.update(), "β οΈ No drawing detected. Use the brush to paint over the watermark."
|
| 341 |
+
|
| 342 |
+
try:
|
| 343 |
+
meta = _dict_to_meta(meta_state)
|
| 344 |
+
# Rescale mask from editor/display resolution to full source-frame resolution
|
| 345 |
+
full_mask = _rescale_mask_to_frame(raw_mask, meta.width, meta.height)
|
| 346 |
+
bbox = mask_to_bbox(full_mask)
|
| 347 |
+
crop_region = compute_crop_region(
|
| 348 |
+
bbox, meta.width, meta.height, context_px=context_px
|
| 349 |
+
)
|
| 350 |
+
inpaint_mask = build_inpaint_mask(
|
| 351 |
+
crop_region, source_mask=full_mask, dilate_px=6
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
bg = editor_value.get("background")
|
| 355 |
+
if bg is None:
|
| 356 |
+
first_frame = np.zeros((meta.height, meta.width, 3), dtype=np.uint8)
|
| 357 |
+
else:
|
| 358 |
+
first_frame = np.array(Image.fromarray(np.array(bg)).convert("RGB"))
|
| 359 |
+
# Ensure first_frame is at full source resolution for the overlay
|
| 360 |
+
if first_frame.shape[1] != meta.width or first_frame.shape[0] != meta.height:
|
| 361 |
+
first_frame = np.array(
|
| 362 |
+
Image.fromarray(first_frame).resize(
|
| 363 |
+
(meta.width, meta.height), Image.LANCZOS
|
| 364 |
+
)
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
preview = _create_crop_preview(first_frame, crop_region, inpaint_mask)
|
| 368 |
+
|
| 369 |
+
status = (
|
| 370 |
+
f"β Crop computed\n"
|
| 371 |
+
f" Watermark bbox : {bbox.width}Γ{bbox.height} px\n"
|
| 372 |
+
f" Crop region : {crop_region.frame_w}Γ{crop_region.frame_h} "
|
| 373 |
+
f"@ ({crop_region.frame_x}, {crop_region.frame_y})\n"
|
| 374 |
+
f" VACE target : {crop_region.target_w}Γ{crop_region.target_h}\n"
|
| 375 |
+
f"\nLooks good? Hit Remove Watermark."
|
| 376 |
+
)
|
| 377 |
+
return gr.update(value=preview), status
|
| 378 |
+
|
| 379 |
+
except Exception as e:
|
| 380 |
+
return gr.update(), f"β {e}"
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
@spaces.GPU(duration=180)
|
| 384 |
+
def _inpaint_frames_gpu(
|
| 385 |
+
frame_paths: list,
|
| 386 |
+
crop_region: CropRegion,
|
| 387 |
+
inpaint_mask: np.ndarray,
|
| 388 |
+
mode: str,
|
| 389 |
+
total: int,
|
| 390 |
+
progress,
|
| 391 |
+
) -> list:
|
| 392 |
+
"""
|
| 393 |
+
GPU-accelerated inpainting step.
|
| 394 |
+
|
| 395 |
+
This is the *only* function that holds the ZeroGPU allocation.
|
| 396 |
+
Frame extraction, compositing, and video encoding are all CPU-only and
|
| 397 |
+
run outside this function to avoid burning GPU quota on I/O work.
|
| 398 |
+
"""
|
| 399 |
+
if mode == "Fast (LaMa)":
|
| 400 |
+
from pipeline.lama import inpaint_frames_lama
|
| 401 |
+
|
| 402 |
+
def _lama_progress(i: int) -> None:
|
| 403 |
+
progress(
|
| 404 |
+
0.20 + 0.70 * ((i + 1) / total),
|
| 405 |
+
desc=f"LaMa frame {i + 1}/{total}β¦",
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
return inpaint_frames_lama(
|
| 409 |
+
frame_paths, crop_region, inpaint_mask, progress_fn=_lama_progress
|
| 410 |
+
)
|
| 411 |
+
else: # Quality (VACE)
|
| 412 |
+
from pipeline.vace import inpaint_frames_vace
|
| 413 |
+
progress(0.5, desc="Running VACE-14Bβ¦")
|
| 414 |
+
return inpaint_frames_vace(frame_paths, crop_region, inpaint_mask)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def run_pipeline(
|
| 418 |
+
video_path: str | None,
|
| 419 |
+
editor_value: dict | None,
|
| 420 |
+
mode: str,
|
| 421 |
+
context_px: int,
|
| 422 |
+
meta_state: dict | None,
|
| 423 |
+
progress=gr.Progress(),
|
| 424 |
+
):
|
| 425 |
+
"""
|
| 426 |
+
Pipeline orchestrator β CPU work only.
|
| 427 |
+
|
| 428 |
+
GPU allocation is acquired and released inside _inpaint_frames_gpu; the
|
| 429 |
+
rest of the pipeline (frame extraction, compositing, encoding) is pure
|
| 430 |
+
CPU/disk I/O and does not consume GPU quota.
|
| 431 |
+
"""
|
| 432 |
+
if video_path is None:
|
| 433 |
+
raise gr.Error("Upload a video first.")
|
| 434 |
+
if meta_state is None:
|
| 435 |
+
raise gr.Error("Video metadata missing β re-upload the video.")
|
| 436 |
+
|
| 437 |
+
raw_mask = _get_mask_from_editor(editor_value)
|
| 438 |
+
if raw_mask is None or raw_mask.max() == 0:
|
| 439 |
+
raise gr.Error("Draw over the watermark before processing.")
|
| 440 |
+
|
| 441 |
+
meta = _dict_to_meta(meta_state)
|
| 442 |
+
full_mask = _rescale_mask_to_frame(raw_mask, meta.width, meta.height)
|
| 443 |
+
|
| 444 |
+
# ββ Compute crop + mask βββββββββββββββββββββββββββββββββββββββββββββ
|
| 445 |
+
progress(0.05, desc="Computing crop regionβ¦")
|
| 446 |
+
bbox = mask_to_bbox(full_mask)
|
| 447 |
+
crop_region = compute_crop_region(
|
| 448 |
+
bbox, meta.width, meta.height, context_px=context_px
|
| 449 |
+
)
|
| 450 |
+
inpaint_mask = build_inpaint_mask(
|
| 451 |
+
crop_region, source_mask=full_mask, dilate_px=6
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
with VideoWorkspace() as ws:
|
| 455 |
+
safe_video = ws.path("source" + Path(video_path).suffix)
|
| 456 |
+
shutil.copy2(video_path, safe_video)
|
| 457 |
+
|
| 458 |
+
# ββ Extract frames (CFR-forced for VFR safety) βββββββββββββββββ
|
| 459 |
+
progress(0.10, desc="Extracting framesβ¦")
|
| 460 |
+
frame_paths = extract_frames(safe_video, ws.frames_dir, fps=meta.fps)
|
| 461 |
+
total = len(frame_paths)
|
| 462 |
+
|
| 463 |
+
# ββ GPU: inpainting only βββββββββββββββββββββββββββββββββββ
|
| 464 |
+
progress(0.15, desc="Starting inpaintingβ¦")
|
| 465 |
+
inpainted_crops = _inpaint_frames_gpu(
|
| 466 |
+
frame_paths, crop_region, inpaint_mask, mode, total, progress
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# ββ CPU: composite ββββββββββββββββββββββββββββββββββββββββββ
|
| 470 |
+
progress(0.90, desc="Compositing framesβ¦")
|
| 471 |
+
from pipeline.composite import composite_frame
|
| 472 |
+
|
| 473 |
+
for i, (fp, crop) in enumerate(zip(frame_paths, inpainted_crops)):
|
| 474 |
+
original = np.array(Image.open(fp).convert("RGB"))
|
| 475 |
+
composited = composite_frame(original, crop, crop_region, inpaint_mask)
|
| 476 |
+
Image.fromarray(composited).save(ws.out_frames_dir / f"{i+1:06d}.png")
|
| 477 |
+
|
| 478 |
+
# ββ CPU: encode + mux βββββββββββββββββββββββββββββββββββββββ
|
| 479 |
+
progress(0.95, desc="Encoding videoβ¦")
|
| 480 |
+
silent_path = ws.path("silent.mp4")
|
| 481 |
+
frames_to_video(ws.out_frames_dir, silent_path, meta)
|
| 482 |
+
|
| 483 |
+
fd, final_path = tempfile.mkstemp(suffix=".mp4", prefix="wm_out_")
|
| 484 |
+
os.close(fd)
|
| 485 |
+
try:
|
| 486 |
+
attach_audio(safe_video, silent_path, final_path)
|
| 487 |
+
except Exception:
|
| 488 |
+
try:
|
| 489 |
+
os.unlink(final_path)
|
| 490 |
+
except OSError:
|
| 491 |
+
pass
|
| 492 |
+
raise
|
| 493 |
+
# ws (frames, composited pngs, silent.mp4) cleaned up here
|
| 494 |
+
|
| 495 |
+
progress(1.0, desc="Done!")
|
| 496 |
+
return final_path, f"β Done β {total} frames processed ({mode})"
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
# ---------------------------------------------------------------------------
|
| 500 |
+
# UI
|
| 501 |
+
# ---------------------------------------------------------------------------
|
| 502 |
+
|
| 503 |
+
with gr.Blocks(title="Video Watermark Remover", css=CSS) as demo:
|
| 504 |
+
|
| 505 |
+
# State
|
| 506 |
+
meta_state = gr.State(None)
|
| 507 |
+
|
| 508 |
+
# ββ Header ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 509 |
+
gr.HTML("""
|
| 510 |
+
<div class="header-block">
|
| 511 |
+
<h1>π¦ Video Watermark Remover</h1>
|
| 512 |
+
<p>Draw over the watermark Β· choose a mode Β· get clean footage</p>
|
| 513 |
+
</div>
|
| 514 |
+
""")
|
| 515 |
+
|
| 516 |
+
# ββ Step 1 + 2 side by side βββββββββββββββββββββββββββββββββββββββββββββ
|
| 517 |
+
with gr.Row(equal_height=False):
|
| 518 |
+
with gr.Column(scale=1):
|
| 519 |
+
gr.HTML('<div class="card-title"><span class="step-badge">1</span>Upload Video</div>')
|
| 520 |
+
video_input = gr.Video(
|
| 521 |
+
label="Source clip (β€15 s, 1080p)",
|
| 522 |
+
elem_id="video-input",
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
gr.HTML('<div class="card-title" style="margin-top:16px"><span class="step-badge">2</span>Mode</div>')
|
| 526 |
+
mode_radio = gr.Radio(
|
| 527 |
+
choices=["Fast (LaMa)", "Quality (VACE-14B)"],
|
| 528 |
+
value="Fast (LaMa)",
|
| 529 |
+
label="",
|
| 530 |
+
elem_classes=["mode-radio"],
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
gr.HTML('<div class="card-title" style="margin-top:16px">βοΈ Advanced</div>')
|
| 534 |
+
context_slider = gr.Slider(
|
| 535 |
+
minimum=32,
|
| 536 |
+
maximum=192,
|
| 537 |
+
value=64,
|
| 538 |
+
step=16,
|
| 539 |
+
label="Context padding (px)",
|
| 540 |
+
info="Extra scene context around the watermark given to the model",
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
with gr.Column(scale=2):
|
| 544 |
+
gr.HTML('<div class="card-title"><span class="step-badge">3</span>Draw Over the Watermark</div>')
|
| 545 |
+
editor = gr.ImageEditor(
|
| 546 |
+
label="Paint over the watermark (brush tool)",
|
| 547 |
+
type="numpy",
|
| 548 |
+
height=480,
|
| 549 |
+
brush=gr.Brush(colors=["#ef4444"], default_size=12),
|
| 550 |
+
eraser=gr.Eraser(default_size=12),
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
# ββ Action buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 554 |
+
with gr.Row():
|
| 555 |
+
preview_btn = gr.Button(
|
| 556 |
+
"π Preview Crop Region",
|
| 557 |
+
elem_classes=["btn-secondary"],
|
| 558 |
+
)
|
| 559 |
+
process_btn = gr.Button(
|
| 560 |
+
"β¨ Remove Watermark",
|
| 561 |
+
variant="primary",
|
| 562 |
+
elem_classes=["btn-primary"],
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
+
# ββ Status βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 566 |
+
status_box = gr.Textbox(
|
| 567 |
+
label="Status",
|
| 568 |
+
value="Upload a video to begin.",
|
| 569 |
+
lines=4,
|
| 570 |
+
interactive=False,
|
| 571 |
+
elem_classes=["status-box"],
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
# ββ Outputs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 575 |
+
with gr.Row():
|
| 576 |
+
with gr.Column():
|
| 577 |
+
gr.HTML('<div class="card-title">Crop Preview</div>')
|
| 578 |
+
crop_preview = gr.Image(
|
| 579 |
+
label="",
|
| 580 |
+
type="numpy",
|
| 581 |
+
show_label=False,
|
| 582 |
+
)
|
| 583 |
+
with gr.Column():
|
| 584 |
+
gr.HTML('<div class="card-title">Output Video</div>')
|
| 585 |
+
video_output = gr.Video(
|
| 586 |
+
label="",
|
| 587 |
+
show_label=False,
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
# ββ Wiring βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 591 |
+
video_input.upload(
|
| 592 |
+
fn=on_video_upload,
|
| 593 |
+
inputs=[video_input],
|
| 594 |
+
outputs=[editor, crop_preview, meta_state, status_box],
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
preview_btn.click(
|
| 598 |
+
fn=on_preview_crop,
|
| 599 |
+
inputs=[editor, meta_state, context_slider],
|
| 600 |
+
outputs=[crop_preview, status_box],
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
process_btn.click(
|
| 604 |
+
fn=run_pipeline,
|
| 605 |
+
inputs=[video_input, editor, mode_radio, context_slider, meta_state],
|
| 606 |
+
outputs=[video_output, status_box],
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
if __name__ == "__main__":
|
| 611 |
+
demo.launch()
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
ffmpeg
|
pipeline/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/__init__.py
|
| 3 |
+
"""
|
pipeline/composite.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/composite.py
|
| 3 |
+
---------------------
|
| 4 |
+
Feathered alpha compositing: paste an inpainted crop back into the
|
| 5 |
+
original full frame.
|
| 6 |
+
|
| 7 |
+
The feather/blend zone creates a smooth transition along the crop border,
|
| 8 |
+
avoiding hard edge seams. Only pixels inside the inpaint mask are replaced;
|
| 9 |
+
the rest of the crop (the "context" ring) is discarded.
|
| 10 |
+
|
| 11 |
+
Design
|
| 12 |
+
------
|
| 13 |
+
Two-stage blend:
|
| 14 |
+
1. Mask feathering: the inpaint mask is Gaussian-blurred to create a
|
| 15 |
+
soft alpha ramp around the watermark boundary. This blends between
|
| 16 |
+
the inpainted content and the original frame within the crop.
|
| 17 |
+
2. Crop-border feathering (optional, default off): a second linear ramp
|
| 18 |
+
from the crop border inward, so the context ring blends too. Usually
|
| 19 |
+
not needed because context pixels in the crop are identical to the
|
| 20 |
+
original frame anyway (LaMa / VACE don't alter them much).
|
| 21 |
+
|
| 22 |
+
All operations are pure NumPy + PIL β no cv2 required.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
from PIL import Image, ImageFilter
|
| 29 |
+
|
| 30 |
+
from pipeline.crop import CropRegion
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def composite_frame(
|
| 34 |
+
original: np.ndarray,
|
| 35 |
+
inpainted_crop: np.ndarray,
|
| 36 |
+
crop_region: CropRegion,
|
| 37 |
+
inpaint_mask: np.ndarray,
|
| 38 |
+
feather_radius: int = 8,
|
| 39 |
+
) -> np.ndarray:
|
| 40 |
+
"""
|
| 41 |
+
Blend an inpainted crop back into the original full frame.
|
| 42 |
+
|
| 43 |
+
Parameters
|
| 44 |
+
----------
|
| 45 |
+
original : np.ndarray
|
| 46 |
+
Full-frame image (H x W x 3, uint8 RGB). Modified in-place
|
| 47 |
+
on a copy β caller's array is not mutated.
|
| 48 |
+
inpainted_crop : np.ndarray
|
| 49 |
+
Inpainted crop image (crop_h x crop_w x 3, uint8 RGB).
|
| 50 |
+
Must match crop_region dimensions exactly.
|
| 51 |
+
crop_region : CropRegion
|
| 52 |
+
Defines where the crop sits in the full frame.
|
| 53 |
+
inpaint_mask : np.ndarray
|
| 54 |
+
Crop-local binary mask (crop_h x crop_w, uint8). 255=inpaint, 0=keep.
|
| 55 |
+
Same mask that was passed to the inpainting model.
|
| 56 |
+
feather_radius : int
|
| 57 |
+
Gaussian blur radius applied to the mask before blending.
|
| 58 |
+
Larger values = softer transition.
|
| 59 |
+
Set to 0 to disable feathering (hard composite).
|
| 60 |
+
|
| 61 |
+
Returns
|
| 62 |
+
-------
|
| 63 |
+
np.ndarray
|
| 64 |
+
Full-frame output (H x W x 3, uint8 RGB) with watermark removed.
|
| 65 |
+
"""
|
| 66 |
+
cr = crop_region
|
| 67 |
+
result = original.copy()
|
| 68 |
+
|
| 69 |
+
# Sanity check dimensions
|
| 70 |
+
expected_h, expected_w = cr.frame_h, cr.frame_w
|
| 71 |
+
actual_h, actual_w = inpainted_crop.shape[:2]
|
| 72 |
+
if (actual_h, actual_w) != (expected_h, expected_w):
|
| 73 |
+
# Resize inpainted crop to match crop region (handles VACE resize case)
|
| 74 |
+
inpainted_crop = np.array(
|
| 75 |
+
Image.fromarray(inpainted_crop).resize(
|
| 76 |
+
(expected_w, expected_h), Image.LANCZOS
|
| 77 |
+
)
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# ------------------------------------------------------------------
|
| 81 |
+
# Build the blend alpha from the inpaint mask
|
| 82 |
+
# ------------------------------------------------------------------
|
| 83 |
+
alpha = _feathered_alpha(inpaint_mask, feather_radius) # float32, 0..1
|
| 84 |
+
|
| 85 |
+
# ------------------------------------------------------------------
|
| 86 |
+
# Composite: result = alpha * inpainted + (1 - alpha) * original_crop
|
| 87 |
+
# ------------------------------------------------------------------
|
| 88 |
+
original_crop = result[
|
| 89 |
+
cr.frame_y : cr.frame_y + cr.frame_h,
|
| 90 |
+
cr.frame_x : cr.frame_x + cr.frame_w,
|
| 91 |
+
].astype(np.float32)
|
| 92 |
+
|
| 93 |
+
inpainted_f = inpainted_crop.astype(np.float32)
|
| 94 |
+
alpha_3 = alpha[:, :, np.newaxis] # broadcast over RGB channels
|
| 95 |
+
|
| 96 |
+
blended = alpha_3 * inpainted_f + (1.0 - alpha_3) * original_crop
|
| 97 |
+
blended_uint8 = np.clip(blended, 0, 255).astype(np.uint8)
|
| 98 |
+
|
| 99 |
+
result[
|
| 100 |
+
cr.frame_y : cr.frame_y + cr.frame_h,
|
| 101 |
+
cr.frame_x : cr.frame_x + cr.frame_w,
|
| 102 |
+
] = blended_uint8
|
| 103 |
+
|
| 104 |
+
return result
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def composite_frames(
|
| 108 |
+
original_frame_paths,
|
| 109 |
+
inpainted_crops: list[np.ndarray],
|
| 110 |
+
crop_region: CropRegion,
|
| 111 |
+
inpaint_mask: np.ndarray,
|
| 112 |
+
feather_radius: int = 8,
|
| 113 |
+
) -> list[np.ndarray]:
|
| 114 |
+
"""
|
| 115 |
+
Batch version: composite a list of inpainted crops onto their
|
| 116 |
+
corresponding original frames.
|
| 117 |
+
|
| 118 |
+
Parameters
|
| 119 |
+
----------
|
| 120 |
+
original_frame_paths : List[Path]
|
| 121 |
+
Full-frame PNG paths (same order as inpainted_crops).
|
| 122 |
+
inpainted_crops : List[np.ndarray]
|
| 123 |
+
One inpainted crop per frame.
|
| 124 |
+
crop_region : CropRegion
|
| 125 |
+
inpaint_mask : np.ndarray
|
| 126 |
+
Shared mask (same for all frames β watermark is static).
|
| 127 |
+
feather_radius : int
|
| 128 |
+
|
| 129 |
+
Returns
|
| 130 |
+
-------
|
| 131 |
+
List[np.ndarray]
|
| 132 |
+
Full-frame composited images (uint8 RGB), one per input frame.
|
| 133 |
+
"""
|
| 134 |
+
# Pre-compute feathered alpha once (shared mask)
|
| 135 |
+
alpha = _feathered_alpha(inpaint_mask, feather_radius)
|
| 136 |
+
|
| 137 |
+
composited: list[np.ndarray] = []
|
| 138 |
+
for frame_path, crop in zip(original_frame_paths, inpainted_crops):
|
| 139 |
+
original = np.array(Image.open(frame_path).convert("RGB"))
|
| 140 |
+
frame_out = _composite_with_alpha(original, crop, crop_region, alpha)
|
| 141 |
+
composited.append(frame_out)
|
| 142 |
+
|
| 143 |
+
return composited
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
# Private helpers
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
|
| 150 |
+
def _feathered_alpha(mask: np.ndarray, radius: int) -> np.ndarray:
|
| 151 |
+
"""
|
| 152 |
+
Convert a uint8 binary mask (0/255) to a float32 alpha map (0..1)
|
| 153 |
+
with a Gaussian-blurred soft edge.
|
| 154 |
+
|
| 155 |
+
Parameters
|
| 156 |
+
----------
|
| 157 |
+
mask : np.ndarray
|
| 158 |
+
Crop-local binary mask (H x W, uint8).
|
| 159 |
+
radius : int
|
| 160 |
+
Gaussian blur radius. 0 = hard composite.
|
| 161 |
+
|
| 162 |
+
Returns
|
| 163 |
+
-------
|
| 164 |
+
np.ndarray
|
| 165 |
+
Float32 alpha map (H x W), values in [0.0, 1.0].
|
| 166 |
+
"""
|
| 167 |
+
alpha_f = mask.astype(np.float32) / 255.0
|
| 168 |
+
|
| 169 |
+
if radius > 0:
|
| 170 |
+
# Use scipy gaussian_filter on float32 directly β avoids uint8 quantisation
|
| 171 |
+
# that would staircase the feather ramp over 8 discrete levels.
|
| 172 |
+
# sigma: radius β 3Ο is the standard Gaussian convention.
|
| 173 |
+
from scipy.ndimage import gaussian_filter
|
| 174 |
+
sigma = max(radius / 3.0, 0.5)
|
| 175 |
+
alpha_f = gaussian_filter(alpha_f, sigma=sigma)
|
| 176 |
+
alpha_f = np.clip(alpha_f, 0.0, 1.0)
|
| 177 |
+
|
| 178 |
+
return alpha_f
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _composite_with_alpha(
|
| 182 |
+
original: np.ndarray,
|
| 183 |
+
inpainted_crop: np.ndarray,
|
| 184 |
+
crop_region: CropRegion,
|
| 185 |
+
alpha: np.ndarray,
|
| 186 |
+
) -> np.ndarray:
|
| 187 |
+
"""
|
| 188 |
+
Internal composite given a pre-computed alpha map.
|
| 189 |
+
Returns a copy of original with the crop region blended in.
|
| 190 |
+
"""
|
| 191 |
+
cr = crop_region
|
| 192 |
+
result = original.copy()
|
| 193 |
+
|
| 194 |
+
# Resize crop if needed (e.g. VACE returned a different resolution)
|
| 195 |
+
expected_h, expected_w = cr.frame_h, cr.frame_w
|
| 196 |
+
actual_h, actual_w = inpainted_crop.shape[:2]
|
| 197 |
+
if (actual_h, actual_w) != (expected_h, expected_w):
|
| 198 |
+
inpainted_crop = np.array(
|
| 199 |
+
Image.fromarray(inpainted_crop).resize(
|
| 200 |
+
(expected_w, expected_h), Image.LANCZOS
|
| 201 |
+
)
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
original_crop = result[
|
| 205 |
+
cr.frame_y : cr.frame_y + cr.frame_h,
|
| 206 |
+
cr.frame_x : cr.frame_x + cr.frame_w,
|
| 207 |
+
].astype(np.float32)
|
| 208 |
+
|
| 209 |
+
alpha_3 = alpha[:, :, np.newaxis]
|
| 210 |
+
blended = alpha_3 * inpainted_crop.astype(np.float32) + (1.0 - alpha_3) * original_crop
|
| 211 |
+
blended_uint8 = np.clip(blended, 0, 255).astype(np.uint8)
|
| 212 |
+
|
| 213 |
+
result[
|
| 214 |
+
cr.frame_y : cr.frame_y + cr.frame_h,
|
| 215 |
+
cr.frame_x : cr.frame_x + cr.frame_w,
|
| 216 |
+
] = blended_uint8
|
| 217 |
+
|
| 218 |
+
return result
|
pipeline/crop.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/crop.py
|
| 3 |
+
----------------
|
| 4 |
+
Crop region computation for the watermark inpainting pipeline.
|
| 5 |
+
|
| 6 |
+
Given a user-drawn mask on the first frame, this module:
|
| 7 |
+
1. Computes the bounding box of the drawn region.
|
| 8 |
+
2. Finds the smallest VACE-compatible resolution that fits the bbox
|
| 9 |
+
(with a configurable context border added on each side).
|
| 10 |
+
3. Centers the crop on the watermark centroid and shifts it
|
| 11 |
+
asymmetrically if it would fall off a frame edge.
|
| 12 |
+
4. Returns a CropRegion dataclass consumed by the rest of the pipeline.
|
| 13 |
+
|
| 14 |
+
No model dependencies β pure NumPy / math.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import math
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from typing import Tuple
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# Supported VACE / Wan2.1 resolutions
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# These are the canonical (width, height) pairs that Wan2.1-VACE accepts.
|
| 30 |
+
# They must be divisible by 32 (VAE spatial downsampling factor).
|
| 31 |
+
# Ordered by total pixel count ascending so we can pick the smallest fitting one.
|
| 32 |
+
|
| 33 |
+
VACE_RESOLUTIONS: list[Tuple[int, int]] = sorted(
|
| 34 |
+
[
|
| 35 |
+
# Square
|
| 36 |
+
(512, 512),
|
| 37 |
+
(576, 576),
|
| 38 |
+
(640, 640),
|
| 39 |
+
(704, 704),
|
| 40 |
+
(768, 768),
|
| 41 |
+
(832, 832),
|
| 42 |
+
(896, 896),
|
| 43 |
+
(960, 960),
|
| 44 |
+
(1024, 1024),
|
| 45 |
+
(1280, 1280),
|
| 46 |
+
# Landscape (width > height)
|
| 47 |
+
(640, 480),
|
| 48 |
+
(704, 480),
|
| 49 |
+
(768, 512),
|
| 50 |
+
(832, 576),
|
| 51 |
+
(896, 576),
|
| 52 |
+
(960, 544),
|
| 53 |
+
(1024, 576),
|
| 54 |
+
(1024, 640),
|
| 55 |
+
(1024, 704),
|
| 56 |
+
(1024, 768),
|
| 57 |
+
(1152, 640),
|
| 58 |
+
(1152, 768),
|
| 59 |
+
(1280, 720),
|
| 60 |
+
(1280, 768),
|
| 61 |
+
(1280, 1024),
|
| 62 |
+
# Portrait (height > width)
|
| 63 |
+
(480, 640),
|
| 64 |
+
(480, 704),
|
| 65 |
+
(512, 768),
|
| 66 |
+
(576, 832),
|
| 67 |
+
(576, 896),
|
| 68 |
+
(544, 960),
|
| 69 |
+
(576, 1024),
|
| 70 |
+
(640, 1024),
|
| 71 |
+
(704, 1024),
|
| 72 |
+
(768, 1024),
|
| 73 |
+
(640, 1152),
|
| 74 |
+
(768, 1152),
|
| 75 |
+
(720, 1280),
|
| 76 |
+
(768, 1280),
|
| 77 |
+
(1024, 1280),
|
| 78 |
+
],
|
| 79 |
+
key=lambda wh: wh[0] * wh[1], # ascending by pixel count
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Data types
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
@dataclass(frozen=True)
|
| 88 |
+
class BBox:
|
| 89 |
+
"""Pixel-space bounding box. All values are inclusive, 0-indexed."""
|
| 90 |
+
x1: int
|
| 91 |
+
y1: int
|
| 92 |
+
x2: int
|
| 93 |
+
y2: int
|
| 94 |
+
|
| 95 |
+
@property
|
| 96 |
+
def width(self) -> int:
|
| 97 |
+
# x1 and x2 are both inclusive pixel indices, so pixel count = x2 - x1 + 1
|
| 98 |
+
return self.x2 - self.x1 + 1
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def height(self) -> int:
|
| 102 |
+
return self.y2 - self.y1 + 1
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def cx(self) -> float:
|
| 106 |
+
return (self.x1 + self.x2) / 2.0
|
| 107 |
+
|
| 108 |
+
@property
|
| 109 |
+
def cy(self) -> float:
|
| 110 |
+
return (self.y1 + self.y2) / 2.0
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def area(self) -> int:
|
| 114 |
+
return self.width * self.height
|
| 115 |
+
|
| 116 |
+
def __repr__(self) -> str:
|
| 117 |
+
return (
|
| 118 |
+
f"BBox(x1={self.x1}, y1={self.y1}, x2={self.x2}, y2={self.y2}, "
|
| 119 |
+
f"w={self.width}, h={self.height})"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@dataclass(frozen=True)
|
| 124 |
+
class CropRegion:
|
| 125 |
+
"""
|
| 126 |
+
The crop rectangle to cut from each full frame before inpainting.
|
| 127 |
+
|
| 128 |
+
- ``frame_x / frame_y``: top-left corner in full-frame pixel space.
|
| 129 |
+
- ``frame_w / frame_h``: size in full-frame pixel space.
|
| 130 |
+
- ``target_w / target_h``: VACE-compatible resolution the crop is scaled
|
| 131 |
+
to (usually equal to frame_w/frame_h when we only expand, no downscale).
|
| 132 |
+
- ``mask_bbox``: original watermark bbox in crop-local coordinates.
|
| 133 |
+
"""
|
| 134 |
+
frame_x: int
|
| 135 |
+
frame_y: int
|
| 136 |
+
frame_w: int
|
| 137 |
+
frame_h: int
|
| 138 |
+
target_w: int
|
| 139 |
+
target_h: int
|
| 140 |
+
mask_bbox: BBox # in crop-local pixel space
|
| 141 |
+
|
| 142 |
+
@property
|
| 143 |
+
def frame_rect(self) -> Tuple[int, int, int, int]:
|
| 144 |
+
"""(x, y, w, h) in full-frame space β handy for cv2.getRectSubPix etc."""
|
| 145 |
+
return (self.frame_x, self.frame_y, self.frame_w, self.frame_h)
|
| 146 |
+
|
| 147 |
+
@property
|
| 148 |
+
def needs_resize(self) -> bool:
|
| 149 |
+
return self.frame_w != self.target_w or self.frame_h != self.target_h
|
| 150 |
+
|
| 151 |
+
def __repr__(self) -> str:
|
| 152 |
+
return (
|
| 153 |
+
f"CropRegion(frame=({self.frame_x},{self.frame_y},"
|
| 154 |
+
f"{self.frame_w}x{self.frame_h}), "
|
| 155 |
+
f"target={self.target_w}x{self.target_h}, "
|
| 156 |
+
f"mask={self.mask_bbox})"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
# Public API
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
def mask_to_bbox(mask: np.ndarray) -> BBox:
|
| 165 |
+
"""
|
| 166 |
+
Convert a binary mask (H x W uint8, 255=drawn) to a tight BBox.
|
| 167 |
+
|
| 168 |
+
Parameters
|
| 169 |
+
----------
|
| 170 |
+
mask : np.ndarray
|
| 171 |
+
Single-channel mask, dtype uint8. Non-zero pixels = drawn area.
|
| 172 |
+
|
| 173 |
+
Returns
|
| 174 |
+
-------
|
| 175 |
+
BBox
|
| 176 |
+
Tight bounding box around all non-zero pixels.
|
| 177 |
+
|
| 178 |
+
Raises
|
| 179 |
+
------
|
| 180 |
+
ValueError
|
| 181 |
+
If the mask contains no drawn pixels.
|
| 182 |
+
"""
|
| 183 |
+
if mask.ndim == 3:
|
| 184 |
+
# Accept RGB/RGBA β collapse to single channel
|
| 185 |
+
mask = mask.max(axis=2)
|
| 186 |
+
|
| 187 |
+
ys, xs = np.where(mask > 0)
|
| 188 |
+
if len(xs) == 0:
|
| 189 |
+
raise ValueError(
|
| 190 |
+
"The mask has no drawn pixels. "
|
| 191 |
+
"Please draw around the watermark before processing."
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
return BBox(
|
| 195 |
+
x1=int(xs.min()),
|
| 196 |
+
y1=int(ys.min()),
|
| 197 |
+
x2=int(xs.max()),
|
| 198 |
+
y2=int(ys.max()),
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def find_target_resolution(
|
| 203 |
+
required_w: int,
|
| 204 |
+
required_h: int,
|
| 205 |
+
) -> Tuple[int, int]:
|
| 206 |
+
"""
|
| 207 |
+
Find the smallest VACE-compatible (width, height) pair such that
|
| 208 |
+
width >= required_w AND height >= required_h.
|
| 209 |
+
|
| 210 |
+
The algorithm iterates the catalogue (sorted by area ascending) and
|
| 211 |
+
picks the first entry where both width >= required_w AND height >= required_h.
|
| 212 |
+
|
| 213 |
+
If no resolution in the catalogue fits, the function rounds up both
|
| 214 |
+
dimensions to the next multiple of 32 and returns them β effectively
|
| 215 |
+
a custom resolution that VACE handles via its flexible attention.
|
| 216 |
+
|
| 217 |
+
Parameters
|
| 218 |
+
----------
|
| 219 |
+
required_w, required_h : int
|
| 220 |
+
Minimum pixel dimensions needed (the crop area with context padding).
|
| 221 |
+
|
| 222 |
+
Returns
|
| 223 |
+
-------
|
| 224 |
+
(target_w, target_h) : Tuple[int, int]
|
| 225 |
+
VACE resolution to use. Always >= (required_w, required_h).
|
| 226 |
+
"""
|
| 227 |
+
# VACE_RESOLUTIONS is sorted ascending by area; the first entry that
|
| 228 |
+
# satisfies both constraints is therefore the minimum-area fit.
|
| 229 |
+
best = next(
|
| 230 |
+
((w, h) for (w, h) in VACE_RESOLUTIONS if w >= required_w and h >= required_h),
|
| 231 |
+
None,
|
| 232 |
+
)
|
| 233 |
+
if best is not None:
|
| 234 |
+
return best
|
| 235 |
+
|
| 236 |
+
# Fallback: round up to nearest multiple of 32
|
| 237 |
+
fallback_w = _ceil_to_multiple(required_w, 32)
|
| 238 |
+
fallback_h = _ceil_to_multiple(required_h, 32)
|
| 239 |
+
return (fallback_w, fallback_h)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def compute_crop_region(
|
| 243 |
+
watermark_bbox: BBox,
|
| 244 |
+
frame_w: int,
|
| 245 |
+
frame_h: int,
|
| 246 |
+
context_px: int = 64,
|
| 247 |
+
min_crop_dim: int = 320,
|
| 248 |
+
) -> CropRegion:
|
| 249 |
+
"""
|
| 250 |
+
Compute the fixed crop region for the entire video.
|
| 251 |
+
|
| 252 |
+
Steps
|
| 253 |
+
-----
|
| 254 |
+
1. Add ``context_px`` padding on all four sides of the watermark bbox
|
| 255 |
+
to give the model scene context.
|
| 256 |
+
2. Enforce a minimum crop dimension (``min_crop_dim``).
|
| 257 |
+
3. Find the smallest VACE-compatible resolution that fits the padded bbox.
|
| 258 |
+
4. Centre the crop on the watermark centroid.
|
| 259 |
+
5. Shift the crop (without resizing) if it would overflow the frame edge.
|
| 260 |
+
6. Express the watermark mask in crop-local coordinates.
|
| 261 |
+
|
| 262 |
+
Parameters
|
| 263 |
+
----------
|
| 264 |
+
watermark_bbox : BBox
|
| 265 |
+
Tight bounding box of the drawn watermark mask, in full-frame pixels.
|
| 266 |
+
frame_w, frame_h : int
|
| 267 |
+
Full frame dimensions (e.g. 1920 x 1080).
|
| 268 |
+
context_px : int
|
| 269 |
+
How many pixels of context to add around the watermark on each side.
|
| 270 |
+
Default 64 gives ample scene context for the model.
|
| 271 |
+
min_crop_dim : int
|
| 272 |
+
Smallest allowed crop dimension. Prevents tiny crops (e.g. a 10px
|
| 273 |
+
watermark) from confusing the model.
|
| 274 |
+
|
| 275 |
+
Returns
|
| 276 |
+
-------
|
| 277 |
+
CropRegion
|
| 278 |
+
Fully resolved crop spec, ready for use by video.py and composite.py.
|
| 279 |
+
|
| 280 |
+
Raises
|
| 281 |
+
------
|
| 282 |
+
ValueError
|
| 283 |
+
If the watermark bbox is larger than the full frame (sanity check).
|
| 284 |
+
"""
|
| 285 |
+
if watermark_bbox.width > frame_w or watermark_bbox.height > frame_h:
|
| 286 |
+
raise ValueError(
|
| 287 |
+
f"Watermark bbox {watermark_bbox} is larger than the frame "
|
| 288 |
+
f"({frame_w}x{frame_h}). Check your mask."
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
# ------------------------------------------------------------------
|
| 292 |
+
# 1. Padded required dimensions
|
| 293 |
+
# ------------------------------------------------------------------
|
| 294 |
+
required_w = max(watermark_bbox.width + 2 * context_px, min_crop_dim)
|
| 295 |
+
required_h = max(watermark_bbox.height + 2 * context_px, min_crop_dim)
|
| 296 |
+
|
| 297 |
+
# Clamp: the crop can never exceed the frame itself
|
| 298 |
+
required_w = min(required_w, frame_w)
|
| 299 |
+
required_h = min(required_h, frame_h)
|
| 300 |
+
|
| 301 |
+
# ------------------------------------------------------------------
|
| 302 |
+
# 2. VACE target resolution
|
| 303 |
+
# ------------------------------------------------------------------
|
| 304 |
+
target_w, target_h = find_target_resolution(required_w, required_h)
|
| 305 |
+
|
| 306 |
+
# Again clamp target to frame size (no point upscaling beyond source)
|
| 307 |
+
target_w = min(target_w, frame_w)
|
| 308 |
+
target_h = min(target_h, frame_h)
|
| 309 |
+
|
| 310 |
+
# Round down to multiple of 32 after clamping
|
| 311 |
+
target_w = _floor_to_multiple(target_w, 32)
|
| 312 |
+
target_h = _floor_to_multiple(target_h, 32)
|
| 313 |
+
|
| 314 |
+
# ------------------------------------------------------------------
|
| 315 |
+
# 3. Centre on watermark centroid, then clamp to frame bounds
|
| 316 |
+
# ------------------------------------------------------------------
|
| 317 |
+
crop_x, crop_y = _centre_crop(
|
| 318 |
+
cx=watermark_bbox.cx,
|
| 319 |
+
cy=watermark_bbox.cy,
|
| 320 |
+
crop_w=target_w,
|
| 321 |
+
crop_h=target_h,
|
| 322 |
+
frame_w=frame_w,
|
| 323 |
+
frame_h=frame_h,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
# ------------------------------------------------------------------
|
| 327 |
+
# 4. Mask in crop-local coordinates
|
| 328 |
+
# ------------------------------------------------------------------
|
| 329 |
+
local_bbox = BBox(
|
| 330 |
+
x1=watermark_bbox.x1 - crop_x,
|
| 331 |
+
y1=watermark_bbox.y1 - crop_y,
|
| 332 |
+
x2=watermark_bbox.x2 - crop_x,
|
| 333 |
+
y2=watermark_bbox.y2 - crop_y,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
return CropRegion(
|
| 337 |
+
frame_x=crop_x,
|
| 338 |
+
frame_y=crop_y,
|
| 339 |
+
frame_w=target_w,
|
| 340 |
+
frame_h=target_h,
|
| 341 |
+
target_w=target_w,
|
| 342 |
+
target_h=target_h,
|
| 343 |
+
mask_bbox=local_bbox,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def build_inpaint_mask(
|
| 348 |
+
crop_region: CropRegion,
|
| 349 |
+
source_mask: np.ndarray | None = None,
|
| 350 |
+
dilate_px: int = 6,
|
| 351 |
+
) -> np.ndarray:
|
| 352 |
+
"""
|
| 353 |
+
Build the binary inpainting mask in crop-local coordinates.
|
| 354 |
+
|
| 355 |
+
If ``source_mask`` is provided (a full-frame mask from the user's drawing),
|
| 356 |
+
it is cropped to the crop region. Otherwise, the mask is synthesised from
|
| 357 |
+
``crop_region.mask_bbox`` (i.e. a filled rectangle).
|
| 358 |
+
|
| 359 |
+
The mask is then dilated by ``dilate_px`` pixels to prevent edge artefacts.
|
| 360 |
+
|
| 361 |
+
Parameters
|
| 362 |
+
----------
|
| 363 |
+
crop_region : CropRegion
|
| 364 |
+
source_mask : np.ndarray, optional
|
| 365 |
+
Full-frame binary mask (H x W uint8), 255=inpaint. If None, uses bbox.
|
| 366 |
+
dilate_px : int
|
| 367 |
+
Dilation radius in pixels. Default 6.
|
| 368 |
+
|
| 369 |
+
Returns
|
| 370 |
+
-------
|
| 371 |
+
np.ndarray
|
| 372 |
+
Crop-local mask (target_h x target_w, uint8). 255=inpaint, 0=keep.
|
| 373 |
+
"""
|
| 374 |
+
h, w = crop_region.frame_h, crop_region.frame_w
|
| 375 |
+
mask = np.zeros((h, w), dtype=np.uint8)
|
| 376 |
+
|
| 377 |
+
if source_mask is not None:
|
| 378 |
+
# Collapse to single channel if needed
|
| 379 |
+
if source_mask.ndim == 3:
|
| 380 |
+
source_mask = source_mask.max(axis=2)
|
| 381 |
+
# Crop to the crop region
|
| 382 |
+
y1 = crop_region.frame_y
|
| 383 |
+
y2 = crop_region.frame_y + crop_region.frame_h
|
| 384 |
+
x1 = crop_region.frame_x
|
| 385 |
+
x2 = crop_region.frame_x + crop_region.frame_w
|
| 386 |
+
mask = source_mask[y1:y2, x1:x2].copy()
|
| 387 |
+
else:
|
| 388 |
+
# Fill the watermark bbox rectangle
|
| 389 |
+
b = crop_region.mask_bbox
|
| 390 |
+
mask[b.y1 : b.y2 + 1, b.x1 : b.x2 + 1] = 255
|
| 391 |
+
|
| 392 |
+
# Dilate
|
| 393 |
+
if dilate_px > 0:
|
| 394 |
+
mask = _dilate_mask(mask, dilate_px)
|
| 395 |
+
|
| 396 |
+
return mask
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# ---------------------------------------------------------------------------
|
| 400 |
+
# Private helpers
|
| 401 |
+
# ---------------------------------------------------------------------------
|
| 402 |
+
|
| 403 |
+
def _centre_crop(
|
| 404 |
+
cx: float,
|
| 405 |
+
cy: float,
|
| 406 |
+
crop_w: int,
|
| 407 |
+
crop_h: int,
|
| 408 |
+
frame_w: int,
|
| 409 |
+
frame_h: int,
|
| 410 |
+
) -> Tuple[int, int]:
|
| 411 |
+
"""
|
| 412 |
+
Compute top-left (x, y) of a crop_w x crop_h rectangle centred at
|
| 413 |
+
(cx, cy), then clamp so the rectangle stays within the frame.
|
| 414 |
+
|
| 415 |
+
Clamping shifts asymmetrically β the crop size never changes.
|
| 416 |
+
"""
|
| 417 |
+
x = int(round(cx - crop_w / 2.0))
|
| 418 |
+
y = int(round(cy - crop_h / 2.0))
|
| 419 |
+
|
| 420 |
+
# Clamp: do not go off any edge
|
| 421 |
+
x = max(0, min(x, frame_w - crop_w))
|
| 422 |
+
y = max(0, min(y, frame_h - crop_h))
|
| 423 |
+
|
| 424 |
+
return x, y
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _ceil_to_multiple(value: int, multiple: int) -> int:
|
| 428 |
+
return math.ceil(value / multiple) * multiple
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _floor_to_multiple(value: int, multiple: int) -> int:
|
| 432 |
+
return (value // multiple) * multiple
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def _dilate_mask(mask: np.ndarray, radius: int) -> np.ndarray:
|
| 436 |
+
"""
|
| 437 |
+
Dilate a binary mask using a square structuring element of side
|
| 438 |
+
(2*radius + 1). Uses scipy.ndimage for correctness without requiring cv2.
|
| 439 |
+
|
| 440 |
+
For the typical 4-8 px dilation used here this is fast enough.
|
| 441 |
+
"""
|
| 442 |
+
from scipy.ndimage import binary_dilation # lazy import
|
| 443 |
+
|
| 444 |
+
struct = np.ones((2 * radius + 1, 2 * radius + 1), dtype=bool)
|
| 445 |
+
binary = mask > 0
|
| 446 |
+
dilated = binary_dilation(binary, structure=struct)
|
| 447 |
+
return (dilated * 255).astype(np.uint8)
|
pipeline/lama.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/lama.py
|
| 3 |
+
----------------
|
| 4 |
+
Fast mode: per-frame LaMa inpainting.
|
| 5 |
+
|
| 6 |
+
Uses the `simple-lama-inpainting` PyPI package, which wraps the
|
| 7 |
+
Resolution-robust Large Mask inpainting (LaMa) model. Runs on CPU
|
| 8 |
+
or GPU without requiring a @spaces.GPU call β negligible quota burn.
|
| 9 |
+
|
| 10 |
+
Pipeline for each frame:
|
| 11 |
+
1. Crop to the CropRegion.
|
| 12 |
+
2. Run LaMa on the crop with the dilated inpaint mask.
|
| 13 |
+
3. Return the inpainted crop; compositing is handled by composite.py.
|
| 14 |
+
|
| 15 |
+
License: LaMa is Apache 2.0.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import List
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
from PIL import Image
|
| 25 |
+
|
| 26 |
+
from pipeline.crop import CropRegion
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# Model singleton
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Loaded lazily on first call; shared across all frames in a run.
|
| 33 |
+
# Tracks the device it was loaded on β reloads if GPU becomes available
|
| 34 |
+
# after a cold CPU-only initialisation (ZeroGPU warm/cold start handling).
|
| 35 |
+
_lama_model = None
|
| 36 |
+
_lama_device: str | None = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _get_model():
|
| 40 |
+
global _lama_model, _lama_device
|
| 41 |
+
import torch
|
| 42 |
+
current_device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 43 |
+
# One-way latch: only reload when *upgrading* from CPU to GPU.
|
| 44 |
+
# After the @spaces.GPU call ends the process returns to CPU, but we keep
|
| 45 |
+
# the model reference β it will be valid again on the next GPU allocation.
|
| 46 |
+
# Reloading on every cpuβcuda transition (once per cold start) is correct;
|
| 47 |
+
# reloading on cudaβcpu would double the startup cost for no benefit.
|
| 48 |
+
if _lama_model is None or (current_device == "cuda" and _lama_device != "cuda"):
|
| 49 |
+
from simple_lama_inpainting import SimpleLama # type: ignore
|
| 50 |
+
_lama_model = SimpleLama()
|
| 51 |
+
_lama_device = current_device
|
| 52 |
+
return _lama_model
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Public API
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
def inpaint_frames_lama(
|
| 60 |
+
frame_paths: List[Path],
|
| 61 |
+
crop_region: CropRegion,
|
| 62 |
+
inpaint_mask: np.ndarray,
|
| 63 |
+
progress_fn=None,
|
| 64 |
+
) -> List[np.ndarray]:
|
| 65 |
+
"""
|
| 66 |
+
Run LaMa inpainting on the crop region of each frame.
|
| 67 |
+
|
| 68 |
+
Parameters
|
| 69 |
+
----------
|
| 70 |
+
frame_paths : List[Path]
|
| 71 |
+
Ordered list of full-frame PNG paths.
|
| 72 |
+
crop_region : CropRegion
|
| 73 |
+
Defines the rectangle to extract from each frame.
|
| 74 |
+
inpaint_mask : np.ndarray
|
| 75 |
+
Crop-local binary mask (H x W, uint8). 255=inpaint, 0=keep.
|
| 76 |
+
Must match crop_region dimensions.
|
| 77 |
+
progress_fn : callable, optional
|
| 78 |
+
Called as ``progress_fn(i)`` after each frame where i is the
|
| 79 |
+
0-based frame index. Use for Gradio progress reporting.
|
| 80 |
+
|
| 81 |
+
Returns
|
| 82 |
+
-------
|
| 83 |
+
List[np.ndarray]
|
| 84 |
+
List of inpainted crop images (H x W x 3, uint8 RGB),
|
| 85 |
+
one per input frame. Full-frame compositing is done in composite.py.
|
| 86 |
+
"""
|
| 87 |
+
model = _get_model()
|
| 88 |
+
# Create mask PIL image once β it is identical for every frame
|
| 89 |
+
mask_pil = _mask_to_pil(inpaint_mask)
|
| 90 |
+
results: List[np.ndarray] = []
|
| 91 |
+
|
| 92 |
+
for i, frame_path in enumerate(frame_paths):
|
| 93 |
+
crop_np = _load_crop(frame_path, crop_region)
|
| 94 |
+
crop_pil = Image.fromarray(crop_np)
|
| 95 |
+
|
| 96 |
+
# simple-lama-inpainting expects (image: PIL.Image, mask: PIL.Image)
|
| 97 |
+
# mask must be mode "L": 255=inpaint, 0=keep
|
| 98 |
+
inpainted_pil: Image.Image = model(crop_pil, mask_pil)
|
| 99 |
+
results.append(np.array(inpainted_pil.convert("RGB")))
|
| 100 |
+
|
| 101 |
+
if progress_fn is not None:
|
| 102 |
+
progress_fn(i)
|
| 103 |
+
|
| 104 |
+
return results
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def inpaint_image_lama(
|
| 108 |
+
image: np.ndarray,
|
| 109 |
+
crop_region: CropRegion,
|
| 110 |
+
inpaint_mask: np.ndarray,
|
| 111 |
+
) -> np.ndarray:
|
| 112 |
+
"""
|
| 113 |
+
Run LaMa on a single already-loaded image array (H x W x 3 uint8 RGB).
|
| 114 |
+
Convenience wrapper used by the preview step in app.py.
|
| 115 |
+
|
| 116 |
+
Returns the inpainted crop (crop_region dimensions, RGB uint8).
|
| 117 |
+
"""
|
| 118 |
+
model = _get_model()
|
| 119 |
+
mask_pil = _mask_to_pil(inpaint_mask)
|
| 120 |
+
|
| 121 |
+
cr = crop_region
|
| 122 |
+
crop_np = image[
|
| 123 |
+
cr.frame_y : cr.frame_y + cr.frame_h,
|
| 124 |
+
cr.frame_x : cr.frame_x + cr.frame_w,
|
| 125 |
+
]
|
| 126 |
+
inpainted_pil: Image.Image = model(Image.fromarray(crop_np), mask_pil)
|
| 127 |
+
return np.array(inpainted_pil.convert("RGB"))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Private helpers
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
def _load_crop(frame_path: Path, crop_region: CropRegion) -> np.ndarray:
|
| 135 |
+
"""Load a frame and return only the crop region (RGB uint8)."""
|
| 136 |
+
img = Image.open(frame_path).convert("RGB")
|
| 137 |
+
cr = crop_region
|
| 138 |
+
# PIL box is (left, upper, right, lower)
|
| 139 |
+
box = (
|
| 140 |
+
cr.frame_x,
|
| 141 |
+
cr.frame_y,
|
| 142 |
+
cr.frame_x + cr.frame_w,
|
| 143 |
+
cr.frame_y + cr.frame_h,
|
| 144 |
+
)
|
| 145 |
+
return np.array(img.crop(box))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _mask_to_pil(mask: np.ndarray) -> Image.Image:
|
| 149 |
+
"""Convert a uint8 numpy mask to a PIL L-mode image for LaMa."""
|
| 150 |
+
return Image.fromarray(mask, mode="L")
|
pipeline/vace.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/vace.py
|
| 3 |
+
----------------
|
| 4 |
+
Quality mode: VACE-14B video inpainting via Wan2.1-VACE-14B-diffusers.
|
| 5 |
+
|
| 6 |
+
STUB β implemented in the next iteration after pipeline validation with LaMa.
|
| 7 |
+
|
| 8 |
+
Planned implementation:
|
| 9 |
+
- WanVACEPipeline from diffusers
|
| 10 |
+
- FP8 quantization via torchao
|
| 11 |
+
- AoT compilation for speed
|
| 12 |
+
- 8-step inference (step-distilled schedule)
|
| 13 |
+
- Temporal chunking: split frame list into ~33-frame windows, run each
|
| 14 |
+
chunk with overlapping context frames to avoid seam artefacts
|
| 15 |
+
- Memory: model.enable_model_cpu_offload() + torch.cuda.empty_cache()
|
| 16 |
+
between chunks
|
| 17 |
+
- @spaces.GPU(duration=200) decorator on the main entry function
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import List
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
from pipeline.crop import CropRegion
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def inpaint_frames_vace(
|
| 31 |
+
frame_paths: List[Path],
|
| 32 |
+
crop_region: CropRegion,
|
| 33 |
+
inpaint_mask: np.ndarray,
|
| 34 |
+
num_inference_steps: int = 8,
|
| 35 |
+
guidance_scale: float = 5.0,
|
| 36 |
+
) -> List[np.ndarray]:
|
| 37 |
+
"""
|
| 38 |
+
Run VACE-14B inpainting on the crop region of each frame.
|
| 39 |
+
|
| 40 |
+
Parameters
|
| 41 |
+
----------
|
| 42 |
+
frame_paths : List[Path]
|
| 43 |
+
Ordered full-frame PNG paths.
|
| 44 |
+
crop_region : CropRegion
|
| 45 |
+
inpaint_mask : np.ndarray
|
| 46 |
+
Crop-local binary mask (H x W, uint8). 255=inpaint.
|
| 47 |
+
num_inference_steps : int
|
| 48 |
+
Default 8 for step-distilled fast inference.
|
| 49 |
+
guidance_scale : float
|
| 50 |
+
|
| 51 |
+
Returns
|
| 52 |
+
-------
|
| 53 |
+
List[np.ndarray]
|
| 54 |
+
Inpainted crop arrays (H x W x 3, uint8 RGB), one per frame.
|
| 55 |
+
"""
|
| 56 |
+
raise NotImplementedError(
|
| 57 |
+
"VACE-14B pipeline is not yet implemented. "
|
| 58 |
+
"Use Fast (LaMa) mode for now."
|
| 59 |
+
)
|
pipeline/video.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline/video.py
|
| 3 |
+
-----------------
|
| 4 |
+
FFmpeg I/O wrappers for the watermark removal pipeline.
|
| 5 |
+
|
| 6 |
+
Responsibilities:
|
| 7 |
+
- Extract frames from input video to a temp directory (PNG, lossless).
|
| 8 |
+
- Probe frame rate, resolution, and color metadata from source.
|
| 9 |
+
- Reassemble inpainted frames into a no-audio video.
|
| 10 |
+
- Reattach the original audio stream (simple copy remux).
|
| 11 |
+
- Passthrough V-Log / HLG / HDR color metadata flags.
|
| 12 |
+
|
| 13 |
+
All FFmpeg calls raise RuntimeError on non-zero exit so callers can surface
|
| 14 |
+
clean error messages to the Gradio UI.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import shutil
|
| 22 |
+
import subprocess
|
| 23 |
+
import tempfile
|
| 24 |
+
from dataclasses import dataclass
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import List, Optional
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# Video metadata
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class VideoMeta:
|
| 35 |
+
"""Probed properties of the input video."""
|
| 36 |
+
width: int
|
| 37 |
+
height: int
|
| 38 |
+
fps: float
|
| 39 |
+
frame_count: int
|
| 40 |
+
duration_s: float
|
| 41 |
+
# FFmpeg color metadata strings (may be None if not present)
|
| 42 |
+
color_primaries: Optional[str]
|
| 43 |
+
color_trc: Optional[str] # transfer characteristics (e.g. "arib-std-b67" for HLG, "bt709")
|
| 44 |
+
colorspace: Optional[str] # YCbCr matrix (e.g. "bt2020nc")
|
| 45 |
+
color_range: Optional[str] # "tv" or "pc"
|
| 46 |
+
# Raw codec name, e.g. "h264"
|
| 47 |
+
codec_name: str
|
| 48 |
+
# Bit depth per channel. 8 = standard SDR; 10 = V-Log / HLG / HDR10.
|
| 49 |
+
# Default 8 so existing callers that omit this field keep working.
|
| 50 |
+
bit_depth: int = 8
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def probe(video_path: str | Path) -> VideoMeta:
|
| 54 |
+
"""
|
| 55 |
+
Run ffprobe on *video_path* and return a VideoMeta.
|
| 56 |
+
|
| 57 |
+
Parameters
|
| 58 |
+
----------
|
| 59 |
+
video_path : str | Path
|
| 60 |
+
|
| 61 |
+
Returns
|
| 62 |
+
-------
|
| 63 |
+
VideoMeta
|
| 64 |
+
|
| 65 |
+
Raises
|
| 66 |
+
------
|
| 67 |
+
RuntimeError
|
| 68 |
+
If ffprobe fails or the file has no video stream.
|
| 69 |
+
"""
|
| 70 |
+
cmd = [
|
| 71 |
+
"ffprobe",
|
| 72 |
+
"-v", "quiet",
|
| 73 |
+
"-print_format", "json",
|
| 74 |
+
"-show_streams",
|
| 75 |
+
"-show_format",
|
| 76 |
+
str(video_path),
|
| 77 |
+
]
|
| 78 |
+
result = _run(cmd)
|
| 79 |
+
data = json.loads(result.stdout)
|
| 80 |
+
|
| 81 |
+
# Find the first video stream
|
| 82 |
+
video_stream = next(
|
| 83 |
+
(s for s in data.get("streams", []) if s.get("codec_type") == "video"),
|
| 84 |
+
None,
|
| 85 |
+
)
|
| 86 |
+
if video_stream is None:
|
| 87 |
+
raise RuntimeError(f"No video stream found in {video_path}")
|
| 88 |
+
|
| 89 |
+
# Parse frame rate (stored as "num/den" string)
|
| 90 |
+
fps = _parse_rational(video_stream.get("r_frame_rate", "30/1"))
|
| 91 |
+
|
| 92 |
+
# Duration: prefer stream-level, fall back to format-level
|
| 93 |
+
dur_str = video_stream.get("duration") or data.get("format", {}).get("duration", "0")
|
| 94 |
+
duration_s = float(dur_str)
|
| 95 |
+
|
| 96 |
+
# Frame count
|
| 97 |
+
nb_frames = video_stream.get("nb_frames")
|
| 98 |
+
if nb_frames and nb_frames != "N/A":
|
| 99 |
+
frame_count = int(nb_frames)
|
| 100 |
+
else:
|
| 101 |
+
frame_count = max(1, round(duration_s * fps))
|
| 102 |
+
|
| 103 |
+
# Bit depth: prefer bits_per_raw_sample; fall back to pixel format name.
|
| 104 |
+
# bits_per_raw_sample is "0" or missing for most H.264 sources.
|
| 105 |
+
bits_raw = video_stream.get("bits_per_raw_sample") or "0"
|
| 106 |
+
pix_fmt_val = video_stream.get("pix_fmt", "")
|
| 107 |
+
|
| 108 |
+
def _bd_from_pix_fmt() -> int:
|
| 109 |
+
return 10 if any(d in pix_fmt_val for d in ("10le", "10be", "12le", "12be")) else 8
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
bd = int(bits_raw)
|
| 113 |
+
bit_depth = bd if bd > 0 else _bd_from_pix_fmt()
|
| 114 |
+
except (ValueError, TypeError):
|
| 115 |
+
bit_depth = _bd_from_pix_fmt()
|
| 116 |
+
|
| 117 |
+
return VideoMeta(
|
| 118 |
+
width=int(video_stream["width"]),
|
| 119 |
+
height=int(video_stream["height"]),
|
| 120 |
+
fps=fps,
|
| 121 |
+
frame_count=frame_count,
|
| 122 |
+
duration_s=duration_s,
|
| 123 |
+
color_primaries=video_stream.get("color_primaries"),
|
| 124 |
+
color_trc=video_stream.get("color_transfer"),
|
| 125 |
+
colorspace=video_stream.get("color_space"),
|
| 126 |
+
color_range=video_stream.get("color_range"),
|
| 127 |
+
codec_name=video_stream.get("codec_name", "unknown"),
|
| 128 |
+
bit_depth=bit_depth,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
# Frame extraction
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
|
| 136 |
+
def extract_frames(
|
| 137 |
+
video_path: str | Path,
|
| 138 |
+
out_dir: str | Path,
|
| 139 |
+
pattern: str = "%06d.png",
|
| 140 |
+
fps: float | None = None,
|
| 141 |
+
) -> List[Path]:
|
| 142 |
+
"""
|
| 143 |
+
Extract every frame from *video_path* as PNG images into *out_dir*.
|
| 144 |
+
|
| 145 |
+
Uses lossless PNG so that frame quality is preserved for inpainting.
|
| 146 |
+
When *fps* is provided, a ``-vf fps=<n>`` filter forces constant frame rate
|
| 147 |
+
output, which is required for correct audio sync on VFR sources.
|
| 148 |
+
|
| 149 |
+
Parameters
|
| 150 |
+
----------
|
| 151 |
+
video_path : str | Path
|
| 152 |
+
Source video file.
|
| 153 |
+
out_dir : str | Path
|
| 154 |
+
Directory to write frames into. Created if it does not exist.
|
| 155 |
+
pattern : str
|
| 156 |
+
Output filename pattern, e.g. "%06d.png".
|
| 157 |
+
fps : float, optional
|
| 158 |
+
If given, force constant-rate output at this rate. Pass
|
| 159 |
+
``VideoMeta.fps`` to guarantee A/V sync on reassembly.
|
| 160 |
+
|
| 161 |
+
Returns
|
| 162 |
+
-------
|
| 163 |
+
List[Path]
|
| 164 |
+
Sorted list of extracted frame paths.
|
| 165 |
+
"""
|
| 166 |
+
out_dir = Path(out_dir)
|
| 167 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 168 |
+
|
| 169 |
+
cmd = [
|
| 170 |
+
"ffmpeg",
|
| 171 |
+
"-y",
|
| 172 |
+
"-i", str(video_path),
|
| 173 |
+
]
|
| 174 |
+
if fps is not None:
|
| 175 |
+
# Force constant frame rate; handles VFR sources without re-encoding
|
| 176 |
+
# by inserting duplicate frames where the source skips.
|
| 177 |
+
cmd += ["-vf", f"fps={_fps_str(fps)}"]
|
| 178 |
+
else:
|
| 179 |
+
cmd += ["-vsync", "0"] # passthrough for CFR sources (no dup/drop)
|
| 180 |
+
|
| 181 |
+
cmd.append(str(out_dir / pattern))
|
| 182 |
+
_run(cmd)
|
| 183 |
+
|
| 184 |
+
frames = sorted(out_dir.glob("*.png"), key=lambda p: int(p.stem))
|
| 185 |
+
if not frames:
|
| 186 |
+
raise RuntimeError(f"No frames extracted from {video_path} into {out_dir}")
|
| 187 |
+
return frames
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def extract_first_frame(video_path: str | Path, out_path: str | Path) -> Path:
|
| 191 |
+
"""
|
| 192 |
+
Extract only the first frame, e.g. for mask drawing in the UI.
|
| 193 |
+
|
| 194 |
+
Parameters
|
| 195 |
+
----------
|
| 196 |
+
video_path : str | Path
|
| 197 |
+
out_path : str | Path
|
| 198 |
+
Path to write the PNG (parent directory must exist).
|
| 199 |
+
|
| 200 |
+
Returns
|
| 201 |
+
-------
|
| 202 |
+
Path
|
| 203 |
+
Same as out_path, now guaranteed to exist.
|
| 204 |
+
"""
|
| 205 |
+
out_path = Path(out_path)
|
| 206 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 207 |
+
|
| 208 |
+
cmd = [
|
| 209 |
+
"ffmpeg",
|
| 210 |
+
"-y",
|
| 211 |
+
"-i", str(video_path),
|
| 212 |
+
"-frames:v", "1",
|
| 213 |
+
"-update", "1",
|
| 214 |
+
str(out_path),
|
| 215 |
+
]
|
| 216 |
+
_run(cmd)
|
| 217 |
+
|
| 218 |
+
if not out_path.exists():
|
| 219 |
+
raise RuntimeError(f"First frame extraction failed for {video_path}")
|
| 220 |
+
return out_path
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# ---------------------------------------------------------------------------
|
| 224 |
+
# Frame reassembly
|
| 225 |
+
# ---------------------------------------------------------------------------
|
| 226 |
+
|
| 227 |
+
def frames_to_video(
|
| 228 |
+
frames_dir: str | Path,
|
| 229 |
+
out_path: str | Path,
|
| 230 |
+
meta: VideoMeta,
|
| 231 |
+
pattern: str = "%06d.png",
|
| 232 |
+
crf: int = 16,
|
| 233 |
+
) -> Path:
|
| 234 |
+
"""
|
| 235 |
+
Reassemble a directory of inpainted PNG frames into an H.264 (8-bit) or
|
| 236 |
+
H.265 (10-bit) video, passing through V-Log / HDR colour metadata.
|
| 237 |
+
|
| 238 |
+
No audio is embedded here β use :func:`attach_audio` afterwards.
|
| 239 |
+
|
| 240 |
+
Parameters
|
| 241 |
+
----------
|
| 242 |
+
frames_dir : str | Path
|
| 243 |
+
Directory containing sequentially named PNG frames.
|
| 244 |
+
out_path : str | Path
|
| 245 |
+
Output .mp4 path.
|
| 246 |
+
meta : VideoMeta
|
| 247 |
+
Source video metadata (for fps and color flags).
|
| 248 |
+
pattern : str
|
| 249 |
+
Input filename pattern matching the PNGs.
|
| 250 |
+
crf : int
|
| 251 |
+
H.264 CRF quality level. 16 = near-lossless; increase for smaller files.
|
| 252 |
+
|
| 253 |
+
Returns
|
| 254 |
+
-------
|
| 255 |
+
Path
|
| 256 |
+
Path to the written output video.
|
| 257 |
+
"""
|
| 258 |
+
out_path = Path(out_path)
|
| 259 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 260 |
+
|
| 261 |
+
# Build color metadata flags β pass through whatever the source had
|
| 262 |
+
color_flags: list[str] = []
|
| 263 |
+
if meta.color_primaries and meta.color_primaries != "unknown":
|
| 264 |
+
color_flags += ["-color_primaries", meta.color_primaries]
|
| 265 |
+
if meta.color_trc and meta.color_trc != "unknown":
|
| 266 |
+
color_flags += ["-color_trc", meta.color_trc]
|
| 267 |
+
if meta.colorspace and meta.colorspace != "unknown":
|
| 268 |
+
color_flags += ["-colorspace", meta.colorspace]
|
| 269 |
+
if meta.color_range and meta.color_range != "unknown":
|
| 270 |
+
color_flags += ["-color_range", meta.color_range]
|
| 271 |
+
|
| 272 |
+
# Choose codec + pixel format based on source bit depth.
|
| 273 |
+
# libx264 is 8-bit only β using it on 10-bit V-Log footage silently
|
| 274 |
+
# quantises every pixel value, destroying the HDR tonal range.
|
| 275 |
+
if meta.bit_depth >= 10:
|
| 276 |
+
vid_codec, pix_fmt = "libx265", "yuv420p10le"
|
| 277 |
+
# hvc1 tag makes HEVC readable by QuickTime / Safari / iOS.
|
| 278 |
+
# Only valid for MP4/MOV containers β skip for MKV etc.
|
| 279 |
+
mp4_like = Path(str(out_path)).suffix.lower() in (".mp4", ".mov")
|
| 280 |
+
extra_codec_flags: list[str] = ["-tag:v", "hvc1"] if mp4_like else []
|
| 281 |
+
else:
|
| 282 |
+
vid_codec, pix_fmt = "libx264", "yuv420p"
|
| 283 |
+
extra_codec_flags = []
|
| 284 |
+
|
| 285 |
+
cmd = [
|
| 286 |
+
"ffmpeg",
|
| 287 |
+
"-y",
|
| 288 |
+
"-framerate", _fps_str(meta.fps),
|
| 289 |
+
"-i", str(Path(frames_dir) / pattern),
|
| 290 |
+
"-c:v", vid_codec,
|
| 291 |
+
"-preset", "slow",
|
| 292 |
+
"-crf", str(crf),
|
| 293 |
+
"-pix_fmt", pix_fmt,
|
| 294 |
+
*extra_codec_flags,
|
| 295 |
+
*color_flags,
|
| 296 |
+
str(out_path),
|
| 297 |
+
]
|
| 298 |
+
_run(cmd)
|
| 299 |
+
return out_path
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def attach_audio(
|
| 303 |
+
source_video: str | Path,
|
| 304 |
+
silent_video: str | Path,
|
| 305 |
+
out_path: str | Path,
|
| 306 |
+
) -> Path:
|
| 307 |
+
"""
|
| 308 |
+
Remux audio from *source_video* into *silent_video* (copy both streams,
|
| 309 |
+
no re-encode).
|
| 310 |
+
|
| 311 |
+
If the source has no audio stream the silent video is simply copied
|
| 312 |
+
to *out_path* unchanged.
|
| 313 |
+
|
| 314 |
+
Parameters
|
| 315 |
+
----------
|
| 316 |
+
source_video : str | Path
|
| 317 |
+
Original video (audio source).
|
| 318 |
+
silent_video : str | Path
|
| 319 |
+
Inpainted video without audio.
|
| 320 |
+
out_path : str | Path
|
| 321 |
+
Final output path.
|
| 322 |
+
|
| 323 |
+
Returns
|
| 324 |
+
-------
|
| 325 |
+
Path
|
| 326 |
+
"""
|
| 327 |
+
out_path = Path(out_path)
|
| 328 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 329 |
+
|
| 330 |
+
if not _has_audio(source_video):
|
| 331 |
+
shutil.copy2(str(silent_video), str(out_path))
|
| 332 |
+
return out_path
|
| 333 |
+
|
| 334 |
+
cmd = [
|
| 335 |
+
"ffmpeg",
|
| 336 |
+
"-y",
|
| 337 |
+
"-i", str(silent_video), # stream 0: video
|
| 338 |
+
"-i", str(source_video), # stream 1: audio donor
|
| 339 |
+
"-c", "copy",
|
| 340 |
+
"-map", "0:v:0",
|
| 341 |
+
"-map", "1:a:0",
|
| 342 |
+
"-shortest", # trim to shorter stream (video)
|
| 343 |
+
str(out_path),
|
| 344 |
+
]
|
| 345 |
+
_run(cmd)
|
| 346 |
+
return out_path
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ---------------------------------------------------------------------------
|
| 350 |
+
# Convenience: managed temp workspace
|
| 351 |
+
# ---------------------------------------------------------------------------
|
| 352 |
+
|
| 353 |
+
class VideoWorkspace:
|
| 354 |
+
"""
|
| 355 |
+
Context manager that creates a temporary working directory and cleans it up.
|
| 356 |
+
|
| 357 |
+
Usage::
|
| 358 |
+
|
| 359 |
+
with VideoWorkspace() as ws:
|
| 360 |
+
frames = extract_frames(video_path, ws.frames_dir)
|
| 361 |
+
...
|
| 362 |
+
out = ws.path("output_silent.mp4")
|
| 363 |
+
"""
|
| 364 |
+
|
| 365 |
+
def __init__(self, prefix: str = "wm_remove_") -> None:
|
| 366 |
+
self._prefix = prefix
|
| 367 |
+
self._tmpdir: Optional[tempfile.TemporaryDirectory] = None
|
| 368 |
+
|
| 369 |
+
def __enter__(self) -> "VideoWorkspace":
|
| 370 |
+
self._tmpdir = tempfile.TemporaryDirectory(prefix=self._prefix)
|
| 371 |
+
self.root = Path(self._tmpdir.name)
|
| 372 |
+
self.frames_dir = self.root / "frames"
|
| 373 |
+
self.out_frames_dir = self.root / "out_frames"
|
| 374 |
+
self.frames_dir.mkdir()
|
| 375 |
+
self.out_frames_dir.mkdir()
|
| 376 |
+
return self
|
| 377 |
+
|
| 378 |
+
def __exit__(self, *args) -> None:
|
| 379 |
+
if self._tmpdir:
|
| 380 |
+
self._tmpdir.cleanup()
|
| 381 |
+
|
| 382 |
+
def path(self, name: str) -> Path:
|
| 383 |
+
"""Return a path inside the workspace root."""
|
| 384 |
+
return self.root / name
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# ---------------------------------------------------------------------------
|
| 388 |
+
# Private helpers
|
| 389 |
+
# ---------------------------------------------------------------------------
|
| 390 |
+
|
| 391 |
+
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
|
| 392 |
+
"""Run a subprocess, raising RuntimeError on failure."""
|
| 393 |
+
result = subprocess.run(
|
| 394 |
+
cmd,
|
| 395 |
+
capture_output=True,
|
| 396 |
+
text=True,
|
| 397 |
+
)
|
| 398 |
+
if result.returncode != 0:
|
| 399 |
+
raise RuntimeError(
|
| 400 |
+
f"FFmpeg/ffprobe command failed (exit {result.returncode}):\n"
|
| 401 |
+
f"cmd: {' '.join(cmd)}\n"
|
| 402 |
+
f"stderr: {result.stderr[-2000:]}" # last 2k chars
|
| 403 |
+
)
|
| 404 |
+
return result
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def _parse_rational(rat: str) -> float:
|
| 408 |
+
"""Parse a 'num/den' rational string to float."""
|
| 409 |
+
parts = rat.split("/")
|
| 410 |
+
if len(parts) == 2:
|
| 411 |
+
num, den = int(parts[0]), int(parts[1])
|
| 412 |
+
return num / den if den else 0.0
|
| 413 |
+
return float(rat)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _fps_str(fps: float) -> str:
|
| 417 |
+
"""Convert fps float to a clean string for FFmpeg -framerate."""
|
| 418 |
+
# Keep common exact fractions (24000/1001, 30000/1001, etc.)
|
| 419 |
+
common = {
|
| 420 |
+
23.976: "24000/1001",
|
| 421 |
+
29.97: "30000/1001",
|
| 422 |
+
59.94: "60000/1001",
|
| 423 |
+
}
|
| 424 |
+
for approx, s in common.items():
|
| 425 |
+
if abs(fps - approx) < 0.01:
|
| 426 |
+
return s
|
| 427 |
+
return f"{fps:.6g}"
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def _has_audio(video_path: str | Path) -> bool:
|
| 431 |
+
"""Return True if the video contains at least one audio stream."""
|
| 432 |
+
cmd = [
|
| 433 |
+
"ffprobe",
|
| 434 |
+
"-v", "quiet",
|
| 435 |
+
"-print_format", "json",
|
| 436 |
+
"-show_streams",
|
| 437 |
+
"-select_streams", "a",
|
| 438 |
+
str(video_path),
|
| 439 |
+
]
|
| 440 |
+
try:
|
| 441 |
+
result = _run(cmd)
|
| 442 |
+
data = json.loads(result.stdout)
|
| 443 |
+
return bool(data.get("streams"))
|
| 444 |
+
except RuntimeError:
|
| 445 |
+
return False
|
requirements.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runtime dependencies for the Hugging Face Space
|
| 2 |
+
# Pinned ranges are intentionally loose so HF's resolver can pick compatible versions.
|
| 3 |
+
|
| 4 |
+
# ββ Core ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
gradio>=4.44.0,<5.0.0
|
| 6 |
+
numpy>=1.24.0
|
| 7 |
+
Pillow>=10.0.0
|
| 8 |
+
scipy>=1.11.0 # mask dilation in pipeline/crop.py
|
| 9 |
+
|
| 10 |
+
# ββ Fast mode (LaMa) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
simple-lama-inpainting>=0.1.2
|
| 12 |
+
|
| 13 |
+
# ββ Quality mode (VACE-14B) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
# torch / torchvision are pre-installed on ZeroGPU; do not pin here.
|
| 15 |
+
diffusers>=0.32.0
|
| 16 |
+
transformers>=4.44.0
|
| 17 |
+
accelerate>=0.33.0
|
| 18 |
+
sentencepiece>=0.1.99
|
| 19 |
+
# torchao β FP8 quantization (uncomment when implementing vace.py)
|
| 20 |
+
# torchao>=0.6.0
|
| 21 |
+
|
| 22 |
+
# ββ Video I/O βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
# ffmpeg binary is provided via packages.txt; no Python wrapper needed.
|