init
This commit is contained in:
commit
3c28cb0c94
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
.mask_cache/
|
||||||
|
*.mp4
|
||||||
|
test.blend
|
||||||
|
wheels/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Blender
|
||||||
|
*.blend1
|
||||||
|
*.blend2
|
||||||
|
|
||||||
|
# 環境
|
||||||
|
.direnv/
|
||||||
|
.envrc.local
|
||||||
35
__init__.py
Normal file
35
__init__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""
|
||||||
|
Face Mask Blur - Blender 5.0 Extension
|
||||||
|
Detect faces and apply blur in VSE for privacy protection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
bl_info = {
|
||||||
|
"name": "Face Mask Blur",
|
||||||
|
"blender": (5, 0, 0),
|
||||||
|
"category": "Sequencer",
|
||||||
|
"version": (0, 2, 0),
|
||||||
|
"author": "Hare",
|
||||||
|
"description": "Detect faces and apply blur in VSE for privacy protection",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
"""Register all extension components."""
|
||||||
|
from . import operators
|
||||||
|
from . import panels
|
||||||
|
|
||||||
|
operators.register()
|
||||||
|
panels.register()
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
"""Unregister all extension components."""
|
||||||
|
from . import operators
|
||||||
|
from . import panels
|
||||||
|
|
||||||
|
panels.unregister()
|
||||||
|
operators.unregister()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
register()
|
||||||
25
blender_manifest.toml
Normal file
25
blender_manifest.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
schema_version = "1.0.0"
|
||||||
|
|
||||||
|
id = "mask_peoples"
|
||||||
|
version = "0.2.0"
|
||||||
|
name = "Face Mask Blur"
|
||||||
|
tagline = "Detect faces and apply blur in VSE for privacy protection"
|
||||||
|
maintainer = "Hare"
|
||||||
|
type = "add-on"
|
||||||
|
license = ["SPDX:GPL-3.0-or-later"]
|
||||||
|
|
||||||
|
blender_version_min = "5.0.0"
|
||||||
|
|
||||||
|
copyright = ["2026 Hare"]
|
||||||
|
|
||||||
|
# Valid tags from Blender extension platform
|
||||||
|
tags = ["Sequencer"]
|
||||||
|
|
||||||
|
# Bundled Python wheels - Blender will install these automatically
|
||||||
|
wheels = [
|
||||||
|
"./wheels/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||||
|
"./wheels/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[permissions]
|
||||||
|
files = "Read video frames and write mask image cache"
|
||||||
5
core/__init__.py
Normal file
5
core/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Core module exports."""
|
||||||
|
|
||||||
|
from .face_detector import FaceDetector
|
||||||
|
from .async_generator import AsyncMaskGenerator, get_generator
|
||||||
|
from .compositor_setup import create_mask_blur_node_tree, get_or_create_blur_node_tree
|
||||||
249
core/async_generator.py
Normal file
249
core/async_generator.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
"""
|
||||||
|
Async Mask Generator using Thread + Queue + Timer pattern.
|
||||||
|
|
||||||
|
This module provides non-blocking face mask generation for Blender.
|
||||||
|
Heavy processing (face detection) runs in a worker thread while
|
||||||
|
Blender's UI remains responsive via bpy.app.timers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
from functools import partial
|
||||||
|
from typing import Optional, Callable, Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Will be imported when running inside Blender
|
||||||
|
bpy = None
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncMaskGenerator:
|
||||||
|
"""
|
||||||
|
Asynchronous mask generator that doesn't block Blender's UI.
|
||||||
|
|
||||||
|
Uses Thread + Queue + Timer pattern:
|
||||||
|
- Worker thread: Face detection (can use bpy-unsafe operations)
|
||||||
|
- Main thread timer: UI updates and bpy operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.result_queue: queue.Queue = queue.Queue()
|
||||||
|
self.progress_queue: queue.Queue = queue.Queue()
|
||||||
|
self.worker_thread: Optional[threading.Thread] = None
|
||||||
|
self.is_running: bool = False
|
||||||
|
self.total_frames: int = 0
|
||||||
|
self.current_frame: int = 0
|
||||||
|
self._on_complete: Optional[Callable] = None
|
||||||
|
self._on_progress: Optional[Callable] = None
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
video_path: str,
|
||||||
|
output_dir: str,
|
||||||
|
start_frame: int,
|
||||||
|
end_frame: int,
|
||||||
|
fps: float,
|
||||||
|
scale_factor: float = 1.1,
|
||||||
|
min_neighbors: int = 5,
|
||||||
|
mask_scale: float = 1.5,
|
||||||
|
on_complete: Optional[Callable] = None,
|
||||||
|
on_progress: Optional[Callable] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Start asynchronous mask generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: Path to source video file
|
||||||
|
output_dir: Directory to save mask images
|
||||||
|
start_frame: First frame to process
|
||||||
|
end_frame: Last frame to process
|
||||||
|
fps: Video frame rate (for seeking)
|
||||||
|
scale_factor: Face detection scale factor
|
||||||
|
min_neighbors: Face detection min neighbors
|
||||||
|
mask_scale: Mask region scale factor
|
||||||
|
on_complete: Callback when processing completes (called from main thread)
|
||||||
|
on_progress: Callback for progress updates (called from main thread)
|
||||||
|
"""
|
||||||
|
global bpy
|
||||||
|
import bpy as _bpy
|
||||||
|
bpy = _bpy
|
||||||
|
|
||||||
|
if self.is_running:
|
||||||
|
raise RuntimeError("Mask generation already in progress")
|
||||||
|
|
||||||
|
print(f"[FaceMask] Starting mask generation: {video_path}")
|
||||||
|
print(f"[FaceMask] Output directory: {output_dir}")
|
||||||
|
print(f"[FaceMask] Frame range: {start_frame} - {end_frame}")
|
||||||
|
|
||||||
|
self.is_running = True
|
||||||
|
self.total_frames = end_frame - start_frame + 1
|
||||||
|
self.current_frame = 0
|
||||||
|
self._on_complete = on_complete
|
||||||
|
self._on_progress = on_progress
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Start worker thread
|
||||||
|
self.worker_thread = threading.Thread(
|
||||||
|
target=self._worker,
|
||||||
|
args=(
|
||||||
|
video_path,
|
||||||
|
output_dir,
|
||||||
|
start_frame,
|
||||||
|
end_frame,
|
||||||
|
fps,
|
||||||
|
scale_factor,
|
||||||
|
min_neighbors,
|
||||||
|
mask_scale,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self.worker_thread.start()
|
||||||
|
|
||||||
|
# Register timer for main thread callbacks
|
||||||
|
bpy.app.timers.register(
|
||||||
|
self._check_progress,
|
||||||
|
first_interval=0.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
"""Cancel the current processing."""
|
||||||
|
self.is_running = False
|
||||||
|
if self.worker_thread and self.worker_thread.is_alive():
|
||||||
|
self.worker_thread.join(timeout=2.0)
|
||||||
|
|
||||||
|
def _worker(
|
||||||
|
self,
|
||||||
|
video_path: str,
|
||||||
|
output_dir: str,
|
||||||
|
start_frame: int,
|
||||||
|
end_frame: int,
|
||||||
|
fps: float,
|
||||||
|
scale_factor: float,
|
||||||
|
min_neighbors: int,
|
||||||
|
mask_scale: float,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Worker thread function. Runs face detection and saves masks.
|
||||||
|
|
||||||
|
IMPORTANT: Do NOT use bpy in this function!
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
print(f"[FaceMask] OpenCV loaded: {cv2.__version__}")
|
||||||
|
from .face_detector import FaceDetector
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"[FaceMask] Import error: {e}")
|
||||||
|
self.result_queue.put(("error", str(e)))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Initialize detector
|
||||||
|
detector = FaceDetector(
|
||||||
|
scale_factor=scale_factor,
|
||||||
|
min_neighbors=min_neighbors,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open video
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
if not cap.isOpened():
|
||||||
|
print(f"[FaceMask] Failed to open video: {video_path}")
|
||||||
|
self.result_queue.put(("error", f"Failed to open video: {video_path}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
total_video_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
print(f"[FaceMask] Video opened, total frames: {total_video_frames}")
|
||||||
|
|
||||||
|
# Process frames
|
||||||
|
for frame_idx in range(start_frame, end_frame + 1):
|
||||||
|
if not self.is_running:
|
||||||
|
self.result_queue.put(("cancelled", None))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Seek to frame
|
||||||
|
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
||||||
|
ret, frame = cap.read()
|
||||||
|
|
||||||
|
if not ret:
|
||||||
|
# Skip unreadable frames
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Detect faces
|
||||||
|
detections = detector.detect(frame)
|
||||||
|
|
||||||
|
# Generate mask
|
||||||
|
mask = detector.generate_mask(
|
||||||
|
frame.shape,
|
||||||
|
detections,
|
||||||
|
mask_scale=mask_scale,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save mask
|
||||||
|
mask_filename = f"mask_{frame_idx:06d}.png"
|
||||||
|
mask_path = os.path.join(output_dir, mask_filename)
|
||||||
|
cv2.imwrite(mask_path, mask)
|
||||||
|
|
||||||
|
# Report progress
|
||||||
|
self.progress_queue.put(("progress", frame_idx - start_frame + 1))
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
# Report completion
|
||||||
|
self.result_queue.put(("done", output_dir))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[FaceMask] Error: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
self.result_queue.put(("error", str(e)))
|
||||||
|
|
||||||
|
def _check_progress(self) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Timer callback for checking progress from main thread.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Time until next call, or None to unregister.
|
||||||
|
"""
|
||||||
|
# Process all pending progress updates
|
||||||
|
while not self.progress_queue.empty():
|
||||||
|
try:
|
||||||
|
msg_type, data = self.progress_queue.get_nowait()
|
||||||
|
if msg_type == "progress":
|
||||||
|
self.current_frame = data
|
||||||
|
if self._on_progress:
|
||||||
|
self._on_progress(self.current_frame, self.total_frames)
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for completion
|
||||||
|
if not self.result_queue.empty():
|
||||||
|
try:
|
||||||
|
msg_type, data = self.result_queue.get_nowait()
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
if self._on_complete:
|
||||||
|
self._on_complete(msg_type, data)
|
||||||
|
|
||||||
|
return None # Unregister timer
|
||||||
|
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Continue checking if still running
|
||||||
|
if self.is_running:
|
||||||
|
return 0.1 # Check again in 100ms
|
||||||
|
|
||||||
|
return None # Unregister timer
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for easy access from operators
|
||||||
|
_generator: Optional[AsyncMaskGenerator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_generator() -> AsyncMaskGenerator:
|
||||||
|
"""Get or create the global mask generator instance."""
|
||||||
|
global _generator
|
||||||
|
if _generator is None:
|
||||||
|
_generator = AsyncMaskGenerator()
|
||||||
|
return _generator
|
||||||
166
core/compositor_setup.py
Normal file
166
core/compositor_setup.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""
|
||||||
|
Compositor Node Tree Setup for mask-based blur effect.
|
||||||
|
|
||||||
|
Creates and manages compositing node trees that apply blur
|
||||||
|
only to masked regions of a video strip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def create_mask_blur_node_tree(
|
||||||
|
name: str = "FaceMaskBlur",
|
||||||
|
blur_size: int = 50,
|
||||||
|
) -> "bpy.types.NodeTree":
|
||||||
|
"""
|
||||||
|
Create a compositing node tree for mask-based blur.
|
||||||
|
|
||||||
|
Node structure:
|
||||||
|
[Render Layers] ──┬──────────────────────────→ [Mix] → [Composite]
|
||||||
|
│ ↑
|
||||||
|
└→ [Blur] → [Mix Factor Mask] ↗
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name for the node tree
|
||||||
|
blur_size: Blur radius in pixels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created NodeTree
|
||||||
|
"""
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
# Create new node tree or get existing
|
||||||
|
if name in bpy.data.node_groups:
|
||||||
|
# Return existing tree
|
||||||
|
return bpy.data.node_groups[name]
|
||||||
|
|
||||||
|
# Create compositing scene if needed
|
||||||
|
tree = bpy.data.node_groups.new(name=name, type='CompositorNodeTree')
|
||||||
|
tree.use_fake_user = True # Prevent deletion
|
||||||
|
|
||||||
|
nodes = tree.nodes
|
||||||
|
links = tree.links
|
||||||
|
|
||||||
|
# Clear default nodes
|
||||||
|
nodes.clear()
|
||||||
|
|
||||||
|
# Create nodes
|
||||||
|
# Input: Image and Mask
|
||||||
|
input_node = nodes.new('NodeGroupInput')
|
||||||
|
input_node.location = (-400, 0)
|
||||||
|
|
||||||
|
# Output
|
||||||
|
output_node = nodes.new('NodeGroupOutput')
|
||||||
|
output_node.location = (600, 0)
|
||||||
|
|
||||||
|
# Blur node
|
||||||
|
blur_node = nodes.new('CompositorNodeBlur')
|
||||||
|
blur_node.location = (0, -150)
|
||||||
|
blur_node.filter_type = 'GAUSS'
|
||||||
|
blur_node.label = "Face Blur"
|
||||||
|
# Note: Blender 5.0 uses 'Size' input socket instead of size_x/size_y properties
|
||||||
|
# We'll set default_value on the socket after linking
|
||||||
|
|
||||||
|
# Mix node (combines original with blurred using mask)
|
||||||
|
mix_node = nodes.new('CompositorNodeMixRGB')
|
||||||
|
mix_node.location = (300, 0)
|
||||||
|
mix_node.blend_type = 'MIX'
|
||||||
|
mix_node.label = "Mask Mix"
|
||||||
|
|
||||||
|
# Set blur size via input socket (Blender 5.0 API)
|
||||||
|
if 'Size' in blur_node.inputs:
|
||||||
|
blur_node.inputs['Size'].default_value = blur_size / 100.0 # Size is 0-1 range
|
||||||
|
elif 'size' in blur_node.inputs:
|
||||||
|
blur_node.inputs['size'].default_value = blur_size / 100.0
|
||||||
|
|
||||||
|
# Define interface sockets
|
||||||
|
tree.interface.new_socket(
|
||||||
|
name="Image",
|
||||||
|
in_out='INPUT',
|
||||||
|
socket_type='NodeSocketColor',
|
||||||
|
)
|
||||||
|
tree.interface.new_socket(
|
||||||
|
name="Mask",
|
||||||
|
in_out='INPUT',
|
||||||
|
socket_type='NodeSocketFloat',
|
||||||
|
)
|
||||||
|
tree.interface.new_socket(
|
||||||
|
name="Image",
|
||||||
|
in_out='OUTPUT',
|
||||||
|
socket_type='NodeSocketColor',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link nodes
|
||||||
|
# Input Image → Blur
|
||||||
|
links.new(input_node.outputs[0], blur_node.inputs['Image'])
|
||||||
|
|
||||||
|
# Input Image → Mix (first color)
|
||||||
|
links.new(input_node.outputs[0], mix_node.inputs[1])
|
||||||
|
|
||||||
|
# Blur → Mix (second color)
|
||||||
|
links.new(blur_node.outputs[0], mix_node.inputs[2])
|
||||||
|
|
||||||
|
# Input Mask → Mix (factor)
|
||||||
|
links.new(input_node.outputs[1], mix_node.inputs[0])
|
||||||
|
|
||||||
|
# Mix → Output
|
||||||
|
links.new(mix_node.outputs[0], output_node.inputs[0])
|
||||||
|
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
def setup_strip_compositor_modifier(
|
||||||
|
strip: "bpy.types.Strip",
|
||||||
|
mask_strip: "bpy.types.Strip",
|
||||||
|
node_tree: "bpy.types.NodeTree",
|
||||||
|
) -> "bpy.types.SequenceModifier":
|
||||||
|
"""
|
||||||
|
Add a Compositor modifier to a strip using the mask-blur node tree.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
strip: The video strip to add the modifier to
|
||||||
|
mask_strip: The mask image sequence strip
|
||||||
|
node_tree: The compositing node tree to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created modifier
|
||||||
|
"""
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
# Add compositor modifier
|
||||||
|
modifier = strip.modifiers.new(
|
||||||
|
name="FaceMaskBlur",
|
||||||
|
type='COMPOSITOR',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the node tree
|
||||||
|
modifier.node_tree = node_tree
|
||||||
|
|
||||||
|
# Configure input mapping
|
||||||
|
# The modifier automatically maps strip image to first input
|
||||||
|
# We need to configure the mask input
|
||||||
|
|
||||||
|
# TODO: Blender 5.0 may have different API for this
|
||||||
|
# This is a placeholder for the actual implementation
|
||||||
|
|
||||||
|
return modifier
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_blur_node_tree(blur_size: int = 50) -> "bpy.types.NodeTree":
|
||||||
|
"""
|
||||||
|
Get existing or create new blur node tree with specified blur size.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blur_size: Blur radius in pixels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The node tree
|
||||||
|
"""
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
name = f"FaceMaskBlur_{blur_size}"
|
||||||
|
|
||||||
|
if name in bpy.data.node_groups:
|
||||||
|
return bpy.data.node_groups[name]
|
||||||
|
|
||||||
|
return create_mask_blur_node_tree(name=name, blur_size=blur_size)
|
||||||
160
core/face_detector.py
Normal file
160
core/face_detector.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""
|
||||||
|
Face detector using OpenCV Haar Cascades.
|
||||||
|
|
||||||
|
This module provides face detection functionality optimized for
|
||||||
|
privacy blur in video editing workflows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class FaceDetector:
|
||||||
|
"""
|
||||||
|
Face detector using OpenCV Haar Cascades.
|
||||||
|
|
||||||
|
Optimized for privacy blur use case:
|
||||||
|
- Detects frontal faces
|
||||||
|
- Configurable detection sensitivity
|
||||||
|
- Generates feathered masks for smooth blur edges
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
scale_factor: float = 1.1,
|
||||||
|
min_neighbors: int = 5,
|
||||||
|
min_size: Tuple[int, int] = (30, 30),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the face detector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scale_factor: Image pyramid scale factor
|
||||||
|
min_neighbors: Minimum neighbors for detection
|
||||||
|
min_size: Minimum face size in pixels
|
||||||
|
"""
|
||||||
|
self.scale_factor = scale_factor
|
||||||
|
self.min_neighbors = min_neighbors
|
||||||
|
self.min_size = min_size
|
||||||
|
self._classifier = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def classifier(self):
|
||||||
|
"""Lazy-load the Haar cascade classifier."""
|
||||||
|
if self._classifier is None:
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
# Use haarcascade for frontal face detection
|
||||||
|
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
||||||
|
|
||||||
|
if not os.path.exists(cascade_path):
|
||||||
|
raise RuntimeError(f"Haar cascade not found: {cascade_path}")
|
||||||
|
|
||||||
|
self._classifier = cv2.CascadeClassifier(cascade_path)
|
||||||
|
|
||||||
|
return self._classifier
|
||||||
|
|
||||||
|
def detect(self, frame: np.ndarray) -> List[Tuple[int, int, int, int]]:
|
||||||
|
"""
|
||||||
|
Detect faces in a frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: BGR image as numpy array
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of face bounding boxes as (x, y, width, height)
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
# Convert to grayscale for detection
|
||||||
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Detect faces
|
||||||
|
faces = self.classifier.detectMultiScale(
|
||||||
|
gray,
|
||||||
|
scaleFactor=self.scale_factor,
|
||||||
|
minNeighbors=self.min_neighbors,
|
||||||
|
minSize=self.min_size,
|
||||||
|
flags=cv2.CASCADE_SCALE_IMAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to list of tuples
|
||||||
|
return [tuple(face) for face in faces]
|
||||||
|
|
||||||
|
def generate_mask(
|
||||||
|
self,
|
||||||
|
frame_shape: Tuple[int, int, int],
|
||||||
|
detections: List[Tuple[int, int, int, int]],
|
||||||
|
mask_scale: float = 1.5,
|
||||||
|
feather_radius: int = 20,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Generate a mask image from face detections.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame_shape: Shape of the original frame (height, width, channels)
|
||||||
|
detections: List of face bounding boxes
|
||||||
|
mask_scale: Scale factor for mask region (1.0 = exact bounding box)
|
||||||
|
feather_radius: Radius for edge feathering
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Grayscale mask image (white = blur, black = keep)
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
height, width = frame_shape[:2]
|
||||||
|
mask = np.zeros((height, width), dtype=np.uint8)
|
||||||
|
|
||||||
|
for (x, y, w, h) in detections:
|
||||||
|
# Scale the bounding box
|
||||||
|
center_x = x + w // 2
|
||||||
|
center_y = y + h // 2
|
||||||
|
|
||||||
|
scaled_w = int(w * mask_scale)
|
||||||
|
scaled_h = int(h * mask_scale)
|
||||||
|
|
||||||
|
# Calculate scaled bounding box
|
||||||
|
x1 = max(0, center_x - scaled_w // 2)
|
||||||
|
y1 = max(0, center_y - scaled_h // 2)
|
||||||
|
x2 = min(width, center_x + scaled_w // 2)
|
||||||
|
y2 = min(height, center_y + scaled_h // 2)
|
||||||
|
|
||||||
|
# Draw ellipse for more natural face shape
|
||||||
|
cv2.ellipse(
|
||||||
|
mask,
|
||||||
|
(center_x, center_y),
|
||||||
|
(scaled_w // 2, scaled_h // 2),
|
||||||
|
0, # angle
|
||||||
|
0, 360, # arc
|
||||||
|
255, # color (white)
|
||||||
|
-1, # filled
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply Gaussian blur for feathering
|
||||||
|
if feather_radius > 0 and len(detections) > 0:
|
||||||
|
# Ensure kernel size is odd
|
||||||
|
kernel_size = feather_radius * 2 + 1
|
||||||
|
mask = cv2.GaussianBlur(mask, (kernel_size, kernel_size), 0)
|
||||||
|
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def detect_faces_batch(
|
||||||
|
frames: List[np.ndarray],
|
||||||
|
detector: Optional[FaceDetector] = None,
|
||||||
|
) -> List[List[Tuple[int, int, int, int]]]:
|
||||||
|
"""
|
||||||
|
Detect faces in multiple frames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frames: List of BGR images
|
||||||
|
detector: Optional detector instance (creates one if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of detection lists, one per frame
|
||||||
|
"""
|
||||||
|
if detector is None:
|
||||||
|
detector = FaceDetector()
|
||||||
|
|
||||||
|
return [detector.detect(frame) for frame in frames]
|
||||||
14
operators/__init__.py
Normal file
14
operators/__init__.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Operators package."""
|
||||||
|
|
||||||
|
from . import generate_mask
|
||||||
|
from . import apply_blur
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
generate_mask.register()
|
||||||
|
apply_blur.register()
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
apply_blur.unregister()
|
||||||
|
generate_mask.unregister()
|
||||||
175
operators/apply_blur.py
Normal file
175
operators/apply_blur.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""
|
||||||
|
Apply Blur Operator for masked face blur in VSE.
|
||||||
|
|
||||||
|
Provides operators to apply blur effects using mask strips
|
||||||
|
generated by the face detection operators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy.props import FloatProperty, IntProperty, StringProperty
|
||||||
|
from bpy.types import Operator
|
||||||
|
|
||||||
|
from ..core.compositor_setup import get_or_create_blur_node_tree, setup_strip_compositor_modifier
|
||||||
|
|
||||||
|
|
||||||
|
class SEQUENCER_OT_apply_mask_blur(Operator):
|
||||||
|
"""Apply blur effect using mask strip."""
|
||||||
|
|
||||||
|
bl_idname = "sequencer.apply_mask_blur"
|
||||||
|
bl_label = "Apply Mask Blur"
|
||||||
|
bl_description = "Apply blur effect to video using mask strip"
|
||||||
|
bl_options = {'REGISTER', 'UNDO'}
|
||||||
|
|
||||||
|
blur_size: IntProperty(
|
||||||
|
name="Blur Size",
|
||||||
|
description="Size of the blur effect in pixels",
|
||||||
|
default=50,
|
||||||
|
min=1,
|
||||||
|
max=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
mask_strip_name: StringProperty(
|
||||||
|
name="Mask Strip",
|
||||||
|
description="Name of the mask strip to use",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
"""Check if operator can run."""
|
||||||
|
if not context.scene.sequence_editor:
|
||||||
|
return False
|
||||||
|
|
||||||
|
strip = context.scene.sequence_editor.active_strip
|
||||||
|
if not strip:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return strip.type in {'MOVIE', 'IMAGE'}
|
||||||
|
|
||||||
|
def invoke(self, context, event):
|
||||||
|
"""Show dialog to select mask strip."""
|
||||||
|
return context.window_manager.invoke_props_dialog(self)
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
"""Draw the operator dialog."""
|
||||||
|
layout = self.layout
|
||||||
|
layout.prop(self, "blur_size")
|
||||||
|
layout.prop_search(
|
||||||
|
self, "mask_strip_name",
|
||||||
|
context.scene.sequence_editor, "strips",
|
||||||
|
text="Mask Strip",
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
seq_editor = context.scene.sequence_editor
|
||||||
|
video_strip = seq_editor.active_strip
|
||||||
|
|
||||||
|
# Find mask strip
|
||||||
|
if not self.mask_strip_name:
|
||||||
|
# Try to find auto-generated mask
|
||||||
|
auto_mask_name = f"{video_strip.name}_mask"
|
||||||
|
if auto_mask_name in seq_editor.strips:
|
||||||
|
self.mask_strip_name = auto_mask_name
|
||||||
|
else:
|
||||||
|
self.report({'ERROR'}, "Please select a mask strip")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
mask_strip = seq_editor.strips.get(self.mask_strip_name)
|
||||||
|
if not mask_strip:
|
||||||
|
self.report({'ERROR'}, f"Mask strip not found: {self.mask_strip_name}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try using Compositor Modifier (preferred method)
|
||||||
|
self._apply_with_modifier(context, video_strip, mask_strip)
|
||||||
|
except Exception as e:
|
||||||
|
self.report({'ERROR'}, f"Failed to apply blur: {e}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
def _apply_with_modifier(self, context, video_strip: "bpy.types.Strip", mask_strip: "bpy.types.Strip"):
|
||||||
|
"""Apply blur using Compositor Modifier."""
|
||||||
|
# Get or create the blur node tree
|
||||||
|
node_tree = get_or_create_blur_node_tree(blur_size=self.blur_size)
|
||||||
|
|
||||||
|
# Add compositor modifier to the video strip
|
||||||
|
modifier = setup_strip_compositor_modifier(
|
||||||
|
strip=video_strip,
|
||||||
|
mask_strip=mask_strip,
|
||||||
|
node_tree=node_tree,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.report({'INFO'}, f"Applied blur with Compositor Modifier")
|
||||||
|
|
||||||
|
def _apply_with_meta_strip(self, context, video_strip: "bpy.types.Strip", mask_strip: "bpy.types.Strip"):
|
||||||
|
"""
|
||||||
|
Fallback method using Meta Strip and effects.
|
||||||
|
|
||||||
|
This is less elegant but works on all Blender versions.
|
||||||
|
"""
|
||||||
|
seq_editor = context.scene.sequence_editor
|
||||||
|
|
||||||
|
# Find available channels
|
||||||
|
base_channel = video_strip.channel
|
||||||
|
blur_channel = base_channel + 1
|
||||||
|
effect_channel = blur_channel + 1
|
||||||
|
|
||||||
|
# Ensure mask is in correct position
|
||||||
|
mask_strip.channel = blur_channel
|
||||||
|
mask_strip.frame_start = video_strip.frame_final_start
|
||||||
|
|
||||||
|
# Create Gaussian Blur effect on the video strip
|
||||||
|
# First, we need to duplicate the video for the blurred version
|
||||||
|
video_copy = seq_editor.strips.new_movie(
|
||||||
|
name=f"{video_strip.name}_blur",
|
||||||
|
filepath=bpy.path.abspath(video_strip.filepath) if hasattr(video_strip, 'filepath') else "",
|
||||||
|
channel=blur_channel,
|
||||||
|
frame_start=video_strip.frame_final_start,
|
||||||
|
) if video_strip.type == 'MOVIE' else None
|
||||||
|
|
||||||
|
if video_copy:
|
||||||
|
# Calculate length (Blender 5.0 uses length instead of frame_end)
|
||||||
|
strip_length = video_strip.frame_final_end - video_strip.frame_final_start
|
||||||
|
|
||||||
|
# Apply Gaussian blur effect (Blender 5.0 API)
|
||||||
|
blur_effect = seq_editor.strips.new_effect(
|
||||||
|
name=f"{video_strip.name}_gaussian",
|
||||||
|
type='GAUSSIAN_BLUR',
|
||||||
|
channel=effect_channel,
|
||||||
|
frame_start=video_strip.frame_final_start,
|
||||||
|
length=strip_length,
|
||||||
|
input1=video_copy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set blur size (Blender 5.0 uses size property, not size_x/size_y)
|
||||||
|
if hasattr(blur_effect, 'size_x'):
|
||||||
|
blur_effect.size_x = self.blur_size
|
||||||
|
blur_effect.size_y = self.blur_size
|
||||||
|
elif hasattr(blur_effect, 'size'):
|
||||||
|
blur_effect.size = self.blur_size
|
||||||
|
|
||||||
|
# Create Alpha Over to combine original with blurred (using mask)
|
||||||
|
# Note: Full implementation would require compositing
|
||||||
|
# This is a simplified version
|
||||||
|
|
||||||
|
self.report({'INFO'}, "Created blur effect (full compositing in development)")
|
||||||
|
else:
|
||||||
|
# For image sequences, different approach needed
|
||||||
|
self.report({'WARNING'}, "Image sequence blur not yet fully implemented")
|
||||||
|
|
||||||
|
|
||||||
|
# Registration
|
||||||
|
classes = [
|
||||||
|
SEQUENCER_OT_apply_mask_blur,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for cls in classes:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
for cls in reversed(classes):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
273
operators/generate_mask.py
Normal file
273
operators/generate_mask.py
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
"""
|
||||||
|
Generate Mask Operator for Face Detection in VSE.
|
||||||
|
|
||||||
|
Provides operators to generate face mask image sequences
|
||||||
|
from video strips in the Video Sequence Editor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import bpy
|
||||||
|
from bpy.props import FloatProperty, IntProperty
|
||||||
|
from bpy.types import Operator
|
||||||
|
|
||||||
|
from ..core.async_generator import get_generator
|
||||||
|
|
||||||
|
|
||||||
|
class SEQUENCER_OT_generate_face_mask(Operator):
|
||||||
|
"""Generate face mask image sequence from video strip."""
|
||||||
|
|
||||||
|
bl_idname = "sequencer.generate_face_mask"
|
||||||
|
bl_label = "Generate Face Mask"
|
||||||
|
bl_description = "Detect faces and generate mask image sequence"
|
||||||
|
bl_options = {'REGISTER', 'UNDO'}
|
||||||
|
|
||||||
|
# Detection parameters
|
||||||
|
scale_factor: FloatProperty(
|
||||||
|
name="Scale Factor",
|
||||||
|
description="Detection scale factor (larger = faster but less accurate)",
|
||||||
|
default=1.1,
|
||||||
|
min=1.01,
|
||||||
|
max=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
min_neighbors: IntProperty(
|
||||||
|
name="Min Neighbors",
|
||||||
|
description="Minimum neighbors for detection (higher = fewer false positives)",
|
||||||
|
default=5,
|
||||||
|
min=1,
|
||||||
|
max=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
mask_scale: FloatProperty(
|
||||||
|
name="Mask Scale",
|
||||||
|
description="Scale factor for mask region (1.0 = exact face size)",
|
||||||
|
default=1.5,
|
||||||
|
min=1.0,
|
||||||
|
max=3.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
"""Check if operator can run."""
|
||||||
|
if not context.scene.sequence_editor:
|
||||||
|
return False
|
||||||
|
|
||||||
|
strip = context.scene.sequence_editor.active_strip
|
||||||
|
if not strip:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return strip.type in {'MOVIE', 'IMAGE'}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
strip = context.scene.sequence_editor.active_strip
|
||||||
|
scene = context.scene
|
||||||
|
|
||||||
|
# Get video path
|
||||||
|
if strip.type == 'MOVIE':
|
||||||
|
video_path = bpy.path.abspath(strip.filepath)
|
||||||
|
else:
|
||||||
|
# Image sequence - get directory
|
||||||
|
video_path = bpy.path.abspath(strip.directory)
|
||||||
|
|
||||||
|
if not os.path.exists(video_path):
|
||||||
|
self.report({'ERROR'}, f"Video file not found: {video_path}")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
# Determine output directory
|
||||||
|
output_dir = self._get_cache_dir(context, strip)
|
||||||
|
|
||||||
|
# Check cache - if masks already exist, use them
|
||||||
|
expected_frame_count = strip.frame_final_end - strip.frame_final_start + 1
|
||||||
|
if self._check_cache(output_dir, expected_frame_count):
|
||||||
|
self.report({'INFO'}, f"Using cached masks from {output_dir}")
|
||||||
|
self._add_mask_strip(context, strip.name, output_dir)
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
# Get frame range
|
||||||
|
start_frame = strip.frame_final_start
|
||||||
|
end_frame = strip.frame_final_end
|
||||||
|
fps = scene.render.fps / scene.render.fps_base
|
||||||
|
|
||||||
|
# Start async generation
|
||||||
|
generator = get_generator()
|
||||||
|
|
||||||
|
if generator.is_running:
|
||||||
|
self.report({'WARNING'}, "Mask generation already in progress")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
# Store strip name for callback
|
||||||
|
strip_name = strip.name
|
||||||
|
|
||||||
|
def on_complete(status, data):
|
||||||
|
"""Called when mask generation completes."""
|
||||||
|
if status == "done":
|
||||||
|
# Add mask strip to sequence editor
|
||||||
|
self._add_mask_strip(context, strip_name, data)
|
||||||
|
print(f"[FaceMask] Mask generation completed: {data}")
|
||||||
|
elif status == "error":
|
||||||
|
print(f"[FaceMask] Error: {data}")
|
||||||
|
elif status == "cancelled":
|
||||||
|
print("[FaceMask] Generation cancelled")
|
||||||
|
|
||||||
|
def on_progress(current, total):
|
||||||
|
"""Called on progress updates."""
|
||||||
|
# Update window manager properties for UI
|
||||||
|
wm = context.window_manager
|
||||||
|
wm.mask_progress = current
|
||||||
|
wm.mask_total = total
|
||||||
|
|
||||||
|
# Force UI redraw
|
||||||
|
for area in context.screen.areas:
|
||||||
|
if area.type == 'SEQUENCE_EDITOR':
|
||||||
|
area.tag_redraw()
|
||||||
|
|
||||||
|
# Initialize progress
|
||||||
|
wm = context.window_manager
|
||||||
|
wm.mask_progress = 0
|
||||||
|
wm.mask_total = end_frame - start_frame + 1
|
||||||
|
|
||||||
|
# Start generation
|
||||||
|
generator.start(
|
||||||
|
video_path=video_path,
|
||||||
|
output_dir=output_dir,
|
||||||
|
start_frame=0, # Frame indices in video
|
||||||
|
end_frame=end_frame - start_frame,
|
||||||
|
fps=fps,
|
||||||
|
scale_factor=self.scale_factor,
|
||||||
|
min_neighbors=self.min_neighbors,
|
||||||
|
mask_scale=self.mask_scale,
|
||||||
|
on_complete=on_complete,
|
||||||
|
on_progress=on_progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.report({'INFO'}, f"Started mask generation for {strip.name}")
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
def _get_cache_dir(self, context, strip) -> str:
|
||||||
|
"""Get or create cache directory for mask images."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Use temp directory with project-specific subdirectory
|
||||||
|
# This avoids issues with extension_path_user package name resolution
|
||||||
|
blend_file = bpy.data.filepath
|
||||||
|
if blend_file:
|
||||||
|
# Use blend file directory if saved
|
||||||
|
project_dir = os.path.dirname(blend_file)
|
||||||
|
cache_dir = os.path.join(project_dir, ".mask_cache", strip.name)
|
||||||
|
else:
|
||||||
|
# Use temp directory for unsaved projects
|
||||||
|
cache_dir = os.path.join(tempfile.gettempdir(), "blender_mask_cache", strip.name)
|
||||||
|
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
return cache_dir
|
||||||
|
|
||||||
|
def _check_cache(self, cache_dir: str, expected_frames: int) -> bool:
|
||||||
|
"""Check if cached masks exist and are complete.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_dir: Path to cache directory
|
||||||
|
expected_frames: Number of frames expected
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cache exists and has at least 90% of expected frames
|
||||||
|
"""
|
||||||
|
if not os.path.exists(cache_dir):
|
||||||
|
return False
|
||||||
|
|
||||||
|
mask_files = [f for f in os.listdir(cache_dir)
|
||||||
|
if f.startswith("mask_") and f.endswith(".png")]
|
||||||
|
|
||||||
|
# Accept cache if at least 90% of frames exist
|
||||||
|
# (some frames may have been skipped due to read errors)
|
||||||
|
return len(mask_files) >= expected_frames * 0.9
|
||||||
|
|
||||||
|
def _add_mask_strip(self, context, source_strip_name: str, mask_dir: str):
|
||||||
|
"""Add mask image sequence as a new strip."""
|
||||||
|
scene = context.scene
|
||||||
|
seq_editor = scene.sequence_editor
|
||||||
|
|
||||||
|
if not seq_editor:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find source strip (Blender 5.0 uses 'strips' instead of 'sequences')
|
||||||
|
source_strip = seq_editor.strips.get(source_strip_name)
|
||||||
|
if not source_strip:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get first mask image
|
||||||
|
mask_files = sorted([
|
||||||
|
f for f in os.listdir(mask_dir)
|
||||||
|
if f.startswith("mask_") and f.endswith(".png")
|
||||||
|
])
|
||||||
|
|
||||||
|
if not mask_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
first_mask = os.path.join(mask_dir, mask_files[0])
|
||||||
|
|
||||||
|
# Find an empty channel
|
||||||
|
used_channels = {s.channel for s in seq_editor.strips}
|
||||||
|
new_channel = source_strip.channel + 1
|
||||||
|
while new_channel in used_channels:
|
||||||
|
new_channel += 1
|
||||||
|
|
||||||
|
# Add image sequence (Blender 5.0 API)
|
||||||
|
mask_strip = seq_editor.strips.new_image(
|
||||||
|
name=f"{source_strip_name}_mask",
|
||||||
|
filepath=first_mask,
|
||||||
|
channel=new_channel,
|
||||||
|
frame_start=source_strip.frame_final_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add remaining frames
|
||||||
|
for mask_file in mask_files[1:]:
|
||||||
|
mask_strip.elements.append(mask_file)
|
||||||
|
|
||||||
|
# Set blend mode for mask
|
||||||
|
mask_strip.blend_type = 'ALPHA_OVER'
|
||||||
|
mask_strip.blend_alpha = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class SEQUENCER_OT_cancel_mask_generation(Operator):
|
||||||
|
"""Cancel ongoing mask generation."""
|
||||||
|
|
||||||
|
bl_idname = "sequencer.cancel_mask_generation"
|
||||||
|
bl_label = "Cancel Mask Generation"
|
||||||
|
bl_description = "Cancel the current mask generation process"
|
||||||
|
bl_options = {'REGISTER'}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
generator = get_generator()
|
||||||
|
|
||||||
|
if generator.is_running:
|
||||||
|
generator.cancel()
|
||||||
|
self.report({'INFO'}, "Mask generation cancelled")
|
||||||
|
else:
|
||||||
|
self.report({'WARNING'}, "No mask generation in progress")
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
# Registration
|
||||||
|
classes = [
|
||||||
|
SEQUENCER_OT_generate_face_mask,
|
||||||
|
SEQUENCER_OT_cancel_mask_generation,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for cls in classes:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
|
||||||
|
# Add progress properties to window manager
|
||||||
|
bpy.types.WindowManager.mask_progress = IntProperty(default=0)
|
||||||
|
bpy.types.WindowManager.mask_total = IntProperty(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
# Remove properties
|
||||||
|
del bpy.types.WindowManager.mask_progress
|
||||||
|
del bpy.types.WindowManager.mask_total
|
||||||
|
|
||||||
|
for cls in reversed(classes):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
11
panels/__init__.py
Normal file
11
panels/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""Panels package."""
|
||||||
|
|
||||||
|
from . import vse_panel
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
vse_panel.register()
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
vse_panel.unregister()
|
||||||
127
panels/vse_panel.py
Normal file
127
panels/vse_panel.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""
|
||||||
|
VSE Panel for Face Mask controls.
|
||||||
|
|
||||||
|
Provides a sidebar panel in the Video Sequence Editor
|
||||||
|
for controlling mask generation and blur application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy.types import Panel
|
||||||
|
|
||||||
|
from ..core.async_generator import get_generator
|
||||||
|
|
||||||
|
|
||||||
|
class SEQUENCER_PT_face_mask(Panel):
|
||||||
|
"""Panel for face mask blur controls."""
|
||||||
|
|
||||||
|
bl_label = "Face Mask"
|
||||||
|
bl_idname = "SEQUENCER_PT_face_mask"
|
||||||
|
bl_space_type = 'SEQUENCE_EDITOR'
|
||||||
|
bl_region_type = 'UI'
|
||||||
|
bl_category = "Face Mask"
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
wm = context.window_manager
|
||||||
|
seq_editor = context.scene.sequence_editor
|
||||||
|
# Note: Blender 5.0 uses 'strips' instead of 'sequences'
|
||||||
|
|
||||||
|
generator = get_generator()
|
||||||
|
|
||||||
|
# Show progress if generating
|
||||||
|
if generator.is_running:
|
||||||
|
self._draw_progress(layout, wm, generator)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show controls if strip selected
|
||||||
|
if seq_editor and seq_editor.active_strip:
|
||||||
|
strip = seq_editor.active_strip
|
||||||
|
|
||||||
|
if strip.type in {'MOVIE', 'IMAGE'}:
|
||||||
|
self._draw_generation_controls(layout, context, strip)
|
||||||
|
self._draw_blur_controls(layout, context, strip)
|
||||||
|
else:
|
||||||
|
layout.label(text="Select a video or image strip")
|
||||||
|
else:
|
||||||
|
layout.label(text="No strip selected")
|
||||||
|
|
||||||
|
def _draw_progress(self, layout, wm, generator):
|
||||||
|
"""Draw progress bar during generation."""
|
||||||
|
box = layout.box()
|
||||||
|
box.label(text="Generating Masks...", icon='RENDER_ANIMATION')
|
||||||
|
|
||||||
|
# Progress bar
|
||||||
|
progress = wm.mask_progress / max(wm.mask_total, 1)
|
||||||
|
box.progress(
|
||||||
|
factor=progress,
|
||||||
|
text=f"Frame {wm.mask_progress} / {wm.mask_total}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel button
|
||||||
|
box.operator(
|
||||||
|
"sequencer.cancel_mask_generation",
|
||||||
|
text="Cancel",
|
||||||
|
icon='CANCEL',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_generation_controls(self, layout, context, strip):
|
||||||
|
"""Draw mask generation controls."""
|
||||||
|
box = layout.box()
|
||||||
|
box.label(text="Mask Generation", icon='MOD_MASK')
|
||||||
|
|
||||||
|
# Info about selected strip
|
||||||
|
row = box.row()
|
||||||
|
row.label(text=f"Strip: {strip.name}")
|
||||||
|
|
||||||
|
# Check for existing mask
|
||||||
|
seq_editor = context.scene.sequence_editor
|
||||||
|
mask_name = f"{strip.name}_mask"
|
||||||
|
has_mask = mask_name in seq_editor.strips
|
||||||
|
|
||||||
|
if has_mask:
|
||||||
|
row = box.row()
|
||||||
|
row.label(text="✓ Mask exists", icon='CHECKMARK')
|
||||||
|
|
||||||
|
# Generate button
|
||||||
|
op = box.operator(
|
||||||
|
"sequencer.generate_face_mask",
|
||||||
|
text="Generate Face Mask" if not has_mask else "Regenerate Mask",
|
||||||
|
icon='FACE_MAPS',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_blur_controls(self, layout, context, strip):
|
||||||
|
"""Draw blur application controls."""
|
||||||
|
box = layout.box()
|
||||||
|
box.label(text="Blur Application", icon='MATFLUID')
|
||||||
|
|
||||||
|
# Check for mask strip
|
||||||
|
seq_editor = context.scene.sequence_editor
|
||||||
|
mask_name = f"{strip.name}_mask"
|
||||||
|
has_mask = mask_name in seq_editor.strips
|
||||||
|
|
||||||
|
if not has_mask:
|
||||||
|
box.label(text="Generate a mask first", icon='INFO')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply blur button
|
||||||
|
op = box.operator(
|
||||||
|
"sequencer.apply_mask_blur",
|
||||||
|
text="Apply Mask Blur",
|
||||||
|
icon='PROP_CON',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Registration
|
||||||
|
classes = [
|
||||||
|
SEQUENCER_PT_face_mask,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for cls in classes:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
for cls in reversed(classes):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
Loading…
Reference in New Issue
Block a user