File size: 8,272 Bytes
0a8b11c
 
6ddd1e8
 
 
0a8b11c
 
6ddd1e8
0a8b11c
 
 
6ddd1e8
 
0a8b11c
 
b3cdd04
0a8b11c
63c46aa
b3cdd04
0a8b11c
6ddd1e8
0a8b11c
6ddd1e8
 
 
 
 
 
 
 
 
 
0a8b11c
 
6ddd1e8
0c2e662
6ddd1e8
0a8b11c
6ddd1e8
 
0a8b11c
 
 
 
6ddd1e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c2e662
 
 
 
 
 
6ddd1e8
 
 
 
 
 
 
 
 
 
0a8b11c
 
0c2e662
 
 
 
 
 
 
0a8b11c
 
 
 
 
 
 
0c2e662
0a8b11c
 
b3cdd04
 
 
 
 
63c46aa
0c2e662
63c46aa
 
0a8b11c
 
6ddd1e8
63c46aa
6ddd1e8
 
63c46aa
 
0c2e662
 
 
6ddd1e8
 
63c46aa
 
 
 
 
0a8b11c
6ddd1e8
 
 
0c2e662
 
 
 
 
63c46aa
0c2e662
63c46aa
0a8b11c
 
0c2e662
6ddd1e8
 
0a8b11c
 
 
 
 
6ddd1e8
0a8b11c
 
0c2e662
 
 
 
 
 
 
 
2d008a8
 
 
0a8b11c
 
6ddd1e8
0c2e662
 
 
63c46aa
0a8b11c
 
 
 
b3cdd04
0a8b11c
 
 
 
63c46aa
0c2e662
63c46aa
0c2e662
 
0a8b11c
 
 
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
"""ShadeNet-5 Gradio Space: photo -> albedo, relative depth, surface normals.

Runs the 4/8-bit "MIX" ONNX export of the model on the free CPU tier
(onnxruntime only; torch is used just for the rotation resampling of the
optional enhanced mode).
"""
import json
import os
import time

import gradio as gr
import numpy as np
import onnxruntime as ort
import torch
from huggingface_hub import hf_hub_download
from PIL import Image, ImageOps

from ensemble import mean_of, median_of, tta_passes, unit_normals
from framing import crop_to_box, display_size, letterbox, resize_maps

try:                                   # present on ZeroGPU Spaces only
    import spaces
except ImportError:                    # local tests, plain CPU hosts
    spaces = None

if spaces is not None:
    # ZeroGPU hosting (the free tier for Gradio Spaces) wants one function
    # decorated with @spaces.GPU. It is never called: all inference below runs
    # on the Space's CPU through ONNX Runtime, so no GPU quota is used.
    @spaces.GPU(duration=1)
    def _zerogpu_placeholder():
        return None

MODEL_REPO = "singam96/ShadeNet-5-35M"
ONNX_FILE = "onnx/shadenet5_mix_int4_int8.onnx"
REFINER_FILE = "onnx/shadenet5_refiner.onnx"
ROT_DEG = 4.0                      # the card's enhanced recipe: mirror + +-4 deg

# config.json also tells us the input size (and is what the Hub counts as a
# download of the model).
with open(hf_hub_download(MODEL_REPO, "config.json")) as f:
    CFG = json.load(f)
SIZE = int(CFG["image_size"])

def cpu_budget() -> int:
    """CPUs this container may use (its cgroup quota), not the host's core
    count: asking ONNX Runtime for more threads than the quota made it about
    9x slower in a measurement (64 host cores, 12 CPUs of quota)."""
    try:
        quota, period = open("/sys/fs/cgroup/cpu.max").read().split()
        if quota != "max":
            return max(1, min(8, int(int(quota) / int(period))))
    except Exception:
        pass
    try:
        return max(1, min(8, len(os.sched_getaffinity(0))))
    except Exception:
        return 2


THREADS = int(os.environ.get("ORT_THREADS", cpu_budget()))
torch.set_num_threads(THREADS)
_so = ort.SessionOptions()
_so.log_severity_level = 3
_so.intra_op_num_threads = THREADS
SESS = ort.InferenceSession(hf_hub_download(MODEL_REPO, ONNX_FILE), _so,
                            providers=["CPUExecutionProvider"])
RSESS = ort.InferenceSession(hf_hub_download(MODEL_REPO, REFINER_FILE), _so,
                             providers=["CPUExecutionProvider"])

MODE_SINGLE = "Single pass"
MODE_REFINE = "Single pass + refiner (recommended)"
MODE_SIX = "Six passes (mirror + rotations, slower)"


def to_tensor(img: Image.Image) -> torch.Tensor:
    arr = np.asarray(img, dtype=np.float32) / 255.0
    return torch.from_numpy(arr.transpose(2, 0, 1)[None] * 2.0 - 1.0)


def refine(c: torch.Tensor) -> torch.Tensor:
    out = SESS.run(None, {"input_rgb": np.ascontiguousarray(c.numpy())})[0]
    return torch.from_numpy(out)


def run_refiner(photo: torch.Tensor, maps: torch.Tensor) -> torch.Tensor:
    """The small refiner (trained to turn a single pass into the six-pass result)."""
    out = RSESS.run(None, {"photo": np.ascontiguousarray(photo.numpy()),
                           "maps": np.ascontiguousarray(maps.numpy())})[0]
    return torch.from_numpy(out)


