File size: 6,542 Bytes
217ad7a
 
 
 
 
 
5433669
217ad7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5433669
 
 
 
 
 
 
217ad7a
5433669
 
217ad7a
5433669
217ad7a
 
 
 
 
 
5433669
 
 
 
 
 
 
 
 
 
 
 
 
 
217ad7a
5433669
 
217ad7a
5433669
 
 
 
 
 
 
217ad7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""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)