This commit is contained in:
Keisuke Hirata 2026-02-06 08:16:23 +09:00
commit 3c28cb0c94
12 changed files with 1258 additions and 0 deletions

18
.gitignore vendored Normal file
View 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
View 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
View 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
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
core/async_generator.py Normal file
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
core/compositor_setup.py Normal file
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
core/face_detector.py Normal file
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]

14
operators/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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)