def to_img(x01: torch.Tensor) -> Image.Image:
    if x01.shape[1] == 1:
        x01 = x01.expand(-1, 3, -1, -1)
    arr = (x01[0].permute(1, 2, 0).clamp(0, 1).numpy() * 255).round()
    return Image.fromarray(arr.astype("uint8"))


def predict(image, mode, median, progress=gr.Progress()):
    if image is None:
        raise gr.Error("Upload a photo first.")
    photo = ImageOps.exif_transpose(image).convert("RGB")
    # Fit the WHOLE photo into the square input (long side -> 448, the rest
    # mirrored from the photo's own edge) and cut the padding off the maps again.
    square, box = letterbox(photo, SIZE)
    x = to_tensor(square)
    ow, oh = display_size(*photo.size)
    enhanced = mode == MODE_SIX
    use_median = bool(enhanced and median)
    diff = None
    t0 = time.time()
    with torch.no_grad():
        if enhanced:
            passes = tta_passes(
                refine, x, flip=True, rot_deg=ROT_DEG,
                progress=lambda i, n: progress((i, n), desc="forward pass"))
            mean_maps = mean_of(passes)
            maps = median_of(passes) if use_median else mean_maps
        elif mode == MODE_REFINE:
            single = refine(x)
            maps = run_refiner(x, single).float().clamp(-1.0, 1.0)
        else:
            maps = refine(x)
        maps = resize_maps(crop_to_box(unit_normals(maps.float()), box), ow, oh)
        if use_median:                     # where the two reductions disagree
            ref = resize_maps(crop_to_box(unit_normals(mean_maps.float()), box), ow, oh)
            d = ((maps - ref).abs().mean(dim=1, keepdim=True) * 8).clamp(0, 1)
            diff = to_img(d)
    dt = time.time() - t0
    albedo = to_img((maps[:, 0:3] + 1) / 2)
    depth = to_img((maps[:, 3:4] + 1) / 2)
    normal = to_img((maps[:, 4:7] + 1) / 2)
    how = {MODE_SINGLE: "1 forward pass",
           MODE_REFINE: "1 forward pass + the small refiner",
           MODE_SIX: "6 forward passes (" + ("median" if use_median else "mean") + ")"}[mode]
    note = (f"{how} in {dt:.1f} s on CPU; the whole {photo.size[0]}x{photo.size[1]} "
            f"photo is kept (model input {SIZE}x{SIZE}, long side scaled to {SIZE}).")
    if median and not enhanced:
        note += " Median only applies to the six-pass mode."
    return photo.resize((ow, oh), Image.BICUBIC), albedo, depth, normal, diff, note


EXAMPLES = [[os.path.join("examples", f), MODE_REFINE, False]
            for f in sorted(os.listdir("examples"))
            if f.lower().endswith((".jpg", ".png"))]

with gr.Blocks(title="ShadeNet-5") as demo:
    gr.Markdown(
        "# ShadeNet-5: albedo, depth and normals from one photo\n"
        "A 35M-parameter feed-forward network on a frozen DINOv2 backbone. "
        "This Space runs the 4/8-bit **MIX** ONNX export on a free CPU. "
        "Depth is relative (per-image scale and shift); normals are "
        "x right, y up, z toward the viewer.\n\n"
        "**Single pass** is one forward pass. **Single pass + refiner** adds a "
        "small trained UNet (0.91M parameters) that cleans up the maps, mostly "
        "the normals, for a fraction of a second more: it recovers about "
        "three quarters of what the slower mode gains. **Six passes** averages the "
        "mirrored and rotated (+-4 degrees) photo and is the best but about six "
        "times slower; tick **Median** to take the median of the six instead of "
        "the mean (marginally worse in the card's measurements) and see the "
        "difference image. "
        f"[Model card and files](https://huggingface.co/{MODEL_REPO}). "
        "The example photos are Flickr8k photos shown for illustration only; "
        "see the model card for their licence status.")
    with gr.Row():
        with gr.Column():
            inp = gr.Image(type="pil", label="Photo", sources=["upload", "clipboard"])
            mode = gr.Radio([MODE_SINGLE, MODE_REFINE, MODE_SIX], value=MODE_REFINE,
                            label="Mode")
            med = gr.Checkbox(False, label="Median instead of mean (six passes only)",
                              interactive=False)
            btn = gr.Button("Decompose", variant="primary")
            msg = gr.Markdown()
        with gr.Column():
            with gr.Row():
                o_in = gr.Image(label="Input")
                o_alb = gr.Image(label="Albedo")
            with gr.Row():
                o_dep = gr.Image(label="Depth (lighter = farther)")
                o_nor = gr.Image(label="Normal")
            o_diff = gr.Image(label="Median minus mean (x8, all maps; black = identical)")
    mode.change(lambda m: gr.update(interactive=(m == MODE_SIX), value=False), mode, med)
    outs = [o_in, o_alb, o_dep, o_nor, o_diff, msg]
    btn.click(predict, [inp, mode, med], outs)
    gr.Examples(EXAMPLES, [inp, mode, med], outs, fn=predict, cache_examples=False)

if __name__ == "__main__":
    demo.queue(max_size=8).launch()