This commit is contained in:
2026-02-06 08:16:23 +09:00
commit 3c28cb0c94
12 changed files with 1258 additions and 0 deletions
+5
View 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
View 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
View 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
View 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]