Compare commits

...
75 Commits
Author SHA1 Message Date
Kenneth Estanislao a21ccf488c Update version number in README.md to 2.1.6 2026-05-18 04:15:01 +08:00
Kenneth Estanislao ca8e39e3bb Fix mouth mask 2026-05-18 02:11:04 +08:00
Kenneth Estanislao 0e97e474e4 better swapping 2026-05-18 01:40:01 +08:00
Kenneth Estanislao 9c67a7aacc fixed poisson blend 2026-05-18 01:36:24 +08:00
Dopan 4a674d33ef Merge pull request #1826 from obook/pr/preload-nvidia-libs-linux
Pre-load NVIDIA shared libraries on Linux
2026-05-17 00:04:06 +08:00
Olivier Booklage 682450755f Avoid duplicating LD_LIBRARY_PATH entries
Skip prepending a directory that is already on LD_LIBRARY_PATH, so a
repeated import of run.py does not bloat the variable.

Addresses review feedback on #1826.
2026-05-16 14:57:02 +02:00
Olivier Booklage 12a3f6a007 Pre-load NVIDIA shared libraries on Linux
Mirrors the Windows preload block from #1775. When onnxruntime-gpu is
installed via pip with nvidia-cudnn-cu12, the .so files sit under
venv/lib/pythonX.Y/site-packages/nvidia/<pkg>/lib/ and the dynamic
linker never sees them. LD_LIBRARY_PATH cannot be set after Python
starts.

Pre-loads every lib*.so* via ctypes.CDLL with RTLD_GLOBAL before
onnxruntime opens its CUDA provider. Also extends LD_LIBRARY_PATH so
child processes (ffmpeg) inherit the path.

Fixes "libcudnn.so.9: cannot open shared object file" on pip-only
Linux installs.
2026-05-16 14:45:54 +02:00
Kenneth Estanislao cede099ccb Update version number in README.md to 2.1.5 2026-05-15 16:33:57 +08:00
Kenneth Estanislao 81a1986ef8 Changed to pyqtUI
Standardizing the UI from quickstart to github version
2026-05-15 16:33:27 +08:00
Kenneth Estanislao ed758eb693 Speed optimization 2026-05-15 15:53:55 +08:00
Kenneth Estanislao 9c5f01c7f1 some fix for face enhancers 2026-05-15 15:13:57 +08:00
Kenneth Estanislao 8bdc348779 Update .gitignore 2026-05-15 14:52:56 +08:00
Makaru e34d204c2e Merge pull request #1803 from zuyua9/fix/get-one-face-detected-faces-zuyua9
fix(face): reuse pre-detected face list

comment: tested, all good
2026-05-08 10:20:56 +08:00
zuyua9 d1376b07d1 fix(face): avoid hiding invalid face inputs 2026-05-08 01:50:25 +08:00
zuyua9 5deadaf428 fix(face): reuse pre-detected face list 2026-05-08 01:35:55 +08:00
Kenneth Estanislao 2fba52e11b Merge pull request #1782 from iikuzmychov/fix/black-border-paste-back 2026-04-29 22:31:09 +08:00
Ihor Kuzmychov 0926b65aaf Merge branch 'hacksider:main' into fix/black-border-paste-back 2026-04-23 19:58:12 +02:00
Ihor Kuzmychov 297acded3b fix: use BORDER_REPLICATE for face warp to eliminate black border 2026-04-23 19:42:32 +02:00
KRSHH 014bce0704 Delete PERFORMANCE.md
Removing Claude session summary
2026-04-23 22:12:55 +05:30
KRSHH c962399669 Delete REVIEW_TODOS.md 2026-04-23 22:11:53 +05:30
Kenneth Estanislao 2dd42dfc75 Merge pull request #1777 from maxwbuckley/coreml-scalar-gather-fix
Keep GFPGAN on ANE: widen scalar Gather indices for CoreML EP
2026-04-22 22:17:34 +08:00
Kenneth Estanislao c38d669f7c Merge pull request #1776 from maxwbuckley/paste-back-optimization
Paste-back: O(crop_area) compositing + uint8 cv2 SIMD blend
2026-04-22 22:14:45 +08:00
Max Buckley 890a6d41b6 onnx_optimize: widen scalar Gather indices for CoreML EP
ORT's CoreML EP GatherOpBuilder::IsOpSupportedImpl explicitly rejects
rank-0 (scalar) index tensors. StyleGAN-derived models (GFPGAN's 1024
variant has 16 of them, one per style-code slice) hit this in the
generator, and the resulting CPU fallbacks split the CoreML subgraph
into multiple partitions with boundary crossings on every inference.

Add a load-time ONNX rewrite that promotes each scalar index to [1] and
squeezes the added axis on the Gather output — semantically identical
but CoreML-compatible. GFPGAN now runs as a single CoreML partition with
zero CPU-fallback nodes; inference drops from ~87 ms to ~81 ms on an
M-series Mac.

The fix has been filed upstream as microsoft/onnxruntime#28180 — the
existing code comment in gather_op_builder.cc already describes this
exact workaround, it just isn't applied. Once the upstream fix ships
and the ORT floor is raised, this pass can be deleted.
2026-04-22 14:08:18 +02:00
Max Buckley f95a0bb7fb Make square aligned-face assumption explicit in _fast_paste_back
Addresses Sourcery feedback on PR #1776: _get_soft_alpha caches a single
NxN template keyed by N, which is correct for the inswapper model
(128x128 aligned-face space) but would silently mis-warp if a caller
ever passed a non-square aligned face. Assert the shape instead of
silently assuming it.
2026-04-22 13:40:18 +02:00
Max Buckley e957a7f4dd Move BGR→RGB after resize in preview display path
The processing thread was running cvtColor on the full-resolution 1920×1080
frame before queueing it for display. Since the display thread immediately
resizes the frame to the preview window (~5× smaller pixel count), doing
the colour conversion on the resized buffer is cheaper overall.

Processing thread now queues BGR; display thread resizes then cvtColor.
2026-04-22 13:31:11 +02:00
Kenneth Estanislao 19416cb3cb Merge pull request #1775 from maxwbuckley/unify-mac-windows
Apple Silicon + Windows CUDA perf: 4-5x FPS, wider capture, platform routing
2026-04-22 18:38:32 +08:00
Max Buckley cbf0859347 Paste-back blend: uint8 cv2 SIMD, no float32 round-trip
Both face_swapper._fast_paste_back and face_enhancer._paste_back were
doing a numpy float32 round-trip per frame: convert the target crop and
the warped face to float32, blend, clip, cast back to uint8. That's four
crop-sized allocations plus unvectorized elementwise math.

