Spaces:
Running on Zero
Running on Zero
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)
|