init
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user