Replace with a fused uint8 blend using cv2.merge + cv2.multiply + cv2.add,
which cv2 dispatches to SIMD (NEON on Apple Silicon / AVX on x86). Stored
alpha templates switched from float32 [0, 1] to uint8 [0, 255] so no
conversion is needed per frame. CUDA paths also simplified — upload uint8
alpha (less bandwidth) and scale on device.

Micro-bench on 1000x1000 RGB crop:
  current (float32 numpy): 9.43 ms
  cv2 uint8 fused:         1.16 ms  (8.1× faster, max diff 2/255)

Visual diff is imperceptible (quantization noise in the last step).
2026-04-22 12:05:39 +02:00
Max Buckley a6c99607fc Cut paste-back from quartic to linear in face size
_fast_paste_back used to erode and Gaussian-blur the warped alpha mask in
output coordinates with kernel sizes proportional to the on-screen face
bbox. That made the per-frame cost ~O(area * k^2) — a face filling half
the frame took ~8x the compositing work of one filling a quarter, which
is why FPS fell off when leaning into the camera.

Instead, build a feathered alpha template once at aligned-face resolution
(128x128 for inswapper) and warp the soft mask per-frame. The affine
transform preserves the relative feather width, so the visual output is
equivalent; the per-frame cost is now O(crop_area) with no size-scaled
erode/blur and no size-scaled padding.

Also collapses the CPU fallback onto the same shape — it previously did
a full-frame warpAffine twice per call, which scaled with the whole
frame instead of the face crop.
2026-04-22 11:58:02 +02:00
Max BuckleyandClaude Opus 4.7 0a87d63560 Address PR #1775 review: pipelined-detection race and CUDA-graph monkey-patch
- core._run_pipe_pipeline: hand the background detector its own copy of
  the frame. The frame processors mutate in place via paste-back, which
  was racing with concurrent face detection on the same buffer.
- face_swapper._init_cuda_graph_session: replace the
  `swapper.session.run` monkey-patch with a `_CudaGraphSessionAdapter`
  that proxies every attribute to the underlying session and only
  overrides `.run()`. Guarded so repeat init does not double-wrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:45:59 +02:00
Max BuckleyandClaude Opus 4.7 ea19030c74 Add PERFORMANCE.md and REVIEW_TODOS.md
PERFORMANCE.md documents measured gains on MacBook Pro M3 Max vs
hacksider/Deep-Live-Cam main@64d3f06:

- Face swap only:     <5 FPS  ->  >20 FPS
- Face swap + GFPGAN: <2 FPS  ->  >10 FPS
- Camera:             640x480 ->  960x540 MJPEG @ 60fps

Breaks down the contributors (camera negotiation, CoreML graph
rewrites with before/after op latencies, pipeline overlap, GFPGAN
temporal cache, paste-back optimization, platform routing, Windows
CUDA path) and how to reproduce.

REVIEW_TODOS.md captures 12 findings from two independent reviews
(Claude in-tree + Codex second opinion) grouped as Blockers /
Should-fix / Consider, each with file:line and suggested fix. The
two Blocker/Should-fix items are addressed in the preceding commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:08:33 +02:00
Max BuckleyandClaude Opus 4.7 4d04e830bc Fix CUDA-graph replay race + many_faces enhancer regression
Two issues surfaced in post-squash review of f65aeae:

1. CUDA-graph replay buffers were shared across threads with no lock.
   `_cuda_graph_swap_inference` mutates module-level ort_input/ort_latent
   and runs run_with_iobinding — concurrent swap calls on Windows/CUDA
   could overwrite each other's bound input buffers before replay,
   producing wrong-face output. Added `_cuda_graph_lock` around the
   full update/run/read sequence.

2. Face enhancer loop unconditionally broke after the first face, so
   `many_faces=True` silently enhanced only one face. Also, the
   single-slot temporal cache would paste the same enhancement onto
   every target if reused in many-faces mode. Gated the break on
   `not many_faces_mode` and disabled the cache path in that mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:08:23 +02:00
Max BuckleyandClaude Opus 4.7 f65aeae5db Apple Silicon + Windows CUDA perf: 60 FPS pipeline, cross-platform routing
Bundles CoreML graph rewrites, GPU-accelerated pipeline work, Windows CUDA
fixes, and Mac/Windows runtime routing into a single drop.

