Compare commits

..
23 Commits
Author SHA1 Message Date
Hare eb028ed278 姿勢推定と頭部検知を合成する手段を追加 2026-02-23 04:21:53 +09:00
Hare de99aef9ad 姿勢推定モデルを止め、頭部特化モデルに変更してテスト 2026-02-23 03:56:23 +09:00
Hare dc41327cea blenderの環境依存によるlintエラーを抑制 2026-02-22 16:36:20 +09:00
Hare be65abc6b0 feat: 静画に対応 2026-02-22 16:35:51 +09:00
Hare 32e4fbceb2 fix: ソースFPSの認識の問題 2026-02-22 16:19:52 +09:00
Hare 0fdff5423e Batch処理 2026-02-22 04:36:28 +09:00
Hare d67265aa39 UI周りの改善 2026-02-19 12:55:30 +09:00
Hare a3de61d5ce パフォーマンスの修正・Blurサイズ問題の修正 2026-02-19 11:29:01 +09:00
Hare da9de60697 Blurサイズ問題の修正 2026-02-19 09:45:05 +09:00
Hare 9ce6ec99d3 fix: ROIの実装ミス 2026-02-18 20:21:53 +09:00
Hare 08f20fa6fe Change model: face -> pose 2026-02-18 20:18:53 +09:00
Hare 920695696b 非同期化・unused削除 2026-02-17 00:20:02 +09:00
Hare 914667edbf ROI修正/ハードウェアエンコード 2026-02-16 18:20:31 +09:00
Hare 0d63b2ef6d 旧debug資産の整理 2026-02-16 16:08:22 +09:00
Hare e693f5b694 feat: 中間データのmsgpack移行 2026-02-16 16:07:38 +09:00
Hare 67178e0f52 Blur Bake 2026-02-16 13:51:25 +09:00
Hare fc2dc0a478 パフォーマンス#2 2026-02-13 00:05:55 +09:00
Hare d8d27ddf23 パフォーマンス#1 2026-02-12 23:46:51 +09:00
Hare c15cd659e3 mp4保存 2026-02-12 22:52:00 +09:00
Hare eeb8400727 UI拡充 2026-02-12 22:03:02 +09:00
Hare f2665a49dd Addon起動の環境問題 2026-02-12 21:49:22 +09:00
Hare a2131a962b Addonに適用 2026-02-12 18:52:55 +09:00
Hare 0b6c31501e GPUテスト 2026-02-12 18:26:22 +09:00
20 changed files with 4097 additions and 662 deletions
+37
View File
@@ -3,3 +3,40 @@
街歩き映像に対して自動モザイクを掛けるために開発しました。 街歩き映像に対して自動モザイクを掛けるために開発しました。
使用:https://github.com/akanametov/yolo-face 使用:https://github.com/akanametov/yolo-face
## 開発者向け情報
### GPU環境の確認
推論サーバーは起動時に環境診断情報を出力します:
- Python環境(バージョン、仮想環境の検出)
- ROCm環境変数(ROCM_PATH、HSA_OVERRIDE_GFX_VERSION等)
- GPU検出状況(デバイス名、ROCmバージョン)
```bash
# サーバーを起動して診断ログを確認
python server/main.py
# サーバーのGPU状態を確認
curl -s http://127.0.0.1:8181/status | jq
```
出力例:
```
[FaceMask Server] Startup Diagnostics
======================================================================
[Python Environment]
Python Version: 3.12.12
Virtual Environment: Yes
[ROCm Environment Variables]
ROCM_PATH: /nix/store/.../clr-7.1.1
HSA_OVERRIDE_GFX_VERSION: 11.0.0
[GPU Detection]
torch.cuda.is_available(): True
GPU Device 0: AMD Radeon Graphics
ROCm Version (HIP): 7.0.51831
======================================================================
```
+66
View File
@@ -15,21 +15,87 @@ bl_info = {
def register(): def register():
"""Register all extension components.""" """Register all extension components."""
import bpy
from bpy.props import FloatProperty, IntProperty, EnumProperty, StringProperty
from . import operators from . import operators
from . import panels from . import panels
# Register scene properties for face detection parameters
bpy.types.Scene.facemask_conf_threshold = FloatProperty(
name="Confidence",
description="YOLO confidence threshold (higher = fewer false positives)",
default=0.5,
min=0.1,
max=1.0,
step=0.01,
)
bpy.types.Scene.facemask_iou_threshold = FloatProperty(
name="IOU Threshold",
description="Non-maximum suppression IOU threshold",
default=0.45,
min=0.1,
max=1.0,
step=0.01,
)
bpy.types.Scene.facemask_cache_dir = StringProperty(
name="Cache Directory",
description="Optional cache root directory (empty = default .mask_cache)",
default="",
subtype='DIR_PATH',
)
bpy.types.Scene.facemask_bake_blur_size = IntProperty(
name="Bake Blur Size",
description="Gaussian blur size (pixels) used for bake",
default=50,
min=1,
max=501,
)
bpy.types.Scene.facemask_bake_display_scale = FloatProperty(
name="Mask Scale",
description="Scale factor for the blur mask ellipse at bake time (1.0 = raw detection size)",
default=1.3,
min=0.5,
max=3.0,
step=0.1,
)
bpy.types.Scene.facemask_bake_format = EnumProperty(
name="Bake Format",
description="Output format for baked blur video",
items=[
("MP4", "MP4", "Export as .mp4"),
("AVI", "AVI", "Export as .avi"),
("MOV", "MOV", "Export as .mov"),
],
default="MP4",
)
operators.register() operators.register()
panels.register() panels.register()
def unregister(): def unregister():
"""Unregister all extension components.""" """Unregister all extension components."""
import bpy
from . import operators from . import operators
from . import panels from . import panels
panels.unregister() panels.unregister()
operators.unregister() operators.unregister()
# Unregister scene properties
del bpy.types.Scene.facemask_conf_threshold
del bpy.types.Scene.facemask_iou_threshold
del bpy.types.Scene.facemask_cache_dir
del bpy.types.Scene.facemask_bake_blur_size
del bpy.types.Scene.facemask_bake_display_scale
del bpy.types.Scene.facemask_bake_format
if __name__ == "__main__": if __name__ == "__main__":
register() register()
+3 -2
View File
@@ -1,4 +1,5 @@
"""Core module exports.""" """Core module exports."""
from .async_generator import AsyncMaskGenerator, get_generator from .async_bake_generator import AsyncBakeGenerator as AsyncBakeGenerator, get_bake_generator as get_bake_generator
from .compositor_setup import create_mask_blur_node_tree, get_or_create_blur_node_tree from .async_generator import AsyncMaskGenerator as AsyncMaskGenerator, get_generator as get_generator
from .compositor_setup import create_mask_blur_node_tree as create_mask_blur_node_tree, get_or_create_blur_node_tree as get_or_create_blur_node_tree
+247
View File
@@ -0,0 +1,247 @@
"""
Async blur bake generator using Thread + Queue + Timer pattern.
This module mirrors AsyncMaskGenerator behavior for bake-and-swap workflow,
so Blender UI remains responsive during server-side bake processing.
"""
import threading
import queue
from typing import Optional, Callable
# Will be imported when running inside Blender
bpy = None
class AsyncBakeGenerator:
"""Asynchronous bake generator for non-blocking blur bake tasks."""
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,
detections_path: str,
output_path: str,
blur_size: int,
display_scale: float,
fmt: str,
on_complete: Optional[Callable] = None,
on_progress: Optional[Callable] = None,
):
"""Start asynchronous bake request and progress polling."""
global bpy
import bpy as _bpy
bpy = _bpy
if self.is_running:
raise RuntimeError("Blur bake already in progress")
self.is_running = True
self.total_frames = 0
self.current_frame = 0
self._on_complete = on_complete
self._on_progress = on_progress
self.worker_thread = threading.Thread(
target=self._worker,
args=(video_path, detections_path, output_path, blur_size, display_scale, fmt),
daemon=True,
)
self.worker_thread.start()
bpy.app.timers.register(
self._check_progress,
first_interval=0.1,
)
def start_images(
self,
image_dir: str,
filenames: list,
output_dir: str,
detections_path: str,
blur_size: int,
display_scale: float,
on_complete=None,
on_progress=None,
):
"""画像シーケンスのぼかしBakeを非同期で開始する。"""
global bpy
import bpy as _bpy
bpy = _bpy
if self.is_running:
raise RuntimeError("Blur bake already in progress")
self.is_running = True
self.total_frames = len(filenames)
self.current_frame = 0
self._on_complete = on_complete
self._on_progress = on_progress
self.worker_thread = threading.Thread(
target=self._worker_images,
args=(image_dir, filenames, output_dir, detections_path, blur_size, display_scale),
daemon=True,
)
self.worker_thread.start()
bpy.app.timers.register(self._check_progress, first_interval=0.1)
def cancel(self):
"""Cancel the current bake processing."""
self.is_running = False
if self.worker_thread and self.worker_thread.is_alive():
self.worker_thread.join(timeout=2.0)
def _worker_images(
self,
image_dir: str,
filenames: list,
output_dir: str,
detections_path: str,
blur_size: int,
display_scale: float,
):
import time
from .inference_client import get_client
task_id = None
try:
client = get_client()
task_id = client.bake_image_blur(
image_dir=image_dir,
filenames=filenames,
output_dir=output_dir,
detections_path=detections_path,
blur_size=blur_size,
display_scale=display_scale,
)
while self.is_running:
status = client.get_task_status(task_id)
state = status.get("status")
total = status.get("total", 0)
if total > 0:
self.total_frames = total
progress = status.get("progress", 0)
if progress >= 0:
self.progress_queue.put(("progress", progress))
if state == "completed":
result_path = status.get("result_path", output_dir)
self.result_queue.put(("done", result_path))
return
if state == "failed":
self.result_queue.put(("error", status.get("message", "Unknown error")))
return
if state == "cancelled":
self.result_queue.put(("cancelled", None))
return
time.sleep(0.5)
if task_id:
client.cancel_task(task_id)
self.result_queue.put(("cancelled", None))
except Exception as e:
self.result_queue.put(("error", str(e)))
def _worker(
self,
video_path: str,
detections_path: str,
output_path: str,
blur_size: int,
display_scale: float,
fmt: str,
):
import time
from .inference_client import get_client
task_id = None
try:
client = get_client()
task_id = client.bake_blur(
video_path=video_path,
detections_path=detections_path,
output_path=output_path,
blur_size=blur_size,
display_scale=display_scale,
fmt=fmt,
)
while self.is_running:
status = client.get_task_status(task_id)
state = status.get("status")
total = status.get("total", 0)
if total > 0:
self.total_frames = total
progress = status.get("progress", 0)
if progress >= 0:
self.progress_queue.put(("progress", progress))
if state == "completed":
result_path = status.get("result_path", output_path)
self.result_queue.put(("done", result_path))
return
if state == "failed":
error_msg = status.get("message", "Unknown server error")
self.result_queue.put(("error", error_msg))
return
if state == "cancelled":
self.result_queue.put(("cancelled", None))
return
time.sleep(0.5)
# Local cancel path
if task_id:
client.cancel_task(task_id)
self.result_queue.put(("cancelled", None))
except Exception as e:
self.result_queue.put(("error", str(e)))
def _check_progress(self) -> Optional[float]:
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
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
except queue.Empty:
pass
if self.is_running:
return 0.1
return None
_bake_generator: Optional[AsyncBakeGenerator] = None
def get_bake_generator() -> AsyncBakeGenerator:
global _bake_generator
if _bake_generator is None:
_bake_generator = AsyncBakeGenerator()
return _bake_generator
+202 -9
View File
@@ -9,8 +9,7 @@ Blender's UI remains responsive via bpy.app.timers.
import os import os
import threading import threading
import queue import queue
from functools import partial from typing import Optional, Callable
from typing import Optional, Callable, Tuple
from pathlib import Path from pathlib import Path
# Will be imported when running inside Blender # Will be imported when running inside Blender
@@ -45,7 +44,6 @@ class AsyncMaskGenerator:
fps: float, fps: float,
conf_threshold: float = 0.5, conf_threshold: float = 0.5,
iou_threshold: float = 0.45, iou_threshold: float = 0.45,
mask_scale: float = 1.5,
on_complete: Optional[Callable] = None, on_complete: Optional[Callable] = None,
on_progress: Optional[Callable] = None, on_progress: Optional[Callable] = None,
): ):
@@ -95,7 +93,6 @@ class AsyncMaskGenerator:
fps, fps,
conf_threshold, conf_threshold,
iou_threshold, iou_threshold,
mask_scale,
), ),
daemon=True, daemon=True,
) )
@@ -107,12 +104,189 @@ class AsyncMaskGenerator:
first_interval=0.1, first_interval=0.1,
) )
def start_images(
self,
image_dir: str,
filenames: list,
output_dir: str,
start_index: int,
end_index: int,
conf_threshold: float = 0.5,
iou_threshold: float = 0.45,
on_complete=None,
on_progress=None,
):
"""画像シーケンスの顔検出を非同期で開始する。"""
global bpy
import bpy as _bpy
bpy = _bpy
if self.is_running:
raise RuntimeError("Mask generation already in progress")
self.is_running = True
self.total_frames = end_index - start_index + 1
self.current_frame = 0
self._on_complete = on_complete
self._on_progress = on_progress
os.makedirs(output_dir, exist_ok=True)
self.worker_thread = threading.Thread(
target=self._worker_images,
args=(image_dir, filenames, output_dir, start_index, end_index,
conf_threshold, iou_threshold),
daemon=True,
)
self.worker_thread.start()
bpy.app.timers.register(self._check_progress, first_interval=0.1)
def start_augment_pose(
self,
detections_path: str,
total_frames: int,
conf_threshold: float = 0.5,
iou_threshold: float = 0.45,
on_complete=None,
on_progress=None,
):
"""既存キャッシュへの pose 補完を非同期で開始する。"""
global bpy
import bpy as _bpy
bpy = _bpy
if self.is_running:
raise RuntimeError("Mask generation already in progress")
self.is_running = True
self.total_frames = total_frames
self.current_frame = 0
self._on_complete = on_complete
self._on_progress = on_progress
self.worker_thread = threading.Thread(
target=self._worker_augment_pose,
args=(detections_path, conf_threshold, iou_threshold),
daemon=True,
)
self.worker_thread.start()
bpy.app.timers.register(self._check_progress, first_interval=0.1)
def _worker_augment_pose(
self,
detections_path: str,
conf_threshold: float,
iou_threshold: float,
):
"""client.augment_pose() を呼んで task_id でポーリング。"""
import time
from .inference_client import get_client
try:
client = get_client()
task_id = client.augment_pose(
detections_path=detections_path,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
)
while self.is_running:
status = client.get_task_status(task_id)
state = status.get("status")
total = status.get("total", 0)
if total > 0:
self.total_frames = total
if state == "completed":
progress = status.get("progress", self.total_frames)
if progress >= 0:
self.progress_queue.put(("progress", progress))
result_path = status.get("result_path", detections_path)
self.result_queue.put(("done", result_path))
return
elif state == "failed":
self.result_queue.put(("error", status.get("message", "Unknown error")))
return
elif state == "cancelled":
self.result_queue.put(("cancelled", None))
return
progress = status.get("progress", 0)
if progress >= 0:
self.progress_queue.put(("progress", progress))
time.sleep(0.5)
client.cancel_task(task_id)
self.result_queue.put(("cancelled", None))
except Exception as e:
import traceback
print(f"[FaceMask] Error: {e}")
traceback.print_exc()
self.result_queue.put(("error", str(e)))
def cancel(self): def cancel(self):
"""Cancel the current processing.""" """Cancel the current processing."""
self.is_running = False self.is_running = False
if self.worker_thread and self.worker_thread.is_alive(): if self.worker_thread and self.worker_thread.is_alive():
self.worker_thread.join(timeout=2.0) self.worker_thread.join(timeout=2.0)
def _worker_images(
self,
image_dir: str,
filenames: list,
output_dir: str,
start_index: int,
end_index: int,
conf_threshold: float,
iou_threshold: float,
):
import time
from .inference_client import get_client
try:
client = get_client()
task_id = client.generate_mask_images(
image_dir=image_dir,
filenames=filenames,
output_dir=output_dir,
start_index=start_index,
end_index=end_index,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
)
while self.is_running:
status = client.get_task_status(task_id)
state = status.get("status")
total = status.get("total", 0)
if total > 0:
self.total_frames = total
if state == "completed":
progress = status.get("progress", self.total_frames)
if progress >= 0:
self.progress_queue.put(("progress", progress))
result_path = status.get(
"result_path",
os.path.join(output_dir, "detections.msgpack"),
)
self.result_queue.put(("done", result_path))
return
elif state == "failed":
self.result_queue.put(("error", status.get("message", "Unknown error")))
return
elif state == "cancelled":
self.result_queue.put(("cancelled", None))
return
progress = status.get("progress", 0)
if progress >= 0:
self.progress_queue.put(("progress", progress))
time.sleep(0.5)
client.cancel_task(task_id)
self.result_queue.put(("cancelled", None))
except Exception as e:
self.result_queue.put(("error", str(e)))
def _worker( def _worker(
self, self,
video_path: str, video_path: str,
@@ -122,7 +296,6 @@ class AsyncMaskGenerator:
fps: float, fps: float,
conf_threshold: float, conf_threshold: float,
iou_threshold: float, iou_threshold: float,
mask_scale: float,
): ):
""" """
Worker thread function. Delegates to inference server and polls status. Worker thread function. Delegates to inference server and polls status.
@@ -134,7 +307,7 @@ class AsyncMaskGenerator:
client = get_client() client = get_client()
# Start task on server # Start task on server
print(f"[FaceMask] Requesting generation on server...") print("[FaceMask] Requesting generation on server...")
task_id = client.generate_mask( task_id = client.generate_mask(
video_path=video_path, video_path=video_path,
output_dir=output_dir, output_dir=output_dir,
@@ -142,7 +315,6 @@ class AsyncMaskGenerator:
end_frame=end_frame, end_frame=end_frame,
conf_threshold=conf_threshold, conf_threshold=conf_threshold,
iou_threshold=iou_threshold, iou_threshold=iou_threshold,
mask_scale=mask_scale,
) )
print(f"[FaceMask] Task started: {task_id}") print(f"[FaceMask] Task started: {task_id}")
@@ -151,8 +323,19 @@ class AsyncMaskGenerator:
status = client.get_task_status(task_id) status = client.get_task_status(task_id)
state = status.get("status") state = status.get("status")
total = status.get("total", 0)
if total > 0:
self.total_frames = total
if state == "completed": if state == "completed":
self.result_queue.put(("done", output_dir)) final_progress = status.get("progress", self.total_frames)
if final_progress >= 0:
self.progress_queue.put(("progress", final_progress))
result_path = status.get(
"result_path",
os.path.join(output_dir, "detections.msgpack"),
)
self.result_queue.put(("done", result_path))
return return
elif state == "failed": elif state == "failed":
@@ -167,7 +350,7 @@ class AsyncMaskGenerator:
# Report progress # Report progress
progress = status.get("progress", 0) progress = status.get("progress", 0)
if progress > 0: if progress >= 0:
self.progress_queue.put(("progress", progress)) self.progress_queue.put(("progress", progress))
time.sleep(0.5) time.sleep(0.5)
@@ -207,6 +390,16 @@ class AsyncMaskGenerator:
msg_type, data = self.result_queue.get_nowait() msg_type, data = self.result_queue.get_nowait()
self.is_running = False self.is_running = False
# Ensure UI receives a final progress update before completion.
if (
msg_type == "done"
and self.total_frames > 0
and self.current_frame < self.total_frames
and self._on_progress
):
self.current_frame = self.total_frames
self._on_progress(self.current_frame, self.total_frames)
if self._on_complete: if self._on_complete:
self._on_complete(msg_type, data) self._on_complete(msg_type, data)
+280
View File
@@ -0,0 +1,280 @@
"""
Batch processor for sequential Generate+Bake across multiple VSE strips.
Uses timer-based async chaining so Blender's UI stays responsive.
"""
from typing import List, Optional, Callable, Any
# Lazy-imported inside Blender
bpy = None
class _DummyOperator:
"""Dummy operator object for _start_bake_impl calls."""
def report(self, level, msg):
print(f"[FaceMask] Batch: {msg}")
class BatchProcessor:
"""Manages sequential Generate Detection Cache → Bake across a list of strips."""
def __init__(self):
self.is_running: bool = False
self._mode: str = "full" # "full" or "mask_only"
self._strip_names: List[str] = []
self._current_idx: int = 0
self._context: Any = None
self._cancelled: bool = False
self._results: List[dict] = []
self._on_item_complete: Optional[Callable] = None # (idx, total, name, status)
self._on_all_complete: Optional[Callable] = None # (results)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self, context, strips, on_item_complete=None, on_all_complete=None, mode="full"):
"""Start batch processing for the given strips.
mode:
"full" - マスク生成(キャッシュなければ)→ Bake
"mask_only" - キャッシュを無視してマスク生成のみ(Bakeしない)
"""
global bpy
import bpy as _bpy
bpy = _bpy
if self.is_running:
raise RuntimeError("Batch already running")
self.is_running = True
self._mode = mode
self._strip_names = [s.name for s in strips]
self._current_idx = 0
self._context = context
self._cancelled = False
self._results = []
self._on_item_complete = on_item_complete
self._on_all_complete = on_all_complete
wm = context.window_manager
wm.batch_current = 0
wm.batch_total = len(self._strip_names)
wm.batch_current_name = ""
bpy.app.timers.register(self._process_next, first_interval=0.0)
def cancel(self):
"""Cancel batch. Stops currently running mask gen / bake."""
self._cancelled = True
from .async_generator import get_generator
from .async_bake_generator import get_bake_generator
gen = get_generator()
bake_gen = get_bake_generator()
if gen.is_running:
gen.cancel()
if bake_gen.is_running:
bake_gen.cancel()
# ------------------------------------------------------------------
# Internal: queue stepping
# ------------------------------------------------------------------
def _process_next(self):
"""Process the next strip in the queue (called via timer)."""
if self._cancelled:
self._finish()
return None
if self._current_idx >= len(self._strip_names):
self._finish()
return None
strip_name = self._strip_names[self._current_idx]
seq_editor = self._context.scene.sequence_editor
strip = seq_editor.strips.get(strip_name)
if strip is None:
print(f"[FaceMask] Batch: strip not found, skipping: {strip_name}")
self._results.append({"strip": strip_name, "status": "skipped"})
if self._on_item_complete:
self._on_item_complete(self._current_idx, len(self._strip_names), strip_name, "skipped")
self._current_idx += 1
bpy.app.timers.register(self._process_next, first_interval=0.0)
return None
# Update wm progress labels
wm = self._context.window_manager
wm.batch_current = self._current_idx + 1
wm.batch_current_name = strip_name
for area in self._context.screen.areas:
if area.type == "SEQUENCE_EDITOR":
area.tag_redraw()
if self._mode == "mask_only":
# キャッシュを無視して常にマスク生成(Bakeしない)
self._start_mask_gen(strip)
else:
from .utils import check_detection_cache
if not check_detection_cache(strip.name):
self._start_mask_gen(strip)
else:
self._start_bake(strip)
return None # one-shot timer
def _schedule_next(self):
bpy.app.timers.register(self._process_next, first_interval=0.0)
# ------------------------------------------------------------------
# Mask generation
# ------------------------------------------------------------------
def _start_mask_gen(self, strip):
from ..operators.generate_mask import start_mask_gen_for_strip
strip_name = strip.name
def on_complete(status, data):
self._on_mask_done(strip_name, status, data)
def on_progress(current, total):
wm = self._context.window_manager
wm.mask_progress = current
wm.mask_total = max(total, 1)
for area in self._context.screen.areas:
if area.type == "SEQUENCE_EDITOR":
area.tag_redraw()
try:
start_mask_gen_for_strip(self._context, strip, on_complete, on_progress)
print(f"[FaceMask] Batch: started mask gen for {strip_name}")
except Exception as e:
print(f"[FaceMask] Batch: failed to start mask gen for {strip_name}: {e}")
self._on_mask_done(strip_name, "error", str(e))
def _on_mask_done(self, strip_name, status, data):
if self._cancelled or status == "cancelled":
self._results.append({"strip": strip_name, "status": "cancelled"})
self._finish()
return
if status == "error":
print(f"[FaceMask] Batch: mask gen failed for {strip_name}: {data}")
self._results.append({"strip": strip_name, "status": "error", "reason": str(data)})
if self._on_item_complete:
self._on_item_complete(self._current_idx, len(self._strip_names), strip_name, "error")
self._current_idx += 1
self._schedule_next()
return
# Mask gen succeeded
if self._mode == "mask_only":
# Bakeしない:結果を記録して次へ
self._results.append({"strip": strip_name, "status": "done"})
if self._on_item_complete:
self._on_item_complete(self._current_idx, len(self._strip_names), strip_name, "done")
self._current_idx += 1
self._schedule_next()
return
# full mode: proceed to bake
seq_editor = self._context.scene.sequence_editor
strip = seq_editor.strips.get(strip_name)
if strip is None:
print(f"[FaceMask] Batch: strip removed after mask gen: {strip_name}")
self._results.append({"strip": strip_name, "status": "skipped"})
if self._on_item_complete:
self._on_item_complete(self._current_idx, len(self._strip_names), strip_name, "skipped")
self._current_idx += 1
self._schedule_next()
return
self._start_bake(strip)
# ------------------------------------------------------------------
# Bake
# ------------------------------------------------------------------
def _start_bake(self, strip):
from .async_bake_generator import get_bake_generator
from ..operators.apply_blur import _start_bake_impl
strip_name = strip.name
def on_complete_extra(status, data):
self._on_bake_done(strip_name, status, data)
bake_gen = get_bake_generator()
result = _start_bake_impl(
_DummyOperator(),
self._context,
force=False,
strip=strip,
on_complete_extra=on_complete_extra,
)
if result == {"CANCELLED"}:
# Error starting bake
print(f"[FaceMask] Batch: bake failed to start for {strip_name}")
self._results.append({"strip": strip_name, "status": "error", "reason": "bake failed to start"})
if self._on_item_complete:
self._on_item_complete(self._current_idx, len(self._strip_names), strip_name, "error")
self._current_idx += 1
self._schedule_next()
elif not bake_gen.is_running:
# Cache hit: on_complete_extra was NOT called by _start_bake_impl
print(f"[FaceMask] Batch: bake cache hit for {strip_name}")
self._on_bake_done(strip_name, "done", None)
def _on_bake_done(self, strip_name, status, data):
if self._cancelled or status == "cancelled":
self._results.append({"strip": strip_name, "status": "cancelled"})
self._finish()
return
if status == "error":
print(f"[FaceMask] Batch: bake failed for {strip_name}: {data}")
self._results.append({"strip": strip_name, "status": "error", "reason": str(data)})
else:
self._results.append({"strip": strip_name, "status": "done"})
print(f"[FaceMask] Batch: completed {strip_name}")
if self._on_item_complete:
self._on_item_complete(self._current_idx, len(self._strip_names), strip_name, status)
self._current_idx += 1
self._schedule_next()
# ------------------------------------------------------------------
# Finish
# ------------------------------------------------------------------
def _finish(self):
self.is_running = False
wm = self._context.window_manager
wm.batch_current = 0
wm.batch_total = 0
wm.batch_current_name = ""
print(f"[FaceMask] Batch: all done. Results: {self._results}")
if self._on_all_complete:
self._on_all_complete(self._results)
for area in self._context.screen.areas:
if area.type == "SEQUENCE_EDITOR":
area.tag_redraw()
# Singleton
_batch_processor: Optional[BatchProcessor] = None
def get_batch_processor() -> BatchProcessor:
global _batch_processor
if _batch_processor is None:
_batch_processor = BatchProcessor()
return _batch_processor
+6 -11
View File
@@ -5,13 +5,10 @@ Creates and manages compositing node trees that apply blur
only to masked regions of a video strip. only to masked regions of a video strip.
""" """
from typing import Optional, Tuple
def create_mask_blur_node_tree( def create_mask_blur_node_tree(
name: str = "FaceMaskBlur", name: str = "FaceMaskBlur",
blur_size: int = 50, blur_size: int = 50,
) -> "bpy.types.NodeTree": ) -> "bpy.types.NodeTree": # noqa: F821
""" """
Create a compositing node tree for mask-based blur. Create a compositing node tree for mask-based blur.
@@ -110,10 +107,10 @@ def create_mask_blur_node_tree(
def setup_strip_compositor_modifier( def setup_strip_compositor_modifier(
strip: "bpy.types.Strip", strip: "bpy.types.Strip", # noqa: F821
mask_strip: "bpy.types.Strip", mask_strip: "bpy.types.Strip", # noqa: F821
node_tree: "bpy.types.NodeTree", node_tree: "bpy.types.NodeTree", # noqa: F821
) -> "bpy.types.SequenceModifier": ) -> "bpy.types.SequenceModifier": # noqa: F821
""" """
Add a Compositor modifier to a strip using the mask-blur node tree. Add a Compositor modifier to a strip using the mask-blur node tree.
@@ -125,8 +122,6 @@ def setup_strip_compositor_modifier(
Returns: Returns:
The created modifier The created modifier
""" """
import bpy
# Add compositor modifier # Add compositor modifier
modifier = strip.modifiers.new( modifier = strip.modifiers.new(
name="FaceMaskBlur", name="FaceMaskBlur",
@@ -153,7 +148,7 @@ def setup_strip_compositor_modifier(
return modifier return modifier
def get_or_create_blur_node_tree(blur_size: int = 50) -> "bpy.types.NodeTree": def get_or_create_blur_node_tree(blur_size: int = 50) -> "bpy.types.NodeTree": # noqa: F821
""" """
Get existing or create new blur node tree with specified blur size. Get existing or create new blur node tree with specified blur size.
+276 -32
View File
@@ -5,15 +5,16 @@ Manages the server process and handles HTTP communication
using standard library (avoiding requests dependency). using standard library (avoiding requests dependency).
""" """
import subprocess
import time
import json import json
import urllib.request
import urllib.error
import threading
import os import os
import signal import signal
from typing import Optional, Dict, Any, Tuple import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from typing import Any, Dict, Optional
class InferenceClient: class InferenceClient:
@@ -24,6 +25,8 @@ class InferenceClient:
def __init__(self): def __init__(self):
self.server_process: Optional[subprocess.Popen] = None self.server_process: Optional[subprocess.Popen] = None
self._server_lock = threading.Lock() self._server_lock = threading.Lock()
self.log_file = None
self.log_file_path = None
def start_server(self): def start_server(self):
"""Start the inference server process.""" """Start the inference server process."""
@@ -44,34 +47,71 @@ class InferenceClient:
# Load environment variables from .env file if it exists # Load environment variables from .env file if it exists
env_file = os.path.join(root_dir, ".env") env_file = os.path.join(root_dir, ".env")
if os.path.exists(env_file): if os.path.exists(env_file):
with open(env_file, 'r') as f: with open(env_file, "r") as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if line and not line.startswith('#') and '=' in line: if line and not line.startswith("#") and "=" in line:
key, value = line.split('=', 1) key, value = line.split("=", 1)
server_env[key] = value server_env[key] = value
print(f"[FaceMask] Loaded environment from: {env_file}") print(f"[FaceMask] Loaded environment from: {env_file}")
# Ensure PYTHONPATH includes project root # Clean PYTHONPATH to avoid conflicts with Nix Python packages
pythonpath = server_env.get('PYTHONPATH', '') # Only include project root to allow local imports
if pythonpath: server_env["PYTHONPATH"] = root_dir
server_env['PYTHONPATH'] = f"{root_dir}:{pythonpath}"
else: # Remove Python-related environment variables that might cause conflicts
server_env['PYTHONPATH'] = root_dir # These can cause venv to import packages from Nix instead of venv
env_vars_to_remove = [
"PYTHONUNBUFFERED",
"__PYVENV_LAUNCHER__", # macOS venv variable
"VIRTUAL_ENV", # Will be set by venv's Python automatically
]
for var in env_vars_to_remove:
server_env.pop(var, None)
# If there's a venv in the project, add it to PATH # If there's a venv in the project, add it to PATH
venv_bin = os.path.join(root_dir, ".venv", "bin") venv_bin = os.path.join(root_dir, ".venv", "bin")
if os.path.isdir(venv_bin): if os.path.isdir(venv_bin):
current_path = server_env.get('PATH', '') # Build a clean PATH with venv first, then essential system paths
server_env['PATH'] = f"{venv_bin}:{current_path}" # Filter out any Nix Python-specific paths to avoid version conflicts
current_path = server_env.get("PATH", "")
path_entries = current_path.split(":")
# Filter out Nix Python 3.11 paths
filtered_paths = [
p
for p in path_entries
if not ("/python3.11/" in p.lower() or "/python3-3.11" in p.lower())
]
# Reconstruct PATH with venv first
clean_path = ":".join([venv_bin] + filtered_paths)
server_env["PATH"] = clean_path
print(f"[FaceMask] Using venv from: {venv_bin}") print(f"[FaceMask] Using venv from: {venv_bin}")
# Start process with 'python' command (will use venv if PATH is set correctly) # Prepare log file for server output
import tempfile
log_dir = tempfile.gettempdir()
self.log_file_path = os.path.join(log_dir, "facemask_server.log")
self.log_file = open(self.log_file_path, "w", buffering=1) # Line buffered
print(f"[FaceMask] Server log: {self.log_file_path}")
# Start server with explicit Python executable when available.
python_executable = "python"
venv_python = os.path.join(venv_bin, "python")
if os.path.isfile(venv_python):
python_executable = venv_python
else:
python_executable = sys.executable
self.server_process = subprocess.Popen( self.server_process = subprocess.Popen(
["python", server_script], [python_executable, "-u", server_script], # -u for unbuffered output
cwd=root_dir, cwd=root_dir,
text=True, text=True,
env=server_env, env=server_env,
stdout=self.log_file, # Write to log file
stderr=subprocess.STDOUT, # Merge stderr into stdout
preexec_fn=os.setsid, # Create new process group preexec_fn=os.setsid, # Create new process group
) )
@@ -83,10 +123,46 @@ class InferenceClient:
# Check if process died # Check if process died
if self.server_process.poll() is not None: if self.server_process.poll() is not None:
raise RuntimeError(f"Server failed to start (rc={self.server_process.returncode})") # Read error output from log file
error_msg = f"Server failed to start (exit code: {self.server_process.returncode})"
print(f"[FaceMask] ERROR: {error_msg}")
try:
if self.log_file:
self.log_file.close()
with open(self.log_file_path, "r") as f:
log_content = f.read()
if log_content.strip():
print("[FaceMask] Server log:")
# Show last 50 lines
lines = log_content.strip().split("\n")
for line in lines[-50:]:
print(line)
except Exception as e:
print(f"[FaceMask] Could not read log file: {e}")
self.server_process = None
raise RuntimeError(error_msg)
time.sleep(0.5) time.sleep(0.5)
# If we get here, startup timed out
print("[FaceMask] Server startup timed out")
# Try to read partial log
try:
if self.log_file:
self.log_file.close()
with open(self.log_file_path, "r") as f:
log_content = f.read()
if log_content.strip():
print("[FaceMask] Server log (partial):")
lines = log_content.strip().split("\n")
for line in lines[-30:]:
print(line)
except Exception:
pass
raise RuntimeError("Server startup timed out") raise RuntimeError("Server startup timed out")
def stop_server(self): def stop_server(self):
@@ -102,10 +178,20 @@ class InferenceClient:
finally: finally:
self.server_process = None self.server_process = None
# Close log file
if self.log_file:
try:
self.log_file.close()
except Exception:
pass
self.log_file = None
def is_server_running(self) -> bool: def is_server_running(self) -> bool:
"""Check if server is responding.""" """Check if server is responding."""
try: try:
with urllib.request.urlopen(f"{self.SERVER_URL}/status", timeout=1) as response: with urllib.request.urlopen(
f"{self.SERVER_URL}/status", timeout=1
) as response:
return response.status == 200 return response.status == 200
except (urllib.error.URLError, ConnectionRefusedError, TimeoutError): except (urllib.error.URLError, ConnectionRefusedError, TimeoutError):
return False return False
@@ -118,7 +204,6 @@ class InferenceClient:
end_frame: int, end_frame: int,
conf_threshold: float, conf_threshold: float,
iou_threshold: float, iou_threshold: float,
mask_scale: float,
) -> str: ) -> str:
""" """
Request mask generation. Request mask generation.
@@ -136,37 +221,195 @@ class InferenceClient:
"end_frame": end_frame, "end_frame": end_frame,
"conf_threshold": conf_threshold, "conf_threshold": conf_threshold,
"iou_threshold": iou_threshold, "iou_threshold": iou_threshold,
"mask_scale": mask_scale,
} }
req = urllib.request.Request( req = urllib.request.Request(
f"{self.SERVER_URL}/generate", f"{self.SERVER_URL}/generate",
data=json.dumps(data).encode('utf-8'), data=json.dumps(data).encode("utf-8"),
headers={'Content-Type': 'application/json'}, headers={"Content-Type": "application/json"},
method='POST' method="POST",
) )
try: try:
with urllib.request.urlopen(req) as response: with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode('utf-8')) result = json.loads(response.read().decode("utf-8"))
return result['id'] return result["id"]
except urllib.error.HTTPError as e:
raise RuntimeError(f"Server error: {e.read().decode('utf-8')}")
def augment_pose(
self,
detections_path: str,
conf_threshold: float,
iou_threshold: float,
) -> str:
"""既存キャッシュに pose 推定結果を追加合成する。task_id を返す。"""
if not self.is_server_running():
self.start_server()
data = {
"detections_path": detections_path,
"conf_threshold": conf_threshold,
"iou_threshold": iou_threshold,
}
req = urllib.request.Request(
f"{self.SERVER_URL}/augment_pose",
data=json.dumps(data).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode("utf-8"))
return result["id"]
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
raise RuntimeError(f"Server error: {e.read().decode('utf-8')}") raise RuntimeError(f"Server error: {e.read().decode('utf-8')}")
def get_task_status(self, task_id: str) -> Dict[str, Any]: def get_task_status(self, task_id: str) -> Dict[str, Any]:
"""Get status of a task.""" """Get status of a task."""
try: try:
with urllib.request.urlopen(f"{self.SERVER_URL}/tasks/{task_id}") as response: with urllib.request.urlopen(
return json.loads(response.read().decode('utf-8')) f"{self.SERVER_URL}/tasks/{task_id}"
) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError: except urllib.error.HTTPError:
return {"status": "unknown"} return {"status": "unknown"}
def get_video_info(self, video_path: str) -> Dict[str, Any]:
"""Get video metadata from the inference server."""
if not self.is_server_running():
self.start_server()
req = urllib.request.Request(
f"{self.SERVER_URL}/video_info",
data=json.dumps({"video_path": video_path}).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raise RuntimeError(f"Server error: {e.read().decode('utf-8')}")
def bake_blur(
self,
video_path: str,
detections_path: str,
output_path: str,
blur_size: int,
display_scale: float,
fmt: str,
) -> str:
"""
Request blur bake for a source video + mask video.
Returns:
task_id (str)
"""
if not self.is_server_running():
self.start_server()
data = {
"video_path": video_path,
"detections_path": detections_path,
"output_path": output_path,
"blur_size": blur_size,
"display_scale": display_scale,
"format": fmt,
}
req = urllib.request.Request(
f"{self.SERVER_URL}/bake_blur",
data=json.dumps(data).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode("utf-8"))
return result["id"]
except urllib.error.HTTPError as e:
raise RuntimeError(f"Server error: {e.read().decode('utf-8')}")
def generate_mask_images(
self,
image_dir: str,
filenames: list,
output_dir: str,
start_index: int,
end_index: int,
conf_threshold: float,
iou_threshold: float,
) -> str:
"""画像シーケンスの顔検出タスクを開始して task_id を返す。"""
if not self.is_server_running():
self.start_server()
data = {
"image_dir": image_dir,
"filenames": filenames,
"output_dir": output_dir,
"start_index": start_index,
"end_index": end_index,
"conf_threshold": conf_threshold,
"iou_threshold": iou_threshold,
}
req = urllib.request.Request(
f"{self.SERVER_URL}/generate_images",
data=json.dumps(data).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode("utf-8"))
return result["id"]
except urllib.error.HTTPError as e:
raise RuntimeError(f"Server error: {e.read().decode('utf-8')}")
def bake_image_blur(
self,
image_dir: str,
filenames: list,
output_dir: str,
detections_path: str,
blur_size: int,
display_scale: float,
) -> str:
"""画像シーケンスのぼかしBakeタスクを開始して task_id を返す。"""
if not self.is_server_running():
self.start_server()
data = {
"image_dir": image_dir,
"filenames": filenames,
"output_dir": output_dir,
"detections_path": detections_path,
"blur_size": blur_size,
"display_scale": display_scale,
}
req = urllib.request.Request(
f"{self.SERVER_URL}/bake_image_blur",
data=json.dumps(data).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode("utf-8"))
return result["id"]
except urllib.error.HTTPError as e:
raise RuntimeError(f"Server error: {e.read().decode('utf-8')}")
def cancel_task(self, task_id: str): def cancel_task(self, task_id: str):
"""Cancel a task.""" """Cancel a task."""
try: try:
req = urllib.request.Request( req = urllib.request.Request(
f"{self.SERVER_URL}/tasks/{task_id}/cancel", f"{self.SERVER_URL}/tasks/{task_id}/cancel", method="POST"
method='POST'
) )
with urllib.request.urlopen(req): with urllib.request.urlopen(req):
pass pass
@@ -177,6 +420,7 @@ class InferenceClient:
# Singleton # Singleton
_client: Optional[InferenceClient] = None _client: Optional[InferenceClient] = None
def get_client() -> InferenceClient: def get_client() -> InferenceClient:
global _client global _client
if _client is None: if _client is None:
+141
View File
@@ -0,0 +1,141 @@
"""
Utility functions for Face Mask extension.
Provides helper functions for server status, cache info, etc.
"""
import os
import urllib.request
import urllib.error
import json
import tempfile
from typing import Dict, Tuple, Optional
def get_server_status() -> Dict:
"""
Get server status and GPU information.
Returns:
dict: {
'running': bool,
'gpu_available': bool,
'gpu_device': str or None,
'gpu_count': int,
'rocm_version': str or None,
}
"""
result = {
'running': False,
'gpu_available': False,
'gpu_device': None,
'gpu_count': 0,
'rocm_version': None,
}
try:
with urllib.request.urlopen("http://127.0.0.1:8181/status", timeout=1) as response:
data = json.loads(response.read().decode('utf-8'))
result['running'] = data.get('status') == 'running'
result['gpu_available'] = data.get('gpu_available', False)
result['gpu_device'] = data.get('gpu_device')
result['gpu_count'] = data.get('gpu_count', 0)
result['rocm_version'] = data.get('rocm_version')
except (urllib.error.URLError, ConnectionRefusedError, TimeoutError):
result['running'] = False
return result
def get_cache_root() -> str:
"""
Resolve cache root directory from scene setting or defaults.
Priority:
1) Scene setting: facemask_cache_dir (if non-empty)
2) Saved blend file directory + .mask_cache
3) Temp directory + blender_mask_cache
"""
import bpy
scene = getattr(bpy.context, "scene", None)
cache_setting = ""
if scene is not None:
cache_setting = (getattr(scene, "facemask_cache_dir", "") or "").strip()
if cache_setting:
return bpy.path.abspath(cache_setting)
blend_file = bpy.data.filepath
if blend_file:
project_dir = os.path.dirname(blend_file)
return os.path.join(project_dir, ".mask_cache")
return os.path.join(tempfile.gettempdir(), "blender_mask_cache")
def get_cache_dir_for_strip(strip_name: str) -> str:
"""Get cache directory path for a specific strip."""
return os.path.join(get_cache_root(), strip_name)
def get_detections_path_for_strip(strip_name: str) -> str:
"""Get msgpack detection cache path for a specific strip."""
return os.path.join(get_cache_dir_for_strip(strip_name), "detections.msgpack")
def check_detection_cache(strip_name: str) -> bool:
"""Detection cache ファイルが存在し有効かどうか確認する。"""
path = get_detections_path_for_strip(strip_name)
try:
return os.path.exists(path) and os.path.getsize(path) > 0
except OSError:
return False
def get_cache_info(strip_name: Optional[str] = None) -> Tuple[str, int, int]:
"""
Get cache directory information.
Args:
strip_name: If provided, get info for specific strip. Otherwise, get info for all cache.
Returns:
Tuple of (cache_path, total_size_bytes, file_count)
"""
if strip_name:
cache_path = get_cache_dir_for_strip(strip_name)
else:
cache_path = get_cache_root()
# Calculate size and count
total_size = 0
file_count = 0
if os.path.exists(cache_path):
for root, dirs, files in os.walk(cache_path):
for file in files:
file_path = os.path.join(root, file)
try:
total_size += os.path.getsize(file_path)
file_count += 1
except OSError:
pass
return cache_path, total_size, file_count
def format_size(size_bytes: int) -> str:
"""
Format bytes to human-readable size.
Args:
size_bytes: Size in bytes
Returns:
Formatted string (e.g., "1.5 MB")
"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
+19 -22
View File
@@ -48,8 +48,8 @@
export ROCM_PATH="${pkgs.rocmPackages.clr}" export ROCM_PATH="${pkgs.rocmPackages.clr}"
export HSA_OVERRIDE_GFX_VERSION="11.0.0" # RX 7900 (RDNA 3 / gfx1100) export HSA_OVERRIDE_GFX_VERSION="11.0.0" # RX 7900 (RDNA 3 / gfx1100)
# LD_LIBRARY_PATH: ROCmC++ # LD_LIBRARY_PATH: ROCm libraries FIRST (critical for GPU inference)
export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.zlib}/lib:${pkgs.zstd.out}/lib:${pkgs.rocmPackages.clr}/lib:${pkgs.rocmPackages.rocm-runtime}/lib:$LD_LIBRARY_PATH" export LD_LIBRARY_PATH="${pkgs.rocmPackages.clr}/lib:${pkgs.rocmPackages.rocm-runtime}/lib:${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.zlib}/lib:${pkgs.zstd.out}/lib:$LD_LIBRARY_PATH"
# venv # venv
VENV_DIR="$PWD/.venv" VENV_DIR="$PWD/.venv"
@@ -61,24 +61,22 @@
# venv # venv
source "$VENV_DIR/bin/activate" source "$VENV_DIR/bin/activate"
# # PyTorch ROCmGPU
if ! python -c "import torch; print(torch.cuda.is_available())" 2>/dev/null | grep -q "True"; then if ! python -c "import torch; print(torch.cuda.is_available())" 2>/dev/null | grep -q "True"; then
echo "[Setup] Installing Python dependencies..." echo "[Setup] Installing PyTorch ROCm dependencies..."
# PyTorch ROCmROCm 6.2 pip install --quiet --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.0
pip install --quiet torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2 fi
# PyPI
pip install --quiet \ # requirements.txt
ultralytics \ if [ -f "$PWD/requirements.txt" ]; then
opencv-python-headless \ echo "[Setup] Syncing Python dependencies from requirements.txt..."
numpy \ pip install --quiet -r "$PWD/requirements.txt"
fastapi \ fi
uvicorn \
pydantic # OpenCVimportheadless
# opencv-pythonheadless使 if ! python -c "import cv2" >/dev/null 2>&1; then
pip uninstall -y opencv-python opencv 2>/dev/null || true echo "[Setup] Repairing OpenCV (opencv-python-headless)..."
# opencv-python-headless pip install --quiet --force-reinstall --no-cache-dir opencv-python-headless
pip install --quiet --force-reinstall opencv-python-headless
echo "[Setup] Dependencies installed successfully"
fi fi
# Python # Python
@@ -89,12 +87,11 @@
export BLENDER_USER_ADDONS="$BLENDER_USER_SCRIPTS/addons" export BLENDER_USER_ADDONS="$BLENDER_USER_SCRIPTS/addons"
# #
# CRITICAL: ROCm library paths MUST come first for GPU inference
cat > "$PWD/.env" << EOF cat > "$PWD/.env" << EOF
LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.zlib}/lib:${pkgs.zstd.out}/lib:${pkgs.rocmPackages.clr}/lib:${pkgs.rocmPackages.rocm-runtime}/lib LD_LIBRARY_PATH=${pkgs.rocmPackages.clr}/lib:${pkgs.rocmPackages.rocm-runtime}/lib:${pkgs.stdenv.cc.cc.lib}/lib:${pkgs.zlib}/lib:${pkgs.zstd.out}/lib
ROCM_PATH=${pkgs.rocmPackages.clr} ROCM_PATH=${pkgs.rocmPackages.clr}
HSA_OVERRIDE_GFX_VERSION=11.0.0 HSA_OVERRIDE_GFX_VERSION=11.0.0
PYTORCH_ROCM_ARCH=gfx1100
ROCBLAS_TENSILE_LIBPATH=${pkgs.rocmPackages.clr}/lib/rocblas/library
EOF EOF
echo "[Setup] Environment ready with GPU support" echo "[Setup] Environment ready with GPU support"
+6
View File
@@ -2,13 +2,19 @@
from . import generate_mask from . import generate_mask
from . import apply_blur from . import apply_blur
from . import clear_cache
from . import batch_bake
def register(): def register():
generate_mask.register() generate_mask.register()
apply_blur.register() apply_blur.register()
clear_cache.register()
batch_bake.register()
def unregister(): def unregister():
batch_bake.unregister()
clear_cache.unregister()
apply_blur.unregister() apply_blur.unregister()
generate_mask.unregister() generate_mask.unregister()
+326 -205
View File
@@ -1,242 +1,359 @@
""" """
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 This module bakes masked blur into a regular video file using the inference
generated by the face detection operators. server, then swaps the active strip's source filepath to the baked result.
""" """
import os
import bpy import bpy
from bpy.props import FloatProperty, IntProperty, StringProperty from bpy.props import IntProperty
from bpy.types import Operator 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
from ..core.utils import get_detections_path_for_strip
class SEQUENCER_OT_apply_mask_blur(Operator):
"""Apply blur effect using mask strip."""
bl_idname = "sequencer.apply_mask_blur" KEY_ORIGINAL = "facemask_original_filepath"
bl_label = "Apply Mask Blur" KEY_BAKED = "facemask_baked_filepath"
bl_description = "Apply blur effect to video using mask strip" KEY_MODE = "facemask_source_mode"
bl_options = {'REGISTER', 'UNDO'} KEY_FORMAT = "facemask_bake_format"
KEY_BLUR_SIZE = "facemask_bake_blur_size"
KEY_DISPLAY_SCALE = "facemask_bake_display_scale"
blur_size: IntProperty(
name="Blur Size", FORMAT_EXT = {
description="Size of the blur effect in pixels", "MP4": "mp4",
default=50, "AVI": "avi",
min=1, "MOV": "mov",
max=500, }
def _output_path(video_strip, detections_path: str, fmt: str) -> str:
ext = FORMAT_EXT.get(fmt, "mp4")
out_dir = os.path.dirname(detections_path)
safe_name = video_strip.name.replace("/", "_").replace("\\", "_")
return os.path.join(out_dir, f"{safe_name}_blurred.{ext}")
def _output_dir_for_images(strip, detections_path: str) -> str:
out_dir = os.path.dirname(detections_path)
safe_name = strip.name.replace("/", "_").replace("\\", "_")
return os.path.join(out_dir, f"{safe_name}_blurred")
def _reload_movie_strip(strip):
if hasattr(strip, "reload"):
try:
strip.reload()
except Exception:
pass
def _set_strip_source(strip, path: str):
if strip.type == "IMAGE":
strip.directory = path
else:
strip.filepath = path
_reload_movie_strip(strip)
def _start_bake_impl(operator, context, force: bool = False, strip=None, on_complete_extra=None):
"""Bakeの共通実装。force=True でキャッシュを無視して再Bakeする。
strip: 処理対象のstrip。None の場合は active_strip を使用。
on_complete_extra: 非同期Bake完了時に追加で呼ばれるコールバック (status, data)。
キャッシュヒット即時完了の場合は呼ばれない。
MOVIE / IMAGE 両対応。
"""
seq_editor = context.scene.sequence_editor
scene = context.scene
video_strip = strip if strip is not None else seq_editor.active_strip
is_image = video_strip.type == "IMAGE"
detections_path = get_detections_path_for_strip(video_strip.name)
if not os.path.exists(detections_path):
operator.report({"ERROR"}, f"Detection cache not found: {detections_path}")
return {"CANCELLED"}
blur_size = int(scene.facemask_bake_blur_size)
display_scale = float(scene.facemask_bake_display_scale)
if is_image:
image_dir = bpy.path.abspath(video_strip.directory)
filenames = [elem.filename for elem in video_strip.elements]
if not os.path.isdir(image_dir):
operator.report({"ERROR"}, f"Image directory not found: {image_dir}")
return {"CANCELLED"}
output_dir = _output_dir_for_images(video_strip, detections_path)
original_source = image_dir
bake_format = None # IMAGE strips don't use format
else:
video_path = bpy.path.abspath(video_strip.filepath)
if not os.path.exists(video_path):
operator.report({"ERROR"}, f"Source video not found: {video_path}")
return {"CANCELLED"}
bake_format = scene.facemask_bake_format
output_path = _output_path(video_strip, detections_path, bake_format)
original_source = video_path
if not force:
# パラメータが一致するキャッシュがあればswapのみ
cached_baked_path = video_strip.get(KEY_BAKED)
cached_blur_size = video_strip.get(KEY_BLUR_SIZE)
cached_display_scale = video_strip.get(KEY_DISPLAY_SCALE)
try:
cached_blur_size_int = int(cached_blur_size)
except (TypeError, ValueError):
cached_blur_size_int = None
try:
cached_display_scale_f = float(cached_display_scale)
except (TypeError, ValueError):
cached_display_scale_f = None
cache_exists = (
cached_baked_path and os.path.exists(cached_baked_path)
and cached_blur_size_int == blur_size
and cached_display_scale_f == display_scale
) )
if not is_image:
cache_exists = cache_exists and video_strip.get(KEY_FORMAT) == bake_format
if cache_exists:
if video_strip.get(KEY_MODE) != "baked":
video_strip[KEY_MODE] = "baked"
_set_strip_source(video_strip, cached_baked_path)
operator.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 = data or (output_dir if is_image else output_path)
current_mode = strip.get(KEY_MODE, "original")
if not strip.get(KEY_ORIGINAL) or current_mode != "baked":
strip[KEY_ORIGINAL] = original_source
strip[KEY_BAKED] = result
strip[KEY_MODE] = "baked"
strip[KEY_BLUR_SIZE] = blur_size
strip[KEY_DISPLAY_SCALE] = display_scale
if not is_image:
strip[KEY_FORMAT] = bake_format
_set_strip_source(strip, result)
print(f"[FaceMask] Bake completed and source swapped: {result}")
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()
if on_complete_extra:
on_complete_extra(status, data)
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:
if is_image:
bake_generator.start_images(
image_dir=image_dir,
filenames=filenames,
output_dir=output_dir,
detections_path=detections_path,
blur_size=blur_size,
display_scale=display_scale,
on_complete=on_complete,
on_progress=on_progress,
)
else:
bake_generator.start(
video_path=video_path,
detections_path=detections_path,
output_path=output_path,
blur_size=blur_size,
display_scale=display_scale,
fmt=bake_format.lower(),
on_complete=on_complete,
on_progress=on_progress,
)
except Exception as e:
operator.report({"ERROR"}, f"Failed to start bake: {e}")
return {"CANCELLED"}
operator.report({"INFO"}, "Started blur bake in background")
return {"FINISHED"}
class SEQUENCER_OT_bake_and_swap_blur_source(Operator):
"""Bake masked blur (reuse cache if parameters match)."""
bl_idname = "sequencer.bake_and_swap_blur_source"
bl_label = "Bake"
bl_description = "Bake masked blur to video and swap active strip source"
bl_options = {"REGISTER", "UNDO"}
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
"""Check if operator can run."""
if not context.scene.sequence_editor: if not context.scene.sequence_editor:
return False return False
if get_mask_generator().is_running:
seq_editor = context.scene.sequence_editor
strip = seq_editor.active_strip
if not strip:
return False return False
if get_bake_generator().is_running:
if strip.type not in {'MOVIE', 'IMAGE'}:
return False return False
strip = context.scene.sequence_editor.active_strip
# Check if corresponding mask strip exists return bool(strip and strip.type in {"MOVIE", "IMAGE"})
mask_name = f"{strip.name}_mask"
return mask_name in seq_editor.strips
def execute(self, context): def execute(self, context):
seq_editor = context.scene.sequence_editor return _start_bake_impl(self, context, force=False)
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)
if not mask_strip: class SEQUENCER_OT_force_rebake_blur(Operator):
self.report({'ERROR'}, f"Mask strip not found: {mask_name}") """Force re-bake, ignoring any existing cached result."""
return {'CANCELLED'}
try: bl_idname = "sequencer.force_rebake_blur"
# Use Mask Modifier approach (Blender 5.0 compatible) bl_label = "Re-bake"
self._apply_with_mask_modifier(context, video_strip, mask_strip) bl_description = "Discard cached bake and re-bake from scratch"
except Exception as e: bl_options = {"REGISTER", "UNDO"}
self.report({'ERROR'}, f"Failed to apply blur: {e}")
return {'CANCELLED'}
return {'FINISHED'} @classmethod
def poll(cls, context):
if not context.scene.sequence_editor:
return False
if get_mask_generator().is_running:
return False
if get_bake_generator().is_running:
return False
strip = context.scene.sequence_editor.active_strip
return bool(strip and strip.type in {"MOVIE", "IMAGE"})
def _apply_with_mask_modifier(self, context, video_strip: "bpy.types.Strip", mask_strip: "bpy.types.Strip"): def execute(self, context):
""" return _start_bake_impl(self, context, force=True)
Apply blur using Mask Modifier, grouped in a Meta Strip.
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 class SEQUENCER_OT_swap_to_baked_blur(Operator):
video due to VSE's channel layering system. """Swap active strip source to already-baked video (no re-bake)."""
"""
seq_editor = context.scene.sequence_editor
# Find available channels bl_idname = "sequencer.swap_to_baked_blur"
used_channels = {s.channel for s in seq_editor.strips} bl_label = "Swap to Baked"
duplicate_channel = video_strip.channel + 1 bl_description = "Switch active strip source to the baked video without re-baking"
while duplicate_channel in used_channels: bl_options = {"REGISTER", "UNDO"}
duplicate_channel += 1
blur_channel = duplicate_channel + 1 @classmethod
while blur_channel in used_channels: def poll(cls, context):
blur_channel += 1 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 not in {"MOVIE", "IMAGE"}:
return False
baked_path = strip.get(KEY_BAKED)
return bool(baked_path and os.path.exists(baked_path))
# Step 1: Duplicate the video strip def execute(self, context):
if video_strip.type == 'MOVIE': strip = context.scene.sequence_editor.active_strip
video_copy = seq_editor.strips.new_movie( baked_path = strip.get(KEY_BAKED)
name=f"{video_strip.name}_copy", _set_strip_source(strip, baked_path)
filepath=bpy.path.abspath(video_strip.filepath), strip[KEY_MODE] = "baked"
channel=duplicate_channel, self.report({"INFO"}, "Swapped to baked source")
frame_start=video_strip.frame_final_start, return {"FINISHED"}
)
elif video_strip.type == 'IMAGE':
# For image sequences, duplicate differently class SEQUENCER_OT_restore_original_source(Operator):
video_copy = seq_editor.strips.new_image( """Restore active strip source filepath to original video."""
name=f"{video_strip.name}_copy",
filepath=bpy.path.abspath(video_strip.elements[0].filename) if video_strip.elements else "", bl_idname = "sequencer.restore_original_source"
channel=duplicate_channel, bl_label = "Restore Original"
frame_start=video_strip.frame_final_start, bl_description = "Restore active strip to original source filepath"
) bl_options = {"REGISTER", "UNDO"}
# Copy all elements
for elem in video_strip.elements[1:]: @classmethod
video_copy.elements.append(elem.filename) 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 not in {"MOVIE", "IMAGE"}:
return False
if strip.get(KEY_MODE, "original") == "original":
return False
return bool(strip.get(KEY_ORIGINAL))
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"
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: else:
raise ValueError(f"Unsupported strip type: {video_strip.type}") self.report({"WARNING"}, "No blur bake in progress")
return {"FINISHED"}
# 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")
# Registration
classes = [ classes = [
SEQUENCER_OT_bake_and_swap_blur_source,
SEQUENCER_OT_force_rebake_blur,
SEQUENCER_OT_swap_to_baked_blur,
SEQUENCER_OT_restore_original_source,
SEQUENCER_OT_cancel_bake_blur,
SEQUENCER_OT_apply_mask_blur, SEQUENCER_OT_apply_mask_blur,
] ]
@@ -244,8 +361,12 @@ classes = [
def register(): def register():
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) bpy.utils.register_class(cls)
bpy.types.WindowManager.bake_progress = IntProperty(default=0)
bpy.types.WindowManager.bake_total = IntProperty(default=0)
def unregister(): def unregister():
del bpy.types.WindowManager.bake_progress
del bpy.types.WindowManager.bake_total
for cls in reversed(classes): for cls in reversed(classes):
bpy.utils.unregister_class(cls) bpy.utils.unregister_class(cls)
+191
View File
@@ -0,0 +1,191 @@
"""
Batch Bake operator: sequentially Generate Detection Cache → Bake
for all selected MOVIE strips in the VSE.
"""
import os
import bpy
from bpy.props import IntProperty, StringProperty
from bpy.types import Operator
from ..core.batch_processor import get_batch_processor
from ..core.async_generator import get_generator as get_mask_generator
from ..core.async_bake_generator import get_bake_generator
from .apply_blur import KEY_ORIGINAL, KEY_MODE, _set_strip_source
class SEQUENCER_OT_batch_bake_selected(Operator):
"""Generate detection cache and bake blur for all selected MOVIE/IMAGE strips."""
bl_idname = "sequencer.batch_bake_selected"
bl_label = "Batch Bake Selected"
bl_description = "Generate detection cache and bake blur for all selected MOVIE/IMAGE strips"
bl_options = {"REGISTER"}
@classmethod
def poll(cls, context):
if not context.scene.sequence_editor:
return False
if get_batch_processor().is_running:
return False
if get_mask_generator().is_running:
return False
if get_bake_generator().is_running:
return False
seq_editor = context.scene.sequence_editor
return any(s.select and s.type in {"MOVIE", "IMAGE"} for s in seq_editor.strips)
def execute(self, context):
seq_editor = context.scene.sequence_editor
strips = [s for s in seq_editor.strips if s.select and s.type in {"MOVIE", "IMAGE"}]
if not strips:
self.report({"WARNING"}, "No MOVIE or IMAGE strips selected")
return {"CANCELLED"}
batch = get_batch_processor()
def on_item_complete(idx, total, strip_name, status):
pass # wm properties already updated by BatchProcessor
def on_all_complete(results):
done = sum(1 for r in results if r["status"] == "done")
total = len(results)
print(f"[FaceMask] Batch finished: {done}/{total} strips completed")
wm = context.window_manager
wm.batch_current = 0
wm.batch_total = len(strips)
wm.batch_current_name = ""
batch.start(context, strips, on_item_complete=on_item_complete, on_all_complete=on_all_complete)
self.report({"INFO"}, f"Batch bake started for {len(strips)} strips")
return {"FINISHED"}
class SEQUENCER_OT_batch_regenerate_cache(Operator):
"""Regenerate detection cache for all selected MOVIE/IMAGE strips (ignore existing cache)."""
bl_idname = "sequencer.batch_regenerate_cache"
bl_label = "Batch Regenerate Cache"
bl_description = "Regenerate detection cache for all selected MOVIE/IMAGE strips"
bl_options = {"REGISTER"}
@classmethod
def poll(cls, context):
if not context.scene.sequence_editor:
return False
if get_batch_processor().is_running:
return False
if get_mask_generator().is_running:
return False
if get_bake_generator().is_running:
return False
seq_editor = context.scene.sequence_editor
return any(s.select and s.type in {"MOVIE", "IMAGE"} for s in seq_editor.strips)
def execute(self, context):
seq_editor = context.scene.sequence_editor
strips = [s for s in seq_editor.strips if s.select and s.type in {"MOVIE", "IMAGE"}]
if not strips:
self.report({"WARNING"}, "No MOVIE or IMAGE strips selected")
return {"CANCELLED"}
batch = get_batch_processor()
def on_all_complete(results):
done = sum(1 for r in results if r["status"] == "done")
print(f"[FaceMask] Batch regenerate finished: {done}/{len(results)} strips")
batch.start(
context,
strips,
on_all_complete=on_all_complete,
mode="mask_only",
)
self.report({"INFO"}, f"Batch regenerate cache started for {len(strips)} strips")
return {"FINISHED"}
class SEQUENCER_OT_batch_restore_original(Operator):
"""Restore original source for all selected MOVIE/IMAGE strips."""
bl_idname = "sequencer.batch_restore_original"
bl_label = "Batch Restore Original"
bl_description = "Restore original source filepath for all selected MOVIE/IMAGE strips"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not context.scene.sequence_editor:
return False
if get_batch_processor().is_running:
return False
seq_editor = context.scene.sequence_editor
return any(s.select and s.type in {"MOVIE", "IMAGE"} for s in seq_editor.strips)
def execute(self, context):
seq_editor = context.scene.sequence_editor
strips = [s for s in seq_editor.strips if s.select and s.type in {"MOVIE", "IMAGE"}]
restored = 0
skipped = 0
for strip in strips:
original_path = strip.get(KEY_ORIGINAL)
if not original_path or not os.path.exists(original_path):
skipped += 1
continue
if strip.get(KEY_MODE, "original") != "original":
_set_strip_source(strip, original_path)
strip[KEY_MODE] = "original"
restored += 1
self.report(
{"INFO"},
f"Restored {restored} strip(s)"
+ (f", skipped {skipped} (no original stored)" if skipped else ""),
)
return {"FINISHED"}
class SEQUENCER_OT_cancel_batch_bake(Operator):
"""Cancel ongoing batch bake."""
bl_idname = "sequencer.cancel_batch_bake"
bl_label = "Cancel Batch Bake"
bl_description = "Cancel the current batch bake process"
bl_options = {"REGISTER"}
def execute(self, context):
batch = get_batch_processor()
if batch.is_running:
batch.cancel()
self.report({"INFO"}, "Batch bake cancelled")
else:
self.report({"WARNING"}, "No batch bake in progress")
return {"FINISHED"}
classes = [
SEQUENCER_OT_batch_bake_selected,
SEQUENCER_OT_batch_regenerate_cache,
SEQUENCER_OT_batch_restore_original,
SEQUENCER_OT_cancel_batch_bake,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.WindowManager.batch_current = IntProperty(default=0)
bpy.types.WindowManager.batch_total = IntProperty(default=0)
bpy.types.WindowManager.batch_current_name = StringProperty(default="")
def unregister():
del bpy.types.WindowManager.batch_current_name
del bpy.types.WindowManager.batch_total
del bpy.types.WindowManager.batch_current
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
+113
View File
@@ -0,0 +1,113 @@
"""
Clear Cache Operator.
Provides operators to clear mask cache directories.
"""
import os
import shutil
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."""
bl_idname = "sequencer.clear_mask_cache"
bl_label = "Clear Mask Cache"
bl_description = "Delete cached mask images"
bl_options = {'REGISTER', 'UNDO'}
all_strips: BoolProperty(
name="All Strips",
description="Clear cache for all strips (otherwise only current strip)",
default=False,
)
def execute(self, context):
total_size = 0
if self.all_strips:
# Clear all cache directories
cache_root = get_cache_root()
if os.path.exists(cache_root):
# Calculate size before deletion
for root, dirs, files in os.walk(cache_root):
for file in files:
file_path = os.path.join(root, file)
try:
total_size += os.path.getsize(file_path)
except OSError:
pass
# Delete cache directory
try:
shutil.rmtree(cache_root)
self.report({'INFO'}, f"Cleared all cache ({self._format_size(total_size)})")
except Exception as e:
self.report({'ERROR'}, f"Failed to clear cache: {e}")
return {'CANCELLED'}
else:
self.report({'INFO'}, "No cache to clear")
return {'FINISHED'}
else:
# Clear cache for active strip only
seq_editor = context.scene.sequence_editor
if not seq_editor or not seq_editor.active_strip:
self.report({'WARNING'}, "No strip selected")
return {'CANCELLED'}
strip = seq_editor.active_strip
cache_dir = get_cache_dir_for_strip(strip.name)
if os.path.exists(cache_dir):
# Calculate size
for root, dirs, files in os.walk(cache_dir):
for file in files:
file_path = os.path.join(root, file)
try:
total_size += os.path.getsize(file_path)
except OSError:
pass
# Delete
try:
shutil.rmtree(cache_dir)
self.report({'INFO'}, f"Cleared cache for {strip.name} ({self._format_size(total_size)})")
except Exception as e:
self.report({'ERROR'}, f"Failed to clear cache: {e}")
return {'CANCELLED'}
else:
self.report({'INFO'}, f"No cache for {strip.name}")
return {'FINISHED'}
return {'FINISHED'}
def _format_size(self, size_bytes):
"""Format bytes to human-readable size."""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
# Registration
classes = [
SEQUENCER_OT_clear_mask_cache,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
+197 -164
View File
@@ -7,10 +7,99 @@ from video strips in the Video Sequence Editor.
import os import os
import bpy import bpy
from bpy.props import FloatProperty, IntProperty from bpy.props import IntProperty, BoolProperty
from bpy.types import Operator from bpy.types import Operator
from ..core.async_generator import get_generator from ..core.async_generator import get_generator
from ..core.inference_client import get_client
from ..core.utils import get_cache_dir_for_strip, check_detection_cache
def compute_strip_frame_range(strip, scene, client) -> tuple:
"""(start_frame, end_frame, source_fps) を返す。失敗時は例外を送出。"""
video_path = bpy.path.abspath(strip.filepath)
video_info = client.get_video_info(video_path)
total_video_frames = int(video_info.get("frame_count", 0))
source_fps = float(video_info.get("fps", 0.0))
if total_video_frames <= 0:
raise ValueError(f"Could not read frame count from video: {video_path}")
if source_fps <= 0:
source_fps = scene.render.fps / scene.render.fps_base
project_fps = scene.render.fps / scene.render.fps_base
fps_ratio = source_fps / project_fps
start_frame = int(round(strip.frame_offset_start * fps_ratio))
end_frame = start_frame + int(round(strip.frame_final_duration * fps_ratio)) - 1
start_frame = max(0, min(start_frame, total_video_frames - 1))
end_frame = max(start_frame, min(end_frame, total_video_frames - 1))
return start_frame, end_frame, source_fps
def get_image_strip_files(strip) -> tuple:
"""IMAGE strip の (abs_image_dir, filenames_list) を返す。"""
image_dir = bpy.path.abspath(strip.directory)
filenames = [elem.filename for elem in strip.elements]
return image_dir, filenames
def compute_image_strip_range(strip) -> tuple:
"""IMAGE strip のアクティブ範囲 (start_index, end_index) を返す。"""
total_elements = len(strip.elements)
start_idx = max(0, int(strip.frame_offset_start))
end_idx = start_idx + int(strip.frame_final_duration) - 1
start_idx = min(start_idx, total_elements - 1)
end_idx = max(start_idx, min(end_idx, total_elements - 1))
return start_idx, end_idx
def start_mask_gen_for_strip(context, strip, on_complete, on_progress):
"""Strip のマスク生成を開始する共通処理(MOVIE / IMAGE 両対応)。
generator.is_running 等のエラー時は例外を送出する。
wm.mask_progress / mask_total を初期化してから generator.start*() を呼ぶ。
"""
scene = context.scene
wm = context.window_manager
generator = get_generator()
if generator.is_running:
raise RuntimeError("Mask generation already in progress")
output_dir = get_cache_dir_for_strip(strip.name)
os.makedirs(output_dir, exist_ok=True)
wm.mask_progress = 0
if strip.type == "IMAGE":
image_dir, filenames = get_image_strip_files(strip)
if not filenames:
raise ValueError("Image strip has no elements")
start_idx, end_idx = compute_image_strip_range(strip)
wm.mask_total = end_idx - start_idx + 1
generator.start_images(
image_dir=image_dir,
filenames=filenames,
output_dir=output_dir,
start_index=start_idx,
end_index=end_idx,
conf_threshold=scene.facemask_conf_threshold,
iou_threshold=scene.facemask_iou_threshold,
on_complete=on_complete,
on_progress=on_progress,
)
else:
client = get_client()
start_frame, end_frame, source_fps = compute_strip_frame_range(strip, scene, client)
wm.mask_total = end_frame - start_frame + 1
generator.start(
video_path=bpy.path.abspath(strip.filepath),
output_dir=output_dir,
start_frame=start_frame,
end_frame=end_frame,
fps=source_fps,
conf_threshold=scene.facemask_conf_threshold,
iou_threshold=scene.facemask_iou_threshold,
on_complete=on_complete,
on_progress=on_progress,
)
class SEQUENCER_OT_generate_face_mask(Operator): class SEQUENCER_OT_generate_face_mask(Operator):
@@ -21,212 +110,80 @@ class SEQUENCER_OT_generate_face_mask(Operator):
bl_description = "Detect faces and generate mask image sequence" bl_description = "Detect faces and generate mask image sequence"
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
# YOLO Detection parameters force: BoolProperty(
conf_threshold: FloatProperty( name="Force Regenerate",
name="Confidence", description="既存のキャッシュを無視して再生成する",
description="YOLO confidence threshold (higher = fewer false positives)", default=False,
default=0.25,
min=0.1,
max=1.0,
)
iou_threshold: FloatProperty(
name="IOU Threshold",
description="Non-maximum suppression IOU threshold",
default=0.45,
min=0.1,
max=1.0,
)
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 @classmethod
def poll(cls, context): def poll(cls, context):
"""Check if operator can run."""
if not context.scene.sequence_editor: if not context.scene.sequence_editor:
return False return False
strip = context.scene.sequence_editor.active_strip strip = context.scene.sequence_editor.active_strip
if not strip: if not strip:
return False return False
return strip.type in {'MOVIE', 'IMAGE'} return strip.type in {'MOVIE', 'IMAGE'}
def execute(self, context): def execute(self, context):
strip = context.scene.sequence_editor.active_strip strip = context.scene.sequence_editor.active_strip
scene = context.scene
# Get video path # ファイル存在確認
if strip.type == 'MOVIE': if strip.type == 'MOVIE':
video_path = bpy.path.abspath(strip.filepath) video_path = bpy.path.abspath(strip.filepath)
else: else:
# Image sequence - get directory
video_path = bpy.path.abspath(strip.directory) video_path = bpy.path.abspath(strip.directory)
if not os.path.exists(video_path): if not os.path.exists(video_path):
self.report({'ERROR'}, f"Video file not found: {video_path}") self.report({'ERROR'}, f"Video file not found: {video_path}")
return {'CANCELLED'} return {'CANCELLED'}
# Determine output directory # キャッシュ確認(force=True の場合はスキップ)
output_dir = self._get_cache_dir(context, strip) if not self.force and check_detection_cache(strip.name):
self.report({'INFO'}, f"Using cached detections for {strip.name}")
# 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'} 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() 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): def on_complete(status, data):
"""Called when mask generation completes.""" wm = context.window_manager
wm.mask_total = max(wm.mask_total, generator.total_frames)
if status == "done":
wm.mask_progress = wm.mask_total
elif status in {"error", "cancelled"}:
wm.mask_progress = min(wm.mask_progress, wm.mask_total)
if status == "done": if status == "done":
# Add mask strip to sequence editor
self._add_mask_strip(context, strip_name, data)
print(f"[FaceMask] Mask generation completed: {data}") print(f"[FaceMask] Mask generation completed: {data}")
elif status == "error": elif status == "error":
print(f"[FaceMask] Error: {data}") print(f"[FaceMask] Error: {data}")
elif status == "cancelled": elif status == "cancelled":
print("[FaceMask] Generation 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: for area in context.screen.areas:
if area.type == 'SEQUENCE_EDITOR': if area.type == 'SEQUENCE_EDITOR':
area.tag_redraw() area.tag_redraw()
# Initialize progress def on_progress(current, total):
wm = context.window_manager wm = context.window_manager
wm.mask_progress = 0 wm.mask_progress = current
wm.mask_total = end_frame - start_frame + 1 wm.mask_total = total
for area in context.screen.areas:
if area.type == 'SEQUENCE_EDITOR':
area.tag_redraw()
# Start generation try:
generator.start( start_mask_gen_for_strip(context, strip, on_complete, on_progress)
video_path=video_path, except RuntimeError as e:
output_dir=output_dir, self.report({'WARNING'}, str(e))
start_frame=0, # Frame indices in video return {'CANCELLED'}
end_frame=end_frame - start_frame, except Exception as e:
fps=fps, self.report({'ERROR'}, f"Failed to start mask generation: {e}")
conf_threshold=self.conf_threshold, return {'CANCELLED'}
iou_threshold=self.iou_threshold,
mask_scale=self.mask_scale,
on_complete=on_complete,
on_progress=on_progress,
)
self.report({'INFO'}, f"Started mask generation for {strip.name}") self.report({'INFO'}, f"Started mask generation for {strip.name}")
return {'FINISHED'} 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): class SEQUENCER_OT_cancel_mask_generation(Operator):
"""Cancel ongoing mask generation.""" """Cancel ongoing mask generation."""
@@ -248,10 +205,88 @@ class SEQUENCER_OT_cancel_mask_generation(Operator):
return {'FINISHED'} return {'FINISHED'}
class SEQUENCER_OT_augment_pose_mask(Operator):
"""Add pose-based head detections to existing detection cache."""
bl_idname = "sequencer.augment_pose_mask"
bl_label = "Augment with Pose"
bl_description = "Run pose estimation and merge results into existing detection cache"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
if not context.scene.sequence_editor:
return False
strip = context.scene.sequence_editor.active_strip
if not strip or strip.type != 'MOVIE':
return False
return check_detection_cache(strip.name)
def execute(self, context):
strip = context.scene.sequence_editor.active_strip
output_dir = get_cache_dir_for_strip(strip.name)
detections_path = os.path.join(output_dir, "detections.msgpack")
if not os.path.exists(detections_path):
self.report({'ERROR'}, f"Detection cache not found: {detections_path}")
return {'CANCELLED'}
generator = get_generator()
scene = context.scene
wm = context.window_manager
wm.mask_progress = 0
wm.mask_total = 0 # サーバー側から実際の値に更新される
def on_complete(status, data):
wm.mask_total = max(wm.mask_total, generator.total_frames)
if status == "done":
wm.mask_progress = wm.mask_total
elif status in {"error", "cancelled"}:
wm.mask_progress = min(wm.mask_progress, wm.mask_total)
if status == "done":
print(f"[FaceMask] Pose augmentation completed: {data}")
elif status == "error":
print(f"[FaceMask] Error: {data}")
elif status == "cancelled":
print("[FaceMask] Pose augmentation cancelled")
for area in context.screen.areas:
if area.type == 'SEQUENCE_EDITOR':
area.tag_redraw()
def on_progress(current, total_f):
wm.mask_progress = current
wm.mask_total = total_f
for area in context.screen.areas:
if area.type == 'SEQUENCE_EDITOR':
area.tag_redraw()
try:
generator.start_augment_pose(
detections_path=detections_path,
total_frames=0,
conf_threshold=scene.facemask_conf_threshold,
iou_threshold=scene.facemask_iou_threshold,
on_complete=on_complete,
on_progress=on_progress,
)
except RuntimeError as e:
self.report({'WARNING'}, str(e))
return {'CANCELLED'}
except Exception as e:
self.report({'ERROR'}, f"Failed to start pose augmentation: {e}")
return {'CANCELLED'}
self.report({'INFO'}, f"Started pose augmentation for {strip.name}")
return {'FINISHED'}
# Registration # Registration
classes = [ classes = [
SEQUENCER_OT_generate_face_mask, SEQUENCER_OT_generate_face_mask,
SEQUENCER_OT_cancel_mask_generation, SEQUENCER_OT_cancel_mask_generation,
SEQUENCER_OT_augment_pose_mask,
] ]
@@ -259,13 +294,11 @@ def register():
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) bpy.utils.register_class(cls)
# Add progress properties to window manager
bpy.types.WindowManager.mask_progress = IntProperty(default=0) bpy.types.WindowManager.mask_progress = IntProperty(default=0)
bpy.types.WindowManager.mask_total = IntProperty(default=0) bpy.types.WindowManager.mask_total = IntProperty(default=0)
def unregister(): def unregister():
# Remove properties
del bpy.types.WindowManager.mask_progress del bpy.types.WindowManager.mask_progress
del bpy.types.WindowManager.mask_total del bpy.types.WindowManager.mask_total
+259 -21
View File
@@ -5,10 +5,19 @@ Provides a sidebar panel in the Video Sequence Editor
for controlling mask generation and blur application. for controlling mask generation and blur application.
""" """
import os
import bpy import bpy
from bpy.types import Panel from bpy.types import Panel
from ..core.async_bake_generator import get_bake_generator
from ..core.async_generator import get_generator from ..core.async_generator import get_generator
from ..core.batch_processor import get_batch_processor
from ..core.utils import (
get_server_status,
get_cache_info,
format_size,
check_detection_cache,
)
class SEQUENCER_PT_face_mask(Panel): class SEQUENCER_PT_face_mask(Panel):
@@ -22,18 +31,30 @@ class SEQUENCER_PT_face_mask(Panel):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
scene = context.scene
wm = context.window_manager wm = context.window_manager
seq_editor = context.scene.sequence_editor seq_editor = context.scene.sequence_editor
# Note: Blender 5.0 uses 'strips' instead of 'sequences' # Note: Blender 5.0 uses 'strips' instead of 'sequences'
batch = get_batch_processor()
generator = get_generator() generator = get_generator()
bake_generator = get_bake_generator()
# Show progress if generating # Batch progress (highest priority)
if batch.is_running:
self._draw_batch_progress(layout, wm, batch, generator, bake_generator)
return
# Show progress if generating masks
if generator.is_running: if generator.is_running:
self._draw_progress(layout, wm, generator) self._draw_progress(layout, wm, generator)
return return
# Show progress if baking blur
if bake_generator.is_running:
self._draw_bake_progress(layout, wm, bake_generator)
return
# Show controls if strip selected # Show primary controls first (top priority in UI)
if seq_editor and seq_editor.active_strip: if seq_editor and seq_editor.active_strip:
strip = seq_editor.active_strip strip = seq_editor.active_strip
@@ -45,6 +66,94 @@ class SEQUENCER_PT_face_mask(Panel):
else: else:
layout.label(text="No strip selected") layout.label(text="No strip selected")
layout.separator()
# Secondary sections
self._draw_parameters(layout, scene)
self._draw_server_status(layout)
self._draw_cache_info(layout, context, seq_editor)
self._draw_batch_controls(layout, context, seq_editor)
def _draw_parameters(self, layout, scene):
"""Draw detection parameters."""
box = layout.box()
box.label(text="Parameters", icon='PREFERENCES')
col = box.column(align=True)
col.prop(scene, "facemask_conf_threshold")
col.prop(scene, "facemask_iou_threshold")
def _draw_server_status(self, layout):
"""Draw server status and GPU info."""
box = layout.box()
box.label(text="Server Status", icon='SYSTEM')
status = get_server_status()
# Server status
row = box.row()
if status['running']:
row.label(text="Server:", icon='CHECKMARK')
row.label(text="Running")
else:
row.label(text="Server:", icon='ERROR')
row.label(text="Stopped")
# GPU status
if status['running']:
row = box.row()
if status['gpu_available']:
row.label(text="GPU:", icon='CHECKMARK')
gpu_name = status['gpu_device'] or "Available"
# Truncate long GPU names
if len(gpu_name) > 25:
gpu_name = gpu_name[:22] + "..."
row.label(text=gpu_name)
else:
row.label(text="GPU:", icon='ERROR')
row.label(text="Not Available")
def _draw_cache_info(self, layout, context, seq_editor):
"""Draw cache information and clear button."""
box = layout.box()
box.label(text="Cache", icon='FILE_CACHE')
# Get cache info
if seq_editor and seq_editor.active_strip:
strip_name = seq_editor.active_strip.name
cache_path, total_size, file_count = get_cache_info(strip_name)
else:
cache_path, total_size, file_count = get_cache_info()
# Cache info
row = box.row()
row.label(text="Size:")
row.label(text=format_size(total_size))
row = box.row()
row.label(text="Files:")
row.label(text=str(file_count))
# Cache directory setting
box.prop(context.scene, "facemask_cache_dir")
# Clear cache buttons
row = box.row(align=True)
if seq_editor and seq_editor.active_strip:
op = row.operator(
"sequencer.clear_mask_cache",
text="Clear Strip Cache",
icon='TRASH',
)
op.all_strips = False
op = row.operator(
"sequencer.clear_mask_cache",
text="Clear All",
icon='TRASH',
)
op.all_strips = True
def _draw_progress(self, layout, wm, generator): def _draw_progress(self, layout, wm, generator):
"""Draw progress bar during generation.""" """Draw progress bar during generation."""
box = layout.box() box = layout.box()
@@ -64,6 +173,93 @@ class SEQUENCER_PT_face_mask(Panel):
icon='CANCEL', icon='CANCEL',
) )
def _draw_bake_progress(self, layout, wm, generator):
"""Draw progress bar during blur bake."""
box = layout.box()
box.label(text="Baking Blur...", icon='RENDER_ANIMATION')
progress = wm.bake_progress / max(wm.bake_total, 1)
box.progress(
factor=progress,
text=f"Frame {wm.bake_progress} / {wm.bake_total}",
)
box.operator(
"sequencer.cancel_bake_blur",
text="Cancel",
icon='CANCEL',
)
def _draw_batch_progress(self, layout, wm, batch, generator, bake_generator):
"""Draw batch bake progress."""
box = layout.box()
if batch._mode == "mask_only":
box.label(text="Batch Generating Cache...", icon='RENDER_ANIMATION')
else:
box.label(text="Batch Baking...", icon='RENDER_ANIMATION')
# Overall progress
total = max(wm.batch_total, 1)
# Show n-1/total while current strip is in progress, n/total when moving to next
done_count = max(wm.batch_current - 1, 0)
overall_factor = done_count / total
box.progress(
factor=overall_factor,
text=f"{wm.batch_current} / {wm.batch_total}",
)
if wm.batch_current_name:
box.label(text=f"Strip: {wm.batch_current_name}")
# Inner progress (mask gen or bake)
if generator.is_running:
inner = wm.mask_progress / max(wm.mask_total, 1)
box.progress(
factor=inner,
text=f"Detecting: {wm.mask_progress} / {wm.mask_total}",
)
elif bake_generator.is_running:
inner = wm.bake_progress / max(wm.bake_total, 1)
box.progress(
factor=inner,
text=f"Baking: {wm.bake_progress} / {wm.bake_total}",
)
box.operator(
"sequencer.cancel_batch_bake",
text="Cancel Batch",
icon='CANCEL',
)
def _draw_batch_controls(self, layout, context, seq_editor):
"""Draw batch bake button when multiple MOVIE/IMAGE strips are selected."""
if not seq_editor:
return
selected_movies = [s for s in seq_editor.strips if s.select and s.type in {"MOVIE", "IMAGE"}]
if not selected_movies:
return
count = len(selected_movies)
image_count = sum(1 for s in selected_movies if s.type == "IMAGE")
video_count = sum(1 for s in selected_movies if s.type == "MOVIE")
label = f"Batch ({count} selected, image: {image_count}, video: {video_count})"
box = layout.box()
box.label(text=label, icon='RENDER_ANIMATION')
box.operator(
"sequencer.batch_bake_selected",
text="Batch Bake Selected",
icon='RENDER_ANIMATION',
)
box.operator(
"sequencer.batch_regenerate_cache",
text="Batch Regenerate Cache",
icon='FILE_REFRESH',
)
box.operator(
"sequencer.batch_restore_original",
text="Batch Restore Original",
icon='LOOP_BACK',
)
def _draw_generation_controls(self, layout, context, strip): def _draw_generation_controls(self, layout, context, strip):
"""Draw mask generation controls.""" """Draw mask generation controls."""
box = layout.box() box = layout.box()
@@ -73,41 +269,83 @@ class SEQUENCER_PT_face_mask(Panel):
row = box.row() row = box.row()
row.label(text=f"Strip: {strip.name}") row.label(text=f"Strip: {strip.name}")
# Check for existing mask has_mask = check_detection_cache(strip.name)
seq_editor = context.scene.sequence_editor
mask_name = f"{strip.name}_mask"
has_mask = mask_name in seq_editor.strips
if has_mask: if has_mask:
row = box.row() row = box.row()
row.label(text="Mask exists", icon='CHECKMARK') row.label(text="Detection cache exists", icon='CHECKMARK')
# Generate button # Generate / Regenerate button
if not has_mask:
box.operator(
"sequencer.generate_face_mask",
text="Generate Detection Cache",
icon='FACE_MAPS',
)
else:
op = box.operator( op = box.operator(
"sequencer.generate_face_mask", "sequencer.generate_face_mask",
text="Generate Face Mask" if not has_mask else "Regenerate Mask", text="Regenerate Cache",
icon='FACE_MAPS', icon='FILE_REFRESH',
)
op.force = True
if strip.type == 'MOVIE':
box.operator(
"sequencer.augment_pose_mask",
text="Augment with Pose",
icon='MOD_ARMATURE',
) )
def _draw_blur_controls(self, layout, context, strip): def _draw_blur_controls(self, layout, context, strip):
"""Draw blur application controls.""" """Draw blur application controls."""
box = layout.box() box = layout.box()
box.label(text="Blur Application", icon='MATFLUID') box.label(text="Blur Bake", icon='MATFLUID')
# Check for mask strip has_mask = check_detection_cache(strip.name)
seq_editor = context.scene.sequence_editor
mask_name = f"{strip.name}_mask"
has_mask = mask_name in seq_editor.strips
if not has_mask: if not has_mask:
box.label(text="Generate a mask first", icon='INFO') box.label(text="Generate detection cache first", icon='INFO')
return return
# Apply blur button # Bake parameters
op = box.operator( col = box.column(align=True)
"sequencer.apply_mask_blur", col.prop(context.scene, "facemask_bake_blur_size")
text="Apply Mask Blur", col.prop(context.scene, "facemask_bake_display_scale")
icon='PROP_CON', if strip.type == "MOVIE":
col.prop(context.scene, "facemask_bake_format")
box.separator()
baked_path = strip.get("facemask_baked_filepath", "")
has_baked = bool(baked_path and os.path.exists(bpy.path.abspath(baked_path)))
source_mode = strip.get("facemask_source_mode", "original")
if not has_baked:
# 初回: Bakeのみ
box.operator(
"sequencer.bake_and_swap_blur_source",
text="Bake",
icon='RENDER_STILL',
)
else:
# Bake済み: ソース切り替え + Re-bake
row = box.row(align=True)
if source_mode == "baked":
row.operator(
"sequencer.restore_original_source",
text="Restore Original",
icon='LOOP_BACK',
)
else:
row.operator(
"sequencer.swap_to_baked_blur",
text="Swap to Baked",
icon='PLAY',
)
row.operator(
"sequencer.force_rebake_blur",
text="Re-bake",
icon='FILE_REFRESH',
) )
+7
View File
@@ -0,0 +1,7 @@
ultralytics
opencv-python-headless
msgpack
numpy
fastapi
uvicorn
pydantic
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 推論サーバーの単体起動スクリプト
set -e
echo "=== Face Detection Inference Server ==="
echo ""
# 環境変数の読み込み
if [ -f ".env" ]; then
echo "環境変数を読み込み中..."
export $(cat .env | grep -v '^#' | xargs)
else
echo "警告: .env ファイルが見つかりません"
fi
# 仮想環境の確認とアクティベート
if [ ! -d ".venv" ]; then
echo "エラー: .venv が見つかりません"
echo "仮想環境を作成してください: python -m venv .venv"
exit 1
fi
source .venv/bin/activate
# モデルの確認
MODEL_PATH="models/yolov8n-face-lindevs.pt"
if [ ! -f "$MODEL_PATH" ]; then
echo "警告: モデルファイルが見つかりません: $MODEL_PATH"
echo "最初のリクエスト時にエラーになる可能性があります"
echo ""
fi
# GPU情報の表示
echo "=== GPU情報 ==="
python -c "
import torch
if torch.cuda.is_available():
print(f'GPU検出: {torch.cuda.get_device_name(0)}')
print(f'ROCm version: {torch.version.hip if hasattr(torch.version, \"hip\") else \"N/A\"}')
else:
print('GPU未検出(CPUモードで動作します)')
" 2>/dev/null || echo "PyTorchが見つかりません"
echo ""
# サーバー起動
echo "=== サーバーを起動中 ==="
echo "URL: http://127.0.0.1:8181"
echo "終了するには Ctrl+C を押してください"
echo ""
python server/main.py
+292 -70
View File
@@ -1,28 +1,36 @@
""" """
YOLOv8 Face Detector using PyTorch with ROCm support. YOLOv8 Head Detector using CrowdHuman-trained model with PyTorch ROCm support.
This module provides high-performance face detection using Directly detects human heads (frontal, profile, rear) using the Owen718
YOLOv8-face model with AMD GPU (ROCm) acceleration. CrowdHuman YOLOv8 model, which was trained on dense crowd scenes.
""" """
import os import os
from typing import List, Tuple, Optional from typing import List, Tuple, Optional
from pathlib import Path
import numpy as np import numpy as np
class YOLOFaceDetector: def _download_model(dest_path: str):
""" """モデルが存在しない場合に手動ダウンロード手順を表示して例外を送出する。"""
YOLOv8 face detector with PyTorch ROCm support. gdrive_id = "1qlBmiEU4GBV13fxPhLZqjhjBbREvs8-m"
raise RuntimeError(
f"モデルファイルが見つかりません: {dest_path}\n"
"以下の手順でダウンロードしてください:\n"
f" 1. https://drive.google.com/file/d/{gdrive_id} を開く\n"
f" 2. ダウンロードしたファイルを {dest_path} に配置する"
)
Features:
- ROCm GPU acceleration for AMD GPUs class YOLOHeadDetector:
- High accuracy face detection """
- Automatic NMS for overlapping detections Head detector using CrowdHuman-trained YOLOv8 model with PyTorch ROCm support.
Directly detects heads (class 0: head) without pose estimation,
enabling robust detection of rear-facing, side-facing, and partially
visible people in dense crowd scenes.
""" """
# Default model path relative to this file DEFAULT_MODEL = os.path.join("models", "crowdhuman_yolov8_head.pt")
DEFAULT_MODEL = "yolov8n-face-lindevs.pt"
def __init__( def __init__(
self, self,
@@ -31,15 +39,6 @@ class YOLOFaceDetector:
iou_threshold: float = 0.45, iou_threshold: float = 0.45,
input_size: Tuple[int, int] = (640, 640), input_size: Tuple[int, int] = (640, 640),
): ):
"""
Initialize the YOLO face detector.
Args:
model_path: Path to PyTorch model file. If None, uses default model.
conf_threshold: Confidence threshold for detections
iou_threshold: IoU threshold for NMS
input_size: Model input size (width, height)
"""
self.conf_threshold = conf_threshold self.conf_threshold = conf_threshold
self.iou_threshold = iou_threshold self.iou_threshold = iou_threshold
self.input_size = input_size self.input_size = input_size
@@ -49,23 +48,20 @@ class YOLOFaceDetector:
@property @property
def model(self): def model(self):
"""Lazy-load YOLO model.""" """Lazy-load YOLO head detection model."""
if self._model is None: if self._model is None:
from ultralytics import YOLO from ultralytics import YOLO
import torch import torch
# Determine model path if self._model_path is not None:
if self._model_path is None: if not os.path.exists(self._model_path):
# Assuming models are in ../models relative to server/detector.py raise FileNotFoundError(f"Model not found: {self._model_path}")
models_dir = Path(__file__).parent.parent / "models"
model_path = str(models_dir / self.DEFAULT_MODEL)
else:
model_path = self._model_path model_path = self._model_path
else:
model_path = self.DEFAULT_MODEL
if not os.path.exists(model_path): if not os.path.exists(model_path):
raise FileNotFoundError(f"Model not found: {model_path}") _download_model(model_path)
# Detect device (ROCm GPU or CPU)
if torch.cuda.is_available(): if torch.cuda.is_available():
self._device = 'cuda' self._device = 'cuda'
device_name = torch.cuda.get_device_name(0) device_name = torch.cuda.get_device_name(0)
@@ -74,25 +70,32 @@ class YOLOFaceDetector:
self._device = 'cpu' self._device = 'cpu'
print("[FaceMask] Using CPU for inference (ROCm GPU not available)") print("[FaceMask] Using CPU for inference (ROCm GPU not available)")
# Load model (let Ultralytics handle device management)
try: try:
self._model = YOLO(model_path) self._model = YOLO(model_path)
# Don't call .to() - let predict() handle device assignment print(f"[FaceMask] Head detection model loaded: {model_path}")
print(f"[FaceMask] Model loaded, will use device: {self._device}") print(f"[FaceMask] Device: {self._device}")
except Exception as e: except Exception as e:
print(f"[FaceMask] Error loading model: {e}") print(f"[FaceMask] Error loading model: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
raise raise
print(f"[FaceMask] YOLO model loaded: {model_path}")
print(f"[FaceMask] Device: {self._device}")
return self._model return self._model
def _results_to_detections(self, result) -> List[Tuple[int, int, int, int, float]]:
"""Convert a single YOLO result to (x, y, w, h, conf) tuples."""
if result.boxes is None:
return []
detections = []
for box in result.boxes:
conf = float(box.conf[0].cpu().numpy())
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
detections.append((int(x1), int(y1), int(x2 - x1), int(y2 - y1), conf))
return detections
def detect(self, frame: np.ndarray) -> List[Tuple[int, int, int, int, float]]: def detect(self, frame: np.ndarray) -> List[Tuple[int, int, int, int, float]]:
""" """
Detect faces in a frame. Detect heads in a frame.
Args: Args:
frame: BGR image as numpy array (H, W, C) frame: BGR image as numpy array (H, W, C)
@@ -100,7 +103,6 @@ class YOLOFaceDetector:
Returns: Returns:
List of detections as (x, y, width, height, confidence) List of detections as (x, y, width, height, confidence)
""" """
# Run inference
import torch import torch
print(f"[FaceMask] Inference device: {self._device}, CUDA available: {torch.cuda.is_available()}") print(f"[FaceMask] Inference device: {self._device}, CUDA available: {torch.cuda.is_available()}")
try: try:
@@ -116,7 +118,6 @@ class YOLOFaceDetector:
print(f"[FaceMask] ERROR during inference: {e}") print(f"[FaceMask] ERROR during inference: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Fallback to CPU
print("[FaceMask] Falling back to CPU inference...") print("[FaceMask] Falling back to CPU inference...")
self._device = 'cpu' self._device = 'cpu'
results = self.model.predict( results = self.model.predict(
@@ -128,24 +129,49 @@ class YOLOFaceDetector:
device='cpu', device='cpu',
) )
# Extract detections if results:
detections = [] return self._results_to_detections(results[0])
if len(results) > 0 and results[0].boxes is not None: return []
boxes = results[0].boxes
for box in boxes:
# Get coordinates in xyxy format
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
conf = float(box.conf[0].cpu().numpy())
# Convert to x, y, width, height def detect_batch(self, frames: List[np.ndarray]) -> List[List[Tuple[int, int, int, int, float]]]:
x = int(x1) """
y = int(y1) Detect heads in multiple frames at once (batch processing).
w = int(x2 - x1)
h = int(y2 - y1)
detections.append((x, y, w, h, conf)) Args:
frames: List of BGR images as numpy arrays (H, W, C)
return detections Returns:
List of detection lists, one per frame.
Each detection: (x, y, width, height, confidence)
"""
if not frames:
return []
try:
results = self.model.predict(
frames,
conf=self.conf_threshold,
iou=self.iou_threshold,
imgsz=self.input_size[0],
verbose=False,
device=self._device,
)
except Exception as e:
print(f"[FaceMask] ERROR during batch inference: {e}")
import traceback
traceback.print_exc()
print("[FaceMask] Falling back to CPU inference...")
self._device = 'cpu'
results = self.model.predict(
frames,
conf=self.conf_threshold,
iou=self.iou_threshold,
imgsz=self.input_size[0],
verbose=False,
device='cpu',
)
return [self._results_to_detections(r) for r in results]
def generate_mask( def generate_mask(
self, self,
@@ -155,11 +181,11 @@ class YOLOFaceDetector:
feather_radius: int = 20, feather_radius: int = 20,
) -> np.ndarray: ) -> np.ndarray:
""" """
Generate a mask image from face detections. Generate a mask image from head detections.
Args: Args:
frame_shape: Shape of the original frame (height, width, channels) frame_shape: Shape of the original frame (height, width, channels)
detections: List of face detections (x, y, w, h, conf) detections: List of head detections (x, y, w, h, conf)
mask_scale: Scale factor for mask region mask_scale: Scale factor for mask region
feather_radius: Radius for edge feathering feather_radius: Radius for edge feathering
@@ -172,25 +198,19 @@ class YOLOFaceDetector:
mask = np.zeros((height, width), dtype=np.uint8) mask = np.zeros((height, width), dtype=np.uint8)
for (x, y, w, h, conf) in detections: for (x, y, w, h, conf) in detections:
# Scale the bounding box
center_x = x + w // 2 center_x = x + w // 2
center_y = y + h // 2 center_y = y + h // 2
scaled_w = int(w * mask_scale) scaled_w = int(w * mask_scale)
scaled_h = int(h * mask_scale) scaled_h = int(h * mask_scale)
# Draw ellipse for natural face shape
cv2.ellipse( cv2.ellipse(
mask, mask,
(center_x, center_y), (center_x, center_y),
(scaled_w // 2, scaled_h // 2), (scaled_w // 2, scaled_h // 2),
0, # angle 0, 0, 360,
0, 360, # arc 255, -1,
255, # color (white)
-1, # filled
) )
# Apply Gaussian blur for feathering
if feather_radius > 0 and len(detections) > 0: if feather_radius > 0 and len(detections) > 0:
kernel_size = feather_radius * 2 + 1 kernel_size = feather_radius * 2 + 1
mask = cv2.GaussianBlur(mask, (kernel_size, kernel_size), 0) mask = cv2.GaussianBlur(mask, (kernel_size, kernel_size), 0)
@@ -199,12 +219,214 @@ class YOLOFaceDetector:
# Singleton instance # Singleton instance
_detector: Optional[YOLOFaceDetector] = None _detector: Optional[YOLOHeadDetector] = None
def get_detector(**kwargs) -> YOLOFaceDetector: def get_detector(**kwargs) -> YOLOHeadDetector:
"""Get or create the global YOLO detector instance.""" """Get or create the global YOLO head detector instance."""
global _detector global _detector
if _detector is None: if _detector is None:
_detector = YOLOFaceDetector(**kwargs) _detector = YOLOHeadDetector(**kwargs)
return _detector return _detector
# ---------------------------------------------------------------------------
# Pose-based head detector (YOLOv8 pose estimation)
# ---------------------------------------------------------------------------
# COCO pose keypoint indices
_HEAD_KP = [0, 1, 2, 3, 4] # nose, left_eye, right_eye, left_ear, right_ear
_SHOULDER_KP = [5, 6] # left_shoulder, right_shoulder
_KP_CONF_THRESH = 0.3
def _head_bbox_from_pose(
kp_xy: np.ndarray,
kp_conf: np.ndarray,
person_x1: float,
person_y1: float,
person_x2: float,
person_y2: float,
) -> Tuple[int, int, int, int]:
"""
Estimate head bounding box (x, y, w, h) from COCO pose keypoints.
Strategy:
1. Use head keypoints (0-4: nose, eyes, ears) if visible.
2. Fall back to shoulder keypoints (5-6) to infer head position.
3. Last resort: use top of the person bounding box.
"""
person_w = max(person_x2 - person_x1, 1.0)
# --- Step 1: head keypoints ---
visible_head = [
(float(kp_xy[i][0]), float(kp_xy[i][1]))
for i in _HEAD_KP
if float(kp_conf[i]) > _KP_CONF_THRESH
]
if visible_head:
xs = [p[0] for p in visible_head]
ys = [p[1] for p in visible_head]
kp_x1, kp_y1 = min(xs), min(ys)
kp_x2, kp_y2 = max(xs), max(ys)
span = max(kp_x2 - kp_x1, kp_y2 - kp_y1, 1.0)
cx = (kp_x1 + kp_x2) / 2.0
cy = (kp_y1 + kp_y2) / 2.0
r = max(span * 0.5, person_w * 0.10)
x1 = int(cx - r)
y1 = int(cy - r)
x2 = int(cx + r)
y2 = int(cy + r)
return x1, y1, x2 - x1, y2 - y1
# --- Step 2: shoulder keypoints ---
visible_shoulder = [
(float(kp_xy[i][0]), float(kp_xy[i][1]))
for i in _SHOULDER_KP
if float(kp_conf[i]) > _KP_CONF_THRESH
]
if visible_shoulder:
cx = sum(p[0] for p in visible_shoulder) / len(visible_shoulder)
cy_sh = sum(p[1] for p in visible_shoulder) / len(visible_shoulder)
if len(visible_shoulder) == 2:
sh_width = abs(visible_shoulder[1][0] - visible_shoulder[0][0])
else:
sh_width = person_w * 0.5
r = max(sh_width * 0.3, person_w * 0.12)
cy = cy_sh - r * 1.3
x1 = int(cx - r)
y1 = int(cy - r)
x2 = int(cx + r)
y2 = int(cy + r)
return x1, y1, x2 - x1, y2 - y1
# --- Step 3: person bbox top ---
r = max(person_w * 0.15, 20.0)
cx = (person_x1 + person_x2) / 2.0
x1 = int(cx - r)
y1 = int(person_y1)
x2 = int(cx + r)
y2 = int(person_y1 + r * 2.0)
return x1, y1, x2 - x1, y2 - y1
class YOLOPoseHeadDetector:
"""
Head detector using YOLOv8 pose estimation with PyTorch ROCm support.
Extracts head bounding boxes from COCO pose keypoints (nose, eyes, ears).
yolov8l-pose.pt is auto-downloaded by Ultralytics on first use.
"""
DEFAULT_MODEL = os.path.join("models", "yolov8l-pose.pt")
def __init__(
self,
model_path: Optional[str] = None,
conf_threshold: float = 0.25,
iou_threshold: float = 0.45,
input_size: Tuple[int, int] = (640, 640),
):
self.conf_threshold = conf_threshold
self.iou_threshold = iou_threshold
self.input_size = input_size
self._model = None
self._model_path = model_path
self._device = None
@property
def model(self):
"""Lazy-load YOLO pose model."""
if self._model is None:
from ultralytics import YOLO
import torch
model_path = self._model_path if self._model_path is not None else self.DEFAULT_MODEL
if torch.cuda.is_available():
self._device = 'cuda'
device_name = torch.cuda.get_device_name(0)
print(f"[FaceMask] Using ROCm GPU for pose inference: {device_name}")
else:
self._device = 'cpu'
print("[FaceMask] Using CPU for pose inference (ROCm GPU not available)")
try:
self._model = YOLO(model_path)
print(f"[FaceMask] Pose model loaded: {model_path}")
print(f"[FaceMask] Device: {self._device}")
except Exception as e:
print(f"[FaceMask] Error loading pose model: {e}")
import traceback
traceback.print_exc()
raise
return self._model
def _results_to_detections(self, result) -> List[Tuple[int, int, int, int, float]]:
"""Convert a single YOLO pose result to (x, y, w, h, conf) tuples."""
detections = []
if result.boxes is None or result.keypoints is None:
return detections
boxes = result.boxes
keypoints = result.keypoints
for i, box in enumerate(boxes):
conf = float(box.conf[0].cpu().numpy())
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
kp_data = keypoints.data[i].cpu().numpy() # shape (17, 3): x, y, conf
kp_xy = kp_data[:, :2]
kp_conf = kp_data[:, 2]
hx, hy, hw, hh = _head_bbox_from_pose(
kp_xy, kp_conf,
float(x1), float(y1), float(x2), float(y2),
)
detections.append((hx, hy, hw, hh, conf))
return detections
def detect_batch(self, frames: List[np.ndarray]) -> List[List[Tuple[int, int, int, int, float]]]:
"""Detect heads in multiple frames at once (batch processing)."""
if not frames:
return []
try:
results = self.model.predict(
frames,
conf=self.conf_threshold,
iou=self.iou_threshold,
imgsz=self.input_size[0],
verbose=False,
device=self._device,
)
except Exception as e:
print(f"[FaceMask] ERROR during pose batch inference: {e}")
import traceback
traceback.print_exc()
print("[FaceMask] Falling back to CPU inference...")
self._device = 'cpu'
results = self.model.predict(
frames,
conf=self.conf_threshold,
iou=self.iou_threshold,
imgsz=self.input_size[0],
verbose=False,
device='cpu',
)
return [self._results_to_detections(r) for r in results]
# Pose detector singleton
_pose_detector: Optional[YOLOPoseHeadDetector] = None
def get_pose_detector(**kwargs) -> YOLOPoseHeadDetector:
"""Get or create the global YOLO pose head detector instance."""
global _pose_detector
if _pose_detector is None:
_pose_detector = YOLOPoseHeadDetector(**kwargs)
return _pose_detector
+1303 -52
View File
File diff suppressed because it is too large Load Diff