File size: 14,037 Bytes
5e0bc83
 
 
1b2bbee
 
 
 
 
 
 
 
 
 
 
 
 
 
5e0bc83
 
 
 
 
 
 
 
 
 
 
 
 
ff7acef
 
 
 
 
7caae58
 
 
 
 
 
 
 
651251b
 
 
7caae58
 
651251b
 
7caae58
 
 
ff7acef
7caae58
 
 
 
 
 
 
ff7acef
 
7caae58
 
ff7acef
5e0bc83
651251b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e0bc83
2fa0b62
 
 
 
5e0bc83
 
e4977dc
 
 
 
 
 
 
 
 
5e0bc83
 
ff7acef
7caae58
 
ff7acef
 
7caae58
ff7acef
 
 
7caae58
ff7acef
 
 
 
7caae58
 
 
ff7acef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7caae58
 
 
 
651251b
 
 
 
 
 
7caae58
 
 
 
 
ff7acef
 
 
 
651251b
 
 
 
 
 
 
 
ff7acef
 
7caae58
ff7acef
 
 
 
651251b
7caae58
651251b
 
 
 
 
 
 
 
 
 
 
 
7caae58
 
 
5e0bc83
651251b
5e0bc83
 
 
 
 
7caae58
651251b
 
 
 
 
 
 
 
 
 
 
5e0bc83
 
651251b
 
5e0bc83
 
7caae58
 
 
 
ff7acef
 
 
651251b
 
 
 
 
ff7acef
7caae58
 
 
 
 
 
 
 
 
5e0bc83
 
7caae58
 
5e0bc83
 
 
8382183
 
 
 
 
 
 
 
 
 
7caae58
e4977dc
8382183
 
 
 
 
 
 
 
7caae58
 
651251b
 
ff7acef
7caae58
8382183
 
 
5e0bc83
651251b
 
 
 
 
 
 
 
 
 
 
 
5e0bc83
 
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
"""Gradio Space demo for the Universal Upscale model."""
from pathlib import Path

import gradio_client.utils as gutils

# Monkey-patch gradio_client schema parser to handle boolean additionalProperties in Gradio 5.
_orig_json_schema = gutils._json_schema_to_python_type


def _safe_json_schema_to_python_type(schema, defs=None):
    if isinstance(schema, bool):
        return "Any" if schema else "Never"
    return _orig_json_schema(schema, defs)


gutils._json_schema_to_python_type = _safe_json_schema_to_python_type

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

try:
    import spaces
    gpu_decorator = spaces.GPU
except ImportError:
    def gpu_decorator(fn):
        return fn

MODEL_PATH = Path(__file__).resolve().parent / "model_scripted.pt"
# The distilled student ships beside it as its own TorchScript file: the same torch runtime the
# default engine already uses, so the student lands on the Space GPU. The ONNX build was dropped
# because the Space installs the CPU-only `onnxruntime` wheel, which pinned it to CPU.
DISTILLED_PT_PATH = Path(__file__).resolve().parent / "model_distilled.pt"
# If the Space folder does not carry model_distilled.pt, it is pulled from the distilled repo on first use.
DISTILLED_REPO_ID = "tailot/universal-upscale-distilled"

MAX_SIDE = 4096
# Largest scale factor verified end-to-end for the published checkpoint. The slider is capped
# here; "Target Long Side" still accepts any size up to MAX_SIDE.
MAX_SCALE = 9.2

# The distilled student renders ONE factor natively. Any other factor is produced by resampling
# its output: area-downsample below the native factor, bicubic above it. The Space UI locks the
# scale controls to this factor when the distilled model is selected; the only remaining shrink is
# the global MAX_SIDE safety cap.
DISTILLED_NATIVE = 9

DEFAULT_SCALE = 4.0

