File size: 12,993 Bytes
3a0c45e
 
 
 
 
 
 
 
 
 
 
 
 
4668ada
cec97c6
e15ab07
3a0c45e
 
 
 
 
 
 
 
 
ab212d8
 
 
 
 
 
 
3a0c45e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cec97c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a0c45e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cec97c6
3a0c45e
 
 
 
 
 
 
 
 
 
 
 
 
 
cec97c6
3a0c45e
 
 
 
 
 
 
c6b261d
 
 
 
 
 
 
3a0c45e
c6b261d
 
 
3a0c45e
 
 
e15ab07
 
 
 
 
 
 
cec97c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4290242
 
ab212d8
 
4290242
 
 
 
ab212d8
 
 
 
 
 
 
 
 
 
 
 
 
4290242
 
e15ab07
3a0c45e
 
4290242
3a0c45e
 
e15ab07
 
 
4668ada
 
 
 
 
 
e15ab07
 
 
 
ab212d8
 
 
 
 
 
cec97c6
4668ada
3a0c45e
cec97c6
 
 
 
 
e15ab07
 
ab212d8
 
 
 
 
 
 
 
 
60224ac
4668ada
e15ab07
4668ada
 
 
 
 
e15ab07
ab212d8
 
 
 
 
 
4668ada
 
 
 
 
 
3a0c45e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60224ac
 
 
 
 
 
4668ada
 
60224ac
4668ada
 
3a0c45e
 
67c592b
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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
"""iPhoneBlur Restore on ZeroGPU.

Full resolution, no lossy step anywhere in the pipeline: the photo is decoded, restored
in overlapping 512 px tiles and handed back as PNG.

Tiles are kept at 512 px on purpose. The channel attention averages over whatever it is
given, so feeding a whole photo changes those statistics away from what the model was
trained on. Measured on the test split, 512 px tiling scored 30.25 dB against 29.59 dB
for whole-image inference, and on one image the gap was 8 dB. Tiling is a quality
decision here, not only a memory one.
"""
import os
import re
import tempfile
import threading
import time

import gradio as gr
import numpy as np
import torch
from huggingface_hub import hf_hub_download
from PIL import Image

from restore_net import RestoreNet

try:                      # iPhones shoot HEIC by default, Pillow cannot read it unaided
    import pillow_heif
    pillow_heif.register_heif_opener()
    HEIF = True
except Exception:
    HEIF = False

try:
    import spaces
except ImportError:                                   # runs locally without ZeroGPU too
    class _Stub:
        @staticmethod
        def GPU(*a, **kw):
            if a and callable(a[0]):
                return a[0]

            def wrap(fn):
                return fn
            return wrap
    spaces = _Stub()

REPO = os.environ.get("MODEL_REPO", "Shafi99/iPhoneBlur_Finetune_Models")
CKPT = os.environ.get("MODEL_FILE", "nafnet_final_weights.pth")
TILE, OVERLAP = 512, 48
BATCH = 8
MAX_PIXELS = 40_000_000       # refuse absurd inputs rather than hang
Image.MAX_IMAGE_PIXELS = None


def _infer_config(sd):
    width = sd["intro.weight"].shape[0]

    def count(prefix):
        idx = {}
        for k in sd:
            m = re.match(r"%s\.(\d+)\.(\d+)\." % prefix, k)
            if m:
                idx.setdefault(int(m.group(1)), set()).add(int(m.group(2)))
        return tuple(len(idx[i]) for i in sorted(idx))

    mid = {int(m.group(1)) for k in sd for m in [re.match(r"middle_blks\.(\d+)\.", k)] if m}
    return dict(img_channel=sd["intro.weight"].shape[1], width=width,
                enc_blk_nums=count("encoders"), dec_blk_nums=count("decoders"),
                middle_blk_num=len(mid))


def _load():
    path = hf_hub_download(REPO, CKPT)
    raw = torch.load(path, map_location="cpu", weights_only=False)
    if isinstance(raw, dict):
        for key in ("params", "params_ema", "model_state_dict", "state_dict", "model", "net_g"):
            if key in raw and isinstance(raw[key], dict):
                raw = raw[key]
                break
    sd = {}
    for k, v in raw.items():
        for prefix in ("module.", "model.", "net_g."):
            if k.startswith(prefix):
                k = k[len(prefix):]
        sd[k] = v
    net = RestoreNet(**_infer_config(sd))
    net.load_state_dict(sd)          # strict: the layout must match exactly
    return net.eval()


# ZeroGPU wants the model placed on cuda at module level, not inside the GPU function.
model = _load()
if torch.cuda.is_available() or os.environ.get("SPACES_ZERO_GPU"):
    model = model.to("cuda")

# Second copy, kept on the CPU, built only if the GPU allowance runs out. Measured cost
# is roughly 54 s per 512 px tile on one core and 24 s on two, so the fallback works at a
# reduced size: a full 1920x1080 photo would be 15 tiles and several minutes.
CPU_MAX_SIDE = int(os.environ.get("CPU_MAX_SIDE", "1024"))
_cpu_model = None
_cpu_lock = threading.Lock()


