easy-blury / app.py
Abdo96's picture
Upload 19 files
4e782dc verified
Raw
History Blame Contribute Delete
29.5 kB
"""
Video Blur AI - Main Gradio Web Application
Text-prompted video object blurring using Grounding DINO + SAM 2
Runs locally and on Hugging Face Spaces (including ZeroGPU).
Local usage:
python app.py
Then open http://localhost:7860 in your browser.
"""
import os
import sys
import time
import tempfile
import subprocess
from pathlib import Path
# ─── ZeroGPU / Hugging Face Spaces support ─────────────────────
# `spaces` MUST be imported before torch (pulled in transitively by
# config/pipeline) so ZeroGPU can patch CUDA. Locally the package is
# absent, so we fall back to a no-op decorator that supports the same
# @spaces.GPU and @spaces.GPU(duration=...) forms.
try:
import spaces # type: ignore
_HAS_SPACES = True
except Exception:
_HAS_SPACES = False
class _SpacesShim:
@staticmethod
def GPU(*args, **kwargs):
# Used as a bare decorator: @spaces.GPU
if len(args) == 1 and callable(args[0]) and not kwargs:
return args[0]
# Used with arguments: @spaces.GPU(duration=...)
def _decorator(fn):
return fn
return _decorator
spaces = _SpacesShim() # type: ignore
import gradio as gr
import numpy as np
# ─── Work around a gradio_client JSON-schema bug ───────────────
# In some gradio 4.x builds, gradio_client.utils.get_type() raises
# "argument of type 'bool' is not iterable" when a component's schema has a
# boolean `additionalProperties`. This happens during API-info generation,
# which Gradio calls in its startup health check β€” the failed check then
# makes launch() abort demanding share=True. We make the schema walkers
# tolerate non-dict / boolean schemas so startup succeeds on Spaces.
try:
import gradio_client.utils as _gc_utils
_orig_get_type = _gc_utils.get_type
def _get_type_safe(schema):
if not isinstance(schema, dict):
return "Any"
return _orig_get_type(schema)
_gc_utils.get_type = _get_type_safe
_orig_j2pt = _gc_utils._json_schema_to_python_type
def _j2pt_safe(schema, defs=None):
if isinstance(schema, bool):
return "Any"
return _orig_j2pt(schema, defs)
_gc_utils._json_schema_to_python_type = _j2pt_safe
except Exception as _patch_err:
print(f"⚠️ Could not apply gradio_client schema patch: {_patch_err}")
# ─── Compat shim for Starlette TemplateResponse ────────────────
# Gradio 4.x calls templates.TemplateResponse(name, context) (old signature).
# Newer Starlette put `request` first and dropped old-style support, so the
# template name gets mistaken for the request and Gradio's page render crashes
# with "unhashable type: 'dict'" (which then makes launch() demand share=True).
# We restore old-style support. We patch the EXACT instance Gradio's route uses
# (gradio.routes.templates) β€” the most reliable target β€” and the class as a
# fallback, so it works regardless of Starlette version.
def _old_style_args(args, kwargs):
"""Return (name, request, context, rest, kwargs) for an old-style call."""
name = args[0]
context = args[1] if len(args) > 1 else kwargs.pop("context", None)
context = dict(context) if isinstance(context, dict) else {}
request = context.get("request")
return name, request, context, args[2:], kwargs
def _install_templateresponse_compat():
patched = []
# 1) Patch Gradio's actual templates instance (primary β€” bulletproof).
try:
import gradio.routes as _gr_routes
_inst = getattr(_gr_routes, "templates", None)
if _inst is not None and not getattr(_inst.TemplateResponse, "_vb_compat", False):
_orig_bound = _inst.TemplateResponse # bound method (self baked in)
def _inst_compat(*args, **kwargs): # instance attr => no self
if args and isinstance(args[0], str):
name, request, context, rest, kwargs = _old_style_args(args, kwargs)
return _orig_bound(request, name, context, *rest, **kwargs)
return _orig_bound(*args, **kwargs)
_inst_compat._vb_compat = True
_inst.TemplateResponse = _inst_compat
patched.append("gradio.routes.templates")
except Exception as e:
print(f" (instance-level TemplateResponse patch skipped: {e})")
# 2) Patch the class too (fallback for any other Jinja2Templates instances).
try:
import starlette.templating as _st
_JT = _st.Jinja2Templates
if not getattr(_JT.TemplateResponse, "_vb_compat", False):
_class_orig = _JT.TemplateResponse
def _class_compat(self, *args, **kwargs):
if args and isinstance(args[0], str):
name, request, context, rest, kwargs = _old_style_args(args, kwargs)
return _class_orig(self, request, name, context, *rest, **kwargs)
return _class_orig(self, *args, **kwargs)
_class_compat._vb_compat = True
_JT.TemplateResponse = _class_compat
patched.append("Jinja2Templates(class)")
except Exception as e:
print(f" (class-level TemplateResponse patch skipped: {e})")
return patched
try:
_tr_patched = _install_templateresponse_compat()
if _tr_patched:
print(f"βœ… TemplateResponse compat shim installed on: {', '.join(_tr_patched)}")
else:
print("ℹ️ TemplateResponse compat shim not needed (nothing to patch).")
except Exception as _tr_err:
print(f"⚠️ TemplateResponse compat shim failed: {_tr_err}")
from config import get_config, AppConfig, IS_ZERO_GPU
from pipeline import VideoBlurPipeline
# ─── Global State ──────────────────────────────────────────────
config = get_config()
pipeline = VideoBlurPipeline(config)
# GPU time budget (seconds) requested from ZeroGPU per call.
PROCESS_DURATION = getattr(config, "gpu_duration", 120)
PREVIEW_DURATION = 60
# NOTE: models are loaded LAZILY inside the @spaces.GPU functions below
# (pipeline.ensure_models_loaded() runs at the start of process/preview).
# We deliberately do NOT preload at import time: on ZeroGPU the real GPU is
# only attached *inside* a @spaces.GPU call, so building SAM 2 on CUDA at
# import mixes devices ("cuda:0 (ZeroGPU) and cpu"). Loading on first request
# β€” inside the GPU context β€” avoids that. The first request is a little
# slower because it loads the weights; subsequent ones reuse the cache.
# ─── Custom CSS ────────────────────────────────────────────────
CUSTOM_CSS = """
/* Main container */
.gradio-container {
max-width: 1200px !important;
margin: auto !important;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif !important;
}
/* Header */
.app-header {
text-align: center;
padding: 20px 0;
margin-bottom: 10px;
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
border-radius: 16px;
color: white;
}
.app-header h1 {
font-size: 2.2em !important;
font-weight: 700 !important;
margin: 0 !important;
background: linear-gradient(90deg, #00d2ff, #7b2ff7, #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-header p {
color: #a0a0c0;
font-size: 1.05em;
margin: 8px 0 0 0;
}
/* Prompt input */
.prompt-input textarea {
font-size: 1.15em !important;
border: 2px solid #7b2ff7 !important;
border-radius: 12px !important;
padding: 14px !important;
transition: all 0.3s ease !important;
}
.prompt-input textarea:focus {
border-color: #00d2ff !important;
box-shadow: 0 0 0 3px rgba(0, 210, 255, 0.15) !important;
}
/* Process button */
.process-btn {
background: linear-gradient(135deg, #7b2ff7, #00d2ff) !important;
border: none !important;
font-size: 1.15em !important;
font-weight: 600 !important;
padding: 14px 32px !important;
border-radius: 12px !important;
transition: all 0.3s ease !important;
}
.process-btn:hover {
transform: translateY(-2px) !important;
box-shadow: 0 6px 20px rgba(123, 47, 247, 0.35) !important;
}
/* Preview button */
.preview-btn {
background: linear-gradient(135deg, #ff6b6b, #ffa07a) !important;
border: none !important;
border-radius: 12px !important;
font-weight: 600 !important;
}
/* Status */
.status-text {
font-size: 1.05em;
padding: 10px;
border-radius: 8px;
text-align: center;
}
/* Settings panel */
.settings-panel {
background: rgba(123, 47, 247, 0.04);
border: 1px solid rgba(123, 47, 247, 0.12);
border-radius: 12px;
padding: 16px;
}
/* Example prompts */
.example-box {
border: 1px solid rgba(123, 47, 247, 0.2);
border-radius: 10px;
padding: 12px;
margin: 4px 0;
}
"""
# ─── Processing Functions ──────────────────────────────────────
def trim_clip(video_path, start, length):
"""Cut [start, start+length] seconds from a video with ffmpeg.
Returns the path to a new .mp4. Re-encodes (fast preset) so the cut is
frame-accurate. This is CPU-only work (no GPU needed).
"""
start = max(0.0, float(start))
length = max(0.1, float(length))
out_dir = tempfile.mkdtemp(prefix="trim_")
out_path = os.path.join(out_dir, f"trimmed_{Path(video_path).stem}.mp4")
cmd = [
"ffmpeg", "-y",
"-ss", str(start),
"-i", video_path,
"-t", str(length),
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-c:a", "aac",
"-movflags", "+faststart",
out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0 or not os.path.exists(out_path):
raise RuntimeError((proc.stderr or "ffmpeg failed")[-400:])
return out_path
def preview_trim(video_file, trim_start, trim_length):
"""Produce a preview of the trimmed segment so the user can confirm it."""
if video_file is None:
gr.Warning("⚠️ Please upload a video first!")
return None
try:
video_path = video_file if isinstance(video_file, str) else video_file.name
cap = getattr(config, "max_video_duration", 600)
length = min(float(trim_length) if trim_length else cap, cap)
return trim_clip(video_path, trim_start, length)
except Exception as e:
gr.Warning(f"Trim error: {e}")
return None
@spaces.GPU(duration=PROCESS_DURATION)
def process_video(
video_file,
text_prompt,
blur_type,
blur_strength,
edge_feather,
processing_mode,
keyframe_interval,
detection_threshold,
model_quality,
trim_enable,
trim_start,
trim_length,
progress=gr.Progress()
):
"""Main video processing function"""
# Validate inputs
if video_file is None:
gr.Warning("⚠️ Please upload a video first!")
return None, "❌ No video uploaded"
if not text_prompt or not text_prompt.strip():
gr.Warning("⚠️ Please enter a text prompt describing what to blur!")
return None, "❌ No prompt entered"
text_prompt = text_prompt.strip()
# Update model based on quality selection
if model_quality == "base":
config.model.gdino_model_id = "IDEA-Research/grounding-dino-base"
else:
config.model.gdino_model_id = "IDEA-Research/grounding-dino-tiny"
# Progress wrapper for Gradio
def gradio_progress(value, text):
progress(value, desc=text)
try:
# Get video path
if isinstance(video_file, str):
video_path = video_file
else:
video_path = video_file.name if hasattr(video_file, 'name') else str(video_file)
# ── Trim (for long videos) ──
# Trim when the user asked to, OR automatically when the clip is longer
# than the cap β€” so long videos are handled instead of rejected.
max_secs = getattr(config, "max_video_duration", 600)
try:
_info = pipeline.video_processor.get_video_info(video_path)
duration = _info.duration or 0.0
except Exception:
duration = 0.0
should_trim = bool(trim_enable) or (duration and duration > max_secs)
if should_trim:
length = min(float(trim_length) if trim_length else max_secs, max_secs)
start = max(0.0, float(trim_start) if trim_start else 0.0)
if duration and start >= duration:
start = 0.0
gradio_progress(0.02, f"βœ‚οΈ Trimming to {start:.0f}–{start + length:.0f}s...")
try:
video_path = trim_clip(video_path, start, length)
except Exception as e:
gr.Warning(f"Trim failed: {e}")
return None, f"❌ Trim failed: {e}"
# Generate output path (from the possibly-trimmed video)
output_dir = tempfile.mkdtemp(prefix="blur_output_")
output_name = f"blurred_{Path(video_path).stem}.mp4"
output_path = os.path.join(output_dir, output_name)
# Process
result_path = pipeline.process_video(
video_path=video_path,
text_prompt=text_prompt,
output_path=output_path,
blur_type=blur_type,
blur_strength=int(blur_strength),
edge_feather=int(edge_feather),
processing_mode=processing_mode,
keyframe_interval=int(keyframe_interval),
detection_threshold=detection_threshold,
progress_callback=gradio_progress,
)
status = "βœ… Done! Video saved successfully."
return result_path, status
except Exception as e:
error_msg = f"❌ Error: {str(e)}"
gr.Warning(error_msg)
return None, error_msg
@spaces.GPU(duration=PREVIEW_DURATION)
def preview_detection(video_file, text_prompt, frame_slider):
"""Preview detection on a single frame"""
if video_file is None:
gr.Warning("⚠️ Please upload a video first!")
return None
if not text_prompt or not text_prompt.strip():
gr.Warning("⚠️ Please enter a prompt!")
return None
try:
video_path = video_file if isinstance(video_file, str) else video_file.name
vis = pipeline.preview_detection(
video_path,
text_prompt.strip(),
frame_number=int(frame_slider)
)
# Convert BGR -> RGB for Gradio
vis_rgb = vis[..., ::-1]
return vis_rgb
except Exception as e:
gr.Warning(f"Preview error: {str(e)}")
return None
def get_video_info_text(video_file):
"""Get video info when uploaded, and set up the trim controls."""
cap = getattr(config, "max_video_duration", 600)
if video_file is None:
return (
"No video uploaded",
gr.Slider(maximum=0, value=0), # frame_slider
gr.Checkbox(value=False), # trim_enable
gr.Slider(maximum=1, value=0), # trim_start
gr.Slider(maximum=cap, value=cap), # trim_length
"", # trim_info
)
try:
video_path = video_file if isinstance(video_file, str) else video_file.name
info = pipeline.video_processor.get_video_info(video_path)
text = (
f"πŸ“Ή **{Path(video_path).name}**\n"
f"Resolution: {info.width}Γ—{info.height} | "
f"FPS: {info.fps:.1f} | "
f"Frames: {info.total_frames} | "
f"Duration: {info.duration:.1f}s | "
f"Audio: {'Yes βœ…' if info.has_audio else 'No ❌'}"
)
max_frame = max(0, info.total_frames - 1)
dur = info.duration or 0.0
too_long = bool(dur and dur > cap)
length_default = min(cap, dur) if dur else cap
trim_info = ""
if too_long:
trim_info = (
f"⏱️ This clip is **{dur:.0f}s**, longer than the **{cap}s** limit. "
f"It will be trimmed automatically β€” set the start below to choose "
f"which part to keep (defaults to the first {cap}s), and optionally "
f"**Preview trimmed clip** to check it."
)
return (
text,
gr.Slider(maximum=max_frame, value=0), # frame_slider
gr.Checkbox(value=too_long), # trim_enable (auto-on if long)
gr.Slider(maximum=max(0.1, dur), value=0), # trim_start
gr.Slider(maximum=cap, value=length_default), # trim_length
trim_info, # trim_info
)
except Exception as e:
return (
f"❌ Error reading video: {e}",
gr.Slider(maximum=0, value=0),
gr.Checkbox(value=False),
gr.Slider(maximum=1, value=0),
gr.Slider(maximum=cap, value=cap),
"",
)
# ─── Build UI ──────────────────────────────────────────────────
def create_app():
"""Build the Gradio interface"""
# Match the model-quality selector to the active config so the first
# run doesn't trigger an unnecessary model reload.
default_quality = "tiny" if "tiny" in config.model.gdino_model_id else "base"
# Note shown to users about the environment limits (ZeroGPU only).
limits_note = ""
if IS_ZERO_GPU:
limits_note = (
f"<p style='font-size:0.8em;color:#8a8aa8;'>Running on ZeroGPU Β· "
f"processed at {config.video.max_resolution}p Β· clips up to "
f"{config.max_video_duration}s (longer videos can be trimmed)</p>"
)
with gr.Blocks(
title="Video Blur AI",
css=CUSTOM_CSS,
theme=gr.themes.Soft(primary_hue="violet", secondary_hue="cyan"),
) as app:
# ── Header ──
gr.HTML(f"""
<div class="app-header">
<h1>🎬 Video Blur AI</h1>
<p>Type the regions you want to blur β€” faces, hands, text, plates, logos, anything.</p>
<p style="font-size: 0.85em; color: #7a7a9a;">
Grounding DINO + SAM 2 Β· Runs on Hugging Face ZeroGPU
</p>
{limits_note}
</div>
""")
with gr.Row():
# ── Left Column: Input ──
with gr.Column(scale=1):
# Video Upload
video_input = gr.Video(
label="πŸ“€ Upload Video",
)
# Video Info
video_info_text = gr.Markdown("No video uploaded")
# ── Trim controls (for long videos) ──
with gr.Accordion("βœ‚οΈ Trim (for long videos)", open=False):
trim_info = gr.Markdown("")
trim_enable = gr.Checkbox(
label="Trim this video before processing",
value=False,
info=(
f"Clips here are limited to {config.max_video_duration}s. "
"Turn this on to process only a chosen segment. Long videos "
"are trimmed automatically."
),
)
with gr.Row():
trim_start = gr.Slider(
minimum=0, maximum=60, value=0, step=0.5,
label="Start (seconds)",
)
trim_length = gr.Slider(
minimum=1, maximum=config.max_video_duration,
value=config.max_video_duration, step=1,
label=f"Clip length (max {config.max_video_duration}s)",
)
trim_preview_btn = gr.Button(
"βœ‚οΈ Preview trimmed clip", variant="secondary"
)
trim_preview_video = gr.Video(
label="Trimmed preview", interactive=False
)
# Prompt Input β€” this is where the user WRITES the regions to blur
text_prompt = gr.Textbox(
label="✏️ What to blur? (Text Prompt)",
placeholder="e.g., face, hand, license plate, person, text, logo...",
info="Describe the regions you want to blur. Use periods to separate multiple objects: 'face. hand. text.'",
lines=2,
elem_classes=["prompt-input"],
)
# Example Prompts
gr.Examples(
examples=[
["face."],
["face. hand."],
["license plate."],
["text. writing."],
["person."],
["logo. brand."],
["phone. screen."],
["face. license plate. text."],
],
inputs=[text_prompt],
label="πŸ’‘ Example Prompts (click to use)",
)
# Settings
with gr.Accordion("βš™οΈ Advanced Settings", open=False):
model_quality = gr.Radio(
choices=[
("⚑ Fast (Tiny model ~350MB, good accuracy)", "tiny"),
("🎯 Accurate (Base model ~999MB, best accuracy)", "base"),
],
value=default_quality,
label="Model Quality",
info="Tiny downloads faster and runs faster. Base is more accurate."
)
blur_type = gr.Radio(
choices=["gaussian", "pixelate", "black"],
value="gaussian",
label="Blur Type",
info="Gaussian = smooth blur, Pixelate = mosaic, Black = solid cover"
)
blur_strength = gr.Slider(
minimum=11, maximum=151, value=51, step=2,
label="Blur Strength",
info="Higher = more blur (must be odd number)"
)
edge_feather = gr.Slider(
minimum=3, maximum=51, value=11, step=2,
label="Edge Feathering",
info="Smooth transition at blur edges"
)
processing_mode = gr.Radio(
choices=[
("🎯 SAM 2 Video Tracking (recommended)", "video_tracking"),
("⚑ Frame-by-Frame (faster, less consistent)", "frame_by_frame"),
],
value="video_tracking",
label="Processing Mode"
)
keyframe_interval = gr.Slider(
minimum=1, maximum=30, value=5, step=1,
label="Keyframe Interval (frame-by-frame mode)",
info="Re-detect every N frames"
)
detection_threshold = gr.Slider(
minimum=0.1, maximum=0.9, value=0.3, step=0.05,
label="Detection Confidence Threshold",
info="Lower = more detections, higher = more precise"
)
# ── Right Column: Output ──
with gr.Column(scale=1):
# Preview
with gr.Tab("πŸ” Preview Detection"):
preview_image = gr.Image(
label="Detection Preview",
type="numpy"
)
frame_slider = gr.Slider(
minimum=0, maximum=100, value=0, step=1,
label="Frame Number"
)
preview_btn = gr.Button(
"πŸ” Preview Detection",
variant="secondary",
elem_classes=["preview-btn"]
)
# Output Video
with gr.Tab("🎬 Result"):
video_output = gr.Video(
label="Blurred Video",
)
status_text = gr.Markdown(
"Ready to process",
elem_classes=["status-text"]
)
# Process Button
process_btn = gr.Button(
"πŸš€ Process Video",
variant="primary",
size="lg",
elem_classes=["process-btn"]
)
# ── Usage Guide ──
with gr.Accordion("πŸ“– How to Use", open=False):
gr.Markdown("""
### Quick Start
1. **Upload** a video file (MP4, AVI, MOV, MKV supported)
2. **Type** the regions you want to blur in the text prompt
3. **Click** "Preview Detection" to check if the AI found the right objects
4. **Click** "Process Video" to apply the blur
5. **Download** your blurred video
### Tips for Better Results
- Use **specific** descriptions: "face" works better than "person's head"
- Separate multiple objects with **periods**: `face. license plate. text.`
- Lower the **detection threshold** if objects aren't being found
- Use **SAM 2 Video Tracking** mode for smooth, consistent blur across frames
- Use **Frame-by-Frame** mode if objects appear/disappear frequently
### Processing Modes
- **SAM 2 Video Tracking**: Detects on first frame, then tracks objects through the entire video using SAM 2's memory mechanism. Best for consistent results.
- **Frame-by-Frame**: Runs detection periodically and reuses masks between keyframes. Faster but less temporally consistent.
### Running on ZeroGPU
- The first run loads the models onto the GPU, so it's a bit slower; later runs are faster.
- Each run gets the GPU for a limited time, so clips are processed at a reduced resolution and capped in length by default for speed.
- **Long video?** Open **βœ‚οΈ Trim** to pick a segment. Videos over the limit are trimmed automatically (to the first part, or your chosen start).
- Want longer / higher-res? Upgrade the Space GPU and raise `max_video_duration`, `max_resolution` and the `@spaces.GPU(duration=...)` budget.
""")
# ── Event Handlers ──
# Video upload β†’ show info + set up trim controls
video_input.change(
fn=get_video_info_text,
inputs=[video_input],
outputs=[
video_info_text, frame_slider,
trim_enable, trim_start, trim_length, trim_info,
]
)
# Trim preview button
trim_preview_btn.click(
fn=preview_trim,
inputs=[video_input, trim_start, trim_length],
outputs=[trim_preview_video]
)
# Preview button
preview_btn.click(
fn=preview_detection,
inputs=[video_input, text_prompt, frame_slider],
outputs=[preview_image]
)
# Process button
process_btn.click(
fn=process_video,
inputs=[
video_input, text_prompt, blur_type, blur_strength,
edge_feather, processing_mode, keyframe_interval,
detection_threshold, model_quality,
trim_enable, trim_start, trim_length
],
outputs=[video_output, status_text]
)
return app
# ─── Entry Point ───────────────────────────────────────────────
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Video Blur AI")
parser.add_argument("--port", type=int, default=7860, help="Server port")
parser.add_argument("--share", action="store_true", help="Create public link")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Server host")
args = parser.parse_args()
print("\n" + "=" * 60)
print(" 🎬 Video Blur AI - Starting...")
print("=" * 60)
app = create_app()
app.queue() # required for long jobs + progress + ZeroGPU scheduling
app.launch(
server_name=args.host,
server_port=args.port,
share=args.share,
show_api=False, # no public API page; also avoids schema edge cases
)