# Plain strings (not (label, value) tuples) so the dropdown works on any Gradio 5 minor version;
# upscale() maps the label back to a model key.
MODEL_LIIF = "🔍 Universal Upscale - arbitrary scale 1x-9.2x (default)"
MODEL_DISTILLED = "⚡ Universal Upscale Distilled - fixed 9x, runs on GPU"
MODEL_CHOICES = [MODEL_LIIF, MODEL_DISTILLED]


def normalize_model_choice(value) -> str:
    """Map the dropdown selection to a model key.



    Gradio pads short example rows with None, and a Dropdown can hand back either the label or

    the value, so the mapping is deliberately loose: anything mentioning "distilled" (or the old

    ONNX labels) is the fixed-factor student, everything else stays on the published default.

    """
    text = str(value or "").strip().lower()
    return "distilled" if "distill" in text or text in ("onnx", "student", "torchscript") else "liif"

def scale_controls_for_model(model_choice):
    """Lock the scale controls to the distilled student's native factor."""
    if normalize_model_choice(model_choice) == "distilled":
        native = float(DISTILLED_NATIVE)
        return (
            gr.update(
                value=native,
                interactive=False,
                minimum=native,
                maximum=native,
                label=f"Scale Factor (fixed {DISTILLED_NATIVE:g}x)",
            ),
            gr.update(
                value=0,
                interactive=False,
                label="Target Long Side (px) - disabled for the distilled model",
            ),
        )
    return (
        gr.update(
            value=DEFAULT_SCALE,
            interactive=True,
            minimum=1.0,
            maximum=float(MAX_SCALE),
            label="Scale Factor",
        ),
        gr.update(
            value=0,
            interactive=True,
            label="Or Target Long Side (px) [0 = use scale factor]",
        ),
    )



def load_model(device: str):
    model = torch.jit.load(str(MODEL_PATH), map_location=device)
    model.eval()
    return model


def resolve_factor(input_size, scale, target_long_side):
    """Scale factor to apply: an explicit target size wins, scale requests stay under MAX_SCALE."""
    target = float(target_long_side) if target_long_side is not None else 0.0
    if target > 0:
        return target / float(max(input_size)), ""
    factor = float(scale if scale is not None else 4.0)
    if factor > MAX_SCALE:
        return MAX_SCALE, f" (clamped to the tested maximum {MAX_SCALE:g}x)"
    return factor, ""


_DISTILLED = None  # (model, native factor, device), built once per container


def distilled_model():
    """(model, native factor, device) for the distilled student, built once per container.



    The student is loaded with the same torch runtime as the default engine, so it runs on the

    Space GPU whenever there is one. ONNX Runtime was dropped on purpose: the Space installs the

    CPU-only `onnxruntime` wheel, so the ONNX build of these weights could never reach the GPU.

    """
    global _DISTILLED
    if _DISTILLED is not None:
        return _DISTILLED
    path = DISTILLED_PT_PATH
    if not path.exists():
        from huggingface_hub import hf_hub_download

        path = Path(hf_hub_download(repo_id=DISTILLED_REPO_ID, filename="model_scripted.pt"))
    device = "cuda" if torch.cuda.is_available() else "cpu"
    model = torch.jit.load(str(path), map_location=device)
    model.eval()
    with torch.no_grad():
        # Read the fixed factor from the model itself instead of trusting a constant.
        probe = model(torch.zeros(1, 3, 8, 9, device=device))
        native = int(round(max(probe.shape[-2] / 8.0, probe.shape[-1] / 9.0)))
        # Warm the kernels with one full window (tile 64 + 2*6 padding, the defaults of
        # upscale_distilled below) so the first real request does not pay for cuDNN kernel
        # selection, which costs seconds on a cold GPU container.
        model(torch.zeros(1, 3, 76, 76, device=device))
    _DISTILLED = (model, native, device)
    return _DISTILLED