def _cpu_net():
    global _cpu_model
    with _cpu_lock:
        if _cpu_model is None:
            torch.set_num_threads(max(1, os.cpu_count() or 1))
            _cpu_model = _load()
    return _cpu_model


def _window():
    ramp = np.minimum(1.0, (np.arange(TILE) + 1) / OVERLAP)
    ramp = np.minimum(ramp, ramp[::-1])
    return np.outer(ramp, ramp).astype(np.float32)


def _grid(n):
    """Padded length and tile origins along one axis.

    Deliberately identical to the server pipeline in api/_core.py, including padding the
    canvas up to a whole number of strides. A simpler placement that skips that padding
    measured up to 0.28 dB worse on the test split, so the two must not drift apart.
    """
    stride = TILE - 2 * OVERLAP
    padded = max(TILE, n + (-(n - TILE) % stride if n > TILE else 0))
    pos = list(range(0, padded - TILE + 1, stride))
    if pos[-1] != padded - TILE:
        pos.append(padded - TILE)
    return padded, pos


def _tile_count(w, h):
    return len(_grid(w)[1]) * len(_grid(h)[1])


def _restore(arr, device, net=None):
    """arr: float32 HxWx3 in [0,1]. Overlapping 512 tiles, linear ramp blend."""
    h, w, _ = arr.shape
    ph, ys = _grid(h)
    pw, xs = _grid(w)
    if ph > h or pw > w:
        arr = np.pad(arr, ((0, ph - h), (0, pw - w), (0, 0)), mode="reflect")
    out = np.zeros((ph, pw, 3), np.float32)
    acc = np.zeros((ph, pw, 1), np.float32)
    win = _window()[..., None]
    coords = [(y, x) for y in ys for x in xs]
    for i in range(0, len(coords), BATCH):
        chunk = coords[i:i + BATCH]
        batch = np.stack([arr[y:y + TILE, x:x + TILE].transpose(2, 0, 1) for y, x in chunk])
        with torch.no_grad():
            res = (net or model)(torch.from_numpy(batch).to(device)).float().cpu().numpy()
        for (y, x), r in zip(chunk, res):
            out[y:y + TILE, x:x + TILE] += r.transpose(1, 2, 0) * win
            acc[y:y + TILE, x:x + TILE] += win
    return np.clip(out[:h, :w] / np.maximum(acc[:h, :w], 1e-6), 0.0, 1.0)


def _duration(image):
    """Ask for as little GPU time as is safe.

    Quota is charged per visitor and a shorter request also earns better queue
    priority, so this tracks the measured cost rather than padding it: about
    0.15 s per 512 px tile once the model is resident, plus a fixed allowance for
    getting it there.
    """
    if image is None:
        return 25
    n = _tile_count(*getattr(image, "size", (512, 512)))
    return int(min(180, 20 + n * 0.4))


@spaces.GPU(duration=_duration)
def _run(image):
    """The GPU half. Everything else stays off the GPU clock."""
    device = "cuda" if torch.cuda.is_available() else "cpu"
    arr = np.asarray(image, np.float32) / 255.0
    return Image.fromarray((_restore(arr, device) * 255.0 + 0.5).astype(np.uint8))


def _run_on_cpu(image):
    """Slow path, used only when the GPU allowance is exhausted.

    Shrunk to CPU_MAX_SIDE first, because the cost is linear in tiles and a full
    resolution photo would take several minutes on these cores.
    """
    k = min(1.0, float(CPU_MAX_SIDE) / max(image.size))
    small = image if k >= 1.0 else image.resize(
        (max(1, round(image.width * k)), max(1, round(image.height * k))), Image.LANCZOS)
    arr = np.asarray(small, np.float32) / 255.0
    out = _restore(arr, "cpu", _cpu_net())
    return Image.fromarray((out * 255.0 + 0.5).astype(np.uint8)), small.size


def _out_of_quota(err):
    t = str(err).lower()
    return ("quota" in t or "exceeded" in t or "gpu task aborted" in t
            or "zerogpu" in t or "no gpu" in t)


def _restore_any(image):
    """GPU first, CPU if the allowance is gone. Returns (image, how, size)."""
    try:
        return _run(image), "gpu", image.size
    except Exception as e:
        if not _out_of_quota(e):
            raise
        result, size = _run_on_cpu(image)
        return result, "cpu", size


