Blur Bake

This commit is contained in:
2026-02-16 13:51:25 +09:00
parent fc2dc0a478
commit 67178e0f52
10 changed files with 826 additions and 285 deletions
+224 -205
View File
@@ -1,242 +1,257 @@
"""
Apply Blur Operator for masked face blur in VSE.
Bake-and-swap blur operators for VSE.
Provides operators to apply blur effects using mask strips
generated by the face detection operators.
This module bakes masked blur into a regular video file using the inference
server, then swaps the active strip's source filepath to the baked result.
"""
import os
import bpy
from bpy.props import FloatProperty, IntProperty, StringProperty
from bpy.props import IntProperty
from bpy.types import Operator
from ..core.async_bake_generator import get_bake_generator
from ..core.async_generator import get_generator as get_mask_generator
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,
)
KEY_ORIGINAL = "facemask_original_filepath"
KEY_BAKED = "facemask_baked_filepath"
KEY_MODE = "facemask_source_mode"
KEY_FORMAT = "facemask_bake_format"
KEY_BLUR_SIZE = "facemask_bake_blur_size"
FORMAT_EXT = {
"MP4": "mp4",
"AVI": "avi",
"MOV": "mov",
}
def _find_mask_strip(seq_editor, strip_name: str):
return seq_editor.strips.get(f"{strip_name}_mask")
def _resolve_mask_path(mask_strip) -> str:
if mask_strip.type == "MOVIE":
return bpy.path.abspath(mask_strip.filepath)
return ""
def _output_path(video_strip, mask_path: str, fmt: str) -> str:
ext = FORMAT_EXT.get(fmt, "mp4")
out_dir = os.path.dirname(mask_path)
safe_name = video_strip.name.replace("/", "_").replace("\\", "_")
return os.path.join(out_dir, f"{safe_name}_blurred.{ext}")
def _reload_movie_strip(strip):
if hasattr(strip, "reload"):
try:
strip.reload()
except Exception:
pass
def _set_strip_source(strip, filepath: str):
strip.filepath = filepath
_reload_movie_strip(strip)
class SEQUENCER_OT_bake_and_swap_blur_source(Operator):
"""Bake masked blur and replace active strip source with baked video."""
bl_idname = "sequencer.bake_and_swap_blur_source"
bl_label = "Bake & Swap Source"
bl_description = "Bake masked blur to video and swap active strip source"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
"""Check if operator can run."""
if not context.scene.sequence_editor:
return False
seq_editor = context.scene.sequence_editor
strip = seq_editor.active_strip
if not strip:
# Prevent overlapping heavy tasks
if get_mask_generator().is_running:
return False
if strip.type not in {'MOVIE', 'IMAGE'}:
if get_bake_generator().is_running:
return False
strip = context.scene.sequence_editor.active_strip
return bool(strip and strip.type == "MOVIE")
# Check if corresponding mask strip exists
mask_name = f"{strip.name}_mask"
return mask_name in seq_editor.strips
def execute(self, context):
seq_editor = context.scene.sequence_editor
scene = context.scene
video_strip = seq_editor.active_strip
# Auto-detect mask strip
mask_name = f"{video_strip.name}_mask"
mask_strip = seq_editor.strips.get(mask_name)
mask_strip = _find_mask_strip(seq_editor, video_strip.name)
if not mask_strip:
self.report({'ERROR'}, f"Mask strip not found: {mask_name}")
return {'CANCELLED'}
self.report({"ERROR"}, f"Mask strip not found: {video_strip.name}_mask")
return {"CANCELLED"}
video_path = bpy.path.abspath(video_strip.filepath)
mask_path = _resolve_mask_path(mask_strip)
if not os.path.exists(video_path):
self.report({"ERROR"}, f"Source video not found: {video_path}")
return {"CANCELLED"}
if not mask_path or not os.path.exists(mask_path):
self.report({"ERROR"}, f"Mask video not found: {mask_path}")
return {"CANCELLED"}
bake_format = scene.facemask_bake_format
output_path = _output_path(video_strip, mask_path, bake_format)
blur_size = int(scene.facemask_bake_blur_size)
# Reuse baked cache when parameters match and file still exists.
cached_baked_path = video_strip.get(KEY_BAKED)
cached_format = video_strip.get(KEY_FORMAT)
cached_blur_size = video_strip.get(KEY_BLUR_SIZE)
try:
cached_blur_size_int = int(cached_blur_size)
except (TypeError, ValueError):
cached_blur_size_int = None
if (
cached_baked_path
and os.path.exists(cached_baked_path)
and cached_format == bake_format
and cached_blur_size_int == blur_size
):
if video_strip.get(KEY_MODE) != "baked":
video_strip[KEY_MODE] = "baked"
_set_strip_source(video_strip, cached_baked_path)
self.report({"INFO"}, "Using cached baked blur")
return {"FINISHED"}
bake_generator = get_bake_generator()
wm = context.window_manager
def on_complete(status, data):
strip = context.scene.sequence_editor.strips.get(video_strip.name)
if not strip:
print(f"[FaceMask] Bake complete but strip no longer exists: {video_strip.name}")
return
if status == "done":
result_path = data or output_path
original_path = strip.get(KEY_ORIGINAL)
current_mode = strip.get(KEY_MODE, "original")
if not original_path or current_mode != "baked":
strip[KEY_ORIGINAL] = video_path
strip[KEY_BAKED] = result_path
strip[KEY_MODE] = "baked"
strip[KEY_FORMAT] = bake_format
strip[KEY_BLUR_SIZE] = blur_size
_set_strip_source(strip, result_path)
print(f"[FaceMask] Bake completed and source swapped: {result_path}")
elif status == "error":
print(f"[FaceMask] Bake failed: {data}")
elif status == "cancelled":
print("[FaceMask] Bake cancelled")
for area in context.screen.areas:
if area.type == "SEQUENCE_EDITOR":
area.tag_redraw()
def on_progress(current, total):
wm.bake_progress = current
wm.bake_total = max(total, 1)
for area in context.screen.areas:
if area.type == "SEQUENCE_EDITOR":
area.tag_redraw()
wm.bake_progress = 0
wm.bake_total = 1
try:
# Use Mask Modifier approach (Blender 5.0 compatible)
self._apply_with_mask_modifier(context, video_strip, mask_strip)
bake_generator.start(
video_path=video_path,
mask_path=mask_path,
output_path=output_path,
blur_size=blur_size,
fmt=bake_format.lower(),
on_complete=on_complete,
on_progress=on_progress,
)
except Exception as e:
self.report({'ERROR'}, f"Failed to apply blur: {e}")
return {'CANCELLED'}
self.report({"ERROR"}, f"Failed to start bake: {e}")
return {"CANCELLED"}
return {'FINISHED'}
def _apply_with_mask_modifier(self, context, video_strip: "bpy.types.Strip", mask_strip: "bpy.types.Strip"):
"""
Apply blur using Mask Modifier, grouped in a Meta Strip.
self.report({"INFO"}, "Started blur bake in background")
return {"FINISHED"}
Workflow:
1. Duplicate the video strip
2. Create Gaussian Blur effect on the duplicate
3. Add Mask modifier to the blur effect (references mask strip)
4. Group all into a Meta Strip
The blur effect with mask will automatically composite over the original
video due to VSE's channel layering system.
"""
seq_editor = context.scene.sequence_editor
class SEQUENCER_OT_restore_original_source(Operator):
"""Restore active strip source filepath to original video."""
# Find available channels
used_channels = {s.channel for s in seq_editor.strips}
duplicate_channel = video_strip.channel + 1
while duplicate_channel in used_channels:
duplicate_channel += 1
bl_idname = "sequencer.restore_original_source"
bl_label = "Restore Original Source"
bl_description = "Restore active strip to original source filepath"
bl_options = {"REGISTER", "UNDO"}
blur_channel = duplicate_channel + 1
while blur_channel in used_channels:
blur_channel += 1
@classmethod
def poll(cls, context):
if not context.scene.sequence_editor:
return False
if get_bake_generator().is_running:
return False
strip = context.scene.sequence_editor.active_strip
if not strip or strip.type != "MOVIE":
return False
return bool(strip.get(KEY_ORIGINAL))
# Step 1: Duplicate the video strip
if video_strip.type == 'MOVIE':
video_copy = seq_editor.strips.new_movie(
name=f"{video_strip.name}_copy",
filepath=bpy.path.abspath(video_strip.filepath),
channel=duplicate_channel,
frame_start=video_strip.frame_final_start,
)
elif video_strip.type == 'IMAGE':
# For image sequences, duplicate differently
video_copy = seq_editor.strips.new_image(
name=f"{video_strip.name}_copy",
filepath=bpy.path.abspath(video_strip.elements[0].filename) if video_strip.elements else "",
channel=duplicate_channel,
frame_start=video_strip.frame_final_start,
)
# Copy all elements
for elem in video_strip.elements[1:]:
video_copy.elements.append(elem.filename)
def execute(self, context):
strip = context.scene.sequence_editor.active_strip
original_path = strip.get(KEY_ORIGINAL)
if not original_path:
self.report({"ERROR"}, "Original source path is not stored")
return {"CANCELLED"}
if not os.path.exists(original_path):
self.report({"ERROR"}, f"Original source not found: {original_path}")
return {"CANCELLED"}
_set_strip_source(strip, original_path)
strip[KEY_MODE] = "original"
self.report({"INFO"}, "Restored original source")
return {"FINISHED"}
class SEQUENCER_OT_apply_mask_blur(Operator):
"""Compatibility alias: run bake-and-swap blur workflow."""
bl_idname = "sequencer.apply_mask_blur"
bl_label = "Apply Mask Blur"
bl_description = "Compatibility alias for Bake & Swap Source"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return SEQUENCER_OT_bake_and_swap_blur_source.poll(context)
def execute(self, context):
return bpy.ops.sequencer.bake_and_swap_blur_source("EXEC_DEFAULT")
class SEQUENCER_OT_cancel_bake_blur(Operator):
"""Cancel ongoing blur bake."""
bl_idname = "sequencer.cancel_bake_blur"
bl_label = "Cancel Blur Bake"
bl_description = "Cancel current blur bake process"
bl_options = {"REGISTER"}
def execute(self, context):
bake_generator = get_bake_generator()
if bake_generator.is_running:
bake_generator.cancel()
self.report({"INFO"}, "Blur bake cancelled")
else:
raise ValueError(f"Unsupported strip type: {video_strip.type}")
# Match strip length
strip_length = video_strip.frame_final_end - video_strip.frame_final_start
video_copy.frame_final_end = video_copy.frame_final_start + strip_length
# Step 2: Create Gaussian Blur effect on the duplicate
blur_effect = seq_editor.strips.new_effect(
name=f"{video_strip.name}_blur",
type='GAUSSIAN_BLUR',
channel=blur_channel,
frame_start=video_strip.frame_final_start,
length=strip_length,
input1=video_copy,
)
# Set blur size (Blender 5.0 API)
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
# Step 3: Add Mask modifier to the blur effect
mask_mod = blur_effect.modifiers.new(
name="FaceMask",
type='MASK'
)
# Set mask input (Blender 5.0 API)
if hasattr(mask_mod, 'input_mask_strip'):
mask_mod.input_mask_strip = mask_strip
elif hasattr(mask_mod, 'input_mask_id'):
mask_mod.input_mask_type = 'STRIP'
mask_mod.input_mask_id = mask_strip
# Hide the mask strip (but keep it active for the modifier)
mask_strip.mute = True
# Step 4: Create Meta Strip to group everything
# Deselect all first
for strip in seq_editor.strips:
strip.select = False
# Select the strips to group
video_copy.select = True
blur_effect.select = True
mask_strip.select = True
# Set active strip for context
seq_editor.active_strip = blur_effect
# Create meta strip using operator
bpy.ops.sequencer.meta_make()
# Find the newly created meta strip (it will be selected)
meta_strip = None
for strip in seq_editor.strips:
if strip.select and strip.type == 'META':
meta_strip = strip
break
if meta_strip:
meta_strip.name = f"{video_strip.name}_blurred_meta"
self.report({'INFO'}, f"Applied blur with Mask Modifier (grouped in Meta Strip)")
else:
self.report({'INFO'}, f"Applied blur with Mask Modifier (blur on channel {blur_channel})")
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")
self.report({"WARNING"}, "No blur bake in progress")
return {"FINISHED"}
# Registration
classes = [
SEQUENCER_OT_bake_and_swap_blur_source,
SEQUENCER_OT_restore_original_source,
SEQUENCER_OT_cancel_bake_blur,
SEQUENCER_OT_apply_mask_blur,
]
@@ -244,8 +259,12 @@ classes = [
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.WindowManager.bake_progress = IntProperty(default=0)
bpy.types.WindowManager.bake_total = IntProperty(default=0)
def unregister():
del bpy.types.WindowManager.bake_progress
del bpy.types.WindowManager.bake_total
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
+4 -15
View File
@@ -10,6 +10,8 @@ import bpy
from bpy.types import Operator
from bpy.props import BoolProperty
from ..core.utils import get_cache_root, get_cache_dir_for_strip
class SEQUENCER_OT_clear_mask_cache(Operator):
"""Clear mask cache directories."""
@@ -26,21 +28,12 @@ class SEQUENCER_OT_clear_mask_cache(Operator):
)
def execute(self, context):
import tempfile
blend_file = bpy.data.filepath
total_size = 0
cleared_count = 0
if self.all_strips:
# Clear all cache directories
if blend_file:
# Project cache
project_dir = os.path.dirname(blend_file)
cache_root = os.path.join(project_dir, ".mask_cache")
else:
# Temp cache
cache_root = os.path.join(tempfile.gettempdir(), "blender_mask_cache")
cache_root = get_cache_root()
if os.path.exists(cache_root):
# Calculate size before deletion
@@ -72,11 +65,7 @@ class SEQUENCER_OT_clear_mask_cache(Operator):
return {'CANCELLED'}
strip = seq_editor.active_strip
if blend_file:
project_dir = os.path.dirname(blend_file)
cache_dir = os.path.join(project_dir, ".mask_cache", strip.name)
else:
cache_dir = os.path.join(tempfile.gettempdir(), "blender_mask_cache", strip.name)
cache_dir = get_cache_dir_for_strip(strip.name)
if os.path.exists(cache_dir):
# Calculate size
+17 -22
View File
@@ -11,6 +11,7 @@ from bpy.props import IntProperty
from bpy.types import Operator
from ..core.async_generator import get_generator
from ..core.utils import get_cache_dir_for_strip
class SEQUENCER_OT_generate_face_mask(Operator):
@@ -125,19 +126,7 @@ class SEQUENCER_OT_generate_face_mask(Operator):
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)
cache_dir = get_cache_dir_for_strip(strip.name)
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
@@ -157,16 +146,22 @@ class SEQUENCER_OT_generate_face_mask(Operator):
# Check for MP4 video (new format)
mask_video = os.path.join(cache_dir, "mask.mp4")
if os.path.exists(mask_video):
# Verify video has expected number of frames
import cv2
cap = cv2.VideoCapture(mask_video)
if cap.isOpened():
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
# Prefer frame-count verification when cv2 is available, but do not
# hard-fail on Blender Python environments without cv2.
try:
import cv2
cap = cv2.VideoCapture(mask_video)
if cap.isOpened():
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
# Accept cache if at least 90% of frames exist
return frame_count >= expected_frames * 0.9
cap.release()
# Accept cache if at least 90% of frames exist
return frame_count >= expected_frames * 0.9
cap.release()
return False
return False
except Exception:
# Fallback: treat existing MP4 cache as valid when cv2 is unavailable.
return True
# Fallback: check for PNG sequence (backward compatibility)
mask_files = [f for f in os.listdir(cache_dir)