def upscale_distilled(rgb, scale, native, out_w, out_h, model=None, device="cpu",

                      tile: int = 64, pad: int = 6):
    """Tiled inference in LR space for the fixed-factor student.



    Same recipe as scripts/upscale_onnx.py: run the padded tile, resample the WHOLE padded tile

    to its own target size (so the crop offset stays inside it), then paste the crop on the canvas.



    Each tile owns the canvas span up to the NEXT tile origin, so the spans butt together exactly.

    Sizing the paste from that grid instead of from round(tile * scale) is what closes the seams:

    at a fractional scale round(top * scale) can land a pixel past where the previous tile's

    round((bottom - top) * scale) stopped, and the pixel in between kept the canvas' initial

    value - a black hairline every few tiles whenever scale * tile is not an integer.

    """
    height, width = rgb.shape[:2]
    canvas = np.zeros((out_h, out_w, 3), dtype=np.uint8)
    resample = Image.Resampling.LANCZOS if native > scale else Image.Resampling.BICUBIC
    tile = max(8, min(int(tile), height))
    # Every window keeps the SAME size: at the borders it slides inward instead of shrinking.
    # TorchScript re-specialises its fused convolutions for the first window of each new shape
    # (~0.3 s each), so a variable window would make the first request pay for up to nine shapes.
    win_h, win_w = min(tile + 2 * pad, height), min(tile + 2 * pad, width)
    tops, lefts = list(range(0, height, tile)), list(range(0, width, tile))
    # Canvas cuts: tile k paints [cut[k], cut[k+1]), so consecutive tiles share no pixel and
    # leave none out, whatever the scale.
    row_cuts = [int(round(t * scale)) for t in tops] + [out_h]
    col_cuts = [int(round(l * scale)) for l in lefts] + [out_w]
    written = np.zeros((out_h, out_w), dtype=bool)
    for k, top in enumerate(tops):
        for j, left in enumerate(lefts):
            y0, x0 = max(0, min(top - pad, height - win_h)), max(0, min(left - pad, width - win_w))
            y1, x1 = y0 + win_h, x0 + win_w
            patch = rgb[y0:y1, x0:x1].astype(np.float32) / 255.0
            tensor = torch.from_numpy(patch).permute(2, 0, 1).unsqueeze(0).to(device)
            with torch.no_grad():
                sr = model(tensor)[0].clamp(0.0, 1.0).permute(1, 2, 0).cpu().numpy()
            sr = (sr * 255.0).round().astype(np.uint8)
            full_h, full_w = int(round((y1 - y0) * scale)), int(round((x1 - x0) * scale))
            sr = np.asarray(Image.fromarray(sr).resize((full_w, full_h), resample))
            oy = min(int(round((top - y0) * scale)), max(0, full_h - 1))
            ox = min(int(round((left - x0) * scale)), max(0, full_w - 1))
            th = min(row_cuts[k + 1] - row_cuts[k], full_h - oy, out_h - row_cuts[k])
            tw = min(col_cuts[j + 1] - col_cuts[j], full_w - ox, out_w - col_cuts[j])
            if th <= 0 or tw <= 0:
                continue
            canvas[row_cuts[k]:row_cuts[k] + th, col_cuts[j]:col_cuts[j] + tw] = sr[oy:oy + th, ox:ox + tw]
            written[row_cuts[k]:row_cuts[k] + th, col_cuts[j]:col_cuts[j] + tw] = True
    if not written.all():
        # A window clipped at the far border can run out of rows before its span ends. Those
        # last pixels take the plain resample of the whole image instead of the canvas' black.
        canvas[~written] = np.asarray(Image.fromarray(rgb).resize((out_w, out_h), resample))[~written]
    return canvas