def _as_image(x):
    """Accept what either caller sends: a PIL image from the page, or the file
    descriptor dict for an uploaded file. Any decode failure becomes a sentence the
    person can act on rather than a stack trace."""
    if isinstance(x, Image.Image):
        return x
    if isinstance(x, dict):
        x = x.get("path") or x.get("url")
    if not isinstance(x, (str, bytes, os.PathLike)):
        raise gr.Error("Could not read that file as an image.")
    try:
        im = Image.open(x)
        im.load()
        return im
    except Exception:
        ext = str(os.path.splitext(str(x))[1]).lower()
        if ext in (".heic", ".heif") and not HEIF:
            raise gr.Error("That is a HEIC photo and this server cannot open it. "
                           "On iPhone: Settings, Camera, Formats, Most Compatible.")
        raise gr.Error("That file could not be opened as an image. It may be damaged "
                       "or in a format this server does not support.")


def _check(image):
    if image is None:
        raise gr.Error("Choose a photo first.")
    image = _as_image(image)
    if image.width * image.height > MAX_PIXELS:
        raise gr.Error("That photo is larger than 40 megapixels. Please resize it a little.")
    return image.convert("RGB")


def _outdir():
    """A fresh directory per request. A fixed filename would let two visitors
    overwrite each other's result."""
    return tempfile.mkdtemp(prefix="restore_")


def restore(image):
    """The built-in page: both halves for the slider, plus a lossless download."""
    image = _check(image)
    t0 = time.time()
    try:
        result, how, size = _restore_any(image)
    except gr.Error:
        raise
    except Exception as e:
        raise gr.Error(_friendly(e))
    dt = time.time() - t0
    path = os.path.join(_outdir(), "restored.png")
    result.save(path, "PNG", optimize=False)     # lossless, nothing added after the model
    n = _tile_count(*size)
    note = "%d by %d px, %d tiles, %.1f s on the %s" % (size[0], size[1], n, dt, how.upper())
    if how == "cpu":
        note += ". The shared GPU was busy, so this ran on the processor at reduced size."
    return (image.resize(size) if size != image.size else image, result), path, note


def _friendly(e):
    t = str(e).lower()
    if "memory" in t or "alloc" in t:
        return "That photo was too large to process. Try one under about 40 megapixels."
    if "cuda" in t or "gpu" in t:
        return "The shared GPU could not take this one. Try again in a moment."
    return "Something went wrong while restoring that photo. Try another one."


def restore_api(image):
    """Lean endpoint for a standalone front end.

    Returns two files rather than one: a small preview to show straight away, and the
    lossless PNG. Both come back as URLs, so the browser only pulls the big one if the
    visitor actually clicks download. Measured on a 0.9 Mbps line, the PNG alone took
    23.6 s to transfer while the GPU work took 4.2 s, so the preview is what makes this
    usable on a slow connection. Nothing is re-compressed in the file people keep.
    """
    try:
        result, how, _size = _restore_any(_check(image))
    except gr.Error:
        raise
    except Exception as e:                      # never let a raw traceback reach the page
        raise gr.Error(_friendly(e))
    d = _outdir()
    png = os.path.join(d, "restored.png")
    preview = os.path.join(d, "preview.webp")
    result.save(png, "PNG", optimize=False)
    result.save(preview, "WEBP", quality=92, method=4)
    return preview, png


CSS = ".contain{max-width:1100px!important}"

with gr.Blocks(title="iPhoneBlur Restore") as demo:
    gr.Markdown(
        "# iPhoneBlur Restore\n"
        "Sharpen photos spoiled by motion blur. Processed at full resolution and returned "
        "as PNG, so nothing is re-compressed after restoring. Drag the line to compare."
    )
    with gr.Row():
        with gr.Column(scale=1):
            inp = gr.Image(type="pil", label="Blurry photo", sources=["upload", "clipboard"],
                           image_mode="RGB", format="png", height=320)
            go = gr.Button("Restore", variant="primary")
            info = gr.Markdown("")
            dl = gr.DownloadButton("Download full resolution PNG", visible=True)
        with gr.Column(scale=2):
            out = gr.ImageSlider(label="Before and after", type="pil", format="png",
                                 slider_position=50, height=520)
    gr.Markdown(
        "Built from the iPhoneBlur benchmark of real handheld phone blur. "
        "Trained on pairs from one device, so other cameras and other kinds of blur "
        "(out of focus, very dark, heavy noise) may not improve."
    )
    go.click(restore, inputs=inp, outputs=[out, dl, info], show_progress="full")

    # Same model, lean payload, for the standalone page hosted elsewhere. Real
    # components rather than gr.api, because those serialise the result as a PNG file
    # the caller can fetch; gr.api returned the repr of the image object instead.
    with gr.Row(visible=False):
        api_in = gr.Image(type="pil", image_mode="RGB", format="png")
        api_preview = gr.File()      # gr.File passes bytes through untouched,
        api_full = gr.File()         # gr.Image would re-encode and break "lossless"
        api_btn = gr.Button("api")
    api_btn.click(restore_api, inputs=api_in, outputs=[api_preview, api_full],
                  api_name="restore_image")

if __name__ == "__main__":
    demo.queue(max_size=20).launch(css=CSS, theme=gr.themes.Soft(), show_error=True)