diff --git a/README.md b/README.md index a1a0dea..db5a58a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Built specifically for AI researchers and enthusiasts training custom models (Lo * **Python 3.10+** * **NVIDIA GPU** (with CUDA). * **AMD GPU** (with ROCm on Linux) +* **Apple Silicon Mac** (M-series, with Metal / MPS) ### Linux Setup @@ -74,6 +75,22 @@ pip install -r requirements.txt ***AMD GPU with ROCm support for Windows, more info here:*** https://rocm.docs.amd.com/projects/radeon-ryzen/en/latest/docs/install/installrad/windows/install-pytorch.html +### macOS Setup (Apple Silicon) + +Runs on M-series Macs using Metal / MPS. Install the default PyTorch wheels (they ship MPS support, so there is **no** CUDA index URL): +```bash +git clone https://github.com/Brekel/VisionCaptioner.git +cd VisionCaptioner +python3 -m venv venv +source venv/bin/activate +pip install torch torchvision +pip install -r requirements.txt +``` +Notes: +* NVIDIA/CUDA-only packages (`bitsandbytes`, `nvidia-ml-py`, `triton`) are skipped automatically on macOS, so the Int8/NF4 quantization options are unavailable (the app loads full precision instead). Use `None (BF16)` or FP16, or a GGUF model. +* Transformers models run on MPS. For **GGUF** models, use the built-in installer on the Captions tab (📥 button): it fetches a prebuilt **Metal** `llama-cpp-python` wheel (`pip install llama-cpp-python` does not work — see [readme_models.md](readme_models.md)). +* Launch with `python main.py` or `./run.sh`. + ## Update The easiest way to update is to use the provided update scripts, which will pull the latest code from git **and** upgrade your Python packages in one step: diff --git a/backend.py b/backend.py index 53ecb20..030b53c 100644 --- a/backend.py +++ b/backend.py @@ -73,10 +73,19 @@ def __init__(self, check_fn): def __call__(self, input_ids, scores, **kwargs): if self.check_fn and self.check_fn(): - return True + return True return False +def get_torch_device(): + """Detects the available hardware acceleration: 'cuda', 'mps', or 'cpu'.""" + if torch.cuda.is_available(): + return "cuda" + if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + return "mps" + return "cpu" + + class QwenEngine: def __init__(self): self.model = None @@ -87,11 +96,7 @@ def __init__(self): def get_device_type(self): """Detects the available hardware acceleration.""" - if torch.cuda.is_available(): - return "cuda" - elif torch.backends.mps.is_available(): - return "mps" - return "cpu" + return get_torch_device() def find_files(self, folder_path, skip_existing=False, recursive=False): files = find_media_files(folder_path, exts=IMAGE_EXTS + VIDEO_EXTS, @@ -325,6 +330,10 @@ def load_model(self, model_path, quantization_type="None", max_resolution=512, a torch_dtype = torch.float16 if self.device == "cuda" and torch.cuda.is_bf16_supported(): torch_dtype = torch.bfloat16 + elif self.device == "mps": + # MPS supports bfloat16 on recent macOS/torch. Prefer it over float16: + # many Qwen/Gemma VLMs are bf16-native and float16 overflows to NaN on MPS. + torch_dtype = torch.bfloat16 # Quantization (BitsAndBytes) quant_config = None @@ -684,12 +693,7 @@ class SAM3Engine: def __init__(self): self.model = None self.processor = None - if torch.cuda.is_available(): - self.device = "cuda" - elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): - self.device = "mps" - else: - self.device = "cpu" + self.device = get_torch_device() def is_available(self): try: @@ -761,6 +765,10 @@ def generate_mask(self, image_input, prompt, max_dimension=1024, conf_threshold= # without it some Torch/CUDA combos raise "mat1 and mat2 must have the same dtype". if self.device == "cuda": autocast_ctx = torch.autocast(device_type="cuda", dtype=torch.bfloat16) + elif self.device == "mps": + # MPS autocast supports float16 (not bfloat16); needed so the model's + # mixed bf16/fp32 weights promote consistently and avoid dtype-mismatch. + autocast_ctx = torch.autocast(device_type="mps", dtype=torch.float16) else: import contextlib autocast_ctx = contextlib.nullcontext() diff --git a/gui_workers.py b/gui_workers.py index ff1a318..bd6e946 100644 --- a/gui_workers.py +++ b/gui_workers.py @@ -1,8 +1,10 @@ import os +import re import time import gc import random import platform +import subprocess import datetime import shutil from PySide6.QtCore import QThread, Signal, QObject @@ -21,8 +23,13 @@ class GPUMonitorWorker(QThread): stats_update = Signal(float, float, float, int, bool) def run(self): - is_supported = platform.system() in ["Windows", "Linux"] - if not is_supported: + system = platform.system() + + if system == "Darwin": + self._run_macos() + return + + if system not in ["Windows", "Linux"]: self.stats_update.emit(0, 0, 0, 0, False) return @@ -63,6 +70,65 @@ def run(self): except: self.stats_update.emit(0, 0, 0, 0, False) + def _run_macos(self): + """Apple Silicon: report system-wide unified-memory usage (unified memory + is shared CPU/GPU, so this reflects real GPU pressure). No per-core GPU + utilisation is available without sudo powermetrics.""" + try: + import torch + has_mps = hasattr(torch.backends, "mps") and torch.backends.mps.is_available() + except Exception: + has_mps = False + + if not has_mps: + self.stats_update.emit(0, 0, 0, 0, False) + return + + try: + total_bytes = os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE") + except (ValueError, OSError, AttributeError): + self.stats_update.emit(0, 0, 0, 0, False) + return + + total_gb = total_bytes / (1024 ** 3) + alpha = 0.2 + smoothed_mem = 0.0 + + while not self.isInterruptionRequested(): + used_bytes = self._macos_used_bytes() + if used_bytes is not None: + used_gb = used_bytes / (1024 ** 3) + cur_mem = (used_bytes / total_bytes) * 100 if total_bytes else 0.0 + smoothed_mem = (cur_mem * alpha) + (smoothed_mem * (1 - alpha)) + + # core_pct = -1 flags "no GPU-utilisation source" (needs sudo + # powermetrics on Apple Silicon); the UI shows it as N/A. + self.stats_update.emit(used_gb, total_gb, smoothed_mem, -1, True) + self.msleep(250) + + @staticmethod + def _macos_used_bytes(): + """System-wide 'Memory Used' via vm_stat: active + wired + compressed + pages (matches Activity Monitor). Returns bytes, or None on failure.""" + try: + out = subprocess.check_output(["vm_stat"], text=True, timeout=2) + except Exception: + return None + m = re.search(r"page size of (\d+) bytes", out) + try: + page = int(m.group(1)) if m else os.sysconf("SC_PAGE_SIZE") + except (ValueError, OSError, AttributeError): + return None + + def pages(label): + mm = re.search(rf"{re.escape(label)}:\s+(\d+)\.", out) + return int(mm.group(1)) if mm else 0 + + active = pages("Pages active") + wired = pages("Pages wired down") + compressed = pages("Pages occupied by compressor") + return (active + wired + compressed) * page + class TestWorker(QThread): result_ready = Signal(str, str, float) error = Signal(str) diff --git a/main.py b/main.py index 54391eb..bf96bc6 100644 --- a/main.py +++ b/main.py @@ -190,9 +190,9 @@ def setup_ui(self): QProgressBar { border: 1px solid #555; border-radius: 4px; text-align: center; background-color: #333; color: white; font-weight: bold; } QProgressBar::chunk { background-color: #a15f13; width: 20px; } """ - self.bar_vram = QProgressBar(); self.bar_vram.setStyleSheet(bar_style); self.bar_vram.setFormat("VRAM: %v GB") + self.bar_vram = QProgressBar(); self.bar_vram.setStyleSheet(bar_style); self.bar_vram.setFormat("GPU Mem: %v GB") self.bar_gpu = QProgressBar(); self.bar_gpu.setStyleSheet(bar_style); self.bar_gpu.setFormat("Core: %p%") - stats_layout.addWidget(QLabel("VRAM:")); stats_layout.addWidget(self.bar_vram) + stats_layout.addWidget(QLabel("GPU Mem:")); stats_layout.addWidget(self.bar_vram) stats_layout.addWidget(QLabel("GPU Load:")); stats_layout.addWidget(self.bar_gpu) bottom_layout.addWidget(stats_group) @@ -359,10 +359,14 @@ def lock_interface(self, locked): def update_gpu_stats(self, used_gb, total_gb, vram_pct, core_pct, available): if not available: - self.bar_vram.setFormat("No NVIDIA GPU"); self.bar_vram.setValue(0); self.bar_gpu.setValue(0); return + self.bar_vram.setFormat("GPU N/A"); self.bar_vram.setValue(0); self.bar_gpu.setValue(0); return self.bar_vram.setMaximum(int(total_gb * 100)); self.bar_vram.setValue(int(used_gb * 100)) - self.bar_vram.setFormat(f"VRAM: {used_gb:.1f} GB / {total_gb:.1f} GB ({int(vram_pct)}%)") - self.bar_gpu.setValue(int(core_pct)); self.bar_gpu.setFormat(f"GPU Load: {int(core_pct)}%") + self.bar_vram.setFormat(f"GPU Mem: {used_gb:.1f} GB / {total_gb:.1f} GB ({int(vram_pct)}%)") + if core_pct < 0: + # Utilisation unavailable (e.g. Apple Silicon without sudo powermetrics) + self.bar_gpu.setValue(0); self.bar_gpu.setFormat("GPU Load: N/A") + else: + self.bar_gpu.setValue(int(core_pct)); self.bar_gpu.setFormat(f"GPU Load: {int(core_pct)}%") def play_notification(self): if self.chk_sound.isChecked(): diff --git a/requirements.txt b/requirements.txt index 59f089b..b3051e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,6 @@ qwen-vl-utils nvidia-ml-py; sys_platform != 'darwin' tqdm einops -decord pycocotools mediapipe triton-windows; sys_platform == 'win32' diff --git a/run.sh b/run.sh old mode 100644 new mode 100755 index 2aaa3b6..eae4927 --- a/run.sh +++ b/run.sh @@ -9,7 +9,7 @@ if [ -f "venv/bin/activate" ]; then source venv/bin/activate else echo "Virtual environment not found in $SCRIPT_DIR/venv" - echo "Please ensure you have created the venv on this Linux machine." + echo "Please ensure you have created the venv (see the README for your OS)." exit 1 fi diff --git a/tab_captions.py b/tab_captions.py index 34d4fe6..ef5a347 100644 --- a/tab_captions.py +++ b/tab_captions.py @@ -96,6 +96,16 @@ def setup_ui(self): "Int8: ~50% VRAM usage, excellent quality. (CUDA Only)\n" "NF4: ~25% VRAM usage, good quality. (CUDA Only)" ) + # Int8/NF4 use BitsAndBytes, which is CUDA-only. Off CUDA (MPS/CPU) the + # backend loads full precision instead, so disable those entries there. + if getattr(self.engine, "device", "cpu") != "cuda": + model = self.combo_quant.model() + for idx in range(self.combo_quant.count()): + text = self.combo_quant.itemText(idx) + if text.startswith("Int8") or text.startswith("NF4"): + item = model.item(idx) + item.setEnabled(False) + item.setToolTip("CUDA only (BitsAndBytes).") grid_model.addWidget(self.combo_quant, 1, 0) grid_model.addWidget(QLabel("Max Resolution:"), 0, 1)