@gpu_decorator
def upscale(image, model_choice=None, scale: float = DEFAULT_SCALE, target_long_side: float = 0.0):
    if image is None:
        return None, "No image provided."
    img = image if isinstance(image, Image.Image) else Image.open(image)
    img = img.convert("RGB")

    choice = normalize_model_choice(model_choice)

    if choice == "distilled":
        try:
            model, native, device = distilled_model()
        except Exception as exc:
            return None, f"Distilled model unavailable: {exc}"
        native = native or DISTILLED_NATIVE
        factor = float(native)
        clamped = ""
    else:
        factor, clamped = resolve_factor(img.size, scale, target_long_side)

    out_w, out_h = max(1, int(round(img.width * factor))), max(1, int(round(img.height * factor)))
    capped = max(out_w, out_h) > MAX_SIDE
    if capped:
        shrink = MAX_SIDE / float(max(out_w, out_h))
        out_w, out_h = max(1, int(out_w * shrink)), max(1, int(out_h * shrink))
    # The size actually produced, which is what the status line should report.
    applied = max(out_w / float(img.width), out_h / float(img.height))

    if choice == "distilled":
        arr = upscale_distilled(np.asarray(img, dtype=np.uint8), applied, native, out_w, out_h,
                                model=model, device=device)
        model_note = f"Universal Upscale Distilled (TorchScript, native {native}x"
        if abs(applied - native) > 1e-3:
            model_note += ", output resampled to the Space size cap)"
            clamped += f" (distilled {native:g}x capped to {MAX_SIDE} px)"
        else:
            model_note += ")"
        device = f"torch/{device}"
    else:
        device = "cuda" if torch.cuda.is_available() else "cpu"
        model = load_model(device)

        lr = torch.from_numpy(np.asarray(img, dtype=np.float32) / 255.0).permute(2, 0, 1).contiguous().unsqueeze(0).to(device)
        with torch.no_grad():
            out = model(lr, out_h, out_w)
        arr = (out.squeeze(0).permute(1, 2, 0).cpu().numpy() * 255.0).round().astype(np.uint8)
        model_note = "Universal Upscale (LIIF, arbitrary scale)"

    res_img = Image.fromarray(arr)
    status = (f"Input: {img.width}x{img.height} | Output: {res_img.width}x{res_img.height} "
              f"(Scale: {applied:.2f}x) | Model: {model_note} | Device: {device}{clamped}")
    return res_img, status


example_files = [
    [str(Path(__file__).resolve().parent / name)]
    for name in ("examples/input.png", "examples/demo_input.png")
    if (Path(__file__).resolve().parent / name).exists()
]

demo = gr.Interface(
    fn=upscale,
    inputs=[
        gr.Image(type="pil", label="Input Image"),
        gr.Dropdown(choices=MODEL_CHOICES, value=MODEL_LIIF, label="Model"),
        gr.Slider(minimum=1.0, maximum=9.2, value=4.0, step=0.2, label="Scale Factor"),
        gr.Number(value=0, label="Or Target Long Side (px) [0 = use scale factor]"),
    ],
    outputs=[
        gr.Image(type="pil", label="Upscaled Image"),
        gr.Textbox(label="Status / Image Info"),
    ],
    title="Universal Upscale",
    description=(
        "Pick the engine in the **Model** menu: **Universal Upscale** renders any factor from 1x up to the tested "
        "maximum 9.2x (or an exact target resolution), **Universal Upscale Distilled** is the 1.3 M "
        "parameter student that renders 9x natively in one pass; selecting it locks the "
        "Scale Factor and Target Long Side controls to that native factor. Tiled inference keeps high-resolution "
        "upscaling inside memory (outputs are capped at "
        "4096 px on the long side); the status line reports which model ran and what it did."
    ),
    examples=example_files if example_files else None,
)


# The distilled student has one native factor: lock the scale controls to it when selected, and
# restore the arbitrary-scale controls for the default model.
_input_components = getattr(demo, "input_components", None) or getattr(demo, "inputs", None)
if _input_components is not None and len(_input_components) >= 4:
    with demo:
        _input_components[1].change(
            fn=scale_controls_for_model,
            inputs=_input_components[1],
            outputs=[_input_components[2], _input_components[3]],
        )

if __name__ == "__main__":
    demo.launch()