taellinglin's picture
ZeroGPU: use CUDA only inside a GPU task, fall back to the CPU if it refuses
5433669 verified
Raw History Blame Contribute Delete
6.54 kB
"""Optional CUDA for the two heavy numeric steps (colour clustering, per-label smoothing), and the Hugging Face ZeroGPU hook.
Nothing here is required: with no torch, no CUDA device, or HATCHLING_GPU=0, ling_hatch uses scikit-learn and OpenCV as before.
The GPU path gives the same kind of result, not bit-identical labels, so a sheet made on the GPU can differ in a few edge pixels
from the same sheet made on the CPU.
"""
import functools
import math
import os
try:
import spaces # present on Hugging Face Spaces; on ZeroGPU the GPU is only attached inside @spaces.GPU calls
except Exception:
spaces = None
try:
import torch
import torch.nn.functional as F
except Exception:
torch = None
MIN_PIXELS = 20000 # smaller label maps are faster on the CPU than the trip to the device
_active = 0 # how many gpu_task calls are running in this process
_failed = False # CUDA raised once: stay on the CPU from then on
# On a Hugging Face Space with ZeroGPU, torch pretends to have CUDA at all times but only really has it inside a @spaces.GPU call: outside one, the
# first CUDA operation ends in "Low-level CUDA init reached". So on a Space the GPU is used only inside a gpu_task.
ON_SPACE = spaces is not None and bool(os.environ.get('SPACE_ID') or os.environ.get('SPACES_ZERO_GPU'))
def device():
"""'cuda' when CUDA is usable and not switched off, else None. On a Space this is only so inside a gpu_task call."""
if torch is None or _failed or os.environ.get('HATCHLING_GPU', '1').lower() in ('0', 'false', 'no', 'off'):
return None
if ON_SPACE and not _active: return None
try:
return 'cuda' if torch.cuda.is_available() else None
except Exception:
return None
def fail(e):
"""CUDA refused: stay on the CPU from here on."""
global _failed
_failed = True; print('GPU path failed (%s: %s): using the CPU' % (type(e).__name__, str(e)[:120]))
def attempt(on_gpu, on_cpu):
"""Run the GPU version; if CUDA refuses for any reason, remember that and run the CPU version instead."""
if device():
try: return on_gpu()
except Exception as e: fail(e)
return on_cpu()
def gpu_task(duration=120):
"""Decorator for the functions that reach the heavy steps: ZeroGPU's @spaces.GPU where it exists, otherwise nothing. It marks the call as one in
which the GPU may be used."""
def wrap(fn):
@functools.wraps(fn)
def marked(*a, **k):
global _active
_active += 1
try: return fn(*a, **k)
finally: _active -= 1
return spaces.GPU(duration=duration)(marked) if spaces is not None else marked
return wrap
class KMeans:
"""k-means++ with n_init restarts on the GPU; the part of sklearn's KMeans that ling_hatch uses."""
def __init__(self, k, n_init=4, random_state=0, iters=60):
self.k, self.n_init, self.seed, self.iters = k, n_init, int(random_state), iters
self.cluster_centers_ = None
def fit(self, X):
dev = device()
x = torch.as_tensor(X, dtype=torch.float32, device=dev)
best = None
for r in range(self.n_init):
g = torch.Generator(device=dev).manual_seed(self.seed * 1009 + r)
c = self._init(x, g)
for _ in range(self.iters):
a = torch.cdist(x, c).argmin(1)
nc = torch.zeros_like(c).index_add_(0, a, x)
cnt = torch.bincount(a, minlength=self.k).clamp_min(1).unsqueeze(1)
nc = torch.where(torch.bincount(a, minlength=self.k).unsqueeze(1) > 0, nc / cnt, c)
moved = (nc - c).abs().max().item(); c = nc
if moved < 1e-4: break
inertia = torch.cdist(x, c).min(1).values.pow(2).sum().item()
if best is None or inertia < best[0]: best = (inertia, c)
self._c = best[1]; self.cluster_centers_ = best[1].cpu().numpy().astype('float64')
return self
def _init(self, x, g):
n = x.shape[0]
c = x[torch.randint(n, (1,), generator=g, device=x.device)]
for _ in range(1, self.k):
d2 = torch.cdist(x, c).min(1).values.pow(2)
tot = d2.sum()
i = torch.multinomial(d2 / tot, 1, generator=g) if tot > 0 else torch.randint(n, (1,), generator=g, device=x.device)
c = torch.cat([c, x[i]])
return c
def predict(self, X):
x = torch.as_tensor(X, dtype=torch.float32, device=self._c.device)
out = [torch.cdist(x[i:i + (1 << 20)], self._c).argmin(1) for i in range(0, x.shape[0], 1 << 20)]
return torch.cat(out).cpu().numpy().astype('int32')
def _kernel(sigma, dev):
"""OpenCV's GaussianBlur kernel for float32 with ksize 0: round(8 sigma + 1) | 1 taps."""
n = int(round(sigma * 8 + 1)) | 1; r = n // 2
t = torch.arange(-r, r + 1, device=dev, dtype=torch.float32)
k = torch.exp(-t * t / (2 * sigma * sigma)); return k / k.sum(), r
def _blur(x, sigma):
"""x: (1, 1, H, W) float32. Separable Gaussian with reflect-101 borders, as cv2.GaussianBlur."""
k, r = _kernel(sigma, x.device)
r = min(r, x.shape[2] - 1, x.shape[3] - 1)
if r < 1: return x
k = k[len(k) // 2 - r: len(k) // 2 + r + 1]; k = k / k.sum()
x = F.conv2d(F.pad(x, (r, r, 0, 0), mode='reflect'), k.view(1, 1, 1, -1))
return F.conv2d(F.pad(x, (0, 0, r, r), mode='reflect'), k.view(1, 1, -1, 1))
def mode_clean(lbl, sigma, size=None):
"""Each pixel takes the label with the strongest Gaussian-smoothed presence (cv2 loop of _mode_clean / the upscale in posterize).
size=(w, h): the result is bicubically upscaled to that size first, as posterize does for the working mask."""
import numpy as np
dev = device()
t = torch.as_tensor(lbl, device=dev)
best = torch.full(size[::-1] if size else tuple(t.shape), -1.0 if size else 0.0, device=dev)
out = torch.zeros(tuple(best.shape), dtype=torch.uint8, device=dev) if size else t.clone()
for v in torch.unique(t).tolist():
sc = _blur((t == v).float()[None, None], sigma)
if size: sc = F.interpolate(sc, size=(size[1], size[0]), mode='bicubic', align_corners=False)
sc = sc[0, 0]; m = sc > best
out[m] = v; best = torch.where(m, sc, best)
return out.cpu().numpy().astype(np.uint8)