Merge pull request #136 from vizionik25/feat/apple-silicon-mps-support
feat(gpu): Apple Silicon MPS (Metal) GPU Support
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Apple Silicon (MPS) Support
|
||||
|
||||
OpenMontage supports Apple Silicon Macs (M1/M2/M3/M4/M5) via PyTorch's
|
||||
Metal Performance Shaders (MPS) backend. Local GPU tools — video generation,
|
||||
upscaling, and face restoration — automatically detect and use MPS when
|
||||
available.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 12.3 (Monterey) or later
|
||||
- Apple Silicon Mac (M-series chip)
|
||||
- Python 3.10+
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
# Enable local generation
|
||||
export VIDEO_GEN_LOCAL_ENABLED=true
|
||||
|
||||
# Install dependencies — MPS support is included in the default torch wheel
|
||||
uv pip install diffusers transformers accelerate torch pillow requests
|
||||
|
||||
# For upscaling and face restoration
|
||||
uv pip install realesrgan gfpgan
|
||||
```
|
||||
|
||||
No special CUDA build or separate MPS package is needed — `uv pip install torch`
|
||||
on macOS automatically includes MPS support.
|
||||
|
||||
## How It Works
|
||||
|
||||
The `get_torch_device()` helper in `tools/video/_shared.py` detects the best
|
||||
available device:
|
||||
|
||||
1. **CUDA** (NVIDIA GPU) — used when available; fastest for diffusion models
|
||||
2. **MPS** (Apple Silicon Metal) — used on M-series Macs; good performance
|
||||
3. **CPU** — fallback, always available but significantly slower
|
||||
|
||||
Device selection is automatic. All local GPU tools (`upscale`, `face_restore`,
|
||||
`ltx_video_local`, `wan_video_local`, etc.) route through this helper.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **VRAM**: Apple Silicon uses unified memory. Models that require >16 GB VRAM
|
||||
may not fit on 16 GB Macs. Check the tool's `resource_profile.vram_mb`.
|
||||
- **bfloat16**: Not supported on MPS. The pipeline automatically uses float16
|
||||
on MPS and float32 on CPU.
|
||||
- **CPU offloading**: `enable_model_cpu_offload()` is CUDA-only. On MPS, the
|
||||
pipeline falls back to direct device placement.
|
||||
- **Half-precision in Real-ESRGAN**: fp16 can produce NaN artifacts on MPS, so
|
||||
upscaling automatically uses fp32 on non-CUDA devices.
|
||||
|
||||
## Verifying MPS Is Active
|
||||
|
||||
```python
|
||||
from tools.video._shared import get_torch_device
|
||||
print(get_torch_device()) # Should print "mps" on Apple Silicon
|
||||
```
|
||||
|
||||
If this prints `"cpu"` on an Apple Silicon Mac, verify:
|
||||
- macOS version is 12.3+
|
||||
- PyTorch is installed (`uv pip install torch`)
|
||||
- You're running native ARM Python (not Rosetta x86)
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Tests for Apple Silicon MPS / device resolution helper.
|
||||
|
||||
These tests mock at sys.modules level so they run without torch installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_torch():
|
||||
"""Inject a fake torch module into sys.modules for the duration of a test."""
|
||||
fake = MagicMock()
|
||||
# Restore whatever was there before (real torch, or nothing)
|
||||
previous = sys.modules.get("torch", None)
|
||||
sys.modules["torch"] = fake
|
||||
yield fake
|
||||
if previous is None:
|
||||
sys.modules.pop("torch", None)
|
||||
else:
|
||||
sys.modules["torch"] = previous
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_torch_device — basic routing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_get_torch_device_returns_mps_when_cuda_absent(mock_torch):
|
||||
"""mps is used when cuda is unavailable but mps is available."""
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
mock_torch.backends.mps.is_built.return_value = True
|
||||
mock_torch.backends.mps.is_available.return_value = True
|
||||
|
||||
from tools.video._shared import get_torch_device
|
||||
assert get_torch_device() == "mps"
|
||||
|
||||
|
||||
def test_get_torch_device_returns_cpu_as_fallback(mock_torch):
|
||||
"""cpu is the final fallback when neither cuda nor mps is available."""
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
mock_torch.backends.mps.is_built.return_value = True
|
||||
mock_torch.backends.mps.is_available.return_value = False
|
||||
|
||||
from tools.video._shared import get_torch_device
|
||||
assert get_torch_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_torch_device_returns_cuda_when_available(mock_torch):
|
||||
"""cuda takes priority over mps when both are present."""
|
||||
mock_torch.cuda.is_available.return_value = True
|
||||
mock_torch.backends.mps.is_built.return_value = True
|
||||
mock_torch.backends.mps.is_available.return_value = True
|
||||
|
||||
from tools.video._shared import get_torch_device
|
||||
assert get_torch_device() == "cuda"
|
||||
|
||||
|
||||
def test_get_torch_device_returns_cpu_when_torch_not_installed():
|
||||
"""cpu is returned safely when torch cannot be imported."""
|
||||
previous = sys.modules.pop("torch", None)
|
||||
try:
|
||||
# Make torch unimportable
|
||||
sys.modules["torch"] = None # type: ignore[assignment]
|
||||
from tools.video._shared import get_torch_device
|
||||
assert get_torch_device() == "cpu"
|
||||
finally:
|
||||
if previous is None:
|
||||
sys.modules.pop("torch", None)
|
||||
else:
|
||||
sys.modules["torch"] = previous
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_torch_device — MPS guard (torch.backends.mps missing)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_get_torch_device_cpu_when_mps_backend_missing(mock_torch):
|
||||
"""Falls back to cpu when torch.backends.mps does not exist (e.g. Linux wheels)."""
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
# Simulate a torch build without mps backend
|
||||
del mock_torch.backends.mps
|
||||
|
||||
from tools.video._shared import get_torch_device
|
||||
assert get_torch_device() == "cpu"
|
||||
|
||||
|
||||
def test_get_torch_device_cpu_when_mps_not_built(mock_torch):
|
||||
"""Falls back to cpu when MPS is not built into torch."""
|
||||
mock_torch.cuda.is_available.return_value = False
|
||||
mock_torch.backends.mps.is_built.return_value = False
|
||||
mock_torch.backends.mps.is_available.return_value = False
|
||||
|
||||
from tools.video._shared import get_torch_device
|
||||
assert get_torch_device() == "cpu"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# load_diffusers_pipeline — device and dtype routing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _make_pipeline_mocks(monkeypatch, *, cuda=False, mps=False, bf16=False):
|
||||
"""Helper: inject fake torch + diffusers and return the pipeline mock."""
|
||||
import importlib
|
||||
|
||||
fake_torch = MagicMock()
|
||||
fake_torch.cuda.is_available.return_value = cuda
|
||||
fake_torch.cuda.is_bf16_supported.return_value = bf16
|
||||
fake_torch.backends.mps.is_built.return_value = mps
|
||||
fake_torch.backends.mps.is_available.return_value = mps
|
||||
fake_torch.float16 = "float16"
|
||||
fake_torch.float32 = "float32"
|
||||
fake_torch.bfloat16 = "bfloat16"
|
||||
|
||||
fake_pipeline_instance = MagicMock()
|
||||
fake_pipeline_class = MagicMock()
|
||||
fake_pipeline_class.from_pretrained = MagicMock(return_value=fake_pipeline_instance)
|
||||
fake_diffusers = MagicMock()
|
||||
fake_diffusers.LTXPipeline = fake_pipeline_class
|
||||
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
monkeypatch.setitem(sys.modules, "diffusers", fake_diffusers)
|
||||
|
||||
from tools.video import _shared
|
||||
importlib.reload(_shared)
|
||||
|
||||
return _shared, fake_pipeline_class, fake_pipeline_instance
|
||||
|
||||
|
||||
def test_load_diffusers_pipeline_routes_to_mps(monkeypatch):
|
||||
"""load_diffusers_pipeline must call .to('mps') on Apple Silicon when offload is off."""
|
||||
_shared, fake_cls, fake_inst = _make_pipeline_mocks(monkeypatch, mps=True)
|
||||
|
||||
_shared.load_diffusers_pipeline("LTXPipeline", "Lightricks/LTX-Video", enable_offload=False)
|
||||
|
||||
fake_inst.to.assert_called_once_with("mps")
|
||||
# MPS should use float16 (not bfloat16, not float32)
|
||||
fake_cls.from_pretrained.assert_called_once_with(
|
||||
"Lightricks/LTX-Video", torch_dtype="float16"
|
||||
)
|
||||
|
||||
|
||||
def test_load_diffusers_pipeline_offload_falls_back_on_mps(monkeypatch):
|
||||
"""enable_offload=True on MPS must NOT call enable_model_cpu_offload() — it's CUDA-only."""
|
||||
_shared, _, fake_inst = _make_pipeline_mocks(monkeypatch, mps=True)
|
||||
|
||||
_shared.load_diffusers_pipeline("LTXPipeline", "Lightricks/LTX-Video", enable_offload=True)
|
||||
|
||||
# Must NOT call enable_model_cpu_offload() on MPS
|
||||
fake_inst.enable_model_cpu_offload.assert_not_called()
|
||||
# Must fall back to .to("mps")
|
||||
fake_inst.to.assert_called_once_with("mps")
|
||||
|
||||
|
||||
def test_load_diffusers_pipeline_cpu_uses_float32(monkeypatch):
|
||||
"""CPU fallback must use float32 — float16 is emulated and unreliable on CPU."""
|
||||
_shared, fake_cls, fake_inst = _make_pipeline_mocks(monkeypatch) # neither cuda nor mps
|
||||
|
||||
_shared.load_diffusers_pipeline("LTXPipeline", "Lightricks/LTX-Video", enable_offload=False)
|
||||
|
||||
fake_inst.to.assert_called_once_with("cpu")
|
||||
fake_cls.from_pretrained.assert_called_once_with(
|
||||
"Lightricks/LTX-Video", torch_dtype="float32"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# install_instructions — Apple Silicon guidance
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_upscale_install_instructions_mentions_apple_silicon():
|
||||
"""install_instructions must not tell M-series users they need CUDA."""
|
||||
from tools.enhancement.upscale import Upscale
|
||||
tool = Upscale()
|
||||
inst = tool.install_instructions
|
||||
assert "MPS" in inst or "Apple" in inst or "macOS" in inst, (
|
||||
f"install_instructions is CUDA-only, misleads Apple Silicon users: {inst!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module_path,class_name", [
|
||||
("tools.enhancement.face_restore", "FaceRestore"),
|
||||
("tools.video.ltx_video_local", "LTXVideoLocal"),
|
||||
])
|
||||
def test_local_gpu_tool_mentions_apple_silicon(module_path, class_name):
|
||||
"""Every LOCAL_GPU tool must tell users MPS/Apple Silicon works."""
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
cls = getattr(mod, class_name)
|
||||
tool = cls()
|
||||
inst = tool.install_instructions
|
||||
assert "MPS" in inst or "Apple" in inst or "macOS" in inst, (
|
||||
f"{class_name}.install_instructions has no Apple Silicon guidance: {inst!r}"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# upscale.py — signature guard for device=
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_upscale_build_upsampler_uses_signature_guard(monkeypatch):
|
||||
"""_build_upsampler must use inspect to check if RealESRGANer accepts device=."""
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
fake_torch = MagicMock()
|
||||
fake_torch.device = lambda x: f"device({x})"
|
||||
|
||||
fake_rrdbnet = MagicMock()
|
||||
|
||||
# Build a fake RealESRGANer whose __init__ DOES accept device=
|
||||
class FakeRealESRGANer:
|
||||
def __init__(self, *, scale, model_path, model, dni_weight, half, device=None):
|
||||
self.called_with_device = device
|
||||
fake_realesrganer_cls = FakeRealESRGANer
|
||||
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
basicsr_mock = MagicMock()
|
||||
basicsr_mock.archs.rrdbnet_arch.RRDBNet = fake_rrdbnet
|
||||
monkeypatch.setitem(sys.modules, "basicsr", basicsr_mock)
|
||||
monkeypatch.setitem(sys.modules, "basicsr.archs", basicsr_mock.archs)
|
||||
monkeypatch.setitem(sys.modules, "basicsr.archs.rrdbnet_arch", basicsr_mock.archs.rrdbnet_arch)
|
||||
|
||||
realesrgan_mock = MagicMock()
|
||||
realesrgan_mock.RealESRGANer = fake_realesrganer_cls
|
||||
monkeypatch.setitem(sys.modules, "realesrgan", realesrgan_mock)
|
||||
|
||||
fake_shared = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "tools.video._shared", fake_shared)
|
||||
fake_shared.get_torch_device.return_value = "mps"
|
||||
|
||||
from tools.enhancement import upscale
|
||||
importlib.reload(upscale)
|
||||
|
||||
tool = upscale.Upscale()
|
||||
result = tool._build_upsampler(scale=4, model_name="RealESRGAN_x4plus", denoise_strength=0.5, face_enhance=False)
|
||||
assert result.called_with_device == "device(mps)"
|
||||
|
||||
|
||||
def test_upscale_build_upsampler_skips_device_when_unsupported(monkeypatch):
|
||||
"""_build_upsampler must NOT pass device= if the installed version doesn't accept it."""
|
||||
import importlib
|
||||
|
||||
fake_torch = MagicMock()
|
||||
fake_torch.device = lambda x: f"device({x})"
|
||||
|
||||
fake_rrdbnet = MagicMock()
|
||||
|
||||
# Build a fake RealESRGANer whose __init__ does NOT accept device=
|
||||
class FakeRealESRGANerNoDevice:
|
||||
def __init__(self, *, scale, model_path, model, dni_weight, half):
|
||||
self.called_with_device = None # no device param
|
||||
fake_realesrganer_cls = FakeRealESRGANerNoDevice
|
||||
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
basicsr_mock = MagicMock()
|
||||
basicsr_mock.archs.rrdbnet_arch.RRDBNet = fake_rrdbnet
|
||||
monkeypatch.setitem(sys.modules, "basicsr", basicsr_mock)
|
||||
monkeypatch.setitem(sys.modules, "basicsr.archs", basicsr_mock.archs)
|
||||
monkeypatch.setitem(sys.modules, "basicsr.archs.rrdbnet_arch", basicsr_mock.archs.rrdbnet_arch)
|
||||
|
||||
realesrgan_mock = MagicMock()
|
||||
realesrgan_mock.RealESRGANer = fake_realesrganer_cls
|
||||
monkeypatch.setitem(sys.modules, "realesrgan", realesrgan_mock)
|
||||
|
||||
fake_shared = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "tools.video._shared", fake_shared)
|
||||
fake_shared.get_torch_device.return_value = "mps"
|
||||
|
||||
from tools.enhancement import upscale
|
||||
importlib.reload(upscale)
|
||||
|
||||
tool = upscale.Upscale()
|
||||
# Should NOT raise TypeError about unexpected keyword argument 'device'
|
||||
result = tool._build_upsampler(scale=4, model_name="RealESRGAN_x4plus", denoise_strength=0.5, face_enhance=False)
|
||||
assert result.called_with_device is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# face_restore.py — signature guard and device routing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_face_restore_uses_signature_guard(monkeypatch):
|
||||
"""FaceRestore must use inspect to check if GFPGANer accepts device=."""
|
||||
import importlib
|
||||
|
||||
fake_cv2 = MagicMock()
|
||||
fake_cv2.imread.return_value = "fake_image_data"
|
||||
fake_cv2.imwrite.return_value = True
|
||||
|
||||
fake_torch = MagicMock()
|
||||
fake_torch.device = lambda x: f"device({x})"
|
||||
|
||||
fake_rrdbnet = MagicMock()
|
||||
|
||||
# GFPGANer that DOES accept device=
|
||||
class FakeGFPGANer:
|
||||
def __init__(self, *, model_path, upscale, arch, bg_upsampler=None, device=None):
|
||||
self.called_with_device = device
|
||||
def enhance(self, img, **kwargs):
|
||||
return (None, [1], "fake_restored")
|
||||
|
||||
monkeypatch.setitem(sys.modules, "cv2", fake_cv2)
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
basicsr_mock = MagicMock()
|
||||
basicsr_mock.archs.rrdbnet_arch.RRDBNet = fake_rrdbnet
|
||||
monkeypatch.setitem(sys.modules, "basicsr", basicsr_mock)
|
||||
monkeypatch.setitem(sys.modules, "basicsr.archs", basicsr_mock.archs)
|
||||
monkeypatch.setitem(sys.modules, "basicsr.archs.rrdbnet_arch", basicsr_mock.archs.rrdbnet_arch)
|
||||
|
||||
realesrgan_mock = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "realesrgan", realesrgan_mock)
|
||||
|
||||
gfpgan_mock = MagicMock()
|
||||
gfpgan_mock.GFPGANer = FakeGFPGANer
|
||||
monkeypatch.setitem(sys.modules, "gfpgan", gfpgan_mock)
|
||||
|
||||
fake_shared = MagicMock()
|
||||
fake_shared.get_torch_device.return_value = "mps"
|
||||
monkeypatch.setitem(sys.modules, "tools.video._shared", fake_shared)
|
||||
|
||||
from tools.enhancement import face_restore
|
||||
importlib.reload(face_restore)
|
||||
|
||||
from pathlib import Path
|
||||
monkeypatch.setattr(Path, "exists", lambda self: True)
|
||||
monkeypatch.setattr(Path, "mkdir", lambda self, *a, **kw: None)
|
||||
|
||||
tool = face_restore.FaceRestore()
|
||||
result = tool.execute({"input_path": "dummy.png"})
|
||||
assert result.success is True
|
||||
@@ -37,7 +37,9 @@ class FaceRestore(BaseTool):
|
||||
|
||||
dependencies = ["python:gfpgan", "python:torch"]
|
||||
install_instructions = (
|
||||
"pip install gfpgan # Includes CodeFormer support. Requires PyTorch."
|
||||
"uv pip install gfpgan torch\n"
|
||||
"Works on: CUDA (NVIDIA), MPS (Apple Silicon M-series, macOS >= 12.3), CPU fallback.\n"
|
||||
"No CUDA build needed on macOS — uv pip install torch includes MPS support."
|
||||
)
|
||||
agent_skills = ["ffmpeg"]
|
||||
fallback = None
|
||||
@@ -121,13 +123,18 @@ class FaceRestore(BaseTool):
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import inspect
|
||||
from gfpgan import GFPGANer
|
||||
import torch
|
||||
from tools.video._shared import get_torch_device as _get_device
|
||||
except ImportError as e:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=f"Missing dependency: {e}. Run: pip install gfpgan",
|
||||
error=f"Missing dependency: {e}. Run: uv pip install gfpgan",
|
||||
)
|
||||
|
||||
_device = _get_device()
|
||||
|
||||
start = time.time()
|
||||
|
||||
# Optional background upsampler
|
||||
@@ -141,18 +148,22 @@ class FaceRestore(BaseTool):
|
||||
num_in_ch=3, num_out_ch=3, num_feat=64,
|
||||
num_block=23, num_grow_ch=32, scale=2,
|
||||
)
|
||||
bg_upsampler = RealESRGANer(
|
||||
scale=2,
|
||||
model_path=(
|
||||
bg_kwargs: dict = {
|
||||
"scale": 2,
|
||||
"model_path": (
|
||||
"https://github.com/xinntao/Real-ESRGAN/releases/download/"
|
||||
"v0.2.1/RealESRGAN_x2plus.pth"
|
||||
),
|
||||
model=realesrgan_model,
|
||||
tile=400,
|
||||
tile_pad=10,
|
||||
pre_pad=0,
|
||||
half=True,
|
||||
)
|
||||
"model": realesrgan_model,
|
||||
"tile": 400,
|
||||
"tile_pad": 10,
|
||||
"pre_pad": 0,
|
||||
"half": (_device == "cuda"),
|
||||
}
|
||||
# Guard: only pass device= if the installed version accepts it
|
||||
if "device" in inspect.signature(RealESRGANer.__init__).parameters:
|
||||
bg_kwargs["device"] = torch.device(_device)
|
||||
bg_upsampler = RealESRGANer(**bg_kwargs)
|
||||
except ImportError:
|
||||
bg_upsampler = None
|
||||
|
||||
@@ -172,12 +183,16 @@ class FaceRestore(BaseTool):
|
||||
|
||||
# Instantiate restorer
|
||||
try:
|
||||
restorer = GFPGANer(
|
||||
model_path=model_path,
|
||||
upscale=upscale,
|
||||
arch=arch,
|
||||
bg_upsampler=bg_upsampler,
|
||||
)
|
||||
restorer_kwargs: dict = {
|
||||
"model_path": model_path,
|
||||
"upscale": upscale,
|
||||
"arch": arch,
|
||||
"bg_upsampler": bg_upsampler,
|
||||
}
|
||||
# Guard: only pass device= if the installed version accepts it
|
||||
if "device" in inspect.signature(GFPGANer.__init__).parameters:
|
||||
restorer_kwargs["device"] = torch.device(_device)
|
||||
restorer = GFPGANer(**restorer_kwargs)
|
||||
except Exception as e:
|
||||
return ToolResult(
|
||||
success=False, error=f"Failed to load {model_name} model: {e}"
|
||||
|
||||
@@ -56,7 +56,11 @@ class Upscale(BaseTool):
|
||||
runtime = ToolRuntime.LOCAL_GPU
|
||||
|
||||
dependencies = ["python:realesrgan", "python:torch", "cmd:ffmpeg"]
|
||||
install_instructions = "pip install realesrgan # Requires PyTorch with CUDA"
|
||||
install_instructions = (
|
||||
"uv pip install realesrgan torch\n"
|
||||
"Works on: CUDA (NVIDIA), MPS (Apple Silicon M-series, macOS >= 12.3), CPU fallback.\n"
|
||||
"No separate CUDA build needed on macOS — uv pip install torch includes MPS support."
|
||||
)
|
||||
agent_skills = ["ffmpeg"]
|
||||
|
||||
capabilities = [
|
||||
@@ -266,6 +270,8 @@ class Upscale(BaseTool):
|
||||
face_enhance: bool,
|
||||
):
|
||||
"""Build and return a RealESRGANer instance."""
|
||||
import inspect
|
||||
|
||||
import torch
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
from realesrgan import RealESRGANer
|
||||
@@ -281,25 +287,37 @@ class Upscale(BaseTool):
|
||||
if model_name == "RealESRGAN_x4plus_anime_6B":
|
||||
model_url = f"https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/{model_name}.pth"
|
||||
|
||||
half = torch.cuda.is_available()
|
||||
from tools.video._shared import get_torch_device as _get_device
|
||||
_device = _get_device()
|
||||
half = _device == "cuda" # fp16 only safe on CUDA; MPS/CPU use fp32 for realesrgan
|
||||
|
||||
upsampler = RealESRGANer(
|
||||
scale=4,
|
||||
model_path=model_url,
|
||||
model=model,
|
||||
dni_weight=denoise_strength,
|
||||
half=half,
|
||||
)
|
||||
upsampler_kwargs: dict = {
|
||||
"scale": 4,
|
||||
"model_path": model_url,
|
||||
"model": model,
|
||||
"dni_weight": denoise_strength,
|
||||
"half": half,
|
||||
}
|
||||
# Guard: only pass device= if the installed version accepts it
|
||||
if "device" in inspect.signature(RealESRGANer.__init__).parameters:
|
||||
upsampler_kwargs["device"] = torch.device(_device)
|
||||
|
||||
upsampler = RealESRGANer(**upsampler_kwargs)
|
||||
|
||||
if face_enhance:
|
||||
from gfpgan import GFPGANer
|
||||
face_enhancer = GFPGANer(
|
||||
model_path="https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.3.pth",
|
||||
upscale=scale,
|
||||
arch="clean",
|
||||
channel_multiplier=2,
|
||||
bg_upsampler=upsampler,
|
||||
)
|
||||
face_kwargs: dict = {
|
||||
"model_path": "https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.3.pth",
|
||||
"upscale": scale,
|
||||
"arch": "clean",
|
||||
"channel_multiplier": 2,
|
||||
"bg_upsampler": upsampler,
|
||||
}
|
||||
# Guard: only pass device= if the installed version accepts it
|
||||
if "device" in inspect.signature(GFPGANer.__init__).parameters:
|
||||
face_kwargs["device"] = torch.device(_device)
|
||||
|
||||
face_enhancer = GFPGANer(**face_kwargs)
|
||||
# Monkey-patch so the caller can use the same interface
|
||||
original_enhance = upsampler.enhance
|
||||
|
||||
|
||||
+63
-7
@@ -99,7 +99,7 @@ LTX_LOCAL_VARIANTS = {
|
||||
"default_width": 768,
|
||||
"default_height": 512,
|
||||
"default_num_frames": 121,
|
||||
"fps": 24,
|
||||
"fps": 30,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -147,6 +147,39 @@ LTX2_FRAME_COUNTS = {
|
||||
}
|
||||
|
||||
|
||||
def get_torch_device() -> str:
|
||||
"""Return best available torch device: cuda > mps (Apple Silicon Metal) > cpu.
|
||||
|
||||
Priority order:
|
||||
1. cuda — NVIDIA GPU (fastest for most diffusion models)
|
||||
2. mps — Apple Silicon Metal (M1/M2/M3/M4/M5, macOS >= 12.3)
|
||||
3. cpu — fallback, always available but slow
|
||||
|
||||
MPS detection is guarded for torch builds that lack ``torch.backends.mps``
|
||||
(e.g. older pip wheels or Linux builds). We check both build-time support
|
||||
(``is_built()``) and runtime availability (``is_available()``).
|
||||
"""
|
||||
try:
|
||||
import torch as _torch # noqa: PLC0415
|
||||
except ImportError:
|
||||
return "cpu"
|
||||
if _torch.cuda.is_available():
|
||||
return "cuda"
|
||||
# Guard: torch.backends.mps may not exist on older/non-macOS builds
|
||||
try:
|
||||
mps_backend = getattr(_torch, "backends", None)
|
||||
mps_backend = getattr(mps_backend, "mps", None) if mps_backend else None
|
||||
if mps_backend is not None:
|
||||
# Check build-time support first, then runtime availability
|
||||
is_built = getattr(mps_backend, "is_built", lambda: True)()
|
||||
is_available = getattr(mps_backend, "is_available", lambda: False)()
|
||||
if is_built and is_available:
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def local_generation_enabled() -> bool:
|
||||
return os.environ.get("VIDEO_GEN_LOCAL_ENABLED", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
@@ -165,9 +198,15 @@ def local_generation_status() -> ToolStatus:
|
||||
def local_install_instructions() -> str:
|
||||
return (
|
||||
"Enable local video generation and install the diffusers stack:\n"
|
||||
" set VIDEO_GEN_LOCAL_ENABLED=true\n"
|
||||
" pip install diffusers transformers accelerate torch pillow requests\n"
|
||||
"Use a GPU with the VRAM profile listed on the selected tool."
|
||||
" export VIDEO_GEN_LOCAL_ENABLED=true\n"
|
||||
" uv pip install diffusers transformers accelerate torch pillow requests\n"
|
||||
"\n"
|
||||
"GPU support — pick what matches your hardware:\n"
|
||||
" NVIDIA CUDA — works out of the box with the above\n"
|
||||
" Apple Silicon (MPS, macOS >= 12.3) — works out of the box; no extra build\n"
|
||||
" CPU fallback — slow but functional on any machine\n"
|
||||
"\n"
|
||||
"VRAM profile: see the selected tool's resource_profile for minimum VRAM."
|
||||
)
|
||||
|
||||
|
||||
@@ -201,13 +240,30 @@ def load_diffusers_pipeline(pipeline_class: str, model_id: str, enable_offload:
|
||||
}
|
||||
pipeline_name = pipeline_map.get(pipeline_class, pipeline_class)
|
||||
pipeline_class_obj = getattr(diffusers, pipeline_name)
|
||||
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
||||
|
||||
device = get_torch_device()
|
||||
# bfloat16 is only reliable on CUDA; MPS uses float16 for inference,
|
||||
# CPU must use float32 (float16 is emulated and unreliable on CPU)
|
||||
if device == "cuda" and torch.cuda.is_bf16_supported():
|
||||
dtype = torch.bfloat16
|
||||
elif device == "cpu":
|
||||
dtype = torch.float32
|
||||
else:
|
||||
dtype = torch.float16
|
||||
|
||||
pipeline = pipeline_class_obj.from_pretrained(model_id, torch_dtype=dtype)
|
||||
|
||||
if enable_offload:
|
||||
pipeline.enable_model_cpu_offload()
|
||||
if device == "cuda":
|
||||
pipeline.enable_model_cpu_offload()
|
||||
else:
|
||||
# enable_model_cpu_offload() is CUDA-only; fall back to direct device placement
|
||||
pipeline = pipeline.to(device)
|
||||
else:
|
||||
pipeline = pipeline.to("cuda")
|
||||
pipeline = pipeline.to(device)
|
||||
|
||||
if hasattr(pipeline, "enable_attention_slicing"):
|
||||
pipeline.enable_attention_slicing()
|
||||
|
||||
if hasattr(pipeline, "vae") and pipeline.vae is not None:
|
||||
if hasattr(pipeline.vae, "enable_tiling"):
|
||||
|
||||
Reference in New Issue
Block a user