mirror of
https://github.com/hacksider/Deep-Live-Cam.git
synced 2026-09-19 05:52:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a21ccf488c | ||
|
|
ca8e39e3bb | ||
|
|
0e97e474e4 | ||
|
|
9c67a7aacc | ||
|
|
4a674d33ef | ||
|
|
682450755f | ||
|
|
12a3f6a007 | ||
|
|
cede099ccb | ||
|
|
81a1986ef8 | ||
|
|
ed758eb693 | ||
|
|
9c5f01c7f1 | ||
|
|
8bdc348779 | ||
|
|
e34d204c2e | ||
|
|
d1376b07d1 | ||
|
|
5deadaf428 | ||
|
|
2fba52e11b | ||
|
|
0926b65aaf | ||
|
|
297acded3b | ||
|
|
014bce0704 | ||
|
|
c962399669 | ||
|
|
2dd42dfc75 | ||
|
|
c38d669f7c | ||
|
|
890a6d41b6 | ||
|
|
f95a0bb7fb | ||
|
|
e957a7f4dd | ||
|
|
19416cb3cb | ||
|
|
cbf0859347 | ||
|
|
a6c99607fc | ||
|
|
0a87d63560 | ||
|
|
ea19030c74 | ||
|
|
4d04e830bc | ||
|
|
f65aeae5db | ||
|
|
64d3f06089 | ||
|
|
fceafcb234 | ||
|
|
033475b89c | ||
|
|
07711af712 | ||
|
|
44664d8a7f | ||
|
|
15a3f537a4 | ||
|
|
fbcea9e135 | ||
|
|
646b0f816f | ||
|
|
bcdd0ce2dd | ||
|
|
8703d394d6 | ||
|
|
69e3fc5611 | ||
|
|
2b26d5539e | ||
|
|
fea5a4c2d2 | ||
|
|
51fb7a6ad6 | ||
|
|
6da4f398d5 | ||
|
|
3e362383d8 | ||
|
|
11fb5bfbc6 | ||
|
|
586d8f3fb0 | ||
|
|
1edc4bc298 | ||
|
|
1f3668f7c1 | ||
|
|
3d16ee346f | ||
|
|
ab834d5640 | ||
|
|
bf8a89d20a | ||
|
|
bb4ef4a133 | ||
|
|
b6b6c741a2 | ||
|
|
1b240a45fd | ||
|
|
ecf02d0640 | ||
|
|
0cbc9f126f | ||
|
|
a3fd56a312 | ||
|
|
9525d45291 | ||
|
|
d9a5500bdf | ||
|
|
86134b6e1d | ||
|
|
fbd1cc5973 | ||
|
|
eac2ad2307 | ||
|
|
9e6f30c0a4 | ||
|
|
97321a740d | ||
|
|
9207386e07 | ||
|
|
f5f7ac7764 | ||
|
|
77d3492eef | ||
|
|
8e3d6e7c65 | ||
|
|
ee9699ee70 | ||
|
|
3c8b259a3f | ||
|
|
30b27c2b71 |
@@ -27,3 +27,5 @@ faceswap/
|
|||||||
switch_states.json
|
switch_states.json
|
||||||
/models
|
/models
|
||||||
install.bat
|
install.bat
|
||||||
|
/.claude
|
||||||
|
*.bat
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<h1 align="center">Deep-Live-Cam 2.0.5c</h1>
|
<h1 align="center">Deep-Live-Cam 2.1.6</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Real-time face swap and video deepfake with a single click and only a single image.
|
Real-time face swap and video deepfake with a single click and only a single image.
|
||||||
@@ -30,11 +30,11 @@ By using this software, you agree to these terms and commit to using it in a man
|
|||||||
|
|
||||||
Users are expected to use this software responsibly and legally. If using a real person's face, obtain their consent and clearly label any output as a deepfake when sharing online. We are not responsible for end-user actions.
|
Users are expected to use this software responsibly and legally. If using a real person's face, obtain their consent and clearly label any output as a deepfake when sharing online. We are not responsible for end-user actions.
|
||||||
|
|
||||||
## Exclusive v2.6d Quick Start - Pre-built (Windows/Mac Silicon)
|
## Exclusive v2.7 beta Quick Start - Pre-built (Windows/Mac Silicon/CPU)
|
||||||
|
|
||||||
<a href="https://deeplivecam.net/index.php/quickstart"> <img src="media/Download.png" width="285" height="77" />
|
<a href="https://deeplivecam.net/index.php/quickstart"> <img src="media/Download.png" width="285" height="77" />
|
||||||
|
|
||||||
##### This is the fastest build you can get if you have a discrete NVIDIA or AMD GPU or Mac Silicon, And you'll receive special priority support.
|
##### This is the fastest build you can get if you have a discrete NVIDIA or AMD GPU, CPU or Mac Silicon, And you'll receive special priority support. 2.7 beta is the best you can have with 30+ extra features than the open source version.
|
||||||
|
|
||||||
###### These Pre-builts are perfect for non-technical users or those who don't have time to, or can't manually install all the requirements. Just a heads-up: this is an open-source project, so you can also install it manually.
|
###### These Pre-builts are perfect for non-technical users or those who don't have time to, or can't manually install all the requirements. Just a heads-up: this is an open-source project, so you can also install it manually.
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ pip install -r requirements.txt
|
|||||||
```
|
```
|
||||||
For Linux:
|
For Linux:
|
||||||
```bash
|
```bash
|
||||||
# Ensure you use the installed Python 3.10
|
# Ensure you use the installed Python 3.11
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
@@ -157,7 +157,7 @@ Apple Silicon (M1/M2/M3) requires specific setup:
|
|||||||
brew install python@3.11
|
brew install python@3.11
|
||||||
|
|
||||||
# Install tkinter package (required for the GUI)
|
# Install tkinter package (required for the GUI)
|
||||||
brew install python-tk@3.10
|
brew install python-tk@3.11
|
||||||
|
|
||||||
# Create and activate virtual environment with Python 3.11
|
# Create and activate virtual environment with Python 3.11
|
||||||
python3.11 -m venv venv
|
python3.11 -m venv venv
|
||||||
@@ -214,7 +214,7 @@ python run.py --execution-provider cuda
|
|||||||
|
|
||||||
Apple Silicon (M1/M2/M3) specific installation:
|
Apple Silicon (M1/M2/M3) specific installation:
|
||||||
|
|
||||||
1. Make sure you've completed the macOS setup above using Python 3.10.
|
1. Make sure you've completed the macOS setup above using Python 3.11.
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -222,25 +222,25 @@ pip uninstall onnxruntime onnxruntime-silicon
|
|||||||
pip install onnxruntime-silicon==1.13.1
|
pip install onnxruntime-silicon==1.13.1
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Usage (important: specify Python 3.10):
|
3. Usage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3.10 run.py --execution-provider coreml
|
python3.11 run.py --execution-provider coreml
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important Notes for macOS:**
|
**Important Notes for macOS:**
|
||||||
- You **must** use Python 3.10, not newer versions like 3.11 or 3.13
|
- You **must** use Python 3.11, not newer versions like 3.13
|
||||||
- Always run with `python3.10` command not just `python` if you have multiple Python versions installed
|
- Always run with `python3.11` command not just `python` if you have multiple Python versions installed
|
||||||
- If you get error about `_tkinter` missing, reinstall the tkinter package: `brew reinstall python-tk@3.10`
|
- If you get error about `_tkinter` missing, reinstall the tkinter package: `brew reinstall python-tk@3.11`
|
||||||
- If you get model loading errors, check that your models are in the correct folder
|
- If you get model loading errors, check that your models are in the correct folder
|
||||||
- If you encounter conflicts with other Python versions, consider uninstalling them:
|
- If you encounter conflicts with other Python versions, consider uninstalling them:
|
||||||
```bash
|
```bash
|
||||||
# List all installed Python versions
|
# List all installed Python versions
|
||||||
brew list | grep python
|
brew list | grep python
|
||||||
|
|
||||||
# Uninstall conflicting versions if needed
|
# Uninstall conflicting versions if needed
|
||||||
brew uninstall --ignore-dependencies python@3.11 python@3.13
|
brew uninstall --ignore-dependencies python@3.13
|
||||||
|
|
||||||
# Keep only Python 3.11
|
# Keep only Python 3.11
|
||||||
brew cleanup
|
brew cleanup
|
||||||
```
|
```
|
||||||
@@ -309,6 +309,9 @@ python run.py --execution-provider openvino
|
|||||||
- Use a screen capture tool like OBS to stream.
|
- Use a screen capture tool like OBS to stream.
|
||||||
- To change the face, select a new source image.
|
- To change the face, select a new source image.
|
||||||
|
|
||||||
|
## Download all models in this huggingface link
|
||||||
|
- [**Download models here**](https://huggingface.co/hacksider/deep-live-cam/tree/main)
|
||||||
|
|
||||||
## Command Line Arguments (Unmaintained)
|
## Command Line Arguments (Unmaintained)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -362,6 +365,7 @@ Looking for a CLI mode? Using the -s/--source argument will make the run program
|
|||||||
- [kier007](https://github.com/kier007): for improving the user experience
|
- [kier007](https://github.com/kier007): for improving the user experience
|
||||||
- [qitianai](https://github.com/qitianai): for multi-lingual support
|
- [qitianai](https://github.com/qitianai): for multi-lingual support
|
||||||
- [laurigates](https://github.com/laurigates): Decoupling stuffs to make everything faster!
|
- [laurigates](https://github.com/laurigates): Decoupling stuffs to make everything faster!
|
||||||
|
- [maxwbuckley](https://github.com/maxwbuckley): For making the effort to optimize this for mac!
|
||||||
- and [all developers](https://github.com/hacksider/Deep-Live-Cam/graphs/contributors) behind libraries used in this project.
|
- and [all developers](https://github.com/hacksider/Deep-Live-Cam/graphs/contributors) behind libraries used in this project.
|
||||||
- Footnote: Please be informed that the base author of the code is [s0md3v](https://github.com/s0md3v/roop)
|
- Footnote: Please be informed that the base author of the code is [s0md3v](https://github.com/s0md3v/roop)
|
||||||
- All the wonderful users who helped make this project go viral by starring the repo ❤️
|
- All the wonderful users who helped make this project go viral by starring the repo ❤️
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Standalone pipeline benchmark — no UI required.
|
||||||
|
|
||||||
|
Captures 200 frames from the webcam and runs the full face swap pipeline,
|
||||||
|
printing per-stage timing and effective FPS.
|
||||||
|
"""
|
||||||
|
import os, sys, time, cv2, numpy as np, queue, threading
|
||||||
|
|
||||||
|
# PATH fix for cuDNN (Windows only)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
_sp = os.path.join(sys.prefix, "Lib", "site-packages")
|
||||||
|
_torch_lib = os.path.join(_sp, "torch", "lib")
|
||||||
|
if os.path.isdir(_torch_lib):
|
||||||
|
os.environ["PATH"] = _torch_lib + os.pathsep + os.environ["PATH"]
|
||||||
|
|
||||||
|
import insightface
|
||||||
|
from insightface.app import FaceAnalysis
|
||||||
|
from insightface.utils import face_align
|
||||||
|
from modules.processors.frame.face_swapper import _fast_paste_back
|
||||||
|
from modules import platform_info
|
||||||
|
|
||||||
|
platform_info.print_banner()
|
||||||
|
|
||||||
|
# Pick providers based on what's actually available on this machine.
|
||||||
|
if platform_info.HAS_CUDA_PROVIDER:
|
||||||
|
_providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
||||||
|
elif platform_info.HAS_COREML_PROVIDER:
|
||||||
|
_providers = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
|
||||||
|
else:
|
||||||
|
_providers = ["CPUExecutionProvider"]
|
||||||
|
|
||||||
|
# --- Init models (same as the app) ---
|
||||||
|
print(f"Loading models with providers={_providers}...")
|
||||||
|
fa = FaceAnalysis(
|
||||||
|
name="buffalo_l",
|
||||||
|
providers=_providers,
|
||||||
|
allowed_modules=["detection", "recognition", "landmark_2d_106"],
|
||||||
|
)
|
||||||
|
fa.prepare(ctx_id=0, det_size=(640, 640))
|
||||||
|
swap_model = insightface.model_zoo.get_model(
|
||||||
|
"models/inswapper_128.onnx",
|
||||||
|
providers=_providers,
|
||||||
|
)
|
||||||
|
face_size = swap_model.input_size[0]
|
||||||
|
aimg_dummy = np.empty((face_size, face_size, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
# --- Camera setup ---
|
||||||
|
# Windows: DirectShow explicit for MJPEG 1080p60 support.
|
||||||
|
# macOS/Linux: default backend (AVFoundation / V4L2).
|
||||||
|
print("Opening camera at 1080p60 MJPEG...")
|
||||||
|
if sys.platform == "win32":
|
||||||
|
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
|
||||||
|
else:
|
||||||
|
cap = cv2.VideoCapture(0)
|
||||||
|
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
|
||||||
|
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
|
||||||
|
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
|
||||||
|
cap.set(cv2.CAP_PROP_FPS, 60)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Warmup + get source face
|
||||||
|
for _ in range(15):
|
||||||
|
cap.read()
|
||||||
|
ret, src_frame = cap.read()
|
||||||
|
faces = fa.get(src_frame)
|
||||||
|
if not faces:
|
||||||
|
print("ERROR: No face detected in warmup frame")
|
||||||
|
cap.release()
|
||||||
|
sys.exit(1)
|
||||||
|
source_face = faces[0]
|
||||||
|
print(f"Source face acquired. Frame: {src_frame.shape}")
|
||||||
|
|
||||||
|
# --- Capture thread (same as app) ---
|
||||||
|
capture_queue = queue.Queue(maxsize=2)
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
def capture_thread():
|
||||||
|
while not stop_event.is_set():
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if not ret:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
capture_queue.put_nowait(frame)
|
||||||
|
except queue.Full:
|
||||||
|
try: capture_queue.get_nowait()
|
||||||
|
except queue.Empty: pass
|
||||||
|
try: capture_queue.put_nowait(frame)
|
||||||
|
except queue.Full: pass
|
||||||
|
|
||||||
|
cap_t = threading.Thread(target=capture_thread, daemon=True)
|
||||||
|
cap_t.start()
|
||||||
|
|
||||||
|
# --- Warmup processing ---
|
||||||
|
print("Warming up pipeline...")
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
frame = capture_queue.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
f = frame.copy()
|
||||||
|
det_faces = fa.get(f)
|
||||||
|
if det_faces:
|
||||||
|
tgt = min(det_faces, key=lambda x: x.bbox[0])
|
||||||
|
bgr_fake, M = swap_model.get(f, tgt, source_face, paste_back=False)
|
||||||
|
_fast_paste_back(f, bgr_fake, aimg_dummy, M)
|
||||||
|
|
||||||
|
# --- Benchmark ---
|
||||||
|
N = 200
|
||||||
|
print(f"\nBenchmarking {N} frames...")
|
||||||
|
|
||||||
|
t_queue, t_det, t_onnx, t_paste, t_copy, t_cvt, t_total = [], [], [], [], [], [], []
|
||||||
|
det_count = 0
|
||||||
|
cached_face = None
|
||||||
|
|
||||||
|
for i in range(N):
|
||||||
|
tt = time.perf_counter()
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
frame = capture_queue.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
t_queue.append((time.perf_counter() - t0) * 1000)
|
||||||
|
|
||||||
|
# Detection every 3rd frame — det-only (no landmark/recognition)
|
||||||
|
det_count += 1
|
||||||
|
if det_count % 3 == 0:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
from insightface.app.common import Face as _Face
|
||||||
|
bboxes, kpss = fa.det_model.detect(frame, max_num=0, metric='default')
|
||||||
|
if bboxes.shape[0] > 0:
|
||||||
|
idx = int(bboxes[:, 0].argmin())
|
||||||
|
cached_face = _Face(bbox=bboxes[idx, :4], kps=kpss[idx], det_score=bboxes[idx, 4])
|
||||||
|
t_det.append((time.perf_counter() - t0) * 1000)
|
||||||
|
|
||||||
|
if cached_face is not None:
|
||||||
|
# No frame.copy() — _fast_paste_back writes in-place, we own the frame
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
bgr_fake, M = swap_model.get(frame, cached_face, source_face, paste_back=False)
|
||||||
|
t_onnx.append((time.perf_counter() - t0) * 1000)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
result = _fast_paste_back(frame, bgr_fake, aimg_dummy, M)
|
||||||
|
t_paste.append((time.perf_counter() - t0) * 1000)
|
||||||
|
|
||||||
|
# Display prep — resize then flip (no cvtColor needed)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
small = cv2.resize(result, (640, 360))
|
||||||
|
_ = small[:, :, ::-1] # BGR→RGB zero-copy
|
||||||
|
t_cvt.append((time.perf_counter() - t0) * 1000)
|
||||||
|
|
||||||
|
t_total.append((time.perf_counter() - tt) * 1000)
|
||||||
|
|
||||||
|
stop_event.set()
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
# --- Results ---
|
||||||
|
def s(name, arr):
|
||||||
|
if not arr:
|
||||||
|
return
|
||||||
|
avg = sum(arr) / len(arr)
|
||||||
|
print(f" {name:25s}: avg={avg:6.1f}ms min={min(arr):5.1f}ms max={max(arr):6.1f}ms n={len(arr)}")
|
||||||
|
|
||||||
|
print(f"\n{'='*55}")
|
||||||
|
print(f" 1080p Pipeline Benchmark ({len(t_total)} frames)")
|
||||||
|
print(f"{'='*55}")
|
||||||
|
s("queue.get (wait for cam)", t_queue)
|
||||||
|
s("detection (fa.get)", t_det)
|
||||||
|
s("frame.copy()", t_copy)
|
||||||
|
s("ONNX swap", t_onnx)
|
||||||
|
s("_fast_paste_back", t_paste)
|
||||||
|
s("cvtColor BGR->RGB", t_cvt)
|
||||||
|
s("TOTAL per frame", t_total)
|
||||||
|
|
||||||
|
avg_total = sum(t_total) / len(t_total)
|
||||||
|
avg_queue = sum(t_queue) / len(t_queue)
|
||||||
|
print(f"\n Effective FPS: {1000/avg_total:.1f}")
|
||||||
|
print(f" FPS (excl. cam wait): {1000/(avg_total - avg_queue):.1f}")
|
||||||
|
print(f"{'='*55}")
|
||||||
+88
-42
@@ -2,7 +2,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
# single thread doubles cuda performance - needs to be set before torch import
|
# single thread doubles cuda performance - needs to be set before torch import
|
||||||
if any(arg.startswith('--execution-provider') for arg in sys.argv):
|
if any(arg.startswith('--execution-provider') for arg in sys.argv):
|
||||||
os.environ['OMP_NUM_THREADS'] = '1'
|
os.environ['OMP_NUM_THREADS'] = '6'
|
||||||
# reduce tensorflow log level
|
# reduce tensorflow log level
|
||||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
|
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
|
||||||
import warnings
|
import warnings
|
||||||
@@ -17,12 +17,16 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_TORCH = False
|
HAS_TORCH = False
|
||||||
import onnxruntime
|
import onnxruntime
|
||||||
import tensorflow
|
try:
|
||||||
|
import tensorflow
|
||||||
|
HAS_TENSORFLOW = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_TENSORFLOW = False
|
||||||
|
|
||||||
import modules.globals
|
import modules.globals
|
||||||
import modules.metadata
|
import modules.metadata
|
||||||
import modules.ui as ui
|
import modules.ui as ui
|
||||||
from modules.processors.frame.core import get_frame_processors_modules
|
from modules.processors.frame.core import get_frame_processors_modules, process_video_in_memory
|
||||||
from modules.utilities import has_image_extension, is_image, is_video, detect_fps, create_video, extract_frames, get_temp_frame_paths, restore_audio, create_temp, move_temp, clean_temp, normalize_output_path
|
from modules.utilities import has_image_extension, is_image, is_video, detect_fps, create_video, extract_frames, get_temp_frame_paths, restore_audio, create_temp, move_temp, clean_temp, normalize_output_path
|
||||||
|
|
||||||
if HAS_TORCH and 'ROCMExecutionProvider' in modules.globals.execution_providers:
|
if HAS_TORCH and 'ROCMExecutionProvider' in modules.globals.execution_providers:
|
||||||
@@ -53,7 +57,7 @@ def parse_args() -> None:
|
|||||||
program.add_argument('--live-mirror', help='The live camera display as you see it in the front-facing camera frame', dest='live_mirror', action='store_true', default=False)
|
program.add_argument('--live-mirror', help='The live camera display as you see it in the front-facing camera frame', dest='live_mirror', action='store_true', default=False)
|
||||||
program.add_argument('--live-resizable', help='The live camera frame is resizable', dest='live_resizable', action='store_true', default=False)
|
program.add_argument('--live-resizable', help='The live camera frame is resizable', dest='live_resizable', action='store_true', default=False)
|
||||||
program.add_argument('--max-memory', help='maximum amount of RAM in GB', dest='max_memory', type=int, default=suggest_max_memory())
|
program.add_argument('--max-memory', help='maximum amount of RAM in GB', dest='max_memory', type=int, default=suggest_max_memory())
|
||||||
program.add_argument('--execution-provider', help='execution provider', dest='execution_provider', default=['cpu'], choices=suggest_execution_providers(), nargs='+')
|
program.add_argument('--execution-provider', help='execution provider', dest='execution_provider', default=[suggest_default_execution_provider()], choices=suggest_execution_providers(), nargs='+')
|
||||||
program.add_argument('--execution-threads', help='number of execution threads', dest='execution_threads', type=int, default=suggest_execution_threads())
|
program.add_argument('--execution-threads', help='number of execution threads', dest='execution_threads', type=int, default=suggest_execution_threads())
|
||||||
program.add_argument('-v', '--version', action='version', version=f'{modules.metadata.name} {modules.metadata.version}')
|
program.add_argument('-v', '--version', action='version', version=f'{modules.metadata.name} {modules.metadata.version}')
|
||||||
|
|
||||||
@@ -127,6 +131,15 @@ def suggest_max_memory() -> int:
|
|||||||
return 16
|
return 16
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_default_execution_provider() -> str:
|
||||||
|
"""Pick the best available provider: cuda > rocm > coreml > dml > cpu."""
|
||||||
|
available = encode_execution_providers(onnxruntime.get_available_providers())
|
||||||
|
for pref in ('cuda', 'rocm', 'coreml', 'dml'):
|
||||||
|
if pref in available:
|
||||||
|
return pref
|
||||||
|
return 'cpu'
|
||||||
|
|
||||||
|
|
||||||
def suggest_execution_providers() -> List[str]:
|
def suggest_execution_providers() -> List[str]:
|
||||||
return encode_execution_providers(onnxruntime.get_available_providers())
|
return encode_execution_providers(onnxruntime.get_available_providers())
|
||||||
|
|
||||||
@@ -143,8 +156,7 @@ def suggest_execution_threads() -> int:
|
|||||||
if 'ROCMExecutionProvider' in modules.globals.execution_providers:
|
if 'ROCMExecutionProvider' in modules.globals.execution_providers:
|
||||||
return 1
|
return 1
|
||||||
if 'CUDAExecutionProvider' in modules.globals.execution_providers:
|
if 'CUDAExecutionProvider' in modules.globals.execution_providers:
|
||||||
# For CUDA, use more threads for parallel frame processing
|
return 2
|
||||||
return min(cpu_count, 16)
|
|
||||||
|
|
||||||
# For CPU execution, use most cores but leave some for system
|
# For CPU execution, use most cores but leave some for system
|
||||||
return max(4, min(cpu_count - 2, 16))
|
return max(4, min(cpu_count - 2, 16))
|
||||||
@@ -152,9 +164,10 @@ def suggest_execution_threads() -> int:
|
|||||||
|
|
||||||
def limit_resources() -> None:
|
def limit_resources() -> None:
|
||||||
# prevent tensorflow memory leak
|
# prevent tensorflow memory leak
|
||||||
gpus = tensorflow.config.experimental.list_physical_devices('GPU')
|
if HAS_TENSORFLOW:
|
||||||
for gpu in gpus:
|
gpus = tensorflow.config.experimental.list_physical_devices('GPU')
|
||||||
tensorflow.config.experimental.set_memory_growth(gpu, True)
|
for gpu in gpus:
|
||||||
|
tensorflow.config.experimental.set_memory_growth(gpu, True)
|
||||||
# limit memory usage
|
# limit memory usage
|
||||||
if modules.globals.max_memory:
|
if modules.globals.max_memory:
|
||||||
memory = modules.globals.max_memory * 1024 ** 3
|
memory = modules.globals.max_memory * 1024 ** 3
|
||||||
@@ -223,40 +236,70 @@ def start() -> None:
|
|||||||
if modules.globals.nsfw_filter and ui.check_and_ignore_nsfw(modules.globals.target_path, destroy):
|
if modules.globals.nsfw_filter and ui.check_and_ignore_nsfw(modules.globals.target_path, destroy):
|
||||||
return
|
return
|
||||||
|
|
||||||
extraction_start = time.time()
|
# Detect FPS early (needed by both pipelines)
|
||||||
if not modules.globals.map_faces:
|
|
||||||
update_status('Creating temp resources...')
|
|
||||||
create_temp(modules.globals.target_path)
|
|
||||||
update_status('Extracting frames...')
|
|
||||||
extract_frames(modules.globals.target_path)
|
|
||||||
extraction_time = time.time() - extraction_start
|
|
||||||
update_status(f'Frame extraction completed in {extraction_time:.2f}s')
|
|
||||||
|
|
||||||
temp_frame_paths = get_temp_frame_paths(modules.globals.target_path)
|
|
||||||
total_frames = len(temp_frame_paths)
|
|
||||||
update_status(f'Processing {total_frames} frames with {modules.globals.execution_threads} threads...')
|
|
||||||
|
|
||||||
processing_start = time.time()
|
|
||||||
for frame_processor in get_frame_processors_modules(modules.globals.frame_processors):
|
|
||||||
update_status('Progressing...', frame_processor.NAME)
|
|
||||||
frame_processor.process_video(modules.globals.source_path, temp_frame_paths)
|
|
||||||
release_resources()
|
|
||||||
processing_time = time.time() - processing_start
|
|
||||||
fps_processing = total_frames / processing_time if processing_time > 0 else 0
|
|
||||||
update_status(f'Frame processing completed in {processing_time:.2f}s ({fps_processing:.2f} fps)')
|
|
||||||
|
|
||||||
# handles fps
|
|
||||||
encoding_start = time.time()
|
|
||||||
if modules.globals.keep_fps:
|
if modules.globals.keep_fps:
|
||||||
update_status('Detecting fps...')
|
update_status('Detecting fps...')
|
||||||
fps = detect_fps(modules.globals.target_path)
|
fps = detect_fps(modules.globals.target_path)
|
||||||
update_status(f'Creating video with {fps} fps...')
|
|
||||||
create_video(modules.globals.target_path, fps)
|
|
||||||
else:
|
else:
|
||||||
update_status('Creating video with 30.0 fps...')
|
fps = 30.0
|
||||||
create_video(modules.globals.target_path)
|
|
||||||
encoding_time = time.time() - encoding_start
|
video_created = False
|
||||||
update_status(f'Video encoding completed in {encoding_time:.2f}s')
|
|
||||||
|
# --- In-memory pipeline (non-map_faces only) ---
|
||||||
|
# Reads frames from FFmpeg pipe, processes in memory, encodes directly.
|
||||||
|
# Eliminates all per-frame PNG disk I/O for a major speed-up.
|
||||||
|
if not modules.globals.map_faces:
|
||||||
|
update_status(f'Processing video in-memory at {fps} fps...')
|
||||||
|
create_temp(modules.globals.target_path)
|
||||||
|
|
||||||
|
processing_start = time.time()
|
||||||
|
video_created = process_video_in_memory(
|
||||||
|
modules.globals.source_path,
|
||||||
|
modules.globals.target_path,
|
||||||
|
fps,
|
||||||
|
)
|
||||||
|
processing_time = time.time() - processing_start
|
||||||
|
release_resources()
|
||||||
|
|
||||||
|
if video_created:
|
||||||
|
update_status(f'In-memory processing + encoding completed in {processing_time:.2f}s')
|
||||||
|
|
||||||
|
# --- Disk-based fallback (required for map_faces, or if pipe failed) ---
|
||||||
|
if not video_created:
|
||||||
|
if not modules.globals.map_faces:
|
||||||
|
update_status('Falling back to disk-based processing...')
|
||||||
|
|
||||||
|
extraction_start = time.time()
|
||||||
|
if not modules.globals.map_faces:
|
||||||
|
create_temp(modules.globals.target_path)
|
||||||
|
update_status('Extracting frames...')
|
||||||
|
extract_frames(modules.globals.target_path)
|
||||||
|
extraction_time = time.time() - extraction_start
|
||||||
|
|
||||||
|
temp_frame_paths = get_temp_frame_paths(modules.globals.target_path)
|
||||||
|
total_frames = len(temp_frame_paths)
|
||||||
|
update_status(f'Processing {total_frames} frames with {modules.globals.execution_threads} threads...')
|
||||||
|
|
||||||
|
processing_start = time.time()
|
||||||
|
for frame_processor in get_frame_processors_modules(modules.globals.frame_processors):
|
||||||
|
update_status('Progressing...', frame_processor.NAME)
|
||||||
|
frame_processor.process_video(modules.globals.source_path, temp_frame_paths)
|
||||||
|
release_resources()
|
||||||
|
processing_time = time.time() - processing_start
|
||||||
|
fps_processing = total_frames / processing_time if processing_time > 0 else 0
|
||||||
|
update_status(f'Frame processing completed in {processing_time:.2f}s ({fps_processing:.2f} fps)')
|
||||||
|
|
||||||
|
encoding_start = time.time()
|
||||||
|
update_status(f'Creating video with {fps} fps...')
|
||||||
|
video_created = create_video(modules.globals.target_path, fps)
|
||||||
|
encoding_time = time.time() - encoding_start
|
||||||
|
if video_created:
|
||||||
|
update_status(f'Video encoding completed in {encoding_time:.2f}s')
|
||||||
|
|
||||||
|
if not video_created:
|
||||||
|
update_status('Video encoding failed. No temporary output video was created.')
|
||||||
|
clean_temp(modules.globals.target_path)
|
||||||
|
return
|
||||||
|
|
||||||
# handle audio
|
# handle audio
|
||||||
if modules.globals.keep_audio:
|
if modules.globals.keep_audio:
|
||||||
@@ -272,8 +315,8 @@ def start() -> None:
|
|||||||
clean_temp(modules.globals.target_path)
|
clean_temp(modules.globals.target_path)
|
||||||
|
|
||||||
total_time = time.time() - start_time
|
total_time = time.time() - start_time
|
||||||
if is_video(modules.globals.target_path):
|
if is_video(modules.globals.target_path) and modules.globals.output_path and os.path.isfile(modules.globals.output_path):
|
||||||
update_status(f'Processing to video succeed! Total time: {total_time:.2f}s')
|
update_status(f'Video processing succeeded! Total time: {total_time:.2f}s')
|
||||||
else:
|
else:
|
||||||
update_status('Processing to video failed!')
|
update_status('Processing to video failed!')
|
||||||
|
|
||||||
@@ -291,9 +334,12 @@ def run() -> None:
|
|||||||
for frame_processor in get_frame_processors_modules(modules.globals.frame_processors):
|
for frame_processor in get_frame_processors_modules(modules.globals.frame_processors):
|
||||||
if not frame_processor.pre_check():
|
if not frame_processor.pre_check():
|
||||||
return
|
return
|
||||||
|
# Pre-load face analyser in main thread before GUI starts
|
||||||
|
#from modules.face_analyser import get_face_analyser
|
||||||
|
#get_face_analyser()
|
||||||
limit_resources()
|
limit_resources()
|
||||||
if modules.globals.headless:
|
if modules.globals.headless:
|
||||||
start()
|
start()
|
||||||
else:
|
else:
|
||||||
window = ui.init(start, destroy, modules.globals.lang)
|
window = ui.init(start, destroy, modules.globals.lang)
|
||||||
window.mainloop()
|
window.mainloop()
|
||||||
+177
-8
@@ -16,6 +16,8 @@ from pathlib import Path
|
|||||||
FACE_ANALYSER = None
|
FACE_ANALYSER = None
|
||||||
FACE_ANALYSER_LOCK = threading.Lock()
|
FACE_ANALYSER_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
DET_SIZE = (640, 640)
|
||||||
|
|
||||||
|
|
||||||
def get_face_analyser() -> Any:
|
def get_face_analyser() -> Any:
|
||||||
"""Get face analyser with thread-safe initialization."""
|
"""Get face analyser with thread-safe initialization."""
|
||||||
@@ -25,29 +27,196 @@ def get_face_analyser() -> Any:
|
|||||||
with FACE_ANALYSER_LOCK:
|
with FACE_ANALYSER_LOCK:
|
||||||
# Double-check after acquiring lock
|
# Double-check after acquiring lock
|
||||||
if FACE_ANALYSER is None:
|
if FACE_ANALYSER is None:
|
||||||
|
from modules.processors.frame._onnx_enhancer import (
|
||||||
|
build_provider_config,
|
||||||
|
)
|
||||||
|
providers = build_provider_config()
|
||||||
FACE_ANALYSER = insightface.app.FaceAnalysis(
|
FACE_ANALYSER = insightface.app.FaceAnalysis(
|
||||||
name='buffalo_l',
|
name='buffalo_l',
|
||||||
providers=modules.globals.execution_providers,
|
providers=providers,
|
||||||
allowed_modules=['detection', 'recognition']
|
allowed_modules=['detection', 'recognition', 'landmark_2d_106']
|
||||||
)
|
)
|
||||||
FACE_ANALYSER.prepare(ctx_id=0, det_size=(320, 320))
|
FACE_ANALYSER.prepare(ctx_id=0, det_size=DET_SIZE)
|
||||||
|
_optimize_det_model(FACE_ANALYSER, providers)
|
||||||
return FACE_ANALYSER
|
return FACE_ANALYSER
|
||||||
|
|
||||||
|
|
||||||
def get_one_face(frame: Frame) -> Any:
|
def _optimize_det_model(fa: Any, providers) -> None:
|
||||||
face = get_face_analyser().get(frame)
|
"""Replace the detection model's ONNX session with a CoreML-optimized one.
|
||||||
|
|
||||||
|
Folds dynamic Shape→Gather chains into constants (the input size is
|
||||||
|
fixed at det_size), eliminating CPU↔ANE partition boundaries in the
|
||||||
|
RetinaFace FPN upsampling path. 21ms → 4ms on M3 Max.
|
||||||
|
"""
|
||||||
|
from modules.onnx_optimize import optimize_for_coreml, IS_APPLE_SILICON
|
||||||
|
if not IS_APPLE_SILICON:
|
||||||
|
return
|
||||||
|
|
||||||
|
det_model = fa.det_model
|
||||||
|
model_path = getattr(det_model, 'model_file', None)
|
||||||
|
if model_path is None or not os.path.exists(model_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
input_shape = (1, 3, DET_SIZE[1], DET_SIZE[0])
|
||||||
|
optimized_path = optimize_for_coreml(model_path, input_shape=input_shape)
|
||||||
|
if optimized_path == model_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
import onnxruntime
|
||||||
|
session_options = onnxruntime.SessionOptions()
|
||||||
|
session_options.graph_optimization_level = (
|
||||||
|
onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Route detection to GPU shader cores (CPUAndGPU) instead of ANE.
|
||||||
|
# This lets detection run concurrently with the swap model on the
|
||||||
|
# ANE, overlapping the two inference calls. Detection is fast
|
||||||
|
# enough on GPU (~4ms) and this frees ANE for the heavier swap.
|
||||||
|
det_providers = []
|
||||||
|
for p in providers:
|
||||||
|
name = p[0] if isinstance(p, tuple) else p
|
||||||
|
if name == "CoreMLExecutionProvider":
|
||||||
|
det_providers.append((
|
||||||
|
"CoreMLExecutionProvider",
|
||||||
|
{"ModelFormat": "MLProgram", "MLComputeUnits": "CPUAndGPU"},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
det_providers.append(p)
|
||||||
|
|
||||||
|
det_model.session = onnxruntime.InferenceSession(
|
||||||
|
optimized_path, sess_options=session_options, providers=det_providers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_landmark() -> bool:
|
||||||
|
"""Check whether any active feature requires 106-point landmarks.
|
||||||
|
|
||||||
|
Landmarks are needed by face enhancers and mouth masking, but not
|
||||||
|
by the face swapper alone.
|
||||||
|
"""
|
||||||
|
if getattr(modules.globals, "mouth_mask", False):
|
||||||
|
return True
|
||||||
|
processors = getattr(modules.globals, "frame_processors", [])
|
||||||
|
return any(p in processors for p in
|
||||||
|
("face_enhancer", "face_enhancer_gpen256", "face_enhancer_gpen512"))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dml() -> bool:
|
||||||
|
return any("DmlExecutionProvider" in p for p in modules.globals.execution_providers)
|
||||||
|
|
||||||
|
|
||||||
|
def _analyse_faces(frame: Frame) -> list:
|
||||||
|
"""Run face detection, then recognition (and optionally landmark).
|
||||||
|
|
||||||
|
Replaces InsightFace's ``FaceAnalysis.get()`` to skip the
|
||||||
|
landmark_2d_106 model when only face_swapper is active (saves ~1ms
|
||||||
|
per face and avoids an unnecessary ONNX session call).
|
||||||
|
"""
|
||||||
|
fa = get_face_analyser()
|
||||||
|
|
||||||
|
bboxes, kpss = fa.det_model.detect(frame, max_num=0, metric="default")
|
||||||
|
if bboxes.shape[0] == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
need_landmark = _needs_landmark()
|
||||||
|
rec_model = fa.models.get("recognition")
|
||||||
|
lmk_model = fa.models.get("landmark_2d_106") if need_landmark else None
|
||||||
|
|
||||||
|
from insightface.app.common import Face
|
||||||
|
|
||||||
|
faces = []
|
||||||
|
for i in range(bboxes.shape[0]):
|
||||||
|
face = Face(bbox=bboxes[i, 0:4],
|
||||||
|
kps=kpss[i] if kpss is not None else None,
|
||||||
|
det_score=bboxes[i, 4])
|
||||||
|
if rec_model is not None:
|
||||||
|
rec_model.get(frame, face)
|
||||||
|
if lmk_model is not None:
|
||||||
|
lmk_model.get(frame, face)
|
||||||
|
faces.append(face)
|
||||||
|
|
||||||
|
return faces
|
||||||
|
|
||||||
|
|
||||||
|
def get_one_face(frame: Frame, faces: Any = None) -> Any:
|
||||||
|
if faces is None:
|
||||||
|
if _is_dml():
|
||||||
|
with modules.globals.dml_lock:
|
||||||
|
faces = _analyse_faces(frame)
|
||||||
|
else:
|
||||||
|
faces = _analyse_faces(frame)
|
||||||
try:
|
try:
|
||||||
return min(face, key=lambda x: x.bbox[0])
|
return min(faces, key=lambda x: x.bbox[0])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_many_faces(frame: Frame) -> Any:
|
def get_many_faces(frame: Frame) -> Any:
|
||||||
try:
|
try:
|
||||||
return get_face_analyser().get(frame)
|
if _is_dml():
|
||||||
|
with modules.globals.dml_lock:
|
||||||
|
return _analyse_faces(frame)
|
||||||
|
else:
|
||||||
|
return _analyse_faces(frame)
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def detect_one_face_fast(frame: Frame) -> Any:
|
||||||
|
"""Detection-only — skips landmark and recognition models.
|
||||||
|
|
||||||
|
Returns a Face with bbox, kps, det_score (enough for face swap).
|
||||||
|
~10ms vs ~16ms for full get_one_face() at 1080p.
|
||||||
|
"""
|
||||||
|
from insightface.app.common import Face
|
||||||
|
fa = get_face_analyser()
|
||||||
|
bboxes, kpss = fa.det_model.detect(frame, max_num=0, metric='default')
|
||||||
|
if bboxes.shape[0] == 0:
|
||||||
|
return None
|
||||||
|
idx = int(bboxes[:, 0].argmin())
|
||||||
|
return Face(bbox=bboxes[idx, :4], kps=kpss[idx], det_score=bboxes[idx, 4])
|
||||||
|
|
||||||
|
|
||||||
|
def detect_many_faces_fast(frame: Frame) -> Any:
|
||||||
|
"""Detection-only multi-face — skips landmark and recognition."""
|
||||||
|
from insightface.app.common import Face
|
||||||
|
fa = get_face_analyser()
|
||||||
|
bboxes, kpss = fa.det_model.detect(frame, max_num=0, metric='default')
|
||||||
|
if bboxes.shape[0] == 0:
|
||||||
|
return None
|
||||||
|
return [Face(bbox=bboxes[i, :4], kps=kpss[i], det_score=bboxes[i, 4])
|
||||||
|
for i in range(bboxes.shape[0])]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_landmarks(frame: Frame, faces: Any) -> None:
|
||||||
|
"""Run the 2d106 landmark model in-place on faces that lack it.
|
||||||
|
|
||||||
|
The fast webcam path (detect_one_face_fast / detect_many_faces_fast)
|
||||||
|
produces detection-only Face objects with no ``landmark_2d_106``.
|
||||||
|
Mouth masking needs those landmarks, so add them on demand only when
|
||||||
|
the feature is active — keeping the fast path fast otherwise.
|
||||||
|
"""
|
||||||
|
if faces is None:
|
||||||
|
return
|
||||||
|
if not isinstance(faces, (list, tuple)):
|
||||||
|
faces = [faces]
|
||||||
|
|
||||||
|
fa = get_face_analyser()
|
||||||
|
lmk_model = fa.models.get("landmark_2d_106")
|
||||||
|
if lmk_model is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for face in faces:
|
||||||
|
if face is None:
|
||||||
|
continue
|
||||||
|
# insightface Face is a dict; missing keys raise AttributeError,
|
||||||
|
# so getattr(..., None) is the safe presence check.
|
||||||
|
if getattr(face, "landmark_2d_106", None) is None:
|
||||||
|
try:
|
||||||
|
lmk_model.get(frame, face)
|
||||||
|
except Exception as e: # pragma: no cover - never break the swap
|
||||||
|
print(f"Error computing 2d106 landmarks: {e}")
|
||||||
|
|
||||||
|
|
||||||
def has_valid_map() -> bool:
|
def has_valid_map() -> bool:
|
||||||
for map in modules.globals.source_target_map:
|
for map in modules.globals.source_target_map:
|
||||||
if "source" in map and "target" in map:
|
if "source" in map and "target" in map:
|
||||||
@@ -196,4 +365,4 @@ def dump_faces(centroids: Any, frame_face_embeddings: list):
|
|||||||
|
|
||||||
if temp_frame[int(y_min):int(y_max), int(x_min):int(x_max)].size > 0:
|
if temp_frame[int(y_min):int(y_max), int(x_min):int(x_max)].size > 0:
|
||||||
cv2.imwrite(temp_directory_path + f"/{i}/{frame['frame']}_{j}.png", temp_frame[int(y_min):int(y_max), int(x_min):int(x_max)])
|
cv2.imwrite(temp_directory_path + f"/{i}/{frame['frame']}_{j}.png", temp_frame[int(y_min):int(y_max), int(x_min):int(x_max)])
|
||||||
j += 1
|
j += 1
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ show_mouth_mask_box: bool = False # Visualize the mouth mask area (for debuggin
|
|||||||
mask_feather_ratio: int = 12 # Denominator for feathering calculation (higher = smaller feather)
|
mask_feather_ratio: int = 12 # Denominator for feathering calculation (higher = smaller feather)
|
||||||
mask_down_size: float = 0.1 # Expansion factor for lower lip mask (relative)
|
mask_down_size: float = 0.1 # Expansion factor for lower lip mask (relative)
|
||||||
mask_size: float = 1.0 # Expansion factor for upper lip mask (relative)
|
mask_size: float = 1.0 # Expansion factor for upper lip mask (relative)
|
||||||
|
mouth_mask_size: float = 0.0 # Mouth mask size (0-100; 0=off, 100=mouth to chin)
|
||||||
|
|
||||||
# --- START: Added for Frame Interpolation ---
|
# --- START: Added for Frame Interpolation ---
|
||||||
enable_interpolation: bool = True # Toggle temporal smoothing
|
enable_interpolation: bool = True # Toggle temporal smoothing
|
||||||
@@ -70,3 +71,6 @@ interpolation_weight: float = 0 # Blend weight for current frame (0.0-1.0). Low
|
|||||||
# --- END: Added for Frame Interpolation ---
|
# --- END: Added for Frame Interpolation ---
|
||||||
|
|
||||||
# --- END OF FILE globals.py ---
|
# --- END OF FILE globals.py ---
|
||||||
|
|
||||||
|
import threading
|
||||||
|
dml_lock = threading.Lock()
|
||||||
|
|||||||
+20
-21
@@ -18,6 +18,7 @@ Usage
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Tuple, Optional
|
from typing import Tuple, Optional
|
||||||
@@ -27,27 +28,25 @@ from typing import Tuple, Optional
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
CUDA_AVAILABLE: bool = False
|
CUDA_AVAILABLE: bool = False
|
||||||
|
|
||||||
try:
|
# OpenCV CUDA per-operation acceleration is DISABLED by default.
|
||||||
# cv2.cuda.GpuMat is only present when OpenCV is compiled with CUDA
|
# Each gpu_* call uploads to GPU, processes, then downloads back to CPU.
|
||||||
_test_mat = cv2.cuda.GpuMat()
|
# At webcam resolution (~960x540) this upload/download overhead far exceeds
|
||||||
# Verify we have the required filter / image-processing functions
|
# the time saved on the actual operation, making it slower than pure CPU.
|
||||||
_has_gauss = hasattr(cv2.cuda, "createGaussianFilter")
|
# The heavy lifting (face detection, swap, enhancement) runs on GPU via
|
||||||
_has_resize = hasattr(cv2.cuda, "resize")
|
# ONNX Runtime's CUDAExecutionProvider, which is where GPU matters.
|
||||||
_has_cvt = hasattr(cv2.cuda, "cvtColor")
|
#
|
||||||
if _has_gauss and _has_resize and _has_cvt:
|
# To force-enable, set OPENCV_CUDA_PROCESSING=1 in your environment.
|
||||||
CUDA_AVAILABLE = True
|
if os.environ.get("OPENCV_CUDA_PROCESSING") == "1":
|
||||||
print("[gpu_processing] OpenCV CUDA support detected – GPU-accelerated processing enabled.")
|
try:
|
||||||
else:
|
_test_mat = cv2.cuda.GpuMat()
|
||||||
missing = []
|
_has_gauss = hasattr(cv2.cuda, "createGaussianFilter")
|
||||||
if not _has_gauss:
|
_has_resize = hasattr(cv2.cuda, "resize")
|
||||||
missing.append("createGaussianFilter")
|
_has_cvt = hasattr(cv2.cuda, "cvtColor")
|
||||||
if not _has_resize:
|
if _has_gauss and _has_resize and _has_cvt:
|
||||||
missing.append("resize")
|
CUDA_AVAILABLE = True
|
||||||
if not _has_cvt:
|
print("[gpu_processing] OpenCV CUDA processing enabled via OPENCV_CUDA_PROCESSING=1.")
|
||||||
missing.append("cvtColor")
|
except Exception:
|
||||||
print(f"[gpu_processing] cv2.cuda.GpuMat exists but missing: {', '.join(missing)} – falling back to CPU.")
|
pass
|
||||||
except Exception:
|
|
||||||
print("[gpu_processing] OpenCV CUDA not available – using CPU fallback for all operations.")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
name = 'Deep-Live-Cam'
|
name = 'Deep-Live-Cam'
|
||||||
version = '2.0.3c'
|
version = '2.1.5'
|
||||||
edition = 'GitHub Edition'
|
edition = 'GitHub Edition'
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
"""ONNX model optimizations for CoreML execution on Apple Silicon.
|
||||||
|
|
||||||
|
Each pass eliminates a different CPU↔ANE round-trip that ORT's CoreML EP
|
||||||
|
would otherwise introduce:
|
||||||
|
|
||||||
|
1. **Shape/Gather constant folding** — Dynamic ``Shape`` → ``Gather`` chains
|
||||||
|
(e.g. for FPN upsample target sizes in RetinaFace) force ops onto CPU even
|
||||||
|
when the input dimensions are known at load time. We run ONNX shape
|
||||||
|
inference with the known input size and replace these chains with constants.
|
||||||
|
Float32-noise-level differences only (max ~6e-6).
|
||||||
|
|
||||||
|
2. **Pad(reflect) decomposition** — CoreML doesn't support ``Pad(mode=reflect)``.
|
||||||
|
Models using reflect padding (e.g. inswapper_128) get split into many CoreML
|
||||||
|
subgraphs with CPU fallbacks between each. We rewrite each ``Pad(reflect)``
|
||||||
|
as equivalent ``Slice`` + ``Concat`` ops that CoreML handles natively.
|
||||||
|
Bit-for-bit identical output. (Fixed upstream in microsoft/onnxruntime#28073.)
|
||||||
|
|
||||||
|
3. **Split → Slice decomposition** — CoreML's EP doesn't support the ONNX
|
||||||
|
``Split`` op, causing partition boundaries in models with channel-wise
|
||||||
|
splits (e.g. GFPGAN's SFT modulation). Each 2-way Split becomes two Slices.
|
||||||
|
|
||||||
|
4. **Scalar Gather widening** — ORT's CoreML EP rejects ``Gather`` nodes with
|
||||||
|
rank-0 (scalar) indices. StyleGAN-derived models (GFPGAN) slice per-layer
|
||||||
|
style codes using exactly this pattern. We widen each scalar index to
|
||||||
|
``[1]`` and squeeze the added axis on the Gather output.
|
||||||
|
(Filed upstream as microsoft/onnxruntime#28180.)
|
||||||
|
|
||||||
|
All passes are cached on disk with a ``_coreml`` suffix so the rewrite cost
|
||||||
|
is paid only once per model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
IS_APPLE_SILICON = platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_for_coreml(model_path: str, input_shape: tuple = None) -> str:
|
||||||
|
"""Return path to a CoreML-optimized ONNX model.
|
||||||
|
|
||||||
|
Applies all applicable optimizations and caches the result next to
|
||||||
|
the original model (with ``_coreml`` suffix).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_path: Path to the original ONNX model.
|
||||||
|
input_shape: Optional fixed input shape (e.g. ``(1, 3, 640, 640)``).
|
||||||
|
When provided, enables Shape/Gather constant folding.
|
||||||
|
|
||||||
|
Returns the optimized path, or the original path if no optimizations
|
||||||
|
apply or we're not on Apple Silicon.
|
||||||
|
"""
|
||||||
|
if not IS_APPLE_SILICON:
|
||||||
|
return model_path
|
||||||
|
|
||||||
|
base, ext = os.path.splitext(model_path)
|
||||||
|
optimized_path = f"{base}_coreml{ext}"
|
||||||
|
if os.path.exists(optimized_path):
|
||||||
|
if os.path.getmtime(optimized_path) >= os.path.getmtime(model_path):
|
||||||
|
return optimized_path
|
||||||
|
|
||||||
|
import onnx
|
||||||
|
from onnx import numpy_helper
|
||||||
|
|
||||||
|
model = onnx.load(model_path)
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
if _fold_shape_gather(model, input_shape):
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# TODO(ort>=1.26): drop this pass. Fixed upstream by microsoft/onnxruntime#28073.
|
||||||
|
if _decompose_reflect_pad(model):
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if _decompose_split(model):
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# TODO: drop this pass once microsoft/onnxruntime#28180 ships. The CoreML
|
||||||
|
# Gather op builder rejects rank-0 (scalar) indices; we widen them to [1]
|
||||||
|
# + Squeeze so StyleGAN-family models (GFPGAN) stay on ANE.
|
||||||
|
if _rewrite_scalar_gather(model):
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return model_path
|
||||||
|
|
||||||
|
# Preserve insightface's emap convention: the INSwapper class reads
|
||||||
|
# graph.initializer[-1] as the embedding map. If the original model
|
||||||
|
# had a (512, 512) matrix as its last initializer, keep it last.
|
||||||
|
_preserve_emap_position(model, numpy_helper)
|
||||||
|
|
||||||
|
onnx.save(model, optimized_path)
|
||||||
|
return optimized_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pass 1: Fold Shape → Gather chains into constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fold_shape_gather(model, input_shape) -> bool:
|
||||||
|
"""Replace dynamic Shape→Gather chains with constants when input size is known.
|
||||||
|
|
||||||
|
Only removes a Shape node when ALL of its consumers are Gather nodes
|
||||||
|
that are also being folded. This prevents breaking graphs where
|
||||||
|
a Shape output feeds into other ops as well.
|
||||||
|
"""
|
||||||
|
if input_shape is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from onnx import numpy_helper, shape_inference
|
||||||
|
|
||||||
|
graph = model.graph
|
||||||
|
|
||||||
|
# Set fixed input dimensions for shape inference
|
||||||
|
inp = graph.input[0]
|
||||||
|
dims = inp.type.tensor_type.shape.dim
|
||||||
|
for i, size in enumerate(input_shape):
|
||||||
|
if i < len(dims):
|
||||||
|
dims[i].dim_value = size
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_inferred = shape_inference.infer_shapes(model)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Extract inferred shapes
|
||||||
|
value_shapes = {}
|
||||||
|
for vi in list(model_inferred.graph.value_info) + list(graph.input) + list(graph.output):
|
||||||
|
shape_dims = vi.type.tensor_type.shape.dim
|
||||||
|
shape = []
|
||||||
|
for d in shape_dims:
|
||||||
|
if d.dim_value > 0:
|
||||||
|
shape.append(d.dim_value)
|
||||||
|
else:
|
||||||
|
shape.append(None)
|
||||||
|
value_shapes[vi.name] = shape
|
||||||
|
|
||||||
|
inits = {init.name: numpy_helper.to_array(init) for init in graph.initializer}
|
||||||
|
|
||||||
|
# Build consumer map: output_name → list of consuming nodes
|
||||||
|
consumers = {}
|
||||||
|
for node in graph.node:
|
||||||
|
for i in node.input:
|
||||||
|
consumers.setdefault(i, []).append(node)
|
||||||
|
|
||||||
|
# Also check graph outputs — an output name consumed by the graph
|
||||||
|
# output list must not be removed
|
||||||
|
graph_output_names = {o.name for o in graph.output}
|
||||||
|
|
||||||
|
# Find Shape nodes with fully-known output
|
||||||
|
shape_constants = {}
|
||||||
|
for node in graph.node:
|
||||||
|
if node.op_type == "Shape":
|
||||||
|
inp_shape = value_shapes.get(node.input[0])
|
||||||
|
if inp_shape and all(isinstance(d, int) for d in inp_shape):
|
||||||
|
shape_constants[node.output[0]] = np.array(inp_shape, dtype=np.int64)
|
||||||
|
|
||||||
|
if not shape_constants:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Find Gather nodes consuming Shape constants
|
||||||
|
gather_constants = {}
|
||||||
|
for node in graph.node:
|
||||||
|
if node.op_type == "Gather" and node.input[0] in shape_constants:
|
||||||
|
idx_name = node.input[1]
|
||||||
|
if idx_name in inits:
|
||||||
|
idx = int(inits[idx_name])
|
||||||
|
val = int(shape_constants[node.input[0]][idx])
|
||||||
|
gather_constants[node.output[0]] = np.array(val, dtype=np.int64)
|
||||||
|
|
||||||
|
if not gather_constants:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Determine which Gather nodes to fold (always safe — we replace
|
||||||
|
# the output with a constant initializer)
|
||||||
|
gather_remove_ids = set()
|
||||||
|
for node in graph.node:
|
||||||
|
if node.op_type == "Gather" and node.output[0] in gather_constants:
|
||||||
|
gather_remove_ids.add(id(node))
|
||||||
|
|
||||||
|
# Determine which Shape nodes are safe to remove: only if ALL
|
||||||
|
# consumers of the Shape output are Gather nodes being folded,
|
||||||
|
# and the output isn't a graph output.
|
||||||
|
shape_remove_ids = set()
|
||||||
|
for node in graph.node:
|
||||||
|
if node.op_type == "Shape" and node.output[0] in shape_constants:
|
||||||
|
out_name = node.output[0]
|
||||||
|
if out_name in graph_output_names:
|
||||||
|
continue
|
||||||
|
node_consumers = consumers.get(out_name, [])
|
||||||
|
if all(id(c) in gather_remove_ids for c in node_consumers):
|
||||||
|
shape_remove_ids.add(id(node))
|
||||||
|
|
||||||
|
remove_ids = gather_remove_ids | shape_remove_ids
|
||||||
|
|
||||||
|
# Add Gather output constants as initializers
|
||||||
|
existing = {i.name for i in graph.initializer}
|
||||||
|
for name, val in gather_constants.items():
|
||||||
|
if name not in existing:
|
||||||
|
graph.initializer.append(numpy_helper.from_array(val, name=name))
|
||||||
|
|
||||||
|
new_nodes = [n for n in graph.node if id(n) not in remove_ids]
|
||||||
|
del graph.node[:]
|
||||||
|
graph.node.extend(new_nodes)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pass 2: Decompose Pad(reflect) → Slice + Concat
|
||||||
|
#
|
||||||
|
# TEMPORARY: fixed upstream in microsoft/onnxruntime#28073 (merged 2026-04-20).
|
||||||
|
# Once the ORT floor is >= 1.26.0, MLProgram handles Pad(mode=reflect) natively
|
||||||
|
# via MIL tensor_operation.pad and this entire pass can be deleted.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _decompose_reflect_pad(model) -> bool:
|
||||||
|
"""Rewrite Pad(reflect) as Slice+Concat sequences CoreML can handle."""
|
||||||
|
from onnx import numpy_helper, helper
|
||||||
|
|
||||||
|
graph = model.graph
|
||||||
|
inits = {init.name: numpy_helper.to_array(init) for init in graph.initializer}
|
||||||
|
|
||||||
|
reflect_pads = []
|
||||||
|
for node in graph.node:
|
||||||
|
if node.op_type == "Pad":
|
||||||
|
mode = "constant"
|
||||||
|
for attr in node.attribute:
|
||||||
|
if attr.name == "mode":
|
||||||
|
mode = attr.s.decode()
|
||||||
|
if mode == "reflect" and len(node.input) > 1 and node.input[1] in inits:
|
||||||
|
reflect_pads.append(node)
|
||||||
|
|
||||||
|
if not reflect_pads:
|
||||||
|
return False
|
||||||
|
|
||||||
|
existing_names = {i.name for i in graph.initializer}
|
||||||
|
|
||||||
|
def ensure_const(name, value):
|
||||||
|
if name not in existing_names:
|
||||||
|
graph.initializer.append(
|
||||||
|
numpy_helper.from_array(np.array(value, dtype=np.int64), name=name)
|
||||||
|
)
|
||||||
|
existing_names.add(name)
|
||||||
|
|
||||||
|
ensure_const("_rp_ax2", [2])
|
||||||
|
ensure_const("_rp_ax3", [3])
|
||||||
|
|
||||||
|
max_pad = 0
|
||||||
|
for node in reflect_pads:
|
||||||
|
pads = inits[node.input[1]].tolist()
|
||||||
|
max_pad = max(max_pad, int(pads[2]), int(pads[3]))
|
||||||
|
|
||||||
|
for v in range(1, max_pad + 2):
|
||||||
|
ensure_const(f"_rp_p{v}", [v])
|
||||||
|
ensure_const(f"_rp_n{v}", [-v])
|
||||||
|
|
||||||
|
_counter = [0]
|
||||||
|
|
||||||
|
def uid():
|
||||||
|
_counter[0] += 1
|
||||||
|
return _counter[0]
|
||||||
|
|
||||||
|
pad_ids = {id(n) for n in reflect_pads}
|
||||||
|
pad_init_names = set()
|
||||||
|
|
||||||
|
new_nodes = []
|
||||||
|
for node in graph.node:
|
||||||
|
if id(node) not in pad_ids:
|
||||||
|
new_nodes.append(node)
|
||||||
|
continue
|
||||||
|
|
||||||
|
pads = inits[node.input[1]].tolist()
|
||||||
|
h_pad, w_pad = int(pads[2]), int(pads[3])
|
||||||
|
|
||||||
|
for inp in node.input[1:]:
|
||||||
|
if inp in inits:
|
||||||
|
pad_init_names.add(inp)
|
||||||
|
|
||||||
|
current = node.input[0]
|
||||||
|
|
||||||
|
if h_pad > 0:
|
||||||
|
top = []
|
||||||
|
for i in range(h_pad, 0, -1):
|
||||||
|
name = f"_rp_t{uid()}"
|
||||||
|
new_nodes.append(helper.make_node(
|
||||||
|
"Slice",
|
||||||
|
inputs=[current, f"_rp_p{i}", f"_rp_p{i+1}", "_rp_ax2"],
|
||||||
|
outputs=[name],
|
||||||
|
))
|
||||||
|
top.append(name)
|
||||||
|
|
||||||
|
bot = []
|
||||||
|
for i in range(1, h_pad + 1):
|
||||||
|
name = f"_rp_b{uid()}"
|
||||||
|
new_nodes.append(helper.make_node(
|
||||||
|
"Slice",
|
||||||
|
inputs=[current, f"_rp_n{i+1}", f"_rp_n{i}", "_rp_ax2"],
|
||||||
|
outputs=[name],
|
||||||
|
))
|
||||||
|
bot.append(name)
|
||||||
|
|
||||||
|
h_out = f"_rp_h{uid()}"
|
||||||
|
new_nodes.append(helper.make_node(
|
||||||
|
"Concat", inputs=top + [current] + bot, outputs=[h_out], axis=2
|
||||||
|
))
|
||||||
|
current = h_out
|
||||||
|
|
||||||
|
if w_pad > 0:
|
||||||
|
left = []
|
||||||
|
for i in range(w_pad, 0, -1):
|
||||||
|
name = f"_rp_l{uid()}"
|
||||||
|
new_nodes.append(helper.make_node(
|
||||||
|
"Slice",
|
||||||
|
inputs=[current, f"_rp_p{i}", f"_rp_p{i+1}", "_rp_ax3"],
|
||||||
|
outputs=[name],
|
||||||
|
))
|
||||||
|
left.append(name)
|
||||||
|
|
||||||
|
right = []
|
||||||
|
for i in range(1, w_pad + 1):
|
||||||
|
name = f"_rp_r{uid()}"
|
||||||
|
new_nodes.append(helper.make_node(
|
||||||
|
"Slice",
|
||||||
|
inputs=[current, f"_rp_n{i+1}", f"_rp_n{i}", "_rp_ax3"],
|
||||||
|
outputs=[name],
|
||||||
|
))
|
||||||
|
right.append(name)
|
||||||
|
|
||||||
|
new_nodes.append(helper.make_node(
|
||||||
|
"Concat",
|
||||||
|
inputs=left + [current] + right,
|
||||||
|
outputs=[node.output[0]],
|
||||||
|
axis=3,
|
||||||
|
))
|
||||||
|
elif h_pad > 0:
|
||||||
|
new_nodes.append(helper.make_node(
|
||||||
|
"Identity", inputs=[current], outputs=[node.output[0]]
|
||||||
|
))
|
||||||
|
|
||||||
|
# Remove old Pad initializers
|
||||||
|
clean_inits = [i for i in graph.initializer if i.name not in pad_init_names]
|
||||||
|
del graph.initializer[:]
|
||||||
|
graph.initializer.extend(clean_inits)
|
||||||
|
|
||||||
|
del graph.node[:]
|
||||||
|
graph.node.extend(new_nodes)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pass 3: Decompose Split → Slice pairs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _decompose_split(model) -> bool:
|
||||||
|
"""Rewrite Split(axis=1) as Slice pairs that CoreML can handle.
|
||||||
|
|
||||||
|
CoreML's EP doesn't support the ONNX ``Split`` op, causing partition
|
||||||
|
boundaries in models that use channel-wise splits (e.g. GFPGAN's SFT
|
||||||
|
modulation layers). Each Split with two outputs becomes two Slice ops.
|
||||||
|
"""
|
||||||
|
from onnx import numpy_helper, helper
|
||||||
|
|
||||||
|
graph = model.graph
|
||||||
|
|
||||||
|
splits = []
|
||||||
|
for node in graph.node:
|
||||||
|
if node.op_type == "Split":
|
||||||
|
axis = 0
|
||||||
|
split_sizes = []
|
||||||
|
for attr in node.attribute:
|
||||||
|
if attr.name == "axis":
|
||||||
|
axis = attr.i
|
||||||
|
if attr.name == "split":
|
||||||
|
split_sizes = list(attr.ints)
|
||||||
|
if axis == 1 and len(split_sizes) == 2 and len(node.output) == 2:
|
||||||
|
splits.append((node, split_sizes))
|
||||||
|
|
||||||
|
if not splits:
|
||||||
|
return False
|
||||||
|
|
||||||
|
existing = {i.name for i in graph.initializer}
|
||||||
|
|
||||||
|
def ensure_const(name, value):
|
||||||
|
if name not in existing:
|
||||||
|
graph.initializer.append(
|
||||||
|
numpy_helper.from_array(np.array(value, dtype=np.int64), name=name)
|
||||||
|
)
|
||||||
|
existing.add(name)
|
||||||
|
|
||||||
|
ensure_const("_sp_ax1", [1])
|
||||||
|
|
||||||
|
# Collect all needed boundary constants
|
||||||
|
for _, (a, b) in splits:
|
||||||
|
ensure_const(f"_sp_s0", [0])
|
||||||
|
ensure_const(f"_sp_s{a}", [a])
|
||||||
|
ensure_const(f"_sp_s{a + b}", [a + b])
|
||||||
|
|
||||||
|
split_ids = {id(node) for node, _ in splits}
|
||||||
|
replacements = {}
|
||||||
|
for node, (a, b) in splits:
|
||||||
|
slice0 = helper.make_node(
|
||||||
|
"Slice",
|
||||||
|
inputs=[node.input[0], "_sp_s0", f"_sp_s{a}", "_sp_ax1"],
|
||||||
|
outputs=[node.output[0]],
|
||||||
|
)
|
||||||
|
slice1 = helper.make_node(
|
||||||
|
"Slice",
|
||||||
|
inputs=[node.input[0], f"_sp_s{a}", f"_sp_s{a + b}", "_sp_ax1"],
|
||||||
|
outputs=[node.output[1]],
|
||||||
|
)
|
||||||
|
replacements[id(node)] = [slice0, slice1]
|
||||||
|
|
||||||
|
new_nodes = []
|
||||||
|
for node in graph.node:
|
||||||
|
if id(node) in split_ids:
|
||||||
|
new_nodes.extend(replacements[id(node)])
|
||||||
|
else:
|
||||||
|
new_nodes.append(node)
|
||||||
|
|
||||||
|
del graph.node[:]
|
||||||
|
graph.node.extend(new_nodes)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pass 4: Widen scalar Gather indices to [1] + Squeeze
|
||||||
|
#
|
||||||
|
# TEMPORARY: filed upstream as microsoft/onnxruntime#28180. ORT's CoreML EP
|
||||||
|
# GatherOpBuilder::IsOpSupportedImpl rejects rank-0 (scalar) indices with
|
||||||
|
# `Gather does not support scalar 'indices'`. The builder's own comment
|
||||||
|
# describes the workaround (promote to [1], squeeze the added axis) but
|
||||||
|
# doesn't apply it. We do the same thing at the ONNX level so StyleGAN-
|
||||||
|
# family models (GFPGAN is the hot example — 16 per-layer style-code
|
||||||
|
# slices) don't split the CoreML subgraph. Once the upstream fix ships
|
||||||
|
# and the ORT floor is raised, delete this pass.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _rewrite_scalar_gather(model) -> bool:
|
||||||
|
"""Rewrite Gather(data, scalar_idx) as Gather(data, [scalar_idx]) + Squeeze.
|
||||||
|
|
||||||
|
Only touches Gather nodes whose index is a rank-0 int64 constant or
|
||||||
|
initializer; everything else passes through unchanged. The rewrite
|
||||||
|
is semantically identical — indices get an added leading axis, the
|
||||||
|
Squeeze removes it after the gather.
|
||||||
|
"""
|
||||||
|
from onnx import numpy_helper, helper, TensorProto
|
||||||
|
|
||||||
|
graph = model.graph
|
||||||
|
|
||||||
|
# Opset 13 moved Squeeze's axes from attribute to input.
|
||||||
|
opset = next(
|
||||||
|
(o.version for o in model.opset_import if o.domain in ("", "ai.onnx")),
|
||||||
|
11,
|
||||||
|
)
|
||||||
|
|
||||||
|
const_values = {}
|
||||||
|
for n in graph.node:
|
||||||
|
if n.op_type == "Constant":
|
||||||
|
for a in n.attribute:
|
||||||
|
if a.name == "value":
|
||||||
|
const_values[n.output[0]] = a.t
|
||||||
|
init_values = {i.name: i for i in graph.initializer}
|
||||||
|
|
||||||
|
def scalar_int64(name):
|
||||||
|
"""Return int value if `name` resolves to a rank-0 int64 constant, else None."""
|
||||||
|
tensor = const_values.get(name) or init_values.get(name)
|
||||||
|
if tensor is None or tensor.data_type != TensorProto.INT64:
|
||||||
|
return None
|
||||||
|
arr = numpy_helper.to_array(tensor)
|
||||||
|
return int(arr) if arr.ndim == 0 else None
|
||||||
|
|
||||||
|
rewrote = 0
|
||||||
|
new_nodes = []
|
||||||
|
for n in graph.node:
|
||||||
|
if n.op_type == "Gather":
|
||||||
|
val = scalar_int64(n.input[1])
|
||||||
|
if val is not None:
|
||||||
|
axis = next((a.i for a in n.attribute if a.name == "axis"), 0)
|
||||||
|
idx_1d_name = f"{n.input[1]}_1d_{rewrote}"
|
||||||
|
idx_const = helper.make_node(
|
||||||
|
"Constant",
|
||||||
|
inputs=[],
|
||||||
|
outputs=[idx_1d_name],
|
||||||
|
value=helper.make_tensor(idx_1d_name, TensorProto.INT64, [1], [val]),
|
||||||
|
)
|
||||||
|
gather_out = f"{n.output[0]}_pre_squeeze_{rewrote}"
|
||||||
|
new_gather = helper.make_node(
|
||||||
|
"Gather",
|
||||||
|
inputs=[n.input[0], idx_1d_name],
|
||||||
|
outputs=[gather_out],
|
||||||
|
name=n.name,
|
||||||
|
axis=axis,
|
||||||
|
)
|
||||||
|
if opset < 13:
|
||||||
|
squeeze = helper.make_node(
|
||||||
|
"Squeeze",
|
||||||
|
inputs=[gather_out],
|
||||||
|
outputs=[n.output[0]],
|
||||||
|
name=(n.name or "gather") + "_squeeze",
|
||||||
|
axes=[axis],
|
||||||
|
)
|
||||||
|
new_nodes.extend([idx_const, new_gather, squeeze])
|
||||||
|
else:
|
||||||
|
axes_name = f"{idx_1d_name}_sq_axes"
|
||||||
|
axes_const = helper.make_node(
|
||||||
|
"Constant",
|
||||||
|
inputs=[],
|
||||||
|
outputs=[axes_name],
|
||||||
|
value=helper.make_tensor(axes_name, TensorProto.INT64, [1], [axis]),
|
||||||
|
)
|
||||||
|
squeeze = helper.make_node(
|
||||||
|
"Squeeze",
|
||||||
|
inputs=[gather_out, axes_name],
|
||||||
|
outputs=[n.output[0]],
|
||||||
|
name=(n.name or "gather") + "_squeeze",
|
||||||
|
)
|
||||||
|
new_nodes.extend([idx_const, axes_const, new_gather, squeeze])
|
||||||
|
rewrote += 1
|
||||||
|
continue
|
||||||
|
new_nodes.append(n)
|
||||||
|
|
||||||
|
if rewrote == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
del graph.node[:]
|
||||||
|
graph.node.extend(new_nodes)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _preserve_emap_position(model, numpy_helper):
|
||||||
|
"""Keep the insightface emap (512×512 matrix) as the last initializer."""
|
||||||
|
graph = model.graph
|
||||||
|
emap_init = None
|
||||||
|
for init in graph.initializer:
|
||||||
|
if not init.name.startswith("_rp_"):
|
||||||
|
arr = numpy_helper.to_array(init)
|
||||||
|
if len(arr.shape) == 2 and arr.shape[0] == 512 and arr.shape[1] == 512:
|
||||||
|
emap_init = init
|
||||||
|
break
|
||||||
|
|
||||||
|
if emap_init is not None:
|
||||||
|
inits = [i for i in graph.initializer if i.name != emap_init.name]
|
||||||
|
del graph.initializer[:]
|
||||||
|
graph.initializer.extend(inits)
|
||||||
|
graph.initializer.append(emap_init)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Centralized platform + accelerator detection.
|
||||||
|
|
||||||
|
Imported once at startup to expose typed flags the rest of the codebase
|
||||||
|
can branch on without re-querying `platform`, `torch.cuda`, or
|
||||||
|
`onnxruntime.get_available_providers()` repeatedly.
|
||||||
|
|
||||||
|
The banner printed by :func:`print_banner` is the single user-facing
|
||||||
|
report of which code path the app will take.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import platform as _platform
|
||||||
|
import sys
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
IS_WINDOWS: bool = _platform.system() == "Windows"
|
||||||
|
IS_MACOS: bool = _platform.system() == "Darwin"
|
||||||
|
IS_LINUX: bool = _platform.system() == "Linux"
|
||||||
|
IS_APPLE_SILICON: bool = IS_MACOS and _platform.machine() == "arm64"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_torch_cuda() -> bool:
|
||||||
|
try:
|
||||||
|
import torch # noqa: WPS433 — local import, avoid hard dep at module load
|
||||||
|
return bool(torch.cuda.is_available())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_onnx_providers() -> List[str]:
|
||||||
|
try:
|
||||||
|
import onnxruntime
|
||||||
|
return list(onnxruntime.get_available_providers())
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
HAS_TORCH_CUDA: bool = _detect_torch_cuda()
|
||||||
|
ONNX_PROVIDERS: List[str] = _detect_onnx_providers()
|
||||||
|
HAS_CUDA_PROVIDER: bool = "CUDAExecutionProvider" in ONNX_PROVIDERS
|
||||||
|
HAS_COREML_PROVIDER: bool = "CoreMLExecutionProvider" in ONNX_PROVIDERS
|
||||||
|
HAS_DML_PROVIDER: bool = "DmlExecutionProvider" in ONNX_PROVIDERS
|
||||||
|
|
||||||
|
|
||||||
|
def camera_backends() -> List[Tuple[int, int]]:
|
||||||
|
"""Return an ordered list of ``(device_index, cv2_backend)`` attempts.
|
||||||
|
|
||||||
|
Windows prefers MSMF (60fps capable) with DirectShow as fallback.
|
||||||
|
macOS/Linux use the default backend (AVFoundation / V4L2).
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
if IS_WINDOWS:
|
||||||
|
return [
|
||||||
|
(0, cv2.CAP_MSMF),
|
||||||
|
(0, cv2.CAP_DSHOW),
|
||||||
|
(0, cv2.CAP_ANY),
|
||||||
|
]
|
||||||
|
return [(0, cv2.CAP_ANY)]
|
||||||
|
|
||||||
|
|
||||||
|
def accelerator_label() -> str:
|
||||||
|
if HAS_CUDA_PROVIDER:
|
||||||
|
return "CUDA (NVIDIA)"
|
||||||
|
if IS_APPLE_SILICON and HAS_COREML_PROVIDER:
|
||||||
|
return "CoreML (Apple Neural Engine)"
|
||||||
|
if HAS_COREML_PROVIDER:
|
||||||
|
return "CoreML"
|
||||||
|
if HAS_DML_PROVIDER:
|
||||||
|
return "DirectML"
|
||||||
|
return "CPU"
|
||||||
|
|
||||||
|
|
||||||
|
def print_banner() -> None:
|
||||||
|
"""Print a one-line summary of the platform + accelerator selection."""
|
||||||
|
os_label = f"{_platform.system()} {_platform.machine()}"
|
||||||
|
print(
|
||||||
|
f"[platform] {os_label} | python {sys.version.split()[0]} | "
|
||||||
|
f"accelerator: {accelerator_label()} | providers: {ONNX_PROVIDERS}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
@@ -21,10 +21,104 @@ IS_APPLE_SILICON = platform.system() == "Darwin" and platform.machine() == "arm6
|
|||||||
THREAD_SEMAPHORE = threading.Semaphore(min(max(1, (os.cpu_count() or 1)), 8))
|
THREAD_SEMAPHORE = threading.Semaphore(min(max(1, (os.cpu_count() or 1)), 8))
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider_config(providers=None):
|
||||||
|
"""Wrap raw provider name strings with optimised CUDA / CoreML options.
|
||||||
|
|
||||||
|
Providers that are already ``(name, options_dict)`` tuples are passed
|
||||||
|
through unchanged. Non-CUDA providers are left as bare strings.
|
||||||
|
"""
|
||||||
|
if providers is None:
|
||||||
|
providers = modules.globals.execution_providers
|
||||||
|
|
||||||
|
config = []
|
||||||
|
for p in providers:
|
||||||
|
if isinstance(p, tuple):
|
||||||
|
# Already configured – pass through
|
||||||
|
config.append(p)
|
||||||
|
elif p == "CUDAExecutionProvider":
|
||||||
|
# Use bare provider — ONNX Runtime's defaults are fastest on
|
||||||
|
# modern GPUs (Blackwell/sm_120). Custom options like
|
||||||
|
# EXHAUSTIVE cudnn_conv_algo_search hurt performance on these
|
||||||
|
# architectures.
|
||||||
|
config.append(p)
|
||||||
|
elif p == "CoreMLExecutionProvider" and IS_APPLE_SILICON:
|
||||||
|
config.append((
|
||||||
|
"CoreMLExecutionProvider",
|
||||||
|
{
|
||||||
|
"ModelFormat": "MLProgram",
|
||||||
|
"MLComputeUnits": "ALL",
|
||||||
|
"AllowLowPrecisionAccumulationOnGPU": 1,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
config.append(p)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def run_inference(session: onnxruntime.InferenceSession,
|
||||||
|
input_name: str,
|
||||||
|
input_tensor: "np.ndarray") -> "np.ndarray":
|
||||||
|
"""Run ONNX inference, using IO binding when a CUDA session is active.
|
||||||
|
|
||||||
|
IO binding avoids redundant host↔device copies by transferring the
|
||||||
|
input tensor directly to GPU memory and letting ONNX Runtime allocate
|
||||||
|
the output on the device. Falls back to the standard ``session.run``
|
||||||
|
path for non-CUDA providers or if binding fails.
|
||||||
|
"""
|
||||||
|
if "CUDAExecutionProvider" in session.get_providers():
|
||||||
|
try:
|
||||||
|
io_binding = session.io_binding()
|
||||||
|
|
||||||
|
# Input: numpy → GPU
|
||||||
|
ort_input = onnxruntime.OrtValue.ortvalue_from_numpy(
|
||||||
|
input_tensor, "cuda", 0,
|
||||||
|
)
|
||||||
|
io_binding.bind_ortvalue_input(input_name, ort_input)
|
||||||
|
|
||||||
|
# Output: allocate on GPU (avoids a CPU-side allocation)
|
||||||
|
output_name = session.get_outputs()[0].name
|
||||||
|
io_binding.bind_output(output_name, "cuda", 0)
|
||||||
|
|
||||||
|
session.run_with_iobinding(io_binding)
|
||||||
|
|
||||||
|
return io_binding.get_outputs()[0].numpy()
|
||||||
|
except Exception:
|
||||||
|
# Fall back to standard path (e.g. ORT version mismatch,
|
||||||
|
# unsupported op, or VRAM pressure)
|
||||||
|
pass
|
||||||
|
|
||||||
|
return session.run(None, {input_name: input_tensor})[0]
|
||||||
|
|
||||||
|
|
||||||
def create_onnx_session(model_path: str) -> onnxruntime.InferenceSession:
|
def create_onnx_session(model_path: str) -> onnxruntime.InferenceSession:
|
||||||
"""Create an ONNX Runtime session using the configured execution providers."""
|
"""Create an ONNX Runtime session with optimised provider config.
|
||||||
providers = modules.globals.execution_providers
|
|
||||||
session = onnxruntime.InferenceSession(model_path, providers=providers)
|
On Apple Silicon, applies CoreML graph optimizations (Pad decomposition,
|
||||||
|
Shape/Gather folding, Split decomposition) to reduce CPU↔ANE partition
|
||||||
|
boundaries.
|
||||||
|
"""
|
||||||
|
if IS_APPLE_SILICON:
|
||||||
|
from modules.onnx_optimize import optimize_for_coreml
|
||||||
|
# Infer input shape from the model for Shape/Gather folding
|
||||||
|
try:
|
||||||
|
import onnx
|
||||||
|
m = onnx.load(model_path)
|
||||||
|
inp = m.graph.input[0]
|
||||||
|
dims = inp.type.tensor_type.shape.dim
|
||||||
|
shape = tuple(d.dim_value for d in dims if d.dim_value > 0)
|
||||||
|
input_shape = shape if len(shape) == 4 else None
|
||||||
|
except Exception:
|
||||||
|
input_shape = None
|
||||||
|
model_path = optimize_for_coreml(model_path, input_shape=input_shape)
|
||||||
|
|
||||||
|
providers = build_provider_config()
|
||||||
|
session_options = onnxruntime.SessionOptions()
|
||||||
|
session_options.graph_optimization_level = (
|
||||||
|
onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||||
|
)
|
||||||
|
session = onnxruntime.InferenceSession(
|
||||||
|
model_path, sess_options=session_options, providers=providers,
|
||||||
|
)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
@@ -118,7 +212,8 @@ def enhance_face_onnx(
|
|||||||
|
|
||||||
blob = preprocess_face(face_crop, input_size)
|
blob = preprocess_face(face_crop, input_size)
|
||||||
with THREAD_SEMAPHORE:
|
with THREAD_SEMAPHORE:
|
||||||
output = session.run(None, {session.get_inputs()[0].name: blob})[0]
|
input_name = session.get_inputs()[0].name
|
||||||
|
output = run_inference(session, input_name, blob)
|
||||||
enhanced = postprocess_face(output)
|
enhanced = postprocess_face(output)
|
||||||
|
|
||||||
# Create mask for blending (feathered edges)
|
# Create mask for blending (feathered edges)
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import importlib
|
import importlib
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import Any, List, Callable
|
from typing import Any, List, Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
import modules
|
import modules
|
||||||
import modules.globals
|
import modules.globals
|
||||||
|
from modules.face_analyser import get_one_face
|
||||||
|
|
||||||
FRAME_PROCESSORS_MODULES: List[ModuleType] = []
|
FRAME_PROCESSORS_MODULES: List[ModuleType] = []
|
||||||
FRAME_PROCESSORS_INTERFACE = [
|
FRAME_PROCESSORS_INTERFACE = [
|
||||||
@@ -107,3 +112,295 @@ def process_video(source_path: str, frame_paths: list[str], process_frames: Call
|
|||||||
with tqdm(total=total, desc='Processing', unit='frame', dynamic_ncols=True, bar_format=progress_bar_format) as progress:
|
with tqdm(total=total, desc='Processing', unit='frame', dynamic_ncols=True, bar_format=progress_bar_format) as progress:
|
||||||
progress.set_postfix({'execution_providers': modules.globals.execution_providers, 'execution_threads': modules.globals.execution_threads, 'max_memory': modules.globals.max_memory})
|
progress.set_postfix({'execution_providers': modules.globals.execution_providers, 'execution_threads': modules.globals.execution_threads, 'max_memory': modules.globals.max_memory})
|
||||||
multi_process_frame(source_path, frame_paths, process_frames, progress)
|
multi_process_frame(source_path, frame_paths, process_frames, progress)
|
||||||
|
|
||||||
|
|
||||||
|
def process_video_in_memory(source_path: str, target_path: str, fps: float) -> bool:
|
||||||
|
"""Process video frames in-memory using FFmpeg pipes, eliminating disk I/O.
|
||||||
|
|
||||||
|
Reads raw frames from the source video via an FFmpeg decoder pipe, runs each
|
||||||
|
frame through all active frame processors sequentially, and writes the
|
||||||
|
result directly to an FFmpeg encoder pipe. This avoids extracting frames to
|
||||||
|
PNG on disk, which is the biggest I/O bottleneck in the disk-based pipeline.
|
||||||
|
|
||||||
|
Returns True on success, False on failure (caller should fall back to the
|
||||||
|
disk-based pipeline).
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
from modules.face_analyser import get_one_face
|
||||||
|
from modules.utilities import (
|
||||||
|
get_video_dimensions,
|
||||||
|
estimate_frame_count,
|
||||||
|
get_temp_output_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_output_path = get_temp_output_path(target_path)
|
||||||
|
|
||||||
|
# --- Pre-load source face (needed by face_swapper in simple mode) ---
|
||||||
|
source_face = None
|
||||||
|
if source_path and os.path.exists(source_path):
|
||||||
|
source_img = cv2.imread(source_path)
|
||||||
|
if source_img is not None:
|
||||||
|
source_face = get_one_face(source_img)
|
||||||
|
del source_img
|
||||||
|
if source_face is None:
|
||||||
|
print("[DLC.CORE] Warning: No face detected in source image. "
|
||||||
|
"Face swapping will be skipped.")
|
||||||
|
|
||||||
|
# --- Collect frame processors & reset per-video state ---
|
||||||
|
frame_processors = get_frame_processors_modules(modules.globals.frame_processors)
|
||||||
|
for fp in frame_processors:
|
||||||
|
if hasattr(fp, 'PREVIOUS_FRAME_RESULT'):
|
||||||
|
fp.PREVIOUS_FRAME_RESULT = None
|
||||||
|
|
||||||
|
# --- Video metadata ---
|
||||||
|
try:
|
||||||
|
width, height = get_video_dimensions(target_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DLC.CORE] Failed to get video dimensions: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
total_frames = estimate_frame_count(target_path, fps)
|
||||||
|
frame_size = width * height * 3
|
||||||
|
|
||||||
|
# --- Build encoder arguments ---
|
||||||
|
encoder = modules.globals.video_encoder
|
||||||
|
encoder_options: List[str] = []
|
||||||
|
is_hw_encoder = False
|
||||||
|
|
||||||
|
if 'CUDAExecutionProvider' in modules.globals.execution_providers:
|
||||||
|
if encoder == 'libx264':
|
||||||
|
encoder = 'h264_nvenc'
|
||||||
|
is_hw_encoder = True
|
||||||
|
encoder_options = [
|
||||||
|
'-preset', 'p4', '-tune', 'hq', '-rc', 'vbr',
|
||||||
|
'-cq', str(modules.globals.video_quality), '-b:v', '0',
|
||||||
|
]
|
||||||
|
elif encoder == 'libx265':
|
||||||
|
encoder = 'hevc_nvenc'
|
||||||
|
is_hw_encoder = True
|
||||||
|
encoder_options = [
|
||||||
|
'-preset', 'p4', '-tune', 'hq', '-rc', 'vbr',
|
||||||
|
'-cq', str(modules.globals.video_quality), '-b:v', '0',
|
||||||
|
]
|
||||||
|
elif 'DmlExecutionProvider' in modules.globals.execution_providers:
|
||||||
|
if encoder == 'libx264':
|
||||||
|
encoder = 'h264_amf'
|
||||||
|
is_hw_encoder = True
|
||||||
|
encoder_options = [
|
||||||
|
'-quality', 'quality', '-rc', 'vbr_latency',
|
||||||
|
'-qp_i', str(modules.globals.video_quality),
|
||||||
|
'-qp_p', str(modules.globals.video_quality),
|
||||||
|
]
|
||||||
|
elif encoder == 'libx265':
|
||||||
|
encoder = 'hevc_amf'
|
||||||
|
is_hw_encoder = True
|
||||||
|
encoder_options = [
|
||||||
|
'-quality', 'quality', '-rc', 'vbr_latency',
|
||||||
|
'-qp_i', str(modules.globals.video_quality),
|
||||||
|
'-qp_p', str(modules.globals.video_quality),
|
||||||
|
]
|
||||||
|
|
||||||
|
if not is_hw_encoder:
|
||||||
|
if encoder == 'libx264':
|
||||||
|
encoder_options = [
|
||||||
|
'-preset', 'medium',
|
||||||
|
'-crf', str(modules.globals.video_quality),
|
||||||
|
'-tune', 'film',
|
||||||
|
]
|
||||||
|
elif encoder == 'libx265':
|
||||||
|
encoder_options = [
|
||||||
|
'-preset', 'medium',
|
||||||
|
'-crf', str(modules.globals.video_quality),
|
||||||
|
'-x265-params', 'log-level=error',
|
||||||
|
]
|
||||||
|
elif encoder == 'libvpx-vp9':
|
||||||
|
encoder_options = [
|
||||||
|
'-crf', str(modules.globals.video_quality),
|
||||||
|
'-b:v', '0', '-cpu-used', '2',
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Attempt pipeline (hw encoder first, then sw fallback) ---
|
||||||
|
encoders_to_try = [(encoder, encoder_options)]
|
||||||
|
if is_hw_encoder:
|
||||||
|
# Software fallback
|
||||||
|
sw_encoder = 'libx264'
|
||||||
|
sw_options = [
|
||||||
|
'-preset', 'medium',
|
||||||
|
'-crf', str(modules.globals.video_quality),
|
||||||
|
'-tune', 'film',
|
||||||
|
]
|
||||||
|
encoders_to_try.append((sw_encoder, sw_options))
|
||||||
|
|
||||||
|
for attempt, (enc, enc_opts) in enumerate(encoders_to_try):
|
||||||
|
# Reset interpolation state on retry
|
||||||
|
if attempt > 0:
|
||||||
|
for fp in frame_processors:
|
||||||
|
if hasattr(fp, 'PREVIOUS_FRAME_RESULT'):
|
||||||
|
fp.PREVIOUS_FRAME_RESULT = None
|
||||||
|
|
||||||
|
success = _run_pipe_pipeline(
|
||||||
|
target_path, temp_output_path, fps,
|
||||||
|
source_face, frame_processors,
|
||||||
|
width, height, frame_size, total_frames,
|
||||||
|
enc, enc_opts,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if attempt == 0 and is_hw_encoder:
|
||||||
|
print(f"[DLC.CORE] Hardware encoder '{enc}' failed, "
|
||||||
|
f"retrying with software encoder...")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pipe_pipeline(
|
||||||
|
target_path: str,
|
||||||
|
temp_output_path: str,
|
||||||
|
fps: float,
|
||||||
|
source_face: Any,
|
||||||
|
frame_processors: List[Any],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
frame_size: int,
|
||||||
|
total_frames: int,
|
||||||
|
encoder: str,
|
||||||
|
encoder_options: List[str],
|
||||||
|
) -> bool:
|
||||||
|
"""Run the FFmpeg-pipe read → process → encode pipeline once."""
|
||||||
|
|
||||||
|
# --- Reader: decode source video to raw BGR24 on stdout ---
|
||||||
|
reader_cmd = [
|
||||||
|
'ffmpeg', '-hide_banner',
|
||||||
|
'-hwaccel', 'auto',
|
||||||
|
'-i', target_path,
|
||||||
|
'-f', 'rawvideo',
|
||||||
|
'-pix_fmt', 'bgr24',
|
||||||
|
'-v', 'error',
|
||||||
|
'-',
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Writer: encode raw BGR24 from stdin ---
|
||||||
|
writer_cmd = [
|
||||||
|
'ffmpeg', '-hide_banner',
|
||||||
|
'-f', 'rawvideo',
|
||||||
|
'-pix_fmt', 'bgr24',
|
||||||
|
'-s', f'{width}x{height}',
|
||||||
|
'-r', str(fps),
|
||||||
|
'-i', '-',
|
||||||
|
'-c:v', encoder,
|
||||||
|
]
|
||||||
|
writer_cmd.extend(encoder_options)
|
||||||
|
writer_cmd.extend([
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
'-vf', 'colorspace=bt709:iall=bt601-6-625:fast=1',
|
||||||
|
'-v', 'error',
|
||||||
|
'-y', temp_output_path,
|
||||||
|
])
|
||||||
|
|
||||||
|
reader = None
|
||||||
|
writer = None
|
||||||
|
try:
|
||||||
|
reader = subprocess.Popen(
|
||||||
|
reader_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
writer = subprocess.Popen(
|
||||||
|
writer_cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DLC.CORE] Failed to start FFmpeg pipes: {e}")
|
||||||
|
for proc in (reader, writer):
|
||||||
|
if proc:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
bar_fmt = ('{l_bar}{bar}| {n_fmt}/{total_fmt} '
|
||||||
|
'[{elapsed}<{remaining}, {rate_fmt}{postfix}]')
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tqdm(total=total_frames, desc='Processing', unit='frame',
|
||||||
|
dynamic_ncols=True, bar_format=bar_fmt) as progress:
|
||||||
|
progress.set_postfix({
|
||||||
|
'execution_providers': modules.globals.execution_providers,
|
||||||
|
'threads': modules.globals.execution_threads,
|
||||||
|
'mode': 'in-memory',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Pipelined detection: while processing frame N (swap on
|
||||||
|
# ANE), start detecting the face in the next frame
|
||||||
|
# (detection on GPU). They use different hardware units
|
||||||
|
# so the work overlaps.
|
||||||
|
detect_executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
pending_detect = None
|
||||||
|
use_pipeline = not modules.globals.many_faces
|
||||||
|
|
||||||
|
while True:
|
||||||
|
raw = reader.stdout.read(frame_size)
|
||||||
|
if len(raw) != frame_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
frame = np.frombuffer(raw, dtype=np.uint8).reshape(
|
||||||
|
(height, width, 3)
|
||||||
|
).copy()
|
||||||
|
|
||||||
|
# Get the detection result for THIS frame
|
||||||
|
if use_pipeline:
|
||||||
|
if pending_detect is not None:
|
||||||
|
target_face = pending_detect.result()
|
||||||
|
else:
|
||||||
|
target_face = get_one_face(frame)
|
||||||
|
# Start detecting on THIS frame eagerly — the result
|
||||||
|
# will be used for the next iteration. At video
|
||||||
|
# frame rates the face barely moves between frames.
|
||||||
|
# Hand the detector its own copy: the frame processors
|
||||||
|
# below mutate `frame` in place (paste-back), which
|
||||||
|
# would otherwise race with detection.
|
||||||
|
pending_detect = detect_executor.submit(
|
||||||
|
get_one_face, frame.copy())
|
||||||
|
else:
|
||||||
|
target_face = None
|
||||||
|
|
||||||
|
# Run frame through every active processor
|
||||||
|
for fp in frame_processors:
|
||||||
|
try:
|
||||||
|
frame = fp.process_frame(source_face, frame, target_face=target_face)
|
||||||
|
except TypeError:
|
||||||
|
frame = fp.process_frame(source_face, frame)
|
||||||
|
|
||||||
|
writer.stdin.write(frame.tobytes())
|
||||||
|
processed_count += 1
|
||||||
|
progress.update(1)
|
||||||
|
|
||||||
|
detect_executor.shutdown(wait=True)
|
||||||
|
|
||||||
|
# Graceful shutdown
|
||||||
|
writer.stdin.close()
|
||||||
|
writer.wait()
|
||||||
|
reader.wait()
|
||||||
|
|
||||||
|
if writer.returncode != 0:
|
||||||
|
stderr_out = writer.stderr.read().decode(errors='ignore').strip()
|
||||||
|
if stderr_out:
|
||||||
|
print(f"[DLC.CORE] FFmpeg encoder error: {stderr_out}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return processed_count > 0 and os.path.isfile(temp_output_path)
|
||||||
|
|
||||||
|
except BrokenPipeError:
|
||||||
|
print("[DLC.CORE] FFmpeg pipe broken (encoder may not be available).")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DLC.CORE] In-memory processing error: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
for proc in (reader, writer):
|
||||||
|
if proc:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# --- START OF FILE face_enhancer.py ---
|
|
||||||
# Uses ONNX Runtime for GFPGAN face enhancement (no torch/gfpgan dependency)
|
# Uses ONNX Runtime for GFPGAN face enhancement (no torch/gfpgan dependency)
|
||||||
|
|
||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
@@ -81,18 +80,11 @@ def get_face_enhancer() -> onnxruntime.InferenceSession:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
providers = modules.globals.execution_providers
|
from modules.processors.frame._onnx_enhancer import (
|
||||||
|
create_onnx_session,
|
||||||
session_options = onnxruntime.SessionOptions()
|
|
||||||
session_options.graph_optimization_level = (
|
|
||||||
onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
||||||
)
|
)
|
||||||
|
|
||||||
FACE_ENHANCER = onnxruntime.InferenceSession(
|
FACE_ENHANCER = create_onnx_session(model_path)
|
||||||
model_path,
|
|
||||||
sess_options=session_options,
|
|
||||||
providers=providers,
|
|
||||||
)
|
|
||||||
|
|
||||||
input_info = FACE_ENHANCER.get_inputs()[0]
|
input_info = FACE_ENHANCER.get_inputs()[0]
|
||||||
output_info = FACE_ENHANCER.get_outputs()[0]
|
output_info = FACE_ENHANCER.get_outputs()[0]
|
||||||
@@ -158,6 +150,18 @@ def _align_face(
|
|||||||
return aligned_face, affine_matrix
|
return aligned_face, affine_matrix
|
||||||
|
|
||||||
|
|
||||||
|
_HAS_TORCH_CUDA = False
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
_HAS_TORCH_CUDA = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cache the feathered mask — it's the same for every call at a given size
|
||||||
|
_enhancer_cache: dict = {'mask': None, 'mask_size': 0}
|
||||||
|
|
||||||
|
|
||||||
def _paste_back(
|
def _paste_back(
|
||||||
frame: Frame,
|
frame: Frame,
|
||||||
enhanced_face: np.ndarray,
|
enhanced_face: np.ndarray,
|
||||||
@@ -167,53 +171,77 @@ def _paste_back(
|
|||||||
"""
|
"""
|
||||||
Paste an enhanced (aligned) face back onto the original frame using the
|
Paste an enhanced (aligned) face back onto the original frame using the
|
||||||
inverse affine transform with feathered-edge blending.
|
inverse affine transform with feathered-edge blending.
|
||||||
|
|
||||||
|
Optimized: operates on a tight crop around the face bbox instead of the
|
||||||
|
full frame, and uses GPU for blending when available.
|
||||||
"""
|
"""
|
||||||
h, w = frame.shape[:2]
|
h, w = frame.shape[:2]
|
||||||
|
|
||||||
# Inverse the affine warp
|
|
||||||
inv_matrix = cv2.invertAffineTransform(affine_matrix)
|
inv_matrix = cv2.invertAffineTransform(affine_matrix)
|
||||||
inv_restored = cv2.warpAffine(
|
|
||||||
enhanced_face,
|
# Build or reuse cached feathered mask (uint8 — blended via cv2 SIMD ops)
|
||||||
inv_matrix,
|
if _enhancer_cache['mask_size'] != output_size:
|
||||||
(w, h),
|
face_mask_f = np.ones((output_size, output_size), dtype=np.float32)
|
||||||
borderMode=cv2.BORDER_CONSTANT,
|
border = max(1, int(output_size * 0.05))
|
||||||
borderValue=(0, 0, 0),
|
ramp_up = np.linspace(0.0, 1.0, border, dtype=np.float32)
|
||||||
|
ramp_down = np.linspace(1.0, 0.0, border, dtype=np.float32)
|
||||||
|
face_mask_f[:border, :] *= ramp_up[:, None]
|
||||||
|
face_mask_f[-border:, :] *= ramp_down[:, None]
|
||||||
|
face_mask_f[:, :border] *= ramp_up[None, :]
|
||||||
|
face_mask_f[:, -border:] *= ramp_down[None, :]
|
||||||
|
_enhancer_cache['mask'] = (face_mask_f * 255.0).astype(np.uint8)
|
||||||
|
_enhancer_cache['mask_size'] = output_size
|
||||||
|
|
||||||
|
# Compute tight bbox from affine corners (avoids full-frame warpAffine scan)
|
||||||
|
corners = np.array([[0, 0], [output_size, 0],
|
||||||
|
[output_size, output_size], [0, output_size]],
|
||||||
|
dtype=np.float32)
|
||||||
|
transformed = (inv_matrix[:, :2] @ corners.T).T + inv_matrix[:, 2]
|
||||||
|
x1 = max(0, int(np.floor(transformed[:, 0].min())))
|
||||||
|
x2 = min(w, int(np.ceil(transformed[:, 0].max())))
|
||||||
|
y1 = max(0, int(np.floor(transformed[:, 1].min())))
|
||||||
|
y2 = min(h, int(np.ceil(transformed[:, 1].max())))
|
||||||
|
if x1 >= x2 or y1 >= y2:
|
||||||
|
return frame
|
||||||
|
|
||||||
|
# Pad a few pixels for feathering
|
||||||
|
pad = max(1, int(output_size * 0.05)) + 2
|
||||||
|
y1p, y2p = max(0, y1 - pad), min(h, y2 + pad)
|
||||||
|
x1p, x2p = max(0, x1 - pad), min(w, x2 + pad)
|
||||||
|
crop_w, crop_h = x2p - x1p, y2p - y1p
|
||||||
|
|
||||||
|
# Warp enhanced face and mask into crop space only
|
||||||
|
inv_crop = inv_matrix.copy()
|
||||||
|
inv_crop[0, 2] -= x1p
|
||||||
|
inv_crop[1, 2] -= y1p
|
||||||
|
|
||||||
|
inv_restored_crop = cv2.warpAffine(
|
||||||
|
enhanced_face, inv_crop, (crop_w, crop_h),
|
||||||
|
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0),
|
||||||
|
)
|
||||||
|
inv_mask_crop = cv2.warpAffine(
|
||||||
|
_enhancer_cache['mask'], inv_crop, (crop_w, crop_h),
|
||||||
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build a soft feathered mask in aligned space for edge blending
|
target_crop = frame[y1p:y2p, x1p:x2p]
|
||||||
face_mask = np.ones((output_size, output_size), dtype=np.float32)
|
|
||||||
|
|
||||||
# Feather the border (5 % of the size on each edge)
|
if _HAS_TORCH_CUDA:
|
||||||
border = max(1, int(output_size * 0.05))
|
# Upload uint8 alpha — smaller transfer, scale on device.
|
||||||
ramp_up = np.linspace(0.0, 1.0, border, dtype=np.float32)
|
mask_t = torch.from_numpy(inv_mask_crop).cuda().float().mul_(1.0 / 255.0).unsqueeze(2)
|
||||||
ramp_down = np.linspace(1.0, 0.0, border, dtype=np.float32)
|
enhanced_t = torch.from_numpy(inv_restored_crop).float().cuda()
|
||||||
|
target_t = torch.from_numpy(target_crop).float().cuda()
|
||||||
|
blended = (mask_t * enhanced_t + (1.0 - mask_t) * target_t
|
||||||
|
).to(torch.uint8).cpu().numpy()
|
||||||
|
frame[y1p:y2p, x1p:x2p] = blended
|
||||||
|
else:
|
||||||
|
# Fused uint8 blend via cv2 SIMD — ~7× faster than the float32 round-trip.
|
||||||
|
alpha_3c = cv2.merge([inv_mask_crop, inv_mask_crop, inv_mask_crop])
|
||||||
|
inv_alpha = 255 - alpha_3c
|
||||||
|
a_enh = cv2.multiply(inv_restored_crop, alpha_3c, scale=1.0 / 255.0)
|
||||||
|
a_tgt = cv2.multiply(target_crop, inv_alpha, scale=1.0 / 255.0)
|
||||||
|
frame[y1p:y2p, x1p:x2p] = cv2.add(a_enh, a_tgt)
|
||||||
|
|
||||||
# Top / bottom rows
|
return frame
|
||||||
face_mask[:border, :] *= ramp_up[:, None]
|
|
||||||
face_mask[-border:, :] *= ramp_down[:, None]
|
|
||||||
# Left / right columns
|
|
||||||
face_mask[:, :border] *= ramp_up[None, :]
|
|
||||||
face_mask[:, -border:] *= ramp_down[None, :]
|
|
||||||
|
|
||||||
# Expand to 3-channel
|
|
||||||
face_mask_3c = np.stack([face_mask] * 3, axis=-1)
|
|
||||||
|
|
||||||
# Warp mask back to original frame space
|
|
||||||
inv_mask = cv2.warpAffine(
|
|
||||||
face_mask_3c,
|
|
||||||
inv_matrix,
|
|
||||||
(w, h),
|
|
||||||
borderMode=cv2.BORDER_CONSTANT,
|
|
||||||
borderValue=(0, 0, 0),
|
|
||||||
)
|
|
||||||
inv_mask = np.clip(inv_mask, 0.0, 1.0)
|
|
||||||
|
|
||||||
# Alpha-blend
|
|
||||||
result = (
|
|
||||||
frame.astype(np.float32) * (1.0 - inv_mask)
|
|
||||||
+ inv_restored.astype(np.float32) * inv_mask
|
|
||||||
)
|
|
||||||
return np.clip(result, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
|
|
||||||
def _preprocess_face(aligned_face: np.ndarray) -> np.ndarray:
|
def _preprocess_face(aligned_face: np.ndarray) -> np.ndarray:
|
||||||
@@ -221,14 +249,13 @@ def _preprocess_face(aligned_face: np.ndarray) -> np.ndarray:
|
|||||||
Convert an aligned BGR uint8 face image to the ONNX model input tensor.
|
Convert an aligned BGR uint8 face image to the ONNX model input tensor.
|
||||||
Format: NCHW float32, normalised to [-1, 1].
|
Format: NCHW float32, normalised to [-1, 1].
|
||||||
"""
|
"""
|
||||||
# BGR -> RGB
|
# BGR -> RGB, normalize, and transpose in one pass
|
||||||
rgb = cv2.cvtColor(aligned_face, cv2.COLOR_BGR2RGB).astype(np.float32)
|
# Fused: (x / 255.0 - 0.5) / 0.5 = x / 127.5 - 1.0
|
||||||
# [0, 255] -> [0, 1] -> [-1, 1]
|
rgb = aligned_face[:, :, ::-1] # BGR->RGB zero-copy view
|
||||||
rgb = rgb / 255.0
|
chw = np.transpose(rgb, (2, 0, 1)).astype(np.float32)
|
||||||
rgb = (rgb - 0.5) / 0.5
|
chw *= (1.0 / 127.5)
|
||||||
# HWC -> CHW, add batch dim
|
chw -= 1.0
|
||||||
chw = np.transpose(rgb, (2, 0, 1))
|
return chw[np.newaxis, ...] # shape: (1, 3, H, W)
|
||||||
return np.expand_dims(chw, axis=0) # shape: (1, 3, H, W)
|
|
||||||
|
|
||||||
|
|
||||||
def _postprocess_face(output: np.ndarray) -> np.ndarray:
|
def _postprocess_face(output: np.ndarray) -> np.ndarray:
|
||||||
@@ -236,24 +263,42 @@ def _postprocess_face(output: np.ndarray) -> np.ndarray:
|
|||||||
Convert the ONNX model output tensor back to a BGR uint8 image.
|
Convert the ONNX model output tensor back to a BGR uint8 image.
|
||||||
Expects input in NCHW format with values in [-1, 1].
|
Expects input in NCHW format with values in [-1, 1].
|
||||||
"""
|
"""
|
||||||
face = np.squeeze(output) # remove batch dim -> (3, H, W)
|
# Fused: ((x + 1.0) / 2.0) * 255 = (x + 1.0) * 127.5
|
||||||
face = np.transpose(face, (1, 2, 0)) # CHW -> HWC
|
face = output[0] # remove batch dim -> (3, H, W)
|
||||||
# [-1, 1] -> [0, 1] -> [0, 255]
|
face = (face + 1.0) * 127.5
|
||||||
face = (face + 1.0) / 2.0
|
np.clip(face, 0, 255, out=face)
|
||||||
face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
|
face = face.astype(np.uint8).transpose(1, 2, 0) # CHW -> HWC
|
||||||
# RGB -> BGR
|
return face[:, :, ::-1].copy() # RGB -> BGR
|
||||||
return cv2.cvtColor(face, cv2.COLOR_RGB2BGR)
|
|
||||||
|
|
||||||
|
|
||||||
def enhance_face(temp_frame: Frame) -> Frame:
|
# Cache for temporal enhancement skipping in live mode.
|
||||||
"""Enhances all faces in a frame using the GFPGAN ONNX model."""
|
# GFPGAN output barely changes between consecutive frames (same face,
|
||||||
|
# same position), so we run inference every _ENH_INTERVAL frames and
|
||||||
|
# reuse the cached enhanced face + affine matrix in between.
|
||||||
|
_enh_live_cache: dict = {
|
||||||
|
'enhanced_bgr': None,
|
||||||
|
'affine_matrix': None,
|
||||||
|
'align_size': 0,
|
||||||
|
'frame_count': 0,
|
||||||
|
}
|
||||||
|
_ENH_INTERVAL = 2 # run inference every N frames, paste cached result otherwise
|
||||||
|
|
||||||
|
|
||||||
|
def enhance_face(temp_frame: Frame, detected_faces=None) -> Frame:
|
||||||
|
"""Enhances all faces in a frame using the GFPGAN ONNX model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detected_faces: Pre-detected face list. When provided, skips
|
||||||
|
the internal detection call (saves ~15-20ms per frame).
|
||||||
|
Also enables temporal caching — inference runs every
|
||||||
|
_ENH_INTERVAL frames, reusing the cached result otherwise.
|
||||||
|
"""
|
||||||
session = get_face_enhancer()
|
session = get_face_enhancer()
|
||||||
|
|
||||||
# Determine model input resolution from the session metadata
|
# Determine model input resolution from the session metadata
|
||||||
input_info = session.get_inputs()[0]
|
input_info = session.get_inputs()[0]
|
||||||
input_name = input_info.name
|
input_name = input_info.name
|
||||||
input_shape = input_info.shape # e.g. [1, 3, 512, 512]
|
input_shape = input_info.shape # e.g. [1, 3, 512, 512]
|
||||||
# Safely extract input size (handle dynamic / symbolic dimensions)
|
|
||||||
try:
|
try:
|
||||||
align_size = int(input_shape[2])
|
align_size = int(input_shape[2])
|
||||||
if align_size <= 0:
|
if align_size <= 0:
|
||||||
@@ -261,15 +306,25 @@ def enhance_face(temp_frame: Frame) -> Frame:
|
|||||||
except (ValueError, TypeError, IndexError):
|
except (ValueError, TypeError, IndexError):
|
||||||
align_size = 512
|
align_size = 512
|
||||||
|
|
||||||
# Detect faces using InsightFace (already a project dependency)
|
# Use pre-detected faces if available, otherwise detect
|
||||||
faces = get_many_faces(temp_frame)
|
faces = detected_faces if detected_faces is not None else get_many_faces(temp_frame)
|
||||||
if not faces:
|
if not faces:
|
||||||
return temp_frame
|
return temp_frame
|
||||||
|
|
||||||
result_frame = temp_frame.copy()
|
# Temporal caching: only available when faces are pre-detected (live mode)
|
||||||
|
# AND we're in single-face mode — the cache holds exactly one enhancement,
|
||||||
|
# so reusing it in many_faces mode would paste the same face onto every
|
||||||
|
# detected target.
|
||||||
|
many_faces_mode = getattr(modules.globals, "many_faces", False)
|
||||||
|
use_cache = detected_faces is not None and not many_faces_mode
|
||||||
|
if use_cache:
|
||||||
|
_enh_live_cache['frame_count'] += 1
|
||||||
|
run_inference_this_frame = (_enh_live_cache['frame_count'] % _ENH_INTERVAL == 0
|
||||||
|
or _enh_live_cache['enhanced_bgr'] is None)
|
||||||
|
else:
|
||||||
|
run_inference_this_frame = True
|
||||||
|
|
||||||
for face in faces:
|
for face in faces:
|
||||||
# Need the 5-point key-points for alignment
|
|
||||||
if not hasattr(face, "kps") or face.kps is None:
|
if not hasattr(face, "kps") or face.kps is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -277,48 +332,68 @@ def enhance_face(temp_frame: Frame) -> Frame:
|
|||||||
if landmarks_5.shape[0] < 5:
|
if landmarks_5.shape[0] < 5:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Align / crop the face at the model's INPUT resolution
|
if run_inference_this_frame:
|
||||||
aligned_face, affine_matrix = _align_face(
|
aligned_face, affine_matrix = _align_face(
|
||||||
temp_frame, landmarks_5, output_size=align_size
|
temp_frame, landmarks_5, output_size=align_size
|
||||||
)
|
|
||||||
if aligned_face is None or affine_matrix is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
with THREAD_SEMAPHORE:
|
|
||||||
input_tensor = _preprocess_face(aligned_face)
|
|
||||||
output_tensor = session.run(None, {input_name: input_tensor})[0]
|
|
||||||
enhanced_bgr = _postprocess_face(output_tensor)
|
|
||||||
|
|
||||||
# The model may output at a different resolution than its input
|
|
||||||
# (e.g. input 512x512 → output 1024x1024). Resize the enhanced
|
|
||||||
# face back to the alignment size so the inverse affine maps
|
|
||||||
# correctly.
|
|
||||||
eh, ew = enhanced_bgr.shape[:2]
|
|
||||||
if eh != align_size or ew != align_size:
|
|
||||||
enhanced_bgr = cv2.resize(
|
|
||||||
enhanced_bgr,
|
|
||||||
(align_size, align_size),
|
|
||||||
interpolation=cv2.INTER_LANCZOS4,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Paste enhanced face back onto the frame
|
|
||||||
result_frame = _paste_back(
|
|
||||||
result_frame, enhanced_bgr, affine_matrix, output_size=align_size
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
if aligned_face is None or affine_matrix is None:
|
||||||
print(f"{NAME}: Error enhancing a face: {e}")
|
continue
|
||||||
continue
|
|
||||||
|
|
||||||
return result_frame
|
try:
|
||||||
|
with THREAD_SEMAPHORE:
|
||||||
|
from modules.processors.frame._onnx_enhancer import (
|
||||||
|
run_inference,
|
||||||
|
)
|
||||||
|
input_tensor = _preprocess_face(aligned_face)
|
||||||
|
output_tensor = run_inference(session, input_name, input_tensor)
|
||||||
|
enhanced_bgr = _postprocess_face(output_tensor)
|
||||||
|
|
||||||
|
eh, ew = enhanced_bgr.shape[:2]
|
||||||
|
if eh != align_size or ew != align_size:
|
||||||
|
enhanced_bgr = cv2.resize(
|
||||||
|
enhanced_bgr,
|
||||||
|
(align_size, align_size),
|
||||||
|
interpolation=cv2.INTER_LANCZOS4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache for reuse on next frame
|
||||||
|
if use_cache:
|
||||||
|
_enh_live_cache['enhanced_bgr'] = enhanced_bgr
|
||||||
|
_enh_live_cache['affine_matrix'] = affine_matrix
|
||||||
|
_enh_live_cache['align_size'] = align_size
|
||||||
|
|
||||||
|
_paste_back(
|
||||||
|
temp_frame, enhanced_bgr, affine_matrix, output_size=align_size
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{NAME}: Error enhancing a face: {e}")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Reuse cached enhanced face — just paste back onto current frame
|
||||||
|
cached = _enh_live_cache
|
||||||
|
if cached['enhanced_bgr'] is not None:
|
||||||
|
_paste_back(
|
||||||
|
temp_frame, cached['enhanced_bgr'],
|
||||||
|
cached['affine_matrix'],
|
||||||
|
output_size=cached['align_size'],
|
||||||
|
)
|
||||||
|
if not many_faces_mode:
|
||||||
|
break # single-face live mode — only process first face
|
||||||
|
|
||||||
def process_frame(source_face: Face | None, temp_frame: Frame) -> Frame:
|
|
||||||
"""Processes a frame: enhances face if detected."""
|
|
||||||
temp_frame = enhance_face(temp_frame)
|
|
||||||
return temp_frame
|
return temp_frame
|
||||||
|
|
||||||
|
|
||||||
|
def process_frame(source_face: Face | None, temp_frame: Frame,
|
||||||
|
detected_faces=None) -> Frame:
|
||||||
|
"""Processes a frame: enhances face if detected."""
|
||||||
|
return enhance_face(temp_frame, detected_faces=detected_faces)
|
||||||
|
|
||||||
|
|
||||||
|
def process_frame_v2(temp_frame: Frame, detected_faces=None) -> Frame:
|
||||||
|
"""Processes a frame without source face (used by live webcam preview)."""
|
||||||
|
return enhance_face(temp_frame, detected_faces=detected_faces)
|
||||||
|
|
||||||
|
|
||||||
def process_frames(
|
def process_frames(
|
||||||
source_path: str | None, temp_frame_paths: List[str], progress: Any = None
|
source_path: str | None, temp_frame_paths: List[str], progress: Any = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -367,6 +442,3 @@ def process_video(
|
|||||||
modules.processors.frame.core.process_video(
|
modules.processors.frame.core.process_video(
|
||||||
source_path, temp_frame_paths, process_frames
|
source_path, temp_frame_paths, process_frames
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- END OF FILE face_enhancer.py ---
|
|
||||||
|
|||||||
@@ -82,8 +82,11 @@ def enhance_face(temp_frame: Frame, face: Face) -> Frame:
|
|||||||
return temp_frame
|
return temp_frame
|
||||||
|
|
||||||
|
|
||||||
def process_frame(source_face: Face | None, temp_frame: Frame) -> Frame:
|
def process_frame(source_face: Face | None, temp_frame: Frame, detected_faces=None) -> Frame:
|
||||||
target_face = get_one_face(temp_frame)
|
if detected_faces:
|
||||||
|
target_face = detected_faces[0]
|
||||||
|
else:
|
||||||
|
target_face = get_one_face(temp_frame)
|
||||||
if target_face is None:
|
if target_face is None:
|
||||||
return temp_frame
|
return temp_frame
|
||||||
return enhance_face(temp_frame, target_face)
|
return enhance_face(temp_frame, target_face)
|
||||||
|
|||||||
@@ -82,8 +82,11 @@ def enhance_face(temp_frame: Frame, face: Face) -> Frame:
|
|||||||
return temp_frame
|
return temp_frame
|
||||||
|
|
||||||
|
|
||||||
def process_frame(source_face: Face | None, temp_frame: Frame) -> Frame:
|
def process_frame(source_face: Face | None, temp_frame: Frame, detected_faces=None) -> Frame:
|
||||||
target_face = get_one_face(temp_frame)
|
if detected_faces:
|
||||||
|
target_face = detected_faces[0]
|
||||||
|
else:
|
||||||
|
target_face = get_one_face(temp_frame)
|
||||||
if target_face is None:
|
if target_face is None:
|
||||||
return temp_frame
|
return temp_frame
|
||||||
return enhance_face(temp_frame, target_face)
|
return enhance_face(temp_frame, target_face)
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ def create_lower_mouth_mask(
|
|||||||
|
|
||||||
landmarks = face.landmark_2d_106
|
landmarks = face.landmark_2d_106
|
||||||
if landmarks is not None:
|
if landmarks is not None:
|
||||||
# Use outer mouth landmarks (52-63) to capture the lips only
|
# Use outer mouth landmarks (52-71) to capture the full mouth area
|
||||||
lower_lip_order = list(range(52, 64))
|
lower_lip_order = list(range(52, 72))
|
||||||
|
|
||||||
if max(lower_lip_order) >= landmarks.shape[0]:
|
if max(lower_lip_order) >= landmarks.shape[0]:
|
||||||
return mask, mouth_cutout, mouth_box, lower_lip_polygon
|
return mask, mouth_cutout, mouth_box, lower_lip_polygon
|
||||||
@@ -94,13 +94,16 @@ def create_lower_mouth_mask(
|
|||||||
center = np.mean(lower_lip_landmarks, axis=0)
|
center = np.mean(lower_lip_landmarks, axis=0)
|
||||||
|
|
||||||
# Expand the landmarks outward using the mouth_mask_size
|
# Expand the landmarks outward using the mouth_mask_size
|
||||||
# Use a more conservative expansion to avoid affecting face shape
|
mouth_mask_size = getattr(modules.globals, "mouth_mask_size", 0.0) # 0-100 slider
|
||||||
expansion_factor = (
|
expansion_factor = 1 + (mouth_mask_size / 100.0) * 2.5
|
||||||
1 + modules.globals.mask_down_size * modules.globals.mouth_mask_size
|
|
||||||
)
|
|
||||||
expanded_landmarks = (lower_lip_landmarks - center) * expansion_factor + center
|
|
||||||
|
|
||||||
# Removed specific top/chin extensions to preserve face shape
|
# Expand with extra downward bias toward chin
|
||||||
|
offsets = lower_lip_landmarks - center
|
||||||
|
chin_bias = 1 + (mouth_mask_size / 100.0) * 1.5
|
||||||
|
scale_y = np.where(offsets[:, 1] > 0, expansion_factor * chin_bias, expansion_factor)
|
||||||
|
expanded_landmarks = lower_lip_landmarks.copy()
|
||||||
|
expanded_landmarks[:, 0] = center[0] + offsets[:, 0] * expansion_factor
|
||||||
|
expanded_landmarks[:, 1] = center[1] + offsets[:, 1] * scale_y
|
||||||
|
|
||||||
# Convert back to integer coordinates
|
# Convert back to integer coordinates
|
||||||
expanded_landmarks = expanded_landmarks.astype(np.int32)
|
expanded_landmarks = expanded_landmarks.astype(np.int32)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional, Tuple
|
||||||
import cv2
|
import cv2
|
||||||
import insightface
|
import insightface
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import platform
|
import platform
|
||||||
@@ -28,6 +29,149 @@ NAME = "DLC.FACE-SWAPPER"
|
|||||||
PREVIOUS_FRAME_RESULT = None # Stores the final processed frame from the previous step
|
PREVIOUS_FRAME_RESULT = None # Stores the final processed frame from the previous step
|
||||||
# --- END: Added for Interpolation ---
|
# --- END: Added for Interpolation ---
|
||||||
|
|
||||||
|
# --- Poisson blend (ported from deep-live-cam-gumroad-edition) ---
|
||||||
|
# Root-cause fix for the "wobble": the blend mask is NOT built from the
|
||||||
|
# independently-detected 106-pt landmarks (they jitter sub-pixel every frame
|
||||||
|
# and seamlessClone is hyper-sensitive to its mask boundary). Instead it is
|
||||||
|
# derived from the swap's OWN affine transform (M) + the swapped pixels
|
||||||
|
# (bgr_fake), so the mask is locked exactly to where the swapped face was
|
||||||
|
# placed — no independent jitter source, no EMA, no lag. The mask is cached
|
||||||
|
# when the face is nearly still so an identical array is reused (zero wobble).
|
||||||
|
_ELLIPTICAL_MASK_CACHE: dict = {}
|
||||||
|
_poisson_cached_mask: Optional[np.ndarray] = None
|
||||||
|
_poisson_cached_key: Optional[tuple] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_elliptical_mask(size: Tuple[int, int]) -> np.ndarray:
|
||||||
|
"""Fixed, heavily-blurred elliptical mask in aligned-face space.
|
||||||
|
|
||||||
|
Geometry-based (not content-adaptive) and cached by size — identical
|
||||||
|
every frame for the same model input size, so it contributes no jitter.
|
||||||
|
"""
|
||||||
|
global _ELLIPTICAL_MASK_CACHE
|
||||||
|
if size in _ELLIPTICAL_MASK_CACHE:
|
||||||
|
return _ELLIPTICAL_MASK_CACHE[size]
|
||||||
|
h, w = size
|
||||||
|
center = (w // 2, h // 2)
|
||||||
|
axes = (int(w * 0.44), int(h * 0.44))
|
||||||
|
mask = np.zeros((h, w), dtype=np.float32)
|
||||||
|
cv2.ellipse(mask, center, axes, 0, 0, 360, 1, -1)
|
||||||
|
if h * w < 65536:
|
||||||
|
mask = cv2.GaussianBlur(mask, (31, 31), 12)
|
||||||
|
else:
|
||||||
|
mask = gpu_gaussian_blur(mask, (31, 31), 12)
|
||||||
|
_ELLIPTICAL_MASK_CACHE[size] = mask
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_poisson_blend(swapped_frame: Frame, original_frame: Frame,
|
||||||
|
target_face: Face, affine_matrix: np.ndarray = None,
|
||||||
|
bgr_fake: np.ndarray = None) -> Frame:
|
||||||
|
"""Poisson-blend the swapped face onto the original frame.
|
||||||
|
|
||||||
|
Preferred path derives the blend mask from the swap's inverse affine so
|
||||||
|
it tracks the swapped face exactly per-frame (no landmark jitter, no
|
||||||
|
smoothing). Falls back to a cached bbox-ellipse if the affine is absent.
|
||||||
|
Writes only the blended ellipse back so other faces are preserved.
|
||||||
|
"""
|
||||||
|
global _poisson_cached_mask, _poisson_cached_key
|
||||||
|
try:
|
||||||
|
# ---- Preferred: blend ONLY the genuinely-swapped region ----
|
||||||
|
# Use the exact paste-back mask (warped elliptical mask), eroded so
|
||||||
|
# the Poisson seam sits on solidly-swapped pixels only.
|
||||||
|
if affine_matrix is not None and bgr_fake is not None:
|
||||||
|
try:
|
||||||
|
h, w = swapped_frame.shape[:2]
|
||||||
|
fh, fw = bgr_fake.shape[:2]
|
||||||
|
inv = cv2.invertAffineTransform(affine_matrix)
|
||||||
|
corners = np.array([[0, 0, 1], [fw, 0, 1], [fw, fh, 1], [0, fh, 1]],
|
||||||
|
dtype=np.float32)
|
||||||
|
t = corners @ inv.T
|
||||||
|
px1 = max(0, int(np.floor(t[:, 0].min())))
|
||||||
|
py1 = max(0, int(np.floor(t[:, 1].min())))
|
||||||
|
px2 = min(w, int(np.ceil(t[:, 0].max())))
|
||||||
|
py2 = min(h, int(np.ceil(t[:, 1].max())))
|
||||||
|
rw, rh = px2 - px1, py2 - py1
|
||||||
|
if rw > 8 and rh > 8:
|
||||||
|
roi_aff = inv.copy()
|
||||||
|
roi_aff[0, 2] -= px1
|
||||||
|
roi_aff[1, 2] -= py1
|
||||||
|
fm = _create_elliptical_mask((fh, fw))
|
||||||
|
mroi = cv2.warpAffine(fm, roi_aff, (rw, rh),
|
||||||
|
flags=cv2.INTER_LINEAR,
|
||||||
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||||
|
bin_roi = np.where(mroi > 0.5, np.uint8(255), np.uint8(0))
|
||||||
|
k = max(3, (min(rw, rh) // 20) | 1)
|
||||||
|
bin_roi = cv2.erode(bin_roi,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)))
|
||||||
|
bx, by, bw, bh = cv2.boundingRect(bin_roi)
|
||||||
|
if bw > 0 and bh > 0:
|
||||||
|
mx1, my1 = px1 + bx, py1 + by
|
||||||
|
mx2, my2 = mx1 + bw - 1, my1 + bh - 1
|
||||||
|
# seamlessClone needs the cloned region off the border
|
||||||
|
if mx1 > 0 and my1 > 0 and mx2 < w - 1 and my2 < h - 1:
|
||||||
|
mask = np.zeros((h, w), dtype=np.uint8)
|
||||||
|
mask[py1:py2, px1:px2] = bin_roi
|
||||||
|
center = (mx1 + bw // 2, my1 + bh // 2)
|
||||||
|
blended = cv2.seamlessClone(swapped_frame, original_frame,
|
||||||
|
mask, center, cv2.NORMAL_CLONE)
|
||||||
|
np.copyto(swapped_frame[my1:my2 + 1, mx1:mx2 + 1],
|
||||||
|
blended[my1:my2 + 1, mx1:mx2 + 1],
|
||||||
|
where=mask[my1:my2 + 1, mx1:mx2 + 1, None].astype(bool))
|
||||||
|
return swapped_frame
|
||||||
|
except Exception:
|
||||||
|
pass # fall through to the robust bbox-ellipse path below
|
||||||
|
# ---- Fallback: bbox-ellipse (defensive, cached when still) ----
|
||||||
|
if not hasattr(target_face, 'bbox') or target_face.bbox is None:
|
||||||
|
return swapped_frame
|
||||||
|
x1, y1, x2, y2 = target_face.bbox.astype(int)
|
||||||
|
h, w = swapped_frame.shape[:2]
|
||||||
|
x1, y1 = (max(0, x1), max(0, y1))
|
||||||
|
x2, y2 = (min(w, x2), min(h, y2))
|
||||||
|
if x2 <= x1 or y2 <= y1 or x2 - x1 <= 10 or (y2 - y1 <= 10):
|
||||||
|
return swapped_frame
|
||||||
|
padding = int(min(x2 - x1, y2 - y1) * 0.1)
|
||||||
|
x1_p = max(0, x1 - padding)
|
||||||
|
y1_p = max(0, y1 - padding)
|
||||||
|
x2_p = min(w, x2 + padding)
|
||||||
|
y2_p = min(h, y2 + padding)
|
||||||
|
center_x = int(round((x1 + x2) / 2.0))
|
||||||
|
center_y = int(round((y1 + y2) / 2.0))
|
||||||
|
radius_x = max(1, int(round((x2_p - x1_p) / 2.0)))
|
||||||
|
radius_y = max(1, int(round((y2_p - y1_p) / 2.0)))
|
||||||
|
if not (0 <= center_x < w and 0 <= center_y < h):
|
||||||
|
return swapped_frame
|
||||||
|
center = (center_x, center_y)
|
||||||
|
if center_x - radius_x < 0 or center_x + radius_x >= w or center_y - radius_y < 0 or (center_y + radius_y >= h):
|
||||||
|
return swapped_frame
|
||||||
|
# Reuse cached mask when center/radius unchanged frame-to-frame
|
||||||
|
# (face nearly still) — saves the np.zeros + cv2.ellipse, and the
|
||||||
|
# identical array means literally zero wobble while still.
|
||||||
|
mask_key = (center_x, center_y, radius_x, radius_y, h, w)
|
||||||
|
if _poisson_cached_key == mask_key and _poisson_cached_mask is not None:
|
||||||
|
mask = _poisson_cached_mask
|
||||||
|
else:
|
||||||
|
mask = np.zeros((h, w), dtype=np.uint8)
|
||||||
|
cv2.ellipse(mask, center, (radius_x, radius_y), 0, 0, 360, 255, -1)
|
||||||
|
if np.sum(mask) == 0:
|
||||||
|
return swapped_frame
|
||||||
|
_poisson_cached_mask = mask
|
||||||
|
_poisson_cached_key = mask_key
|
||||||
|
blended = cv2.seamlessClone(swapped_frame, original_frame, mask, center, cv2.NORMAL_CLONE)
|
||||||
|
# Composite ONLY this face's ellipse back (ROI-bounded) so previously
|
||||||
|
# blended faces in multi-face mode are preserved.
|
||||||
|
rx0 = max(0, center_x - radius_x)
|
||||||
|
rx1 = min(w, center_x + radius_x + 1)
|
||||||
|
ry0 = max(0, center_y - radius_y)
|
||||||
|
ry1 = min(h, center_y + radius_y + 1)
|
||||||
|
roi_mask = mask[ry0:ry1, rx0:rx1]
|
||||||
|
np.copyto(swapped_frame[ry0:ry1, rx0:rx1],
|
||||||
|
blended[ry0:ry1, rx0:rx1],
|
||||||
|
where=roi_mask[:, :, None].astype(bool))
|
||||||
|
return swapped_frame
|
||||||
|
except Exception:
|
||||||
|
return swapped_frame
|
||||||
|
|
||||||
# --- START: Mac M1-M5 Optimizations ---
|
# --- START: Mac M1-M5 Optimizations ---
|
||||||
IS_APPLE_SILICON = platform.system() == 'Darwin' and platform.machine() == 'arm64'
|
IS_APPLE_SILICON = platform.system() == 'Darwin' and platform.machine() == 'arm64'
|
||||||
FRAME_CACHE = deque(maxlen=3) # Cache for frame reuse
|
FRAME_CACHE = deque(maxlen=3) # Cache for frame reuse
|
||||||
@@ -54,21 +198,22 @@ def pre_check() -> bool:
|
|||||||
logging.error(f"Failed to create directory {download_directory_path} due to permission error: {e}")
|
logging.error(f"Failed to create directory {download_directory_path} due to permission error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Use the direct download URL from Hugging Face
|
# Use the direct download URL from Hugging Face (FP32 model for broad GPU compatibility)
|
||||||
conditional_download(
|
conditional_download(
|
||||||
download_directory_path,
|
download_directory_path,
|
||||||
[
|
[
|
||||||
"https://huggingface.co/hacksider/deep-live-cam/resolve/main/inswapper_128_fp16.onnx"
|
"https://huggingface.co/hacksider/deep-live-cam/resolve/main/inswapper_128.onnx"
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def pre_start() -> bool:
|
def pre_start() -> bool:
|
||||||
# Simplified pre_start, assuming checks happen before calling process functions
|
# Check for either model variant
|
||||||
model_path = os.path.join(models_dir, "inswapper_128_fp16.onnx")
|
fp16_path = os.path.join(models_dir, "inswapper_128_fp16.onnx")
|
||||||
if not os.path.exists(model_path):
|
fp32_path = os.path.join(models_dir, "inswapper_128.onnx")
|
||||||
update_status(f"Model not found: {model_path}. Please download it.", NAME)
|
if not os.path.exists(fp16_path) and not os.path.exists(fp32_path):
|
||||||
|
update_status(f"Model not found in {models_dir}. Please download inswapper_128.onnx.", NAME)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Try to get the face swapper to ensure it loads correctly
|
# Try to get the face swapper to ensure it loads correctly
|
||||||
@@ -76,7 +221,6 @@ def pre_start() -> bool:
|
|||||||
# Error message already printed within get_face_swapper
|
# Error message already printed within get_face_swapper
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Add other essential checks if needed, e.g., target/source path validity
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -85,13 +229,28 @@ def get_face_swapper() -> Any:
|
|||||||
|
|
||||||
with THREAD_LOCK:
|
with THREAD_LOCK:
|
||||||
if FACE_SWAPPER is None:
|
if FACE_SWAPPER is None:
|
||||||
model_name = "inswapper_128.onnx"
|
# Prefer FP16 on GPUs with Tensor Cores (Turing+) — half the
|
||||||
if "CUDAExecutionProvider" in modules.globals.execution_providers:
|
# memory bandwidth, faster inference. Fall back to FP32 for
|
||||||
model_name = "inswapper_128_fp16.onnx"
|
# older GPUs (e.g. GTX 16xx) where FP16 can produce NaN.
|
||||||
model_path = os.path.join(models_dir, model_name)
|
fp32_path = os.path.join(models_dir, "inswapper_128.onnx")
|
||||||
|
fp16_path = os.path.join(models_dir, "inswapper_128_fp16.onnx")
|
||||||
|
use_fp16 = _HAS_TORCH_CUDA and os.path.exists(fp16_path)
|
||||||
|
if use_fp16:
|
||||||
|
model_path = fp16_path
|
||||||
|
elif os.path.exists(fp32_path):
|
||||||
|
model_path = fp32_path
|
||||||
|
else:
|
||||||
|
update_status(f"No inswapper model found in {models_dir}.", NAME)
|
||||||
|
return None
|
||||||
|
# On Apple Silicon, rewrite Pad(reflect) → Slice+Concat so
|
||||||
|
# CoreML can run the entire model in a single partition on
|
||||||
|
# the Neural Engine instead of bouncing between CPU and ANE.
|
||||||
|
if IS_APPLE_SILICON:
|
||||||
|
from modules.onnx_optimize import optimize_for_coreml
|
||||||
|
model_path = optimize_for_coreml(model_path)
|
||||||
|
|
||||||
update_status(f"Loading face swapper model from: {model_path}", NAME)
|
update_status(f"Loading face swapper model from: {model_path}", NAME)
|
||||||
try:
|
try:
|
||||||
# Optimized provider configuration for Apple Silicon
|
|
||||||
providers_config = []
|
providers_config = []
|
||||||
for p in modules.globals.execution_providers:
|
for p in modules.globals.execution_providers:
|
||||||
if p == "CoreMLExecutionProvider" and IS_APPLE_SILICON:
|
if p == "CoreMLExecutionProvider" and IS_APPLE_SILICON:
|
||||||
@@ -104,17 +263,25 @@ def get_face_swapper() -> Any:
|
|||||||
"SpecializationStrategy": "FastPrediction",
|
"SpecializationStrategy": "FastPrediction",
|
||||||
"AllowLowPrecisionAccumulationOnGPU": 1,
|
"AllowLowPrecisionAccumulationOnGPU": 1,
|
||||||
"EnableOnSubgraphs": 1,
|
"EnableOnSubgraphs": 1,
|
||||||
"RequireStaticShapes": 0,
|
|
||||||
"MaximumCacheSize": 1024 * 1024 * 512, # 512MB cache
|
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
elif p == "CUDAExecutionProvider":
|
||||||
|
# Use bare provider — ONNX Runtime defaults are
|
||||||
|
# fastest on modern GPUs (Blackwell/sm_120).
|
||||||
|
providers_config.append(p)
|
||||||
else:
|
else:
|
||||||
providers_config.append(p)
|
providers_config.append(p)
|
||||||
|
|
||||||
FACE_SWAPPER = insightface.model_zoo.get_model(
|
FACE_SWAPPER = insightface.model_zoo.get_model(
|
||||||
model_path,
|
model_path,
|
||||||
providers=providers_config,
|
providers=providers_config,
|
||||||
)
|
)
|
||||||
|
# Set up CUDA graph session for faster inference
|
||||||
|
if _HAS_TORCH_CUDA and any(
|
||||||
|
p == "CUDAExecutionProvider" or
|
||||||
|
(isinstance(p, tuple) and p[0] == "CUDAExecutionProvider")
|
||||||
|
for p in providers_config
|
||||||
|
):
|
||||||
|
_init_cuda_graph_session(model_path, FACE_SWAPPER)
|
||||||
update_status("Face swapper model loaded successfully.", NAME)
|
update_status("Face swapper model loaded successfully.", NAME)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
update_status(f"Error loading face swapper model: {e}", NAME)
|
update_status(f"Error loading face swapper model: {e}", NAME)
|
||||||
@@ -123,6 +290,214 @@ def get_face_swapper() -> Any:
|
|||||||
return FACE_SWAPPER
|
return FACE_SWAPPER
|
||||||
|
|
||||||
|
|
||||||
|
_HAS_TORCH_CUDA = False
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
_HAS_TORCH_CUDA = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cache for paste-back
|
||||||
|
_paste_cache = {
|
||||||
|
'soft_alpha': None, # feathered alpha mask in aligned-face space
|
||||||
|
'alpha_size': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_soft_alpha(size: int) -> np.ndarray:
|
||||||
|
"""Feathered alpha template in aligned-face space, cached.
|
||||||
|
|
||||||
|
The legacy paste-back eroded and Gaussian-blurred the warped mask in
|
||||||
|
output coordinates with kernels scaled to the output face size, which
|
||||||
|
made the per-frame cost quartic in face linear size. Doing the same
|
||||||
|
erode+blur once in aligned space and then warping the *soft* mask
|
||||||
|
per-frame gives a visually equivalent feather at O(crop_area) cost —
|
||||||
|
the feather radius scales naturally with the affine transform.
|
||||||
|
"""
|
||||||
|
if _paste_cache['alpha_size'] != size:
|
||||||
|
# Elliptical (not square) template — matches the gumroad edition's
|
||||||
|
# _create_elliptical_mask. A full/eroded square leaves the aligned
|
||||||
|
# crop's corners near-opaque, so the swapped square's straight edges
|
||||||
|
# show as a visible box on the face. An ellipse (axes 0.44*size) zeroes
|
||||||
|
# the corners and the heavy blur feathers smoothly into the original.
|
||||||
|
center = (size // 2, size // 2)
|
||||||
|
axes = (int(size * 0.44), int(size * 0.44))
|
||||||
|
mask = np.zeros((size, size), dtype=np.uint8)
|
||||||
|
cv2.ellipse(mask, center, axes, 0, 0, 360, 255, -1)
|
||||||
|
mask = cv2.GaussianBlur(mask, (31, 31), 12)
|
||||||
|
_paste_cache['soft_alpha'] = mask # uint8 [0, 255] — blended via cv2 SIMD ops
|
||||||
|
_paste_cache['alpha_size'] = size
|
||||||
|
return _paste_cache['soft_alpha']
|
||||||
|
|
||||||
|
# CUDA graph swap session cache
|
||||||
|
_cuda_graph_session = {
|
||||||
|
'session': None,
|
||||||
|
'io_binding': None,
|
||||||
|
'ort_input': None,
|
||||||
|
'ort_latent': None,
|
||||||
|
'recorded': False,
|
||||||
|
}
|
||||||
|
# Serializes CUDA-graph replay. The io_binding + ort_input/ort_latent are
|
||||||
|
# shared across threads and run_with_iobinding mutates GPU-side buffers;
|
||||||
|
# concurrent calls would produce wrong output.
|
||||||
|
_cuda_graph_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class _CudaGraphSessionAdapter:
|
||||||
|
"""Drop-in wrapper around an ONNX Runtime session.
|
||||||
|
|
||||||
|
Routes ``.run()`` through CUDA graph replay when a recorded graph is
|
||||||
|
available, and transparently proxies every other attribute to the
|
||||||
|
underlying session so insightface's INSwapper sees an unchanged API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, underlying):
|
||||||
|
# Use object.__setattr__ to bypass our own __setattr__.
|
||||||
|
object.__setattr__(self, "_underlying", underlying)
|
||||||
|
|
||||||
|
def run(self, output_names, input_dict, **kwargs):
|
||||||
|
if _cuda_graph_session['recorded']:
|
||||||
|
try:
|
||||||
|
keys = list(input_dict.keys())
|
||||||
|
blob = input_dict[keys[0]]
|
||||||
|
latent = input_dict[keys[1]]
|
||||||
|
return [_cuda_graph_swap_inference(blob, latent)]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self._underlying.run(output_names, input_dict, **kwargs)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._underlying, name)
|
||||||
|
|
||||||
|
def __setattr__(self, name, value):
|
||||||
|
setattr(self._underlying, name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_cuda_graph_session(model_path: str, swapper):
|
||||||
|
"""Create a CUDA-graph-enabled ONNX session for the swap model.
|
||||||
|
|
||||||
|
CUDA graphs record the GPU kernel launch sequence once, then replay it
|
||||||
|
with near-zero CPU overhead on subsequent runs. Requires static input
|
||||||
|
shapes (inswapper is always 1x3x128x128 + 1x512).
|
||||||
|
"""
|
||||||
|
import onnxruntime as ort
|
||||||
|
try:
|
||||||
|
providers = [('CUDAExecutionProvider', {'enable_cuda_graph': '1'})]
|
||||||
|
sess = ort.InferenceSession(model_path, providers=providers)
|
||||||
|
|
||||||
|
# Pre-allocate GPU buffers with correct shapes
|
||||||
|
inp_shape = (1, 3, swapper.input_size[1], swapper.input_size[0])
|
||||||
|
latent_shape = (1, 512)
|
||||||
|
dummy_inp = np.zeros(inp_shape, dtype=np.float32)
|
||||||
|
dummy_lat = np.zeros(latent_shape, dtype=np.float32)
|
||||||
|
|
||||||
|
ort_input = ort.OrtValue.ortvalue_from_numpy(dummy_inp, 'cuda', 0)
|
||||||
|
ort_latent = ort.OrtValue.ortvalue_from_numpy(dummy_lat, 'cuda', 0)
|
||||||
|
|
||||||
|
io = sess.io_binding()
|
||||||
|
io.bind_ortvalue_input(swapper.input_names[0], ort_input)
|
||||||
|
io.bind_ortvalue_input(swapper.input_names[1], ort_latent)
|
||||||
|
io.bind_output(swapper.output_names[0], 'cuda', 0)
|
||||||
|
|
||||||
|
# First run records the CUDA graph
|
||||||
|
sess.run_with_iobinding(io)
|
||||||
|
|
||||||
|
_cuda_graph_session['session'] = sess
|
||||||
|
_cuda_graph_session['io_binding'] = io
|
||||||
|
_cuda_graph_session['ort_input'] = ort_input
|
||||||
|
_cuda_graph_session['ort_latent'] = ort_latent
|
||||||
|
_cuda_graph_session['recorded'] = True
|
||||||
|
|
||||||
|
# Wrap swapper.session in an adapter instead of rebinding
|
||||||
|
# session.run. insightface's INSwapper.get() reads .run via the
|
||||||
|
# session attribute, so either works; the adapter survives any
|
||||||
|
# later attribute reads on the session and keeps the original
|
||||||
|
# session object untouched.
|
||||||
|
if not isinstance(swapper.session, _CudaGraphSessionAdapter):
|
||||||
|
swapper.session = _CudaGraphSessionAdapter(swapper.session)
|
||||||
|
|
||||||
|
import sys
|
||||||
|
print(f"[{NAME}] CUDA graph session initialized (swap model)")
|
||||||
|
sys.stdout.flush()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{NAME}] CUDA graph init failed, using standard session: {e}")
|
||||||
|
_cuda_graph_session['recorded'] = False
|
||||||
|
|
||||||
|
|
||||||
|
def _cuda_graph_swap_inference(blob: np.ndarray, latent: np.ndarray) -> np.ndarray:
|
||||||
|
"""Run swap model via CUDA graph replay — minimal CPU overhead."""
|
||||||
|
cg = _cuda_graph_session
|
||||||
|
with _cuda_graph_lock:
|
||||||
|
cg['ort_input'].update_inplace(blob)
|
||||||
|
cg['ort_latent'].update_inplace(latent)
|
||||||
|
cg['session'].run_with_iobinding(cg['io_binding'])
|
||||||
|
return cg['io_binding'].get_outputs()[0].numpy()
|
||||||
|
|
||||||
|
|
||||||
|
def _fast_paste_back(target_img: Frame, bgr_fake: np.ndarray, aimg: np.ndarray, M: np.ndarray) -> Frame:
|
||||||
|
"""Paste bgr_fake back onto target_img via the inverse affine of M.
|
||||||
|
|
||||||
|
Restricts work to the face bbox in output coordinates and warps a
|
||||||
|
precomputed feathered alpha template per-frame instead of running a
|
||||||
|
size-scaled erode+blur on the warped mask. Cost is O(crop_area) regardless
|
||||||
|
of how much of the frame the face occupies.
|
||||||
|
"""
|
||||||
|
h, w = target_img.shape[:2]
|
||||||
|
face_h, face_w = aimg.shape[:2]
|
||||||
|
# inswapper's aligned-face space is square (128x128). _get_soft_alpha
|
||||||
|
# caches a single NxN template keyed by N, so fail loudly if that ever
|
||||||
|
# stops being true rather than silently mis-warping the alpha mask.
|
||||||
|
assert face_h == face_w, f"Expected square aligned face, got {face_h}x{face_w}"
|
||||||
|
IM = cv2.invertAffineTransform(M)
|
||||||
|
|
||||||
|
# Bbox in output coords from the affine corners of the aligned-face square.
|
||||||
|
corners = np.array(
|
||||||
|
[[0, 0], [face_w, 0], [face_w, face_h], [0, face_h]], dtype=np.float32
|
||||||
|
)
|
||||||
|
transformed = (IM[:, :2] @ corners.T).T + IM[:, 2]
|
||||||
|
x1 = int(np.floor(transformed[:, 0].min()))
|
||||||
|
x2 = int(np.ceil(transformed[:, 0].max()))
|
||||||
|
y1 = int(np.floor(transformed[:, 1].min()))
|
||||||
|
y2 = int(np.ceil(transformed[:, 1].max()))
|
||||||
|
if x1 >= x2 or y1 >= y2:
|
||||||
|
return target_img
|
||||||
|
|
||||||
|
# Small interpolation margin only — the feather is baked into the template.
|
||||||
|
pad = 2
|
||||||
|
y1p, y2p = max(0, y1 - pad), min(h, y2 + pad + 1)
|
||||||
|
x1p, x2p = max(0, x1 - pad), min(w, x2 + pad + 1)
|
||||||
|
|
||||||
|
IM_crop = IM.copy()
|
||||||
|
IM_crop[0, 2] -= x1p
|
||||||
|
IM_crop[1, 2] -= y1p
|
||||||
|
crop_w, crop_h = x2p - x1p, y2p - y1p
|
||||||
|
|
||||||
|
soft_alpha = _get_soft_alpha(face_h)
|
||||||
|
bgr_fake_crop = cv2.warpAffine(bgr_fake, IM_crop, (crop_w, crop_h), borderMode=cv2.BORDER_REPLICATE)
|
||||||
|
alpha_crop = cv2.warpAffine(soft_alpha, IM_crop, (crop_w, crop_h), borderValue=0)
|
||||||
|
|
||||||
|
target_crop = target_img[y1p:y2p, x1p:x2p]
|
||||||
|
|
||||||
|
if _HAS_TORCH_CUDA:
|
||||||
|
# Scale alpha to [0, 1] on device — cheaper to upload uint8 than float.
|
||||||
|
mask_t = torch.from_numpy(alpha_crop).cuda().float().mul_(1.0 / 255.0).unsqueeze(2)
|
||||||
|
fake_t = torch.from_numpy(bgr_fake_crop).float().cuda()
|
||||||
|
tgt_t = torch.from_numpy(target_crop).float().cuda()
|
||||||
|
blended = (mask_t * fake_t + (1.0 - mask_t) * tgt_t).to(torch.uint8).cpu().numpy()
|
||||||
|
target_img[y1p:y2p, x1p:x2p] = blended
|
||||||
|
else:
|
||||||
|
# Fused uint8 blend via cv2 SIMD — no float32 round-trip.
|
||||||
|
# Measured ~7-8× faster than the old numpy float32 path on a 1000×1000 crop.
|
||||||
|
alpha_3c = cv2.merge([alpha_crop, alpha_crop, alpha_crop])
|
||||||
|
inv_alpha = 255 - alpha_3c
|
||||||
|
a_fake = cv2.multiply(bgr_fake_crop, alpha_3c, scale=1.0 / 255.0)
|
||||||
|
a_tgt = cv2.multiply(target_crop, inv_alpha, scale=1.0 / 255.0)
|
||||||
|
target_img[y1p:y2p, x1p:x2p] = cv2.add(a_fake, a_tgt)
|
||||||
|
|
||||||
|
return target_img
|
||||||
|
|
||||||
|
|
||||||
def swap_face(source_face: Face, target_face: Face, temp_frame: Frame) -> Frame:
|
def swap_face(source_face: Face, target_face: Face, temp_frame: Frame) -> Frame:
|
||||||
"""Optimized face swapping with better memory management and performance."""
|
"""Optimized face swapping with better memory management and performance."""
|
||||||
face_swapper = get_face_swapper()
|
face_swapper = get_face_swapper()
|
||||||
@@ -136,112 +511,92 @@ def swap_face(source_face: Face, target_face: Face, temp_frame: Frame) -> Frame:
|
|||||||
if not hasattr(source_face, 'normed_embedding') or source_face.normed_embedding is None:
|
if not hasattr(source_face, 'normed_embedding') or source_face.normed_embedding is None:
|
||||||
return temp_frame
|
return temp_frame
|
||||||
|
|
||||||
# Store a copy of the original frame before swapping for opacity blending
|
# _fast_paste_back writes in-place on the GPU path. Only copy when
|
||||||
|
# mouth_mask or opacity < 1 need an unmodified original.
|
||||||
opacity = getattr(modules.globals, "opacity", 1.0)
|
opacity = getattr(modules.globals, "opacity", 1.0)
|
||||||
opacity = max(0.0, min(1.0, opacity))
|
opacity = max(0.0, min(1.0, opacity))
|
||||||
original_frame = temp_frame if opacity >= 1.0 else temp_frame.copy()
|
mouth_mask_enabled = getattr(modules.globals, "mouth_mask", False)
|
||||||
|
poisson_blend_enabled = getattr(modules.globals, "poisson_blend", False)
|
||||||
|
# Poisson blend's seamlessClone needs the genuine pre-swap frame as its
|
||||||
|
# destination. Without this, original_frame aliases temp_frame, which
|
||||||
|
# _fast_paste_back mutates in place — so seamlessClone would blend the
|
||||||
|
# swapped face onto the already-swapped frame (no visible effect).
|
||||||
|
needs_original = opacity < 1.0 or mouth_mask_enabled or poisson_blend_enabled
|
||||||
|
if needs_original:
|
||||||
|
original_frame = temp_frame.copy()
|
||||||
|
else:
|
||||||
|
original_frame = temp_frame
|
||||||
|
|
||||||
# Pre-swap Input Check with optimization
|
|
||||||
if temp_frame.dtype != np.uint8:
|
if temp_frame.dtype != np.uint8:
|
||||||
temp_frame = np.clip(temp_frame, 0, 255).astype(np.uint8)
|
temp_frame = np.clip(temp_frame, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
# Apply the face swap with optimized memory handling
|
|
||||||
try:
|
try:
|
||||||
# Ensure contiguous memory layout for better performance on all platforms
|
|
||||||
if not temp_frame.flags['C_CONTIGUOUS']:
|
if not temp_frame.flags['C_CONTIGUOUS']:
|
||||||
temp_frame = np.ascontiguousarray(temp_frame)
|
temp_frame = np.ascontiguousarray(temp_frame)
|
||||||
|
|
||||||
swapped_frame_raw = face_swapper.get(
|
|
||||||
temp_frame, target_face, source_face, paste_back=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- START: CRITICAL FIX FOR ORT 1.17 ---
|
# Use paste_back=False and our optimized paste-back
|
||||||
# Check the output type and range from the model
|
if any("DmlExecutionProvider" in p for p in modules.globals.execution_providers):
|
||||||
if swapped_frame_raw is None:
|
with modules.globals.dml_lock:
|
||||||
# print("Warning: face_swapper.get returned None.") # Debug
|
bgr_fake, M = face_swapper.get(
|
||||||
return original_frame # Return original if swap somehow failed internally
|
temp_frame, target_face, source_face, paste_back=False
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bgr_fake, M = face_swapper.get(
|
||||||
|
temp_frame, target_face, source_face, paste_back=False
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure the output is a numpy array
|
if bgr_fake is None:
|
||||||
if not isinstance(swapped_frame_raw, np.ndarray):
|
|
||||||
# print(f"Warning: face_swapper.get returned type {type(swapped_frame_raw)}, expected numpy array.") # Debug
|
|
||||||
return original_frame
|
return original_frame
|
||||||
|
|
||||||
# Ensure the output has the correct shape (like the input frame)
|
if not isinstance(bgr_fake, np.ndarray):
|
||||||
if swapped_frame_raw.shape != temp_frame.shape:
|
return original_frame
|
||||||
# print(f"Warning: Swapped frame shape {swapped_frame_raw.shape} differs from input {temp_frame.shape}.") # Debug
|
|
||||||
# Attempt resize (might distort if aspect ratio changed, but better than crashing)
|
|
||||||
try:
|
|
||||||
swapped_frame_raw = gpu_resize(swapped_frame_raw, (temp_frame.shape[1], temp_frame.shape[0]))
|
|
||||||
except Exception as resize_e:
|
|
||||||
# print(f"Error resizing swapped frame: {resize_e}") # Debug
|
|
||||||
return original_frame
|
|
||||||
|
|
||||||
# Explicitly clip values to 0-255 and convert to uint8
|
# Pass a dummy aimg with correct shape — _fast_paste_back only uses aimg.shape
|
||||||
# This handles cases where the model might output floats or values outside the valid range
|
# to create the white mask. Avoids redundant norm_crop2 (~0.6ms).
|
||||||
swapped_frame = np.clip(swapped_frame_raw, 0, 255).astype(np.uint8)
|
_face_size = face_swapper.input_size[0]
|
||||||
# --- END: CRITICAL FIX FOR ORT 1.17 ---
|
_aimg_dummy = np.empty((_face_size, _face_size, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
swapped_frame = _fast_paste_back(temp_frame, bgr_fake, _aimg_dummy, M)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during face swap using face_swapper.get: {e}") # More specific error
|
print(f"Error during face swap: {e}")
|
||||||
# import traceback
|
return original_frame
|
||||||
# traceback.print_exc() # Print full traceback for debugging
|
|
||||||
return original_frame # Return original if swap fails
|
|
||||||
|
|
||||||
# --- Post-swap Processing (Masking, Opacity, etc.) ---
|
# --- Post-swap Processing (Masking, Opacity, etc.) ---
|
||||||
# Now, work with the guaranteed uint8 'swapped_frame'
|
# Now, work with the guaranteed uint8 'swapped_frame'
|
||||||
|
|
||||||
if getattr(modules.globals, "mouth_mask", False): # Check if mouth_mask is enabled
|
if mouth_mask_enabled: # Check if mouth_mask is enabled
|
||||||
# Create a mask for the target face
|
# Create a mask for the target face
|
||||||
face_mask = create_face_mask(target_face, temp_frame) # Use temp_frame (original shape) for mask creation geometry
|
face_mask = create_face_mask(target_face, original_frame) # Use original_frame for mask creation geometry
|
||||||
|
|
||||||
# Create the mouth mask using original geometry
|
# Create the mouth mask using the ORIGINAL frame (before swap) for cutout
|
||||||
mouth_mask, mouth_cutout, mouth_box, lower_lip_polygon = (
|
mouth_mask, mouth_cutout, mouth_box, lower_lip_polygon = (
|
||||||
create_lower_mouth_mask(target_face, temp_frame) # Use temp_frame (original) for cutout
|
create_lower_mouth_mask(target_face, original_frame) # Use original_frame for real mouth cutout
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply the mouth area only if mouth_cutout exists
|
# Apply the mouth area only if mouth_cutout exists
|
||||||
if mouth_cutout is not None and mouth_box != (0,0,0,0): # Add check for valid box
|
if mouth_cutout is not None and mouth_box != (0,0,0,0):
|
||||||
# Apply mouth area (from original) onto the 'swapped_frame'
|
# Apply mouth area (from original) onto the 'swapped_frame'
|
||||||
swapped_frame = apply_mouth_area(
|
swapped_frame = apply_mouth_area(
|
||||||
swapped_frame, mouth_cutout, mouth_box, face_mask, lower_lip_polygon
|
swapped_frame, mouth_cutout, mouth_box, face_mask, lower_lip_polygon
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Draw bounding box only while slider is being dragged
|
||||||
if getattr(modules.globals, "show_mouth_mask_box", False):
|
if getattr(modules.globals, "show_mouth_mask_box", False):
|
||||||
mouth_mask_data = (mouth_mask, mouth_cutout, mouth_box, lower_lip_polygon)
|
mouth_mask_data = (mouth_mask, mouth_cutout, mouth_box, lower_lip_polygon)
|
||||||
# Draw visualization on the swapped_frame *before* opacity blending
|
swapped_frame = draw_mouth_mask_visualization(
|
||||||
swapped_frame = draw_mouth_mask_visualization(
|
swapped_frame, target_face, mouth_mask_data
|
||||||
swapped_frame, target_face, mouth_mask_data
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# --- Poisson Blending ---
|
# --- Poisson Blending ---
|
||||||
|
# Mask derived from the swap's own affine (M) + swapped pixels (bgr_fake),
|
||||||
|
# so it tracks the swapped face exactly per-frame — no landmark jitter,
|
||||||
|
# no EMA, no lag. See _apply_poisson_blend.
|
||||||
if getattr(modules.globals, "poisson_blend", False):
|
if getattr(modules.globals, "poisson_blend", False):
|
||||||
face_mask = create_face_mask(target_face, temp_frame)
|
swapped_frame = _apply_poisson_blend(
|
||||||
if face_mask is not None:
|
swapped_frame, original_frame, target_face, M, bgr_fake
|
||||||
# Find bounding box of the mask
|
)
|
||||||
y_indices, x_indices = np.where(face_mask > 0)
|
|
||||||
if len(x_indices) > 0 and len(y_indices) > 0:
|
|
||||||
x_min, x_max = np.min(x_indices), np.max(x_indices)
|
|
||||||
y_min, y_max = np.min(y_indices), np.max(y_indices)
|
|
||||||
|
|
||||||
# Calculate center
|
|
||||||
center = (int((x_min + x_max) / 2), int((y_min + y_max) / 2))
|
|
||||||
|
|
||||||
# Crop src and mask
|
|
||||||
src_crop = swapped_frame[y_min : y_max + 1, x_min : x_max + 1]
|
|
||||||
mask_crop = face_mask[y_min : y_max + 1, x_min : x_max + 1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use original_frame as destination to blend the swapped face onto it
|
|
||||||
swapped_frame = cv2.seamlessClone(
|
|
||||||
src_crop,
|
|
||||||
original_frame,
|
|
||||||
mask_crop,
|
|
||||||
center,
|
|
||||||
cv2.NORMAL_CLONE,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Poisson blending failed: {e}")
|
|
||||||
|
|
||||||
# Apply opacity blend between the original frame and the swapped frame
|
# Apply opacity blend between the original frame and the swapped frame
|
||||||
if opacity >= 1.0:
|
if opacity >= 1.0:
|
||||||
return swapped_frame.astype(np.uint8)
|
return swapped_frame.astype(np.uint8)
|
||||||
@@ -292,6 +647,14 @@ def apply_post_processing(current_frame: Frame, swapped_face_bboxes: List[np.nda
|
|||||||
"""Applies sharpening and interpolation with Apple Silicon optimizations."""
|
"""Applies sharpening and interpolation with Apple Silicon optimizations."""
|
||||||
global PREVIOUS_FRAME_RESULT
|
global PREVIOUS_FRAME_RESULT
|
||||||
|
|
||||||
|
sharpness_value = getattr(modules.globals, "sharpness", 0.0)
|
||||||
|
enable_interpolation = getattr(modules.globals, "enable_interpolation", False)
|
||||||
|
|
||||||
|
# Skip copy when no post-processing is active
|
||||||
|
if sharpness_value <= 0.0 and not enable_interpolation:
|
||||||
|
PREVIOUS_FRAME_RESULT = None
|
||||||
|
return current_frame
|
||||||
|
|
||||||
processed_frame = current_frame.copy()
|
processed_frame = current_frame.copy()
|
||||||
|
|
||||||
# 1. Apply Sharpening (if enabled) with optimized kernel for Apple Silicon
|
# 1. Apply Sharpening (if enabled) with optimized kernel for Apple Silicon
|
||||||
@@ -367,42 +730,40 @@ def apply_post_processing(current_frame: Frame, swapped_face_bboxes: List[np.nda
|
|||||||
# --- END: Helper function for interpolation and sharpening ---
|
# --- END: Helper function for interpolation and sharpening ---
|
||||||
|
|
||||||
|
|
||||||
def process_frame(source_face: Face, temp_frame: Frame) -> Frame:
|
def process_frame(source_face: Face, temp_frame: Frame, target_face: Face = None) -> Frame:
|
||||||
"""
|
"""Process a single frame, swapping source_face onto detected target(s).
|
||||||
DEPRECATED / SIMPLER VERSION - Processes a single frame using one source face.
|
|
||||||
Consider using process_frame_v2 for more complex scenarios.
|
Args:
|
||||||
|
target_face: Pre-detected target face. When provided, skips the
|
||||||
|
internal face detection call (saves ~30-40ms per frame).
|
||||||
|
Ignored when many_faces mode is active.
|
||||||
"""
|
"""
|
||||||
if getattr(modules.globals, "opacity", 1.0) == 0:
|
if getattr(modules.globals, "opacity", 1.0) == 0:
|
||||||
# If opacity is 0, no swap happens, so no post-processing needed.
|
|
||||||
# Also reset interpolation state if it was active.
|
|
||||||
global PREVIOUS_FRAME_RESULT
|
global PREVIOUS_FRAME_RESULT
|
||||||
PREVIOUS_FRAME_RESULT = None
|
PREVIOUS_FRAME_RESULT = None
|
||||||
return temp_frame
|
return temp_frame
|
||||||
|
|
||||||
# Color correction removed from here (better applied before swap if needed)
|
processed_frame = temp_frame
|
||||||
|
swapped_face_bboxes = []
|
||||||
processed_frame = temp_frame # Start with the input frame
|
|
||||||
swapped_face_bboxes = [] # Keep track of where swaps happened
|
|
||||||
|
|
||||||
if modules.globals.many_faces:
|
if modules.globals.many_faces:
|
||||||
many_faces = get_many_faces(processed_frame)
|
many_faces = get_many_faces(processed_frame)
|
||||||
if many_faces:
|
if many_faces:
|
||||||
current_swap_target = processed_frame.copy() # Apply swaps sequentially on a copy
|
current_swap_target = processed_frame.copy()
|
||||||
for target_face in many_faces:
|
for face in many_faces:
|
||||||
current_swap_target = swap_face(source_face, target_face, current_swap_target)
|
current_swap_target = swap_face(source_face, face, current_swap_target)
|
||||||
if target_face is not None and hasattr(target_face, "bbox") and target_face.bbox is not None:
|
if face is not None and hasattr(face, "bbox") and face.bbox is not None:
|
||||||
swapped_face_bboxes.append(target_face.bbox.astype(int))
|
swapped_face_bboxes.append(face.bbox.astype(int))
|
||||||
processed_frame = current_swap_target # Assign the final result after all swaps
|
processed_frame = current_swap_target
|
||||||
else:
|
else:
|
||||||
target_face = get_one_face(processed_frame)
|
if target_face is None:
|
||||||
|
target_face = get_one_face(processed_frame)
|
||||||
if target_face:
|
if target_face:
|
||||||
processed_frame = swap_face(source_face, target_face, processed_frame)
|
processed_frame = swap_face(source_face, target_face, processed_frame)
|
||||||
if target_face is not None and hasattr(target_face, "bbox") and target_face.bbox is not None:
|
if hasattr(target_face, "bbox") and target_face.bbox is not None:
|
||||||
swapped_face_bboxes.append(target_face.bbox.astype(int))
|
swapped_face_bboxes.append(target_face.bbox.astype(int))
|
||||||
|
|
||||||
# Apply sharpening and interpolation
|
|
||||||
final_frame = apply_post_processing(processed_frame, swapped_face_bboxes)
|
final_frame = apply_post_processing(processed_frame, swapped_face_bboxes)
|
||||||
|
|
||||||
return final_frame
|
return final_frame
|
||||||
|
|
||||||
|
|
||||||
@@ -750,13 +1111,20 @@ def create_lower_mouth_mask(
|
|||||||
return mask, mouth_cutout, mouth_box, lower_lip_polygon
|
return mask, mouth_cutout, mouth_box, lower_lip_polygon
|
||||||
|
|
||||||
try: # Wrap main logic in try-except
|
try: # Wrap main logic in try-except
|
||||||
# Use outer mouth landmarks (52-63) to capture the lips only
|
# Outer mouth/lip landmarks (52-63) — the lip outline only. In this
|
||||||
# This avoids including the chin/jawline, preserving the face shape from the swap
|
# repo's insightface 2d106 convention these 12 points, taken in index
|
||||||
|
# order, form a SIMPLE (non-self-intersecting) closed polygon that
|
||||||
|
# cv2.fillPoly fills as one solid region directly over the mouth.
|
||||||
|
# This is the last shipped, known-good landmark set; range(52,72)
|
||||||
|
# (the regression) added the inner-lip points and made the path
|
||||||
|
# self-intersect, and the ancient [65,66,62,...,0,8,7...] indices
|
||||||
|
# belong to a different/older landmark convention (they land on the
|
||||||
|
# inner lip + random jaw points, so the mask never covers the mouth).
|
||||||
lower_lip_order = list(range(52, 64))
|
lower_lip_order = list(range(52, 64))
|
||||||
|
|
||||||
# Check if all indices are valid for the loaded landmarks (already partially done by < 106 check)
|
# All indices must be valid for the loaded landmark set
|
||||||
if max(lower_lip_order) >= landmarks.shape[0]:
|
if max(lower_lip_order) >= landmarks.shape[0]:
|
||||||
# print(f"Warning: Landmark index {max(lower_lip_order)} out of bounds for shape {landmarks.shape[0]}.")
|
# print(f"Warning: Landmark index out of bounds for shape {landmarks.shape[0]}.")
|
||||||
return mask, mouth_cutout, mouth_box, lower_lip_polygon
|
return mask, mouth_cutout, mouth_box, lower_lip_polygon
|
||||||
|
|
||||||
lower_lip_landmarks = landmarks[lower_lip_order].astype(np.float32)
|
lower_lip_landmarks = landmarks[lower_lip_order].astype(np.float32)
|
||||||
@@ -771,10 +1139,25 @@ def create_lower_mouth_mask(
|
|||||||
# print("Warning: Could not calculate valid center for mouth mask.")
|
# print("Warning: Could not calculate valid center for mouth mask.")
|
||||||
return mask, mouth_cutout, mouth_box, lower_lip_polygon
|
return mask, mouth_cutout, mouth_box, lower_lip_polygon
|
||||||
|
|
||||||
|
# Drive expansion from the Mouth Mask slider so it actually responds.
|
||||||
|
# The known-good version expanded by the now-unused mask_down_size
|
||||||
|
# constant, which is why the slider had no effect.
|
||||||
|
# s: 0.0 (slider ~0, tight lip outline) -> 1.0 (slider 100, mouth->chin).
|
||||||
|
mouth_mask_size = getattr(modules.globals, "mouth_mask_size", 0.0) # 0-100 slider
|
||||||
|
s = max(0.0, min(1.0, mouth_mask_size / 100.0))
|
||||||
|
|
||||||
mask_down_size = getattr(modules.globals, "mask_down_size", 0.1) # Default 0.1
|
# Uniformly scaling a simple polygon about its centroid keeps it simple
|
||||||
expansion_factor = 1 + mask_down_size
|
# (no self-intersection). x grows with expansion_factor; points below
|
||||||
expanded_landmarks = (lower_lip_landmarks - center) * expansion_factor + center
|
# centre (toward the chin) also get an extra downward stretch so high
|
||||||
|
# slider values reach from the mouth down to the chin.
|
||||||
|
expansion_factor = 1.0 + s * 2.0 # 1.0x -> 3.0x
|
||||||
|
chin_bias = 1.0 + s * 2.0 # extra downward stretch
|
||||||
|
offsets = lower_lip_landmarks - center
|
||||||
|
scale_y = np.where(offsets[:, 1] > 0,
|
||||||
|
expansion_factor * chin_bias, expansion_factor)
|
||||||
|
expanded_landmarks = lower_lip_landmarks.copy()
|
||||||
|
expanded_landmarks[:, 0] = center[0] + offsets[:, 0] * expansion_factor
|
||||||
|
expanded_landmarks[:, 1] = center[1] + offsets[:, 1] * scale_y
|
||||||
|
|
||||||
# Ensure landmarks are finite after adjustments
|
# Ensure landmarks are finite after adjustments
|
||||||
if not np.all(np.isfinite(expanded_landmarks)):
|
if not np.all(np.isfinite(expanded_landmarks)):
|
||||||
@@ -881,8 +1264,8 @@ def draw_mouth_mask_visualization(
|
|||||||
print(f"Error drawing polygon for visualization: {e}") # Optional debug
|
print(f"Error drawing polygon for visualization: {e}") # Optional debug
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Optional: Draw bounding box (red rectangle)
|
# Draw bounding box (red rectangle)
|
||||||
# cv2.rectangle(vis_frame, (min_x, min_y), (max_x, max_y), (0, 0, 255), 1)
|
cv2.rectangle(vis_frame, (min_x, min_y), (max_x, max_y), (0, 0, 255), 2)
|
||||||
|
|
||||||
# Optional: Add labels
|
# Optional: Add labels
|
||||||
label_pos_y = min_y - 10 if min_y > 20 else max_y + 15 # Adjust position based on box location
|
label_pos_y = min_y - 10 if min_y > 20 else max_y + 15 # Adjust position based on box location
|
||||||
@@ -962,85 +1345,34 @@ def apply_mouth_area(
|
|||||||
# print("Warning: Mouth cutout is invalid after resize attempt.")
|
# print("Warning: Mouth cutout is invalid after resize attempt.")
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
# --- Color Correction Step ---
|
|
||||||
# Apply color transfer from ROI (swapped face region) to the original mouth cutout
|
|
||||||
# This helps match lighting/color before blending
|
|
||||||
color_corrected_mouth = resized_mouth_cutout # Default to resized if correction fails
|
|
||||||
try:
|
|
||||||
# Ensure both images are 3 channels for color transfer
|
|
||||||
if len(resized_mouth_cutout.shape) == 3 and resized_mouth_cutout.shape[2] == 3 and \
|
|
||||||
len(roi.shape) == 3 and roi.shape[2] == 3:
|
|
||||||
color_corrected_mouth = apply_color_transfer(resized_mouth_cutout, roi)
|
|
||||||
else:
|
|
||||||
# print("Warning: Cannot apply color transfer, images not BGR.")
|
|
||||||
pass
|
|
||||||
except cv2.error as ct_e: # Handle potential errors in color transfer
|
|
||||||
# print(f"Warning: Color transfer failed: {ct_e}. Using uncorrected mouth cutout.") # Optional debug
|
|
||||||
pass
|
|
||||||
except Exception as ct_gen_e:
|
|
||||||
# print(f"Warning: Unexpected error during color transfer: {ct_gen_e}")
|
|
||||||
pass
|
|
||||||
# --- End Color Correction ---
|
|
||||||
|
|
||||||
|
|
||||||
# --- Mask Creation ---
|
# --- Mask Creation ---
|
||||||
# Create a mask based *specifically* on the mouth_polygon, relative to the ROI
|
# Create a mask based on the mouth_polygon, relative to the ROI
|
||||||
polygon_mask_roi = np.zeros(roi.shape[:2], dtype=np.uint8)
|
polygon_mask_roi = np.zeros(roi.shape[:2], dtype=np.uint8)
|
||||||
# Adjust polygon coordinates relative to the ROI's top-left corner
|
|
||||||
adjusted_polygon = mouth_polygon - [min_x, min_y]
|
adjusted_polygon = mouth_polygon - [min_x, min_y]
|
||||||
# Draw the filled polygon on the ROI mask
|
|
||||||
cv2.fillPoly(polygon_mask_roi, [adjusted_polygon.astype(np.int32)], 255)
|
cv2.fillPoly(polygon_mask_roi, [adjusted_polygon.astype(np.int32)], 255)
|
||||||
|
|
||||||
# Feather the polygon mask (Gaussian blur)
|
# Feather the edges with Gaussian blur for smooth blending
|
||||||
mask_feather_ratio = getattr(modules.globals, "mask_feather_ratio", 12) # Default 12
|
feather_amount = max(1, min(30, min(box_width, box_height) // 8))
|
||||||
# Calculate feather amount based on the smaller dimension of the box
|
|
||||||
feather_base_dim = min(box_width, box_height)
|
|
||||||
feather_amount = max(1, min(30, feather_base_dim // max(1, mask_feather_ratio))) # Avoid div by zero
|
|
||||||
# Ensure kernel size is odd and positive
|
|
||||||
kernel_size = 2 * feather_amount + 1
|
kernel_size = 2 * feather_amount + 1
|
||||||
feathered_polygon_mask = cv2.GaussianBlur(polygon_mask_roi.astype(np.float32), (kernel_size, kernel_size), 0)
|
feathered_mask = cv2.GaussianBlur(polygon_mask_roi.astype(np.float32), (kernel_size, kernel_size), 0)
|
||||||
|
|
||||||
# Normalize feathered mask to [0.0, 1.0] range
|
# Normalize to [0.0, 1.0]
|
||||||
max_val = feathered_polygon_mask.max()
|
max_val = feathered_mask.max()
|
||||||
if max_val > 1e-6: # Avoid division by zero
|
if max_val > 1e-6:
|
||||||
feathered_polygon_mask = feathered_polygon_mask / max_val
|
feathered_mask = feathered_mask / max_val
|
||||||
else:
|
else:
|
||||||
feathered_polygon_mask.fill(0.0) # Mask is all black if max is near zero
|
feathered_mask.fill(0.0)
|
||||||
# --- End Mask Creation ---
|
|
||||||
|
|
||||||
|
# --- Blending: paste original mouth onto swapped face ---
|
||||||
# --- Refined Blending ---
|
|
||||||
# Get the corresponding ROI from the *full face mask* (already blurred)
|
|
||||||
# Ensure face_mask is float and normalized [0.0, 1.0]
|
|
||||||
if face_mask.dtype != np.float64 and face_mask.dtype != np.float32:
|
|
||||||
face_mask_float = face_mask.astype(np.float32) / 255.0
|
|
||||||
else: # Assume already float [0,1] if type is float
|
|
||||||
face_mask_float = face_mask.astype(np.float32) if face_mask.dtype == np.float64 else face_mask
|
|
||||||
face_mask_roi = face_mask_float[min_y:max_y, min_x:max_x]
|
|
||||||
|
|
||||||
# Combine the feathered mouth polygon mask with the face mask ROI
|
|
||||||
# Use minimum to ensure we only affect area inside both masks (mouth area within face)
|
|
||||||
# This helps blend the edges smoothly with the surrounding swapped face region
|
|
||||||
combined_mask = np.minimum(feathered_polygon_mask, face_mask_roi)
|
|
||||||
|
|
||||||
# Expand mask to 3 channels for blending (ensure it matches image channels)
|
|
||||||
if len(frame.shape) == 3 and frame.shape[2] == 3:
|
if len(frame.shape) == 3 and frame.shape[2] == 3:
|
||||||
combined_mask_3channel = combined_mask[:, :, np.newaxis]
|
mask_3ch = feathered_mask[:, :, np.newaxis].astype(np.float32)
|
||||||
|
inv_mask = 1.0 - mask_3ch
|
||||||
|
|
||||||
# Ensure data types are compatible for blending
|
# Blend: (original_mouth * mask) + (swapped_face * (1 - mask))
|
||||||
# float32 provides sufficient precision for 8-bit image blending
|
blended_roi = (resized_mouth_cutout.astype(np.float32) * mask_3ch +
|
||||||
combined_mask_f32 = combined_mask_3channel.astype(np.float32)
|
roi.astype(np.float32) * inv_mask)
|
||||||
inv_mask = np.float32(1.0) - combined_mask_f32
|
|
||||||
|
|
||||||
# Blend: (original_mouth * combined_mask) + (swapped_face_roi * (1 - combined_mask))
|
frame[min_y:max_y, min_x:max_x] = np.clip(blended_roi, 0, 255).astype(np.uint8)
|
||||||
blended_roi = (color_corrected_mouth * combined_mask_f32 +
|
|
||||||
roi * inv_mask)
|
|
||||||
|
|
||||||
# Place the blended ROI back into the frame
|
|
||||||
frame[min_y:max_y, min_x:max_x] = blended_roi.astype(np.uint8)
|
|
||||||
else:
|
|
||||||
# print("Warning: Cannot apply mouth mask blending, frame is not 3-channel BGR.")
|
|
||||||
pass # Don't modify frame if it's not BGR
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error applying mouth area: {e}") # Optional debug
|
print(f"Error applying mouth area: {e}") # Optional debug
|
||||||
@@ -1053,10 +1385,13 @@ def apply_mouth_area(
|
|||||||
|
|
||||||
def create_face_mask(face: Face, frame: Frame) -> np.ndarray:
|
def create_face_mask(face: Face, frame: Frame) -> np.ndarray:
|
||||||
"""Creates a feathered mask covering the whole face area based on landmarks."""
|
"""Creates a feathered mask covering the whole face area based on landmarks."""
|
||||||
|
if frame is None or not hasattr(frame, "shape") or len(frame.shape) < 2:
|
||||||
|
return np.zeros((0, 0), dtype=np.uint8)
|
||||||
|
|
||||||
mask = np.zeros(frame.shape[:2], dtype=np.uint8) # Start with uint8
|
mask = np.zeros(frame.shape[:2], dtype=np.uint8) # Start with uint8
|
||||||
|
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
if face is None or not hasattr(face, 'landmark_2d_106') or frame is None:
|
if face is None or not hasattr(face, 'landmark_2d_106'):
|
||||||
# print("Warning: Invalid face or frame for create_face_mask.")
|
# print("Warning: Invalid face or frame for create_face_mask.")
|
||||||
return mask # Return empty mask
|
return mask # Return empty mask
|
||||||
|
|
||||||
@@ -1223,4 +1558,4 @@ def apply_color_transfer(source, target):
|
|||||||
# traceback.print_exc()
|
# traceback.print_exc()
|
||||||
return source
|
return source
|
||||||
|
|
||||||
return result_bgr
|
return result_bgr
|
||||||
|
|||||||
+1387
-1372
File diff suppressed because it is too large
Load Diff
+43
-6
@@ -30,8 +30,12 @@ def run_ffmpeg(args: List[str]) -> bool:
|
|||||||
try:
|
try:
|
||||||
subprocess.check_output(commands, stderr=subprocess.STDOUT)
|
subprocess.check_output(commands, stderr=subprocess.STDOUT)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except subprocess.CalledProcessError as error:
|
||||||
pass
|
output = error.output.decode(errors="ignore").strip()
|
||||||
|
if output:
|
||||||
|
print(output)
|
||||||
|
except Exception as error:
|
||||||
|
print(f"ffmpeg execution failed: {error}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -61,19 +65,19 @@ def extract_frames(target_path: str) -> None:
|
|||||||
"""Extract frames with hardware acceleration and optimized settings."""
|
"""Extract frames with hardware acceleration and optimized settings."""
|
||||||
temp_directory_path = get_temp_directory_path(target_path)
|
temp_directory_path = get_temp_directory_path(target_path)
|
||||||
|
|
||||||
# Use hardware-accelerated decoding and optimized pixel format
|
# Write a contiguous image sequence so the later "%04d.png" input pattern
|
||||||
|
# used during encoding can consume every frame reliably.
|
||||||
run_ffmpeg(
|
run_ffmpeg(
|
||||||
[
|
[
|
||||||
"-i", target_path,
|
"-i", target_path,
|
||||||
"-vf", "format=rgb24", # Use video filter for format conversion (faster)
|
"-vf", "format=rgb24", # Use video filter for format conversion (faster)
|
||||||
"-vsync", "0", # Prevent frame duplication
|
"-vsync", "0", # Prevent frame duplication
|
||||||
"-frame_pts", "1", # Preserve frame timing
|
|
||||||
os.path.join(temp_directory_path, "%04d.png"),
|
os.path.join(temp_directory_path, "%04d.png"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_video(target_path: str, fps: float = 30.0) -> None:
|
def create_video(target_path: str, fps: float = 30.0) -> bool:
|
||||||
"""Create video with hardware-accelerated encoding and optimized settings."""
|
"""Create video with hardware-accelerated encoding and optimized settings."""
|
||||||
temp_output_path = get_temp_output_path(target_path)
|
temp_output_path = get_temp_output_path(target_path)
|
||||||
temp_directory_path = get_temp_directory_path(target_path)
|
temp_directory_path = get_temp_directory_path(target_path)
|
||||||
@@ -182,7 +186,8 @@ def create_video(target_path: str, fps: float = 30.0) -> None:
|
|||||||
"-y",
|
"-y",
|
||||||
temp_output_path,
|
temp_output_path,
|
||||||
]
|
]
|
||||||
run_ffmpeg(ffmpeg_args_fallback)
|
success = run_ffmpeg(ffmpeg_args_fallback)
|
||||||
|
return success and os.path.isfile(temp_output_path)
|
||||||
|
|
||||||
|
|
||||||
def restore_audio(target_path: str, output_path: str) -> None:
|
def restore_audio(target_path: str, output_path: str) -> None:
|
||||||
@@ -309,3 +314,35 @@ def conditional_download(download_directory_path: str, urls: List[str]) -> None:
|
|||||||
|
|
||||||
def resolve_relative_path(path: str) -> str:
|
def resolve_relative_path(path: str) -> str:
|
||||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), path))
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), path))
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_dimensions(target_path: str) -> tuple:
|
||||||
|
"""Get video width and height using ffprobe."""
|
||||||
|
command = [
|
||||||
|
"ffprobe", "-v", "error",
|
||||||
|
"-select_streams", "v:0",
|
||||||
|
"-show_entries", "stream=width,height",
|
||||||
|
"-of", "csv=p=0:s=x",
|
||||||
|
target_path,
|
||||||
|
]
|
||||||
|
output = subprocess.check_output(command).decode().strip()
|
||||||
|
width, height = map(int, output.split("x"))
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_frame_count(target_path: str, fps: float = None) -> int:
|
||||||
|
"""Estimate total frame count from video duration and fps."""
|
||||||
|
if fps is None:
|
||||||
|
fps = detect_fps(target_path)
|
||||||
|
command = [
|
||||||
|
"ffprobe", "-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "csv=p=0",
|
||||||
|
target_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(command).decode().strip()
|
||||||
|
duration = float(output)
|
||||||
|
return int(duration * fps)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|||||||
+76
-10
@@ -1,5 +1,7 @@
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
from typing import Optional, Tuple, Callable
|
from typing import Optional, Tuple, Callable
|
||||||
import platform
|
import platform
|
||||||
import threading
|
import threading
|
||||||
@@ -17,6 +19,10 @@ class VideoCapturer:
|
|||||||
self._frame_ready = threading.Event()
|
self._frame_ready = threading.Event()
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.cap = None
|
self.cap = None
|
||||||
|
# Actual values reported by the camera after configuration
|
||||||
|
self.actual_width: int = 0
|
||||||
|
self.actual_height: int = 0
|
||||||
|
self.actual_fps: float = 0.0
|
||||||
|
|
||||||
# Initialize Windows-specific components if on Windows
|
# Initialize Windows-specific components if on Windows
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
@@ -32,17 +38,35 @@ class VideoCapturer:
|
|||||||
"""Initialize and start video capture"""
|
"""Initialize and start video capture"""
|
||||||
try:
|
try:
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
# Windows-specific capture methods
|
# device_index comes from pygrabber.FilterGraph (DirectShow
|
||||||
|
# enumeration), so open with DSHOW first to preserve mapping.
|
||||||
|
# MSMF and DirectShow enumerate cameras in different orders, so
|
||||||
|
# opening MSMF with a DSHOW index silently selects the wrong
|
||||||
|
# camera. MSMF/ANY remain as fallbacks for cameras DSHOW can't
|
||||||
|
# open.
|
||||||
|
#
|
||||||
|
# Pass codec + resolution + fps as construction params (OpenCV
|
||||||
|
# 4.6+). DSHOW locks the pixel format at open time and ignores
|
||||||
|
# later cap.set(CAP_PROP_FOURCC, ...) — without this, DSHOW
|
||||||
|
# falls back to uncompressed YUYV at 1080p, which is USB-
|
||||||
|
# bandwidth-limited to ~5 fps. Setting MJPG at construction
|
||||||
|
# negotiates compressed frames from the first read.
|
||||||
|
mjpg = cv2.VideoWriter_fourcc(*'MJPG')
|
||||||
|
open_params = [
|
||||||
|
cv2.CAP_PROP_FOURCC, mjpg,
|
||||||
|
cv2.CAP_PROP_FRAME_WIDTH, width,
|
||||||
|
cv2.CAP_PROP_FRAME_HEIGHT, height,
|
||||||
|
cv2.CAP_PROP_FPS, fps,
|
||||||
|
]
|
||||||
capture_methods = [
|
capture_methods = [
|
||||||
(self.device_index, cv2.CAP_DSHOW), # Try DirectShow first
|
(self.device_index, cv2.CAP_DSHOW),
|
||||||
(self.device_index, cv2.CAP_ANY), # Then try default backend
|
(self.device_index, cv2.CAP_MSMF),
|
||||||
(-1, cv2.CAP_ANY), # Try -1 as fallback
|
(self.device_index, cv2.CAP_ANY),
|
||||||
(0, cv2.CAP_ANY), # Finally try 0 without specific backend
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for dev_id, backend in capture_methods:
|
for dev_id, backend in capture_methods:
|
||||||
try:
|
try:
|
||||||
self.cap = cv2.VideoCapture(dev_id, backend)
|
self.cap = cv2.VideoCapture(dev_id, backend, open_params)
|
||||||
if self.cap.isOpened():
|
if self.cap.isOpened():
|
||||||
break
|
break
|
||||||
self.cap.release()
|
self.cap.release()
|
||||||
@@ -55,10 +79,29 @@ class VideoCapturer:
|
|||||||
if not self.cap or not self.cap.isOpened():
|
if not self.cap or not self.cap.isOpened():
|
||||||
raise RuntimeError("Failed to open camera")
|
raise RuntimeError("Failed to open camera")
|
||||||
|
|
||||||
# Configure format
|
# Belt-and-braces: also set via cap.set() for backends that honor
|
||||||
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
# post-open changes (MSMF, V4L2). DSHOW ignores these, but the
|
||||||
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
# construction params above already handled it.
|
||||||
self.cap.set(cv2.CAP_PROP_FPS, fps)
|
if platform.system() != "Windows":
|
||||||
|
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
|
||||||
|
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
||||||
|
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
||||||
|
self.cap.set(cv2.CAP_PROP_FPS, fps)
|
||||||
|
|
||||||
|
# Read back resolution (usually reliable)
|
||||||
|
self.actual_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
self.actual_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
|
||||||
|
# CAP_PROP_FPS is unreliable on DirectShow — often reports 30
|
||||||
|
# even when the camera delivers 60. Measure empirically by
|
||||||
|
# timing a burst of frames.
|
||||||
|
reported_fps = self.cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
self.actual_fps = self._measure_fps(warmup=10, sample=30,
|
||||||
|
fallback=reported_fps or fps)
|
||||||
|
|
||||||
|
print(f"[VideoCapturer] {self.actual_width}x{self.actual_height} "
|
||||||
|
f"@ {self.actual_fps:.1f}fps (reported={reported_fps:.0f})",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
return True
|
return True
|
||||||
@@ -89,6 +132,29 @@ class VideoCapturer:
|
|||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.cap = None
|
self.cap = None
|
||||||
|
|
||||||
|
def _measure_fps(self, warmup: int = 10, sample: int = 30,
|
||||||
|
fallback: float = 30.0) -> float:
|
||||||
|
"""Read warmup+sample frames and return measured FPS.
|
||||||
|
|
||||||
|
This is more reliable than CAP_PROP_FPS which often lies on
|
||||||
|
DirectShow. Takes ~0.5-1s at startup but gives a ground-truth
|
||||||
|
number for adaptive polling/detection intervals.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for _ in range(warmup):
|
||||||
|
self.cap.read()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for _ in range(sample):
|
||||||
|
ret, _ = self.cap.read()
|
||||||
|
if not ret:
|
||||||
|
return fallback
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
if elapsed <= 0:
|
||||||
|
return fallback
|
||||||
|
return sample / elapsed
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
def set_frame_callback(self, callback: Callable[[np.ndarray], None]) -> None:
|
def set_frame_callback(self, callback: Callable[[np.ndarray], None]) -> None:
|
||||||
"""Set callback for frame processing"""
|
"""Set callback for frame processing"""
|
||||||
self.frame_callback = callback
|
self.frame_callback = callback
|
||||||
|
|||||||
+6
-5
@@ -5,12 +5,13 @@ cv2_enumerate_cameras==1.1.15
|
|||||||
onnx==1.18.0
|
onnx==1.18.0
|
||||||
insightface==0.7.3
|
insightface==0.7.3
|
||||||
psutil==5.9.8
|
psutil==5.9.8
|
||||||
tk==0.1.0
|
PySide6>=6.7,<7
|
||||||
customtkinter==5.2.2
|
|
||||||
pillow==12.1.1
|
pillow==12.1.1
|
||||||
|
tqdm>=4.65.0
|
||||||
onnxruntime-silicon==1.16.3; sys_platform == 'darwin' and platform_machine == 'arm64'
|
onnxruntime-silicon==1.16.3; sys_platform == 'darwin' and platform_machine == 'arm64'
|
||||||
onnxruntime-gpu==1.24.2; sys_platform != 'darwin'
|
onnxruntime-gpu==1.23.2; sys_platform != 'darwin'
|
||||||
tensorflow; sys_platform != 'darwin'
|
tensorflow>=2.15.0; sys_platform != 'darwin'
|
||||||
|
tensorflow>=2.15.0; sys_platform == 'darwin' and python_version < '3.13'
|
||||||
opennsfw2==0.10.2
|
opennsfw2==0.10.2
|
||||||
protobuf==4.25.1
|
protobuf==4.25.1
|
||||||
pygrabber
|
pygrabber; sys_platform == 'win32'
|
||||||
|
|||||||
@@ -1,7 +1,73 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
# Import the tkinter fix to patch the ScreenChanged error
|
import os
|
||||||
import tkinter_fix
|
import sys
|
||||||
|
|
||||||
|
# Add the project root to PATH so bundled ffmpeg/ffprobe are found
|
||||||
|
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
os.environ["PATH"] = project_root + os.pathsep + os.environ.get("PATH", "")
|
||||||
|
|
||||||
|
# On Windows, register NVIDIA CUDA DLL directories so onnxruntime-gpu can
|
||||||
|
# find cuDNN/cublas. Python 3.8+ ignores PATH for extension-module native deps —
|
||||||
|
# os.add_dll_directory() is required. Also keep PATH for child processes/ffmpeg.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
_site_packages = os.path.join(sys.prefix, "Lib", "site-packages")
|
||||||
|
_venv_site_packages = os.path.join(project_root, "venv", "Lib", "site-packages")
|
||||||
|
for _sp in (_site_packages, _venv_site_packages):
|
||||||
|
_candidate_dirs = []
|
||||||
|
_torch_lib = os.path.join(_sp, "torch", "lib")
|
||||||
|
if os.path.isdir(_torch_lib):
|
||||||
|
_candidate_dirs.append(_torch_lib)
|
||||||
|
_nvidia_dir = os.path.join(_sp, "nvidia")
|
||||||
|
if os.path.isdir(_nvidia_dir):
|
||||||
|
for _pkg in os.listdir(_nvidia_dir):
|
||||||
|
_bin_dir = os.path.join(_nvidia_dir, _pkg, "bin")
|
||||||
|
if os.path.isdir(_bin_dir):
|
||||||
|
_candidate_dirs.append(_bin_dir)
|
||||||
|
for _d in _candidate_dirs:
|
||||||
|
os.environ["PATH"] = _d + os.pathsep + os.environ["PATH"]
|
||||||
|
try:
|
||||||
|
os.add_dll_directory(_d)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# On Linux, pre-load NVIDIA shared libraries (cuDNN, cuBLAS, nvrtc...) shipped
|
||||||
|
# inside the venv via pip wheels (nvidia-cudnn-cu12, etc.). LD_LIBRARY_PATH
|
||||||
|
# cannot be set after Python starts, so we use ctypes.CDLL with RTLD_GLOBAL
|
||||||
|
# instead. This makes symbols available to onnxruntime when it dlopens its
|
||||||
|
# CUDA provider.
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
import ctypes
|
||||||
|
import glob
|
||||||
|
_py_lib = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
||||||
|
_site_packages_candidates = [
|
||||||
|
os.path.join(project_root, "venv", "lib", _py_lib, "site-packages"),
|
||||||
|
os.path.join(sys.prefix, "lib", _py_lib, "site-packages"),
|
||||||
|
]
|
||||||
|
for _sp in _site_packages_candidates:
|
||||||
|
_nvidia_dir = os.path.join(_sp, "nvidia")
|
||||||
|
if not os.path.isdir(_nvidia_dir):
|
||||||
|
continue
|
||||||
|
for _pkg in os.listdir(_nvidia_dir):
|
||||||
|
_lib_dir = os.path.join(_nvidia_dir, _pkg, "lib")
|
||||||
|
if not os.path.isdir(_lib_dir):
|
||||||
|
continue
|
||||||
|
# Also expose the directory to child processes, without
|
||||||
|
# duplicating an entry that is already present.
|
||||||
|
_ldp = os.environ.get("LD_LIBRARY_PATH", "")
|
||||||
|
if _lib_dir not in _ldp.split(os.pathsep):
|
||||||
|
os.environ["LD_LIBRARY_PATH"] = (
|
||||||
|
_lib_dir + (os.pathsep + _ldp if _ldp else "")
|
||||||
|
)
|
||||||
|
for _so in sorted(glob.glob(os.path.join(_lib_dir, "lib*.so*"))):
|
||||||
|
try:
|
||||||
|
ctypes.CDLL(_so, mode=ctypes.RTLD_GLOBAL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
from modules import platform_info
|
||||||
|
platform_info.print_banner()
|
||||||
|
|
||||||
from modules import core
|
from modules import core
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
def _install_import_stubs():
|
||||||
|
sys.modules.setdefault(
|
||||||
|
"insightface",
|
||||||
|
types.SimpleNamespace(app=types.SimpleNamespace(FaceAnalysis=object)),
|
||||||
|
)
|
||||||
|
sys.modules.setdefault(
|
||||||
|
"cv2",
|
||||||
|
types.SimpleNamespace(
|
||||||
|
IMREAD_COLOR=1,
|
||||||
|
imread=lambda *_args, **_kwargs: None,
|
||||||
|
imdecode=lambda *_args, **_kwargs: None,
|
||||||
|
imencode=lambda *_args, **_kwargs: (
|
||||||
|
True,
|
||||||
|
types.SimpleNamespace(tofile=lambda *_a, **_k: None),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sys.modules.setdefault(
|
||||||
|
"numpy",
|
||||||
|
types.SimpleNamespace(uint8=object, fromfile=lambda *_args, **_kwargs: b""),
|
||||||
|
)
|
||||||
|
sys.modules.setdefault(
|
||||||
|
"tqdm",
|
||||||
|
types.SimpleNamespace(tqdm=lambda iterable, **_kwargs: iterable),
|
||||||
|
)
|
||||||
|
sys.modules["modules.typing"] = types.SimpleNamespace(Frame=object)
|
||||||
|
sys.modules["modules.cluster_analysis"] = types.SimpleNamespace(
|
||||||
|
find_cluster_centroids=lambda *args, **kwargs: [],
|
||||||
|
find_closest_centroid=lambda *args, **kwargs: (0, None),
|
||||||
|
)
|
||||||
|
sys.modules["modules.utilities"] = types.SimpleNamespace(
|
||||||
|
get_temp_directory_path=lambda path: path,
|
||||||
|
create_temp=lambda path: None,
|
||||||
|
extract_frames=lambda path: None,
|
||||||
|
clean_temp=lambda path: None,
|
||||||
|
get_temp_frame_paths=lambda path: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_face_analyser():
|
||||||
|
_install_import_stubs()
|
||||||
|
sys.modules.pop("modules.face_analyser", None)
|
||||||
|
return importlib.import_module("modules.face_analyser")
|
||||||
|
|
||||||
|
|
||||||
|
class Face:
|
||||||
|
def __init__(self, left):
|
||||||
|
self.bbox = [left, 0, 10, 10]
|
||||||
|
|
||||||
|
|
||||||
|
class GetOneFaceTests(unittest.TestCase):
|
||||||
|
def test_uses_supplied_detected_faces_without_reanalysing_frame(self):
|
||||||
|
face_analyser = _load_face_analyser()
|
||||||
|
right = Face(20)
|
||||||
|
left = Face(5)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
face_analyser,
|
||||||
|
"_analyse_faces",
|
||||||
|
side_effect=AssertionError("should not analyse"),
|
||||||
|
):
|
||||||
|
self.assertIs(face_analyser.get_one_face("frame", [right, left]), left)
|
||||||
|
|
||||||
|
def test_supplied_empty_detected_faces_returns_none(self):
|
||||||
|
face_analyser = _load_face_analyser()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
face_analyser,
|
||||||
|
"_analyse_faces",
|
||||||
|
side_effect=AssertionError("should not analyse"),
|
||||||
|
):
|
||||||
|
self.assertIsNone(face_analyser.get_one_face("frame", []))
|
||||||
|
|
||||||
|
def test_without_supplied_faces_preserves_existing_detection_path(self):
|
||||||
|
face_analyser = _load_face_analyser()
|
||||||
|
right = Face(30)
|
||||||
|
left = Face(3)
|
||||||
|
|
||||||
|
with patch.object(face_analyser, "_is_dml", return_value=False), patch.object(
|
||||||
|
face_analyser,
|
||||||
|
"_analyse_faces",
|
||||||
|
return_value=[right, left],
|
||||||
|
) as analyse:
|
||||||
|
self.assertIs(face_analyser.get_one_face("frame"), left)
|
||||||
|
|
||||||
|
analyse.assert_called_once_with("frame")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import os
|
||||||
|
os.environ.setdefault('TK_SILENCE_DEPRECATION', '1')
|
||||||
|
|
||||||
import tkinter
|
import tkinter
|
||||||
|
|
||||||
# Only needs to be imported once at the beginning of the application
|
# Only needs to be imported once at the beginning of the application
|
||||||
|
|||||||
Reference in New Issue
Block a user