CoreML (Apple Silicon):
- Decompose Pad(reflect) → Slice+Concat in inswapper_128 so the model
  runs in one CoreML partition instead of 14 (TEMPORARY: fixed upstream
  in microsoft/onnxruntime#28073, drop when ORT >= 1.26.0).
- Fold Shape/Gather chains to constants in det_10g (21ms → 4ms).
- Decompose Split(axis=1) → Slice pairs in GFPGAN (155ms → 89ms).
- Route detection model to GPU so the ANE is free for the swap model.
- Centralize provider/config selection in create_onnx_session.

Pipeline (all platforms):
- Parallelize face landmark + recognition post-detection; skip landmark_2d_106
  when only face_swapper is active.
- Pipeline face detection with swap for ANE overlap.
- GPU-accelerated paste_back, MJPEG capture, zero-copy display path.
- Standalone pipeline benchmark script.

Windows / CUDA:
- CUDA graphs + FP16 model + all-GPU pipeline for 1080p 60 FPS.
- Auto-detect GPU provider and fix DLL discovery for Windows CUDA execution.

Cross-platform:
- platform_info helper for Mac/Windows runtime routing.
- GFPGAN 30 fps + MSMF camera 60 fps with adaptive pipeline tuning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:44:59 +02:00
KRSHH 64d3f06089 Delete tests directory 2026-04-19 17:36:33 +05:30
Kenneth Estanislao fceafcb234 Merge pull request #1751 from Gujiassh/fix/face-mask-none-frame-guard
fix(face-mask): guard create_face_mask against None frame
2026-04-15 14:13:18 +08:00
Kenneth Estanislao 033475b89c Update version in README from 2.1.2 to 2.1.3 2026-04-15 01:29:59 +08:00
Kenneth Estanislao 07711af712 Update contributors section in README.md 2026-04-15 01:29:44 +08:00
Kenneth Estanislao 44664d8a7f Merge pull request #1746 from maxwbuckley/apple-silicon-perf-optimizations
Apple Silicon performance: 1.5 → 10+ FPS (zero quality loss)
2026-04-15 01:25:51 +08:00
gujishh 15a3f537a4 test: cover additional invalid frame guards 2026-04-13 21:09:27 +09:00
gujishh fbcea9e135 fix(face-mask): guard create_face_mask against None frame 2026-04-12 14:19:48 +09:00
Max BuckleyandClaude Opus 4.6 646b0f816f Move hot-path imports to module scope
Address Sourcery review feedback: move face_align and get_one_face
imports from inside per-frame functions to module-level to avoid
repeated attribute lookup overhead in the processing loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:34:53 +02:00
Max BuckleyandClaude Opus 4.6 bcdd0ce2dd Apple Silicon performance: 1.5 → 10+ FPS (zero quality loss)
Fix CoreML execution provider falling back to CPU silently, eliminate
redundant per-frame face detection, and optimize the paste-back blend
to operate on the face bounding box instead of the full frame.

All changes are quality-neutral (pixel-identical output verified) and
benefit non-Mac platforms via the shared detection and paste-back
improvements.

Changes:
- Remove unsupported CoreML options (RequireStaticShapes, MaximumCacheSize)
  that caused ORT 1.24 to silently fall back to CPUExecutionProvider
- Add _fast_paste_back(): bbox-restricted erode/blur/blend, skip dead
  fake_diff code in insightface's inswapper (computed but never used)
- process_frame() accepts optional pre-detected target_face to avoid
  redundant get_one_face() call (~30-40ms saved per frame, all platforms)
- In-memory pipeline detects face once and shares across processors
- Fix get_face_swapper() to fall back to FP16 model when FP32 absent
- Fix pre_start() to accept either model variant (was FP16-only check)
- Make tensorflow import conditional (fixes crash on macOS)
- Add missing tqdm dep, make tensorflow/pygrabber platform-conditional

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:28:07 +02:00
Kenneth Estanislao 8703d394d6 ONNX CUDA exhaustive convolution search + IO binding 2026-04-09 16:34:27 +08:00
Kenneth Estanislao 69e3fc5611 Rendering optimization
The PNG encode/decode alone was consuming significant CPU time per frame. This is eliminated entirely.
2026-04-09 16:25:22 +08:00
Kenneth Estanislao 2b26d5539e supress error message
Some people just want the opencv error gone. I keep on telling them that it is only for blurs and color conversion. It is the onnx runtime who is running the swap.
2026-04-09 16:04:00 +08:00
Kenneth Estanislao fea5a4c2d2 Merge pull request #1707 from rohanrathi99/main
Switch to FP32 model by default, add run script
2026-04-05 23:19:17 +08:00
Kenneth Estanislao 51fb7a6ad6 Merge pull request #1722 from mvanhorn/osc/1654-face-enhancer-v2
fix(face-enhancer): add missing process_frame_v2 method
2026-04-05 23:16:52 +08:00
Kenneth Estanislao 6da4f398d5 Merge pull request #1731 from JiayuuWang/contribot/fix-readme-macos-python-version
docs: fix inconsistent Python version references in macOS/Linux setup (fixes #1632)
2026-04-05 23:16:20 +08:00
Kenneth Estanislao 3e362383d8 Merge pull request #1732 from yetval/fix/cuda-vram-exhaustion-video-processing
Fix CUDA VRAM exhaustion during video processing
2026-04-05 23:15:38 +08:00
yetval 11fb5bfbc6 Fix CUDA VRAM exhaustion during video processing (#1721) 2026-04-02 22:59:41 -04:00
jacob-wang 586d8f3fb0 docs: fix inconsistent Python version references in macOS/Linux setup
The macOS Apple Silicon section installed Python 3.11 but then
referenced Python 3.10 in several places:

- `brew install python-tk@3.10` → python-tk@3.11
- Linux comment "Ensure you use the installed Python 3.10" → 3.11
- CoreML section cross-reference "completed the macOS setup above
  using Python 3.10" → 3.11
- `python3.10 run.py` usage command → python3.11
- "You must use Python 3.10" note → 3.11
- `brew reinstall python-tk@3.10` troubleshooting tip → 3.11
- Removed `python@3.11` from the list of conflicting versions to
  uninstall (it is the required version, not a conflict)

Fixes #1632
2026-04-03 10:33:11 +08:00
Kenneth Estanislao 1edc4bc298 DML Lock fixed for cuda and CPU 2026-04-01 23:56:01 +08:00
ozp3 1f3668f7c1 Delete DeepLiveCam.lnk
remove lnk and bat files as requested
2026-04-01 23:56:01 +08:00
ozp3 3d16ee346f Delete run-dml.bat
remove lnk and bat files as requested
2026-04-01 23:56:01 +08:00
ozp3 ab834d5640 feat: AMD DML optimization - GPU face detection, detection throttle, pre-load fix 2026-04-01 23:56:01 +08:00
Kenneth Estanislao bf8a89d20a Merge pull request #1725 from jhihweijhan/fix/video-output-pipeline
Fix missing video output reporting and encoding flow
2026-04-01 23:14:22 +08:00
Kenneth Estanislaoandsourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> bb4ef4a133 Apply suggestion from @sourcery-ai[bot]
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-04-01 23:13:59 +08:00
Kenneth Estanislao b6b6c741a2 Revert "Merge pull request #1710 from ozp3/amd-dml-optimization"
This reverts commit 1b240a45fd, reversing
changes made to d9a5500bdf.
2026-04-01 22:33:01 +08:00
Kenneth Estanislao 1b240a45fd Merge pull request #1710 from ozp3/amd-dml-optimization
AMD GPU (DirectML) Optimization for Live Mode
2026-04-01 22:29:43 +08:00
ozp3 ecf02d0640 Delete DeepLiveCam.lnk
remove lnk and bat files as requested
2026-04-01 16:46:28 +03:00
ozp3 0cbc9f126f Delete run-dml.bat
remove lnk and bat files as requested
2026-04-01 16:45:31 +03:00
Karl a3fd56a312 Fix missing video output reporting and encoding flow 2026-04-01 15:22:09 +08:00
Matt Van HornandClaude Opus 4.6 9525d45291 fix(face-enhancer): add missing process_frame_v2 method
The live webcam preview in ui.py calls process_frame_v2() on all
frame processors, but face_enhancer.py was missing this method.
This caused an AttributeError crash when the GFPGAN face enhancer
was enabled during live mode.

Fixes https://github.com/hacksider/Deep-Live-Cam/issues/1654

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:49:12 -07:00
Kenneth Estanislao d9a5500bdf Merge pull request #1713 from TeachDian/fix-1705-wsl-onnxruntime-gpu 2026-03-29 04:54:34 +08:00
TeachDian 86134b6e1d Fix #1705: Update onnxruntime-gpu requirement to 1.23.2 for WSL compatibility 2026-03-29 04:46:48 +08:00
ozp3 fbd1cc5973 docs: add AMD DML optimization notes to README 2026-03-28 13:16:43 +03:00
ozp3 eac2ad2307 feat: AMD DML optimization - GPU face detection, detection throttle, pre-load fix 2026-03-28 13:09:20 +03:00
Kenneth Estanislao 9e6f30c0a4 silenced deprecation 2026-03-27 21:35:27 +08:00
Kenneth Estanislao 97321a740d Update face_analyser.py
320 was over optimized, put back to 640
2026-03-27 21:24:19 +08:00
RohanW11p 9207386e07 Switch to FP32 model by default, add run script
Change default face swapper model to FP32 for better GPU compatibility and avoid NaN issues on certain GPUs.
Revamped `run.py` to adjust PATH variables for dependencies setup and re-added with expanded configuration.
2026-03-27 17:29:01 +05:30
Kenneth Estanislao f5f7ac7764 Revise README for clarity and formatting
Updated README to remove emoji and clarify GPU support details.
2026-03-23 10:02:50 +08:00
Kenneth Estanislao 77d3492eef Add download link for models in README
Added a section for downloading models from Hugging Face.
2026-03-13 23:39:46 +08:00
Kenneth Estanislao 8e3d6e7c65 Add emoji to project title in README
Just want to add an emoji 😝
2026-03-13 22:17:32 +08:00
Kenneth Estanislao ee9699ee70 Happy 80k!
2.1 Released!

- Face randomizer added!
2026-03-13 22:09:18 +08:00
Kenneth Estanislao 3c8b259a3f Some edits on the UI
- Grouped the face enhancers
- Make the mouth mask just a slider
- Removed the redundant switches
2026-03-13 22:03:28 +08:00
Kenneth Estanislao 30b27c2b71 Update Quick Start section to v2.7 beta 2026-03-12 02:40:52 +08:00
24 changed files with 3920 additions and 1795 deletions
+2
View File
@@ -27,3 +27,5 @@ faceswap/
switch_states.json
/models
install.bat
/.claude
*.bat
+18 -14
View File
@@ -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">
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.
## 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" />
##### 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.
@@ -142,7 +142,7 @@ pip install -r requirements.txt
```
For Linux:
```bash
# Ensure you use the installed Python 3.10
# Ensure you use the installed Python 3.11
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
@@ -157,7 +157,7 @@ Apple Silicon (M1/M2/M3) requires specific setup:
brew install python@3.11
# 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
python3.11 -m venv venv
@@ -214,7 +214,7 @@ python run.py --execution-provider cuda
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:
```bash
@@ -222,25 +222,25 @@ pip uninstall onnxruntime onnxruntime-silicon
pip install onnxruntime-silicon==1.13.1
```
3. Usage (important: specify Python 3.10):
3. Usage:
```bash
python3.10 run.py --execution-provider coreml
python3.11 run.py --execution-provider coreml
```
**Important Notes for macOS:**
- You **must** use Python 3.10, not newer versions like 3.11 or 3.13
- Always run with `python3.10` 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`
- You **must** use Python 3.11, not newer versions like 3.13
- 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.11`
- 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:
```bash
# List all installed Python versions
brew list | grep python
# 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
brew cleanup
```
@@ -309,6 +309,9 @@ python run.py --execution-provider openvino
- Use a screen capture tool like OBS to stream.
- 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)
```
@@ -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
- [qitianai](https://github.com/qitianai): for multi-lingual support
- [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.
- 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 ❤️
+178
View File
@@ -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
View File
@@ -2,7 +2,7 @@ import os
import sys
# single thread doubles cuda performance - needs to be set before torch import
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
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
import warnings
@@ -17,12 +17,16 @@ try:
except ImportError:
HAS_TORCH = False
import onnxruntime
import tensorflow
try:
import tensorflow
HAS_TENSORFLOW = True
except ImportError:
HAS_TENSORFLOW = False
import modules.globals
import modules.metadata
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
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-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('--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('-v', '--version', action='version', version=f'{modules.metadata.name} {modules.metadata.version}')
@@ -127,6 +131,15 @@ def suggest_max_memory() -> int:
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]:
return encode_execution_providers(onnxruntime.get_available_providers())
@@ -143,8 +156,7 @@ def suggest_execution_threads() -> int:
if 'ROCMExecutionProvider' in modules.globals.execution_providers:
return 1
if 'CUDAExecutionProvider' in modules.globals.execution_providers:
# For CUDA, use more threads for parallel frame processing
return min(cpu_count, 16)
return 2
# For CPU execution, use most cores but leave some for system
return max(4, min(cpu_count - 2, 16))
@@ -152,9 +164,10 @@ def suggest_execution_threads() -> int:
def limit_resources() -> None:
# prevent tensorflow memory leak
gpus = tensorflow.config.experimental.list_physical_devices('GPU')
for gpu in gpus:
tensorflow.config.experimental.set_memory_growth(gpu, True)
if HAS_TENSORFLOW:
gpus = tensorflow.config.experimental.list_physical_devices('GPU')
for gpu in gpus:
tensorflow.config.experimental.set_memory_growth(gpu, True)
# limit memory usage
if modules.globals.max_memory:
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):
return
extraction_start = time.time()
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()
# Detect FPS early (needed by both pipelines)
if modules.globals.keep_fps:
update_status('Detecting fps...')
fps = detect_fps(modules.globals.target_path)
update_status(f'Creating video with {fps} fps...')
create_video(modules.globals.target_path, fps)
else:
update_status('Creating video with 30.0 fps...')
create_video(modules.globals.target_path)
encoding_time = time.time() - encoding_start
update_status(f'Video encoding completed in {encoding_time:.2f}s')
fps = 30.0
video_created = False
# --- 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
if modules.globals.keep_audio:
@@ -272,8 +315,8 @@ def start() -> None:
clean_temp(modules.globals.target_path)
total_time = time.time() - start_time
if is_video(modules.globals.target_path):
update_status(f'Processing to video succeed! Total time: {total_time:.2f}s')
if is_video(modules.globals.target_path) and modules.globals.output_path and os.path.isfile(modules.globals.output_path):
update_status(f'Video processing succeeded! Total time: {total_time:.2f}s')
else:
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):
if not frame_processor.pre_check():
return
# Pre-load face analyser in main thread before GUI starts
#from modules.face_analyser import get_face_analyser
#get_face_analyser()
limit_resources()
if modules.globals.headless:
start()
else:
window = ui.init(start, destroy, modules.globals.lang)
window.mainloop()
window.mainloop()
+177 -8
View File
@@ -16,6 +16,8 @@ from pathlib import Path
FACE_ANALYSER = None
FACE_ANALYSER_LOCK = threading.Lock()
DET_SIZE = (640, 640)
def get_face_analyser() -> Any:
"""Get face analyser with thread-safe initialization."""
@@ -25,29 +27,196 @@ def get_face_analyser() -> Any:
with FACE_ANALYSER_LOCK:
# Double-check after acquiring lock
if FACE_ANALYSER is None:
from modules.processors.frame._onnx_enhancer import (
build_provider_config,
)
providers = build_provider_config()
FACE_ANALYSER = insightface.app.FaceAnalysis(
name='buffalo_l',
providers=modules.globals.execution_providers,
allowed_modules=['detection', 'recognition']
providers=providers,
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
def get_one_face(frame: Frame) -> Any:
face = get_face_analyser().get(frame)
def _optimize_det_model(fa: Any, providers) -> None:
"""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:
return min(face, key=lambda x: x.bbox[0])
return min(faces, key=lambda x: x.bbox[0])
except ValueError:
return None
def get_many_faces(frame: Frame) -> Any:
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:
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:
for map in modules.globals.source_target_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:
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
+4
View File
@@ -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_down_size: float = 0.1 # Expansion factor for lower 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 ---
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 OF FILE globals.py ---
import threading
dml_lock = threading.Lock()
+20 -21
View File
@@ -18,6 +18,7 @@ Usage
from __future__ import annotations
import os
import cv2
import numpy as np
from typing import Tuple, Optional
@@ -27,27 +28,25 @@ from typing import Tuple, Optional
# ---------------------------------------------------------------------------
CUDA_AVAILABLE: bool = False
try:
# cv2.cuda.GpuMat is only present when OpenCV is compiled with CUDA
_test_mat = cv2.cuda.GpuMat()
# Verify we have the required filter / image-processing functions
_has_gauss = hasattr(cv2.cuda, "createGaussianFilter")
_has_resize = hasattr(cv2.cuda, "resize")
_has_cvt = hasattr(cv2.cuda, "cvtColor")
if _has_gauss and _has_resize and _has_cvt:
CUDA_AVAILABLE = True
print("[gpu_processing] OpenCV CUDA support detected GPU-accelerated processing enabled.")
else:
missing = []
if not _has_gauss:
missing.append("createGaussianFilter")
if not _has_resize:
missing.append("resize")
if not _has_cvt:
missing.append("cvtColor")
print(f"[gpu_processing] cv2.cuda.GpuMat exists but missing: {', '.join(missing)} falling back to CPU.")
except Exception:
print("[gpu_processing] OpenCV CUDA not available using CPU fallback for all operations.")
# OpenCV CUDA per-operation acceleration is DISABLED by default.
# Each gpu_* call uploads to GPU, processes, then downloads back to CPU.
# At webcam resolution (~960x540) this upload/download overhead far exceeds
# the time saved on the actual operation, making it slower than pure CPU.
# The heavy lifting (face detection, swap, enhancement) runs on GPU via
# ONNX Runtime's CUDAExecutionProvider, which is where GPU matters.
#
# To force-enable, set OPENCV_CUDA_PROCESSING=1 in your environment.
if os.environ.get("OPENCV_CUDA_PROCESSING") == "1":
try:
_test_mat = cv2.cuda.GpuMat()
_has_gauss = hasattr(cv2.cuda, "createGaussianFilter")
_has_resize = hasattr(cv2.cuda, "resize")
_has_cvt = hasattr(cv2.cuda, "cvtColor")
if _has_gauss and _has_resize and _has_cvt:
CUDA_AVAILABLE = True
print("[gpu_processing] OpenCV CUDA processing enabled via OPENCV_CUDA_PROCESSING=1.")
except Exception:
pass
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,3 +1,3 @@
name = 'Deep-Live-Cam'
version = '2.0.3c'
version = '2.1.5'
edition = 'GitHub Edition'
+550
View File
@@ -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)
+80
View File
@@ -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,
)
+99 -4
View File
@@ -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))
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:
"""Create an ONNX Runtime session using the configured execution providers."""
providers = modules.globals.execution_providers
session = onnxruntime.InferenceSession(model_path, providers=providers)
"""Create an ONNX Runtime session with optimised provider config.
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
@@ -118,7 +212,8 @@ def enhance_face_onnx(
blob = preprocess_face(face_crop, input_size)
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)
# Create mask for blending (feathered edges)
+298 -1
View File
@@ -1,12 +1,17 @@
import os
import subprocess
import sys
import importlib
from concurrent.futures import ThreadPoolExecutor
from types import ModuleType
from typing import Any, List, Callable
import numpy as np
from tqdm import tqdm
import modules
import modules.globals
import modules.globals
from modules.face_analyser import get_one_face
FRAME_PROCESSORS_MODULES: List[ModuleType] = []
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:
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)
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
+183 -111
View File
@@ -1,4 +1,3 @@
# --- START OF FILE face_enhancer.py ---
# Uses ONNX Runtime for GFPGAN face enhancement (no torch/gfpgan dependency)
from typing import Any, List
@@ -81,18 +80,11 @@ def get_face_enhancer() -> onnxruntime.InferenceSession:
)
try:
providers = modules.globals.execution_providers
session_options = onnxruntime.SessionOptions()
session_options.graph_optimization_level = (
onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
from modules.processors.frame._onnx_enhancer import (
create_onnx_session,
)
FACE_ENHANCER = onnxruntime.InferenceSession(
model_path,
sess_options=session_options,
providers=providers,
)
FACE_ENHANCER = create_onnx_session(model_path)
input_info = FACE_ENHANCER.get_inputs()[0]
output_info = FACE_ENHANCER.get_outputs()[0]
@@ -158,6 +150,18 @@ def _align_face(
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(
frame: Frame,
enhanced_face: np.ndarray,
@@ -167,53 +171,77 @@ def _paste_back(
"""
Paste an enhanced (aligned) face back onto the original frame using the
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]
# Inverse the affine warp
inv_matrix = cv2.invertAffineTransform(affine_matrix)
inv_restored = cv2.warpAffine(
enhanced_face,
inv_matrix,
(w, h),
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0),
# Build or reuse cached feathered mask (uint8 — blended via cv2 SIMD ops)
if _enhancer_cache['mask_size'] != output_size:
face_mask_f = np.ones((output_size, output_size), dtype=np.float32)
border = max(1, int(output_size * 0.05))
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
face_mask = np.ones((output_size, output_size), dtype=np.float32)
target_crop = frame[y1p:y2p, x1p:x2p]
# Feather the border (5 % of the size on each edge)
border = max(1, int(output_size * 0.05))
ramp_up = np.linspace(0.0, 1.0, border, dtype=np.float32)
ramp_down = np.linspace(1.0, 0.0, border, dtype=np.float32)
if _HAS_TORCH_CUDA:
# Upload uint8 alpha — smaller transfer, scale on device.
mask_t = torch.from_numpy(inv_mask_crop).cuda().float().mul_(1.0 / 255.0).unsqueeze(2)
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
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)
return frame
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.
Format: NCHW float32, normalised to [-1, 1].
"""
# BGR -> RGB
rgb = cv2.cvtColor(aligned_face, cv2.COLOR_BGR2RGB).astype(np.float32)
# [0, 255] -> [0, 1] -> [-1, 1]
rgb = rgb / 255.0
rgb = (rgb - 0.5) / 0.5
# HWC -> CHW, add batch dim
chw = np.transpose(rgb, (2, 0, 1))
return np.expand_dims(chw, axis=0) # shape: (1, 3, H, W)
# BGR -> RGB, normalize, and transpose in one pass
# Fused: (x / 255.0 - 0.5) / 0.5 = x / 127.5 - 1.0
rgb = aligned_face[:, :, ::-1] # BGR->RGB zero-copy view
chw = np.transpose(rgb, (2, 0, 1)).astype(np.float32)
chw *= (1.0 / 127.5)
chw -= 1.0
return chw[np.newaxis, ...] # shape: (1, 3, H, W)
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.
Expects input in NCHW format with values in [-1, 1].
"""
face = np.squeeze(output) # remove batch dim -> (3, H, W)
face = np.transpose(face, (1, 2, 0)) # CHW -> HWC
# [-1, 1] -> [0, 1] -> [0, 255]
face = (face + 1.0) / 2.0
face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
# RGB -> BGR
return cv2.cvtColor(face, cv2.COLOR_RGB2BGR)
# Fused: ((x + 1.0) / 2.0) * 255 = (x + 1.0) * 127.5
face = output[0] # remove batch dim -> (3, H, W)
face = (face + 1.0) * 127.5
np.clip(face, 0, 255, out=face)
face = face.astype(np.uint8).transpose(1, 2, 0) # CHW -> HWC
return face[:, :, ::-1].copy() # RGB -> BGR
def enhance_face(temp_frame: Frame) -> Frame:
"""Enhances all faces in a frame using the GFPGAN ONNX model."""
# Cache for temporal enhancement skipping in live mode.
# 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()
# Determine model input resolution from the session metadata
input_info = session.get_inputs()[0]
input_name = input_info.name
input_shape = input_info.shape # e.g. [1, 3, 512, 512]
# Safely extract input size (handle dynamic / symbolic dimensions)
try:
align_size = int(input_shape[2])
if align_size <= 0:
@@ -261,15 +306,25 @@ def enhance_face(temp_frame: Frame) -> Frame:
except (ValueError, TypeError, IndexError):
align_size = 512
# Detect faces using InsightFace (already a project dependency)
faces = get_many_faces(temp_frame)
# Use pre-detected faces if available, otherwise detect
faces = detected_faces if detected_faces is not None else get_many_faces(temp_frame)
if not faces:
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:
# Need the 5-point key-points for alignment
if not hasattr(face, "kps") or face.kps is None:
continue
@@ -277,48 +332,68 @@ def enhance_face(temp_frame: Frame) -> Frame:
if landmarks_5.shape[0] < 5:
continue
# Align / crop the face at the model's INPUT resolution
aligned_face, affine_matrix = _align_face(
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
if run_inference_this_frame:
aligned_face, affine_matrix = _align_face(
temp_frame, landmarks_5, output_size=align_size
)
except Exception as e:
print(f"{NAME}: Error enhancing a face: {e}")
continue
if aligned_face is None or affine_matrix is None:
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
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(
source_path: str | None, temp_frame_paths: List[str], progress: Any = None
) -> None:
@@ -367,6 +442,3 @@ def process_video(
modules.processors.frame.core.process_video(
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
def process_frame(source_face: Face | None, temp_frame: Frame) -> Frame:
target_face = get_one_face(temp_frame)
def process_frame(source_face: Face | None, temp_frame: Frame, detected_faces=None) -> Frame:
if detected_faces:
target_face = detected_faces[0]
else:
target_face = get_one_face(temp_frame)
if target_face is None:
return temp_frame
return enhance_face(temp_frame, target_face)
@@ -82,8 +82,11 @@ def enhance_face(temp_frame: Frame, face: Face) -> Frame:
return temp_frame
def process_frame(source_face: Face | None, temp_frame: Frame) -> Frame:
target_face = get_one_face(temp_frame)
def process_frame(source_face: Face | None, temp_frame: Frame, detected_faces=None) -> Frame:
if detected_faces:
target_face = detected_faces[0]
else:
target_face = get_one_face(temp_frame)
if target_face is None:
return temp_frame
return enhance_face(temp_frame, target_face)
+11 -8
View File
@@ -82,8 +82,8 @@ def create_lower_mouth_mask(
landmarks = face.landmark_2d_106
if landmarks is not None:
# Use outer mouth landmarks (52-63) to capture the lips only
lower_lip_order = list(range(52, 64))
# Use outer mouth landmarks (52-71) to capture the full mouth area
lower_lip_order = list(range(52, 72))
if max(lower_lip_order) >= landmarks.shape[0]:
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)
# Expand the landmarks outward using the mouth_mask_size
# Use a more conservative expansion to avoid affecting face shape
expansion_factor = (
1 + modules.globals.mask_down_size * modules.globals.mouth_mask_size
)
expanded_landmarks = (lower_lip_landmarks - center) * expansion_factor + center
mouth_mask_size = getattr(modules.globals, "mouth_mask_size", 0.0) # 0-100 slider
expansion_factor = 1 + (mouth_mask_size / 100.0) * 2.5
# 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
expanded_landmarks = expanded_landmarks.astype(np.int32)
+521 -186
View File
@@ -1,6 +1,7 @@
from typing import Any, List, Optional
from typing import Any, List, Optional, Tuple
import cv2
import insightface
import logging
import threading
import numpy as np
import platform
@@ -28,6 +29,149 @@ NAME = "DLC.FACE-SWAPPER"
PREVIOUS_FRAME_RESULT = None # Stores the final processed frame from the previous step
# --- 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 ---
IS_APPLE_SILICON = platform.system() == 'Darwin' and platform.machine() == 'arm64'
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}")
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(
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
def pre_start() -> bool:
# Simplified pre_start, assuming checks happen before calling process functions
model_path = os.path.join(models_dir, "inswapper_128_fp16.onnx")
if not os.path.exists(model_path):
update_status(f"Model not found: {model_path}. Please download it.", NAME)
# Check for either model variant
fp16_path = os.path.join(models_dir, "inswapper_128_fp16.onnx")
fp32_path = os.path.join(models_dir, "inswapper_128.onnx")
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
# 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
return False
# Add other essential checks if needed, e.g., target/source path validity
return True
@@ -85,13 +229,28 @@ def get_face_swapper() -> Any:
with THREAD_LOCK:
if FACE_SWAPPER is None:
model_name = "inswapper_128.onnx"
if "CUDAExecutionProvider" in modules.globals.execution_providers:
model_name = "inswapper_128_fp16.onnx"
model_path = os.path.join(models_dir, model_name)
# Prefer FP16 on GPUs with Tensor Cores (Turing+) — half the
# memory bandwidth, faster inference. Fall back to FP32 for
# older GPUs (e.g. GTX 16xx) where FP16 can produce NaN.
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)
try:
# Optimized provider configuration for Apple Silicon
providers_config = []
for p in modules.globals.execution_providers:
if p == "CoreMLExecutionProvider" and IS_APPLE_SILICON:
@@ -104,17 +263,25 @@ def get_face_swapper() -> Any:
"SpecializationStrategy": "FastPrediction",
"AllowLowPrecisionAccumulationOnGPU": 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:
providers_config.append(p)
FACE_SWAPPER = insightface.model_zoo.get_model(
model_path,
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)
except Exception as e:
update_status(f"Error loading face swapper model: {e}", NAME)
@@ -123,6 +290,214 @@ def get_face_swapper() -> Any:
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:
"""Optimized face swapping with better memory management and performance."""
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:
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 = 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:
temp_frame = np.clip(temp_frame, 0, 255).astype(np.uint8)
# Apply the face swap with optimized memory handling
try:
# Ensure contiguous memory layout for better performance on all platforms
if not temp_frame.flags['C_CONTIGUOUS']:
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 ---
# Check the output type and range from the model
if swapped_frame_raw is None:
# print("Warning: face_swapper.get returned None.") # Debug
return original_frame # Return original if swap somehow failed internally
# Use paste_back=False and our optimized paste-back
if any("DmlExecutionProvider" in p for p in modules.globals.execution_providers):
with modules.globals.dml_lock:
bgr_fake, M = face_swapper.get(
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 not isinstance(swapped_frame_raw, np.ndarray):
# print(f"Warning: face_swapper.get returned type {type(swapped_frame_raw)}, expected numpy array.") # Debug
if bgr_fake is None:
return original_frame
# Ensure the output has the correct shape (like the input frame)
if swapped_frame_raw.shape != temp_frame.shape:
# 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
if not isinstance(bgr_fake, np.ndarray):
return original_frame
# Explicitly clip values to 0-255 and convert to uint8
# This handles cases where the model might output floats or values outside the valid range
swapped_frame = np.clip(swapped_frame_raw, 0, 255).astype(np.uint8)
# --- END: CRITICAL FIX FOR ORT 1.17 ---
# Pass a dummy aimg with correct shape — _fast_paste_back only uses aimg.shape
# to create the white mask. Avoids redundant norm_crop2 (~0.6ms).
_face_size = face_swapper.input_size[0]
_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:
print(f"Error during face swap using face_swapper.get: {e}") # More specific error
# import traceback
# traceback.print_exc() # Print full traceback for debugging
return original_frame # Return original if swap fails
print(f"Error during face swap: {e}")
return original_frame
# --- Post-swap Processing (Masking, Opacity, etc.) ---
# 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
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 = (
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
if mouth_cutout is not None and mouth_box != (0,0,0,0): # Add check for valid box
# Apply mouth area (from original) onto the 'swapped_frame'
if mouth_cutout is not None and mouth_box != (0,0,0,0):
# Apply mouth area (from original) onto the 'swapped_frame'
swapped_frame = apply_mouth_area(
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):
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, target_face, mouth_mask_data
)
mouth_mask_data = (mouth_mask, mouth_cutout, mouth_box, lower_lip_polygon)
swapped_frame = draw_mouth_mask_visualization(
swapped_frame, target_face, mouth_mask_data
)
# --- 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):
face_mask = create_face_mask(target_face, temp_frame)
if face_mask is not None:
# 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)
swapped_frame = _apply_poisson_blend(
swapped_frame, original_frame, target_face, M, bgr_fake
)
# 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
if opacity >= 1.0:
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."""
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()
# 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 ---
def process_frame(source_face: Face, temp_frame: Frame) -> Frame:
"""
DEPRECATED / SIMPLER VERSION - Processes a single frame using one source face.
Consider using process_frame_v2 for more complex scenarios.
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).
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 opacity is 0, no swap happens, so no post-processing needed.
# Also reset interpolation state if it was active.
global PREVIOUS_FRAME_RESULT
PREVIOUS_FRAME_RESULT = None
return temp_frame
# Color correction removed from here (better applied before swap if needed)
processed_frame = temp_frame # Start with the input frame
swapped_face_bboxes = [] # Keep track of where swaps happened
processed_frame = temp_frame
swapped_face_bboxes = []
if modules.globals.many_faces:
many_faces = get_many_faces(processed_frame)
if many_faces:
current_swap_target = processed_frame.copy() # Apply swaps sequentially on a copy
for target_face in many_faces:
current_swap_target = swap_face(source_face, target_face, current_swap_target)
if target_face is not None and hasattr(target_face, "bbox") and target_face.bbox is not None:
swapped_face_bboxes.append(target_face.bbox.astype(int))
processed_frame = current_swap_target # Assign the final result after all swaps
current_swap_target = processed_frame.copy()
for face in many_faces:
current_swap_target = swap_face(source_face, face, current_swap_target)
if face is not None and hasattr(face, "bbox") and face.bbox is not None:
swapped_face_bboxes.append(face.bbox.astype(int))
processed_frame = current_swap_target
else:
target_face = get_one_face(processed_frame)
if target_face is None:
target_face = get_one_face(processed_frame)
if target_face:
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:
swapped_face_bboxes.append(target_face.bbox.astype(int))
if hasattr(target_face, "bbox") and target_face.bbox is not None:
swapped_face_bboxes.append(target_face.bbox.astype(int))
# Apply sharpening and interpolation
final_frame = apply_post_processing(processed_frame, swapped_face_bboxes)
return final_frame
@@ -750,13 +1111,20 @@ def create_lower_mouth_mask(
return mask, mouth_cutout, mouth_box, lower_lip_polygon
try: # Wrap main logic in try-except
# Use outer mouth landmarks (52-63) to capture the lips only
# This avoids including the chin/jawline, preserving the face shape from the swap
# Outer mouth/lip landmarks (52-63) — the lip outline only. In this
# 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))
# 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]:
# 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
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.")
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
expansion_factor = 1 + mask_down_size
expanded_landmarks = (lower_lip_landmarks - center) * expansion_factor + center
# Uniformly scaling a simple polygon about its centroid keeps it simple
# (no self-intersection). x grows with expansion_factor; points below
# 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
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
pass
# Optional: Draw bounding box (red rectangle)
# cv2.rectangle(vis_frame, (min_x, min_y), (max_x, max_y), (0, 0, 255), 1)
# Draw bounding box (red rectangle)
cv2.rectangle(vis_frame, (min_x, min_y), (max_x, max_y), (0, 0, 255), 2)
# Optional: Add labels
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.")
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 ---
# 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)
# Adjust polygon coordinates relative to the ROI's top-left corner
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)
# Feather the polygon mask (Gaussian blur)
mask_feather_ratio = getattr(modules.globals, "mask_feather_ratio", 12) # Default 12
# 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
# Feather the edges with Gaussian blur for smooth blending
feather_amount = max(1, min(30, min(box_width, box_height) // 8))
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
max_val = feathered_polygon_mask.max()
if max_val > 1e-6: # Avoid division by zero
feathered_polygon_mask = feathered_polygon_mask / max_val
# Normalize to [0.0, 1.0]
max_val = feathered_mask.max()
if max_val > 1e-6:
feathered_mask = feathered_mask / max_val
else:
feathered_polygon_mask.fill(0.0) # Mask is all black if max is near zero
# --- End Mask Creation ---
feathered_mask.fill(0.0)
# --- 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)
# --- Blending: paste original mouth onto swapped face ---
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
# float32 provides sufficient precision for 8-bit image blending
combined_mask_f32 = combined_mask_3channel.astype(np.float32)
inv_mask = np.float32(1.0) - combined_mask_f32
# Blend: (original_mouth * mask) + (swapped_face * (1 - mask))
blended_roi = (resized_mouth_cutout.astype(np.float32) * mask_3ch +
roi.astype(np.float32) * inv_mask)
# Blend: (original_mouth * combined_mask) + (swapped_face_roi * (1 - combined_mask))
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
frame[min_y:max_y, min_x:max_x] = np.clip(blended_roi, 0, 255).astype(np.uint8)
except Exception as e:
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:
"""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
# 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.")
return mask # Return empty mask
@@ -1223,4 +1558,4 @@ def apply_color_transfer(source, target):
# traceback.print_exc()
return source
return result_bgr
return result_bgr
+1387 -1372
View File
File diff suppressed because it is too large Load Diff
+43 -6
View File
@@ -30,8 +30,12 @@ def run_ffmpeg(args: List[str]) -> bool:
try:
subprocess.check_output(commands, stderr=subprocess.STDOUT)
return True
except Exception:
pass
except subprocess.CalledProcessError as error:
output = error.output.decode(errors="ignore").strip()
if output:
print(output)
except Exception as error:
print(f"ffmpeg execution failed: {error}")
return False
@@ -61,19 +65,19 @@ def extract_frames(target_path: str) -> None:
"""Extract frames with hardware acceleration and optimized settings."""
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(
[
"-i", target_path,
"-vf", "format=rgb24", # Use video filter for format conversion (faster)
"-vsync", "0", # Prevent frame duplication
"-frame_pts", "1", # Preserve frame timing
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."""
temp_output_path = get_temp_output_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",
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:
@@ -309,3 +314,35 @@ def conditional_download(download_directory_path: str, urls: List[str]) -> None:
def resolve_relative_path(path: str) -> str:
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
View File
@@ -1,5 +1,7 @@
import cv2
import numpy as np
import sys
import time
from typing import Optional, Tuple, Callable
import platform
import threading
@@ -17,6 +19,10 @@ class VideoCapturer:
self._frame_ready = threading.Event()
self.is_running = False
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
if platform.system() == "Windows":
@@ -32,17 +38,35 @@ class VideoCapturer:
"""Initialize and start video capture"""
try:
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 = [
(self.device_index, cv2.CAP_DSHOW), # Try DirectShow first
(self.device_index, cv2.CAP_ANY), # Then try default backend
(-1, cv2.CAP_ANY), # Try -1 as fallback
(0, cv2.CAP_ANY), # Finally try 0 without specific backend
(self.device_index, cv2.CAP_DSHOW),
(self.device_index, cv2.CAP_MSMF),
(self.device_index, cv2.CAP_ANY),
]
for dev_id, backend in capture_methods:
try:
self.cap = cv2.VideoCapture(dev_id, backend)
self.cap = cv2.VideoCapture(dev_id, backend, open_params)
if self.cap.isOpened():
break
self.cap.release()
@@ -55,10 +79,29 @@ class VideoCapturer:
if not self.cap or not self.cap.isOpened():
raise RuntimeError("Failed to open camera")
# Configure format
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)
# Belt-and-braces: also set via cap.set() for backends that honor
# post-open changes (MSMF, V4L2). DSHOW ignores these, but the
# construction params above already handled it.
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
return True
@@ -89,6 +132,29 @@ class VideoCapturer:
self.is_running = False
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:
"""Set callback for frame processing"""
self.frame_callback = callback
+6 -5
View File
@@ -5,12 +5,13 @@ cv2_enumerate_cameras==1.1.15
onnx==1.18.0
insightface==0.7.3
psutil==5.9.8
tk==0.1.0
customtkinter==5.2.2
PySide6>=6.7,<7
pillow==12.1.1
tqdm>=4.65.0
onnxruntime-silicon==1.16.3; sys_platform == 'darwin' and platform_machine == 'arm64'
onnxruntime-gpu==1.24.2; sys_platform != 'darwin'
tensorflow; sys_platform != 'darwin'
onnxruntime-gpu==1.23.2; 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
protobuf==4.25.1
pygrabber
pygrabber; sys_platform == 'win32'
+68 -2
View File
@@ -1,7 +1,73 @@
#!/usr/bin/env python3
# Import the tkinter fix to patch the ScreenChanged error
import tkinter_fix
import os
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
+97
View File
@@ -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()
+3
View File
@@ -1,3 +1,6 @@
import os
os.environ.setdefault('TK_SILENCE_DEPRECATION', '1')
import tkinter
# Only needs to be imported once at the beginning of the application