Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
32 changes: 20 additions & 12 deletions backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
70 changes: 68 additions & 2 deletions gui_workers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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():
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ qwen-vl-utils
nvidia-ml-py; sys_platform != 'darwin'
tqdm
einops
decord
pycocotools
mediapipe
triton-windows; sys_platform == 'win32'
Expand Down
2 changes: 1 addition & 1 deletion run.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions tab_captions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down