Spaces:
Running on Zero
Running on Zero
Download app.py from Shafi99/Deblur-Engine: direct link, hf CLI and curl.
- Browser
- Download file 13 kB
-
https://huggingface.co/spaces/Shafi99/Deblur-Engine/resolve/main/app.py
- Command line
-
hf download hf://spaces/Shafi99/Deblur-Engine/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Shafi99/Deblur-Engine/resolve/main/app.py
13 kB
| """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: | |
| 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)) | |
| 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) | |