From 3c28cb0c94df52b45fe467f693179415a8c0ca68 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 6 Feb 2026 08:16:23 +0900 Subject: [PATCH] init --- .gitignore | 18 +++ __init__.py | 35 +++++ blender_manifest.toml | 25 ++++ core/__init__.py | 5 + core/async_generator.py | 249 +++++++++++++++++++++++++++++++++ core/compositor_setup.py | 166 ++++++++++++++++++++++ core/face_detector.py | 160 ++++++++++++++++++++++ operators/__init__.py | 14 ++ operators/apply_blur.py | 175 ++++++++++++++++++++++++ operators/generate_mask.py | 273 +++++++++++++++++++++++++++++++++++++ panels/__init__.py | 11 ++ panels/vse_panel.py | 127 +++++++++++++++++ 12 files changed, 1258 insertions(+) create mode 100644 .gitignore create mode 100644 __init__.py create mode 100644 blender_manifest.toml create mode 100644 core/__init__.py create mode 100644 core/async_generator.py create mode 100644 core/compositor_setup.py create mode 100644 core/face_detector.py create mode 100644 operators/__init__.py create mode 100644 operators/apply_blur.py create mode 100644 operators/generate_mask.py create mode 100644 panels/__init__.py create mode 100644 panels/vse_panel.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e66a1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.mask_cache/ +*.mp4 +test.blend +wheels/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Blender +*.blend1 +*.blend2 + +# 環境 +.direnv/ +.envrc.local diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4b2d33b --- /dev/null +++ b/__init__.py @@ -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() diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..fa5e548 --- /dev/null +++ b/blender_manifest.toml @@ -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" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..671bdb1 --- /dev/null +++ b/core/__init__.py @@ -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 diff --git a/core/async_generator.py b/core/async_generator.py new file mode 100644 index 0000000..7edd3b8 --- /dev/null +++ b/core/async_generator.py @@ -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 diff --git a/core/compositor_setup.py b/core/compositor_setup.py new file mode 100644 index 0000000..687f7ed --- /dev/null +++ b/core/compositor_setup.py @@ -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) diff --git a/core/face_detector.py b/core/face_detector.py new file mode 100644 index 0000000..0d76220 --- /dev/null +++ b/core/face_detector.py @@ -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] diff --git a/operators/__init__.py b/operators/__init__.py new file mode 100644 index 0000000..bc3c68d --- /dev/null +++ b/operators/__init__.py @@ -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() diff --git a/operators/apply_blur.py b/operators/apply_blur.py new file mode 100644 index 0000000..bb68e76 --- /dev/null +++ b/operators/apply_blur.py @@ -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) diff --git a/operators/generate_mask.py b/operators/generate_mask.py new file mode 100644 index 0000000..4bfcc8f --- /dev/null +++ b/operators/generate_mask.py @@ -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) diff --git a/panels/__init__.py b/panels/__init__.py new file mode 100644 index 0000000..b1cf0e4 --- /dev/null +++ b/panels/__init__.py @@ -0,0 +1,11 @@ +"""Panels package.""" + +from . import vse_panel + + +def register(): + vse_panel.register() + + +def unregister(): + vse_panel.unregister() diff --git a/panels/vse_panel.py b/panels/vse_panel.py new file mode 100644 index 0000000..c2e1d6f --- /dev/null +++ b/panels/vse_panel.py @@ -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)