singam96 commited on
Commit
63c46aa
·
verified ·
1 Parent(s): b3cdd04

Add a median-instead-of-mean toggle with a difference image

Browse files
Files changed (3) hide show
  1. app.py +31 -14
  2. ensemble.py +30 -5
  3. test_space.py +32 -7
app.py CHANGED
@@ -15,7 +15,7 @@ import torch
15
  from huggingface_hub import hf_hub_download
16
  from PIL import Image, ImageOps
17
 
18
- from ensemble import tta_average, unit_normals
19
  from framing import crop_to_box, display_size, letterbox, resize_maps
20
 
21
  try: # present on ZeroGPU Spaces only
@@ -83,7 +83,7 @@ def to_img(x01: torch.Tensor) -> Image.Image:
83
  return Image.fromarray(arr.astype("uint8"))
84
 
85
 
86
- def predict(image, enhanced, progress=gr.Progress()):
87
  if image is None:
88
  raise gr.Error("Upload a photo first.")
89
  photo = ImageOps.exif_transpose(image).convert("RGB")
@@ -91,29 +91,39 @@ def predict(image, enhanced, progress=gr.Progress()):
91
  # mirrored from the photo's own edge) and cut the padding off the maps again.
92
  square, box = letterbox(photo, SIZE)
93
  x = to_tensor(square)
 
 
 
94
  t0 = time.time()
95
  with torch.no_grad():
96
  if enhanced:
97
- maps = tta_average(
98
  refine, x, flip=True, rot_deg=ROT_DEG,
99
  progress=lambda i, n: progress((i, n), desc="forward pass"))
 
 
100
  else:
101
  maps = refine(x)
102
- maps = crop_to_box(maps.float(), box)
103
- ow, oh = display_size(*photo.size)
104
- maps = resize_maps(maps, ow, oh) # photo's own shape, unit normals
 
 
105
  dt = time.time() - t0
106
  albedo = to_img((maps[:, 0:3] + 1) / 2)
107
  depth = to_img((maps[:, 3:4] + 1) / 2)
108
  normal = to_img((maps[:, 4:7] + 1) / 2)
109
- passes = 6 if enhanced else 1
110
- note = (f"{passes} forward pass{'es' if passes > 1 else ''} in {dt:.1f} s "
 
111
  f"on CPU; the whole {photo.size[0]}x{photo.size[1]} photo is kept "
112
  f"(model input {SIZE}x{SIZE}, long side scaled to {SIZE}).")
113
- return photo.resize((ow, oh), Image.BICUBIC), albedo, depth, normal, note
 
 
114
 
115
 
116
- EXAMPLES = [[os.path.join("examples", f), False]
117
  for f in sorted(os.listdir("examples"))
118
  if f.lower().endswith((".jpg", ".png"))]
119
 
@@ -126,7 +136,10 @@ with gr.Blocks(title="ShadeNet-5") as demo:
126
  "x right, y up, z toward the viewer.\n\n"
127
  "**Fast** is one forward pass. **Enhanced** averages six passes "
128
  "(mirror and rotations of +-4 degrees) and gives better normals and "
129
- "depth, but takes about six times as long. "
 
 
 
130
  f"[Model card and files](https://huggingface.co/{MODEL_REPO}). "
131
  "The example photos are Flickr8k photos shown for illustration only; "
132
  "see the model card for their licence status.")
@@ -134,6 +147,8 @@ with gr.Blocks(title="ShadeNet-5") as demo:
134
  with gr.Column():
135
  inp = gr.Image(type="pil", label="Photo", sources=["upload", "clipboard"])
136
  enh = gr.Checkbox(False, label="Enhanced (6 passes, slower)")
 
 
137
  btn = gr.Button("Decompose", variant="primary")
138
  msg = gr.Markdown()
139
  with gr.Column():
@@ -143,9 +158,11 @@ with gr.Blocks(title="ShadeNet-5") as demo:
143
  with gr.Row():
144
  o_dep = gr.Image(label="Depth (lighter = farther)")
145
  o_nor = gr.Image(label="Normal")
146
- btn.click(predict, [inp, enh], [o_in, o_alb, o_dep, o_nor, msg])
147
- gr.Examples(EXAMPLES, [inp, enh], [o_in, o_alb, o_dep, o_nor, msg],
148
- fn=predict, cache_examples=False)
 
 
149
 
150
  if __name__ == "__main__":
151
  demo.queue(max_size=8).launch()
 
15
  from huggingface_hub import hf_hub_download
16
  from PIL import Image, ImageOps
17
 
18
+ from ensemble import mean_of, median_of, tta_passes, unit_normals
19
  from framing import crop_to_box, display_size, letterbox, resize_maps
20
 
21
  try: # present on ZeroGPU Spaces only
 
83
  return Image.fromarray(arr.astype("uint8"))
84
 
85
 
86
+ def predict(image, enhanced, median, progress=gr.Progress()):
87
  if image is None:
88
  raise gr.Error("Upload a photo first.")
89
  photo = ImageOps.exif_transpose(image).convert("RGB")
 
91
  # mirrored from the photo's own edge) and cut the padding off the maps again.
92
  square, box = letterbox(photo, SIZE)
93
  x = to_tensor(square)
94
+ ow, oh = display_size(*photo.size)
95
+ use_median = bool(enhanced and median)
96
+ diff = None
97
  t0 = time.time()
98
  with torch.no_grad():
99
  if enhanced:
100
+ passes = tta_passes(
101
  refine, x, flip=True, rot_deg=ROT_DEG,
102
  progress=lambda i, n: progress((i, n), desc="forward pass"))
103
+ mean_maps = mean_of(passes)
104
+ maps = median_of(passes) if use_median else mean_maps
105
  else:
106
  maps = refine(x)
107
+ maps = resize_maps(crop_to_box(unit_normals(maps.float()), box), ow, oh)
108
+ if use_median: # where the two reductions disagree
109
+ ref = resize_maps(crop_to_box(unit_normals(mean_maps.float()), box), ow, oh)
110
+ d = ((maps - ref).abs().mean(dim=1, keepdim=True) * 8).clamp(0, 1)
111
+ diff = to_img(d)
112
  dt = time.time() - t0
113
  albedo = to_img((maps[:, 0:3] + 1) / 2)
114
  depth = to_img((maps[:, 3:4] + 1) / 2)
115
  normal = to_img((maps[:, 4:7] + 1) / 2)
116
+ passes_n = 6 if enhanced else 1
117
+ how = ("median" if use_median else "mean") if enhanced else "single pass"
118
+ note = (f"{passes_n} forward pass{'es' if passes_n > 1 else ''} ({how}) in {dt:.1f} s "
119
  f"on CPU; the whole {photo.size[0]}x{photo.size[1]} photo is kept "
120
  f"(model input {SIZE}x{SIZE}, long side scaled to {SIZE}).")
121
+ if median and not enhanced:
122
+ note += " Median needs Enhanced: there is nothing to take a median of in one pass."
123
+ return photo.resize((ow, oh), Image.BICUBIC), albedo, depth, normal, diff, note
124
 
125
 
126
+ EXAMPLES = [[os.path.join("examples", f), False, False]
127
  for f in sorted(os.listdir("examples"))
128
  if f.lower().endswith((".jpg", ".png"))]
129
 
 
136
  "x right, y up, z toward the viewer.\n\n"
137
  "**Fast** is one forward pass. **Enhanced** averages six passes "
138
  "(mirror and rotations of +-4 degrees) and gives better normals and "
139
+ "depth, but takes about six times as long. The model card's recipe "
140
+ "takes the per-pixel **mean** of the six; tick **Median** to take the "
141
+ "median instead (it was marginally worse in the measurements on the "
142
+ "card) and see the difference image. "
143
  f"[Model card and files](https://huggingface.co/{MODEL_REPO}). "
144
  "The example photos are Flickr8k photos shown for illustration only; "
145
  "see the model card for their licence status.")
 
147
  with gr.Column():
148
  inp = gr.Image(type="pil", label="Photo", sources=["upload", "clipboard"])
149
  enh = gr.Checkbox(False, label="Enhanced (6 passes, slower)")
150
+ med = gr.Checkbox(False, label="Median instead of mean (needs Enhanced)",
151
+ interactive=False)
152
  btn = gr.Button("Decompose", variant="primary")
153
  msg = gr.Markdown()
154
  with gr.Column():
 
158
  with gr.Row():
159
  o_dep = gr.Image(label="Depth (lighter = farther)")
160
  o_nor = gr.Image(label="Normal")
161
+ o_diff = gr.Image(label="Median minus mean (x8, all maps; black = identical)")
162
+ enh.change(lambda e: gr.update(interactive=bool(e), value=False), enh, med)
163
+ outs = [o_in, o_alb, o_dep, o_nor, o_diff, msg]
164
+ btn.click(predict, [inp, enh, med], outs)
165
+ gr.Examples(EXAMPLES, [inp, enh, med], outs, fn=predict, cache_examples=False)
166
 
167
  if __name__ == "__main__":
168
  demo.queue(max_size=8).launch()
ensemble.py CHANGED
@@ -22,17 +22,18 @@ def unit_normals(maps, eps=1e-6):
22
 
23
 
24
  @torch.no_grad()
25
- def tta_average(refine_fn, x, flip=False, rot_deg=0.0, progress=None):
26
- """Per-pixel mean over the mirror (optional) x rotations (0, -rot, +rot).
 
27
 
28
  Each pass: mirror -> rotate the photo (reflected border) -> predict ->
29
- rotate the maps back, reorienting the normals -> un-mirror. A pass only
30
- counts at the pixels its rotated canvas covers.
31
  """
32
  angles = (0.0, -float(rot_deg), float(rot_deg)) if rot_deg else (0.0,)
33
  flips = (False, True) if flip else (False,)
34
  passes = list(itertools.product(flips, angles))
35
- num = den = None
36
  for i, (f, a) in enumerate(passes):
37
  if progress is not None:
38
  progress(i, len(passes))
@@ -48,6 +49,30 @@ def tta_average(refine_fn, x, flip=False, rot_deg=0.0, progress=None):
48
  p = p.flip(-1).clone()
49
  p[:, NORMAL_X] = -p[:, NORMAL_X]
50
  valid = valid.flip(-1)
 
 
 
 
 
 
 
 
51
  num = p * valid if num is None else num + p * valid
52
  den = valid if den is None else den + valid
53
  return num / den.clamp_min(1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  @torch.no_grad()
25
+ def tta_passes(refine_fn, x, flip=False, rot_deg=0.0, progress=None):
26
+ """The passes of the mirror (optional) x rotations (0, -rot, +rot) recipe,
27
+ each brought back to the photo's frame, as a list of (maps, valid).
28
 
29
  Each pass: mirror -> rotate the photo (reflected border) -> predict ->
30
+ rotate the maps back, reorienting the normals -> un-mirror. `valid` marks
31
+ the pixels the pass covers (a rotated canvas loses the corners).
32
  """
33
  angles = (0.0, -float(rot_deg), float(rot_deg)) if rot_deg else (0.0,)
34
  flips = (False, True) if flip else (False,)
35
  passes = list(itertools.product(flips, angles))
36
+ out = []
37
  for i, (f, a) in enumerate(passes):
38
  if progress is not None:
39
  progress(i, len(passes))
 
49
  p = p.flip(-1).clone()
50
  p[:, NORMAL_X] = -p[:, NORMAL_X]
51
  valid = valid.flip(-1)
52
+ out.append((p, valid))
53
+ return out
54
+
55
+
56
+ def mean_of(passes):
57
+ """Per-pixel mean over the passes that cover the pixel (the card's recipe)."""
58
+ num = den = None
59
+ for p, valid in passes:
60
  num = p * valid if num is None else num + p * valid
61
  den = valid if den is None else den + valid
62
  return num / den.clamp_min(1.0)
63
+
64
+
65
+ def median_of(passes):
66
+ """Per-pixel median over the passes that cover the pixel (the average of the
67
+ two middle values when an even number cover it). The unrotated pass covers
68
+ everything, so every pixel has at least one value."""
69
+ P = torch.stack([p for p, _ in passes]) # [n, B, C, H, W]
70
+ V = torch.stack([v for _, v in passes]) > 0.5 # [n, B, 1, H, W]
71
+ P = torch.where(V.expand_as(P), P, torch.full_like(P, float("nan")))
72
+ return torch.nanquantile(P, 0.5, dim=0)
73
+
74
+
75
+ @torch.no_grad()
76
+ def tta_average(refine_fn, x, flip=False, rot_deg=0.0, progress=None):
77
+ """Per-pixel mean over the recipe's passes (see `tta_passes`)."""
78
+ return mean_of(tta_passes(refine_fn, x, flip, rot_deg, progress))
test_space.py CHANGED
@@ -15,7 +15,8 @@ REPO = os.path.dirname(HERE)
15
  sys.path.insert(0, HERE)
16
  os.chdir(HERE)
17
 
18
- import app # noqa: E402 (downloads config.json + the MIX ONNX from the Hub)
 
19
 
20
  sys.path.insert(0, REPO)
21
  from detail_diffuser.inference.core import tta_average as ref_tta, unit_normals as ref_unit # noqa: E402
@@ -40,25 +41,49 @@ def stand_in(c):
40
  return torch.cat([c * 0.7, c.mean(1, keepdim=True) * 0.5, c.flip(-1) * 0.3], 1)
41
 
42
 
43
- mine = app.unit_normals(app.tta_average(stand_in, x, flip=True, rot_deg=app.ROT_DEG))
44
  ref = ref_unit(ref_tta(lambda c, nz=None: stand_in(c), x, flip=True, rot_deg=app.ROT_DEG))
45
  d = float((mine - ref).abs().max())
46
  print("averaging code, max |space - repo| =", d)
47
  assert d == 0.0, d
48
 
49
- e1 = app.unit_normals(app.tta_average(app.refine, x, flip=True, rot_deg=app.ROT_DEG))
50
  assert e1.shape == (1, 7, app.SIZE, app.SIZE) and torch.isfinite(e1).all()
51
  n = e1[:, 4:7].norm(dim=1)
52
  assert float((n - 1).abs().max()) < 1e-4, "normals are not unit length"
53
  print("real model, 6 passes ok, mean |maps| =", float(e1.abs().mean()))
54
 
55
- out = app.predict(img, True)
56
- assert len(out) == 5 and out[0].size == out[1].size == img.size, (out[0].size, img.size) # whole photo, own shape
57
- print("predict ok:", out[4])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  # odd shapes: a panorama (padding longer than the photo), tall, square, tiny, big
59
  for size in [(2000, 300), (300, 2000), (448, 448), (64, 48), (1500, 1100)]:
60
  im = Image.effect_noise(size, 40).convert("RGB")
61
- out = app.predict(im, False)
62
  ow, oh = app.display_size(*size)
63
  assert out[0].size == out[1].size == out[2].size == out[3].size == (ow, oh), (size, out[0].size, (ow, oh))
64
  print("odd shapes ok")
 
15
  sys.path.insert(0, HERE)
16
  os.chdir(HERE)
17
 
18
+ import app # noqa: E402
19
+ import ensemble # noqa: E402 (downloads config.json + the MIX ONNX from the Hub)
20
 
21
  sys.path.insert(0, REPO)
22
  from detail_diffuser.inference.core import tta_average as ref_tta, unit_normals as ref_unit # noqa: E402
 
41
  return torch.cat([c * 0.7, c.mean(1, keepdim=True) * 0.5, c.flip(-1) * 0.3], 1)
42
 
43
 
44
+ mine = app.unit_normals(ensemble.tta_average(stand_in, x, flip=True, rot_deg=app.ROT_DEG))
45
  ref = ref_unit(ref_tta(lambda c, nz=None: stand_in(c), x, flip=True, rot_deg=app.ROT_DEG))
46
  d = float((mine - ref).abs().max())
47
  print("averaging code, max |space - repo| =", d)
48
  assert d == 0.0, d
49
 
50
+ e1 = app.unit_normals(ensemble.tta_average(app.refine, x, flip=True, rot_deg=app.ROT_DEG))
51
  assert e1.shape == (1, 7, app.SIZE, app.SIZE) and torch.isfinite(e1).all()
52
  n = e1[:, 4:7].norm(dim=1)
53
  assert float((n - 1).abs().max()) < 1e-4, "normals are not unit length"
54
  print("real model, 6 passes ok, mean |maps| =", float(e1.abs().mean()))
55
 
56
+ # median_of against NumPy's nanmedian on real stand-in passes (corners are covered by fewer passes)
57
+ import numpy as np # noqa: E402
58
+
59
+ passes = app.tta_passes(lambda c: stand_in(c) + 0.05 * torch.randn(1, 7, app.SIZE, app.SIZE),
60
+ x, flip=True, rot_deg=app.ROT_DEG)
61
+ assert len(passes) == 6 and any(float(v.min()) < 0.5 for _, v in passes), "rotated passes should have uncovered corners"
62
+ P = np.stack([p.numpy() for p, _ in passes]).copy()
63
+ V = np.stack([v.numpy() for _, v in passes]) > 0.5
64
+ P[~np.broadcast_to(V, P.shape)] = np.nan
65
+ want = np.nanmedian(P, axis=0)
66
+ got = app.median_of(passes).numpy()
67
+ dm = float(np.abs(got - want).max())
68
+ print("median_of vs numpy nanmedian, max diff =", dm)
69
+ assert dm < 1e-5, dm
70
+ assert float((app.median_of(passes) - app.mean_of(passes)).abs().mean()) > 1e-4, "median should differ from mean on noisy passes"
71
+ one = [passes[0]]
72
+ assert torch.equal(app.median_of(one), app.mean_of(one)), "one pass: median == mean"
73
+
74
+ out = app.predict(img, True, False)
75
+ assert len(out) == 6 and out[0].size == out[1].size == img.size, (out[0].size, img.size) # whole photo, own shape
76
+ assert out[4] is None and "(mean)" in out[5]
77
+ print("predict ok:", out[5])
78
+ out = app.predict(img, True, True)
79
+ assert out[4] is not None and out[4].size == img.size and "(median)" in out[5], out[5]
80
+ print("median ok:", out[5])
81
+ out = app.predict(img, False, True) # median without Enhanced: single pass, note says so
82
+ assert out[4] is None and "single pass" in out[5] and "needs Enhanced" in out[5], out[5]
83
  # odd shapes: a panorama (padding longer than the photo), tall, square, tiny, big
84
  for size in [(2000, 300), (300, 2000), (448, 448), (64, 48), (1500, 1100)]:
85
  im = Image.effect_noise(size, 40).convert("RGB")
86
+ out = app.predict(im, False, False)
87
  ow, oh = app.display_size(*size)
88
  assert out[0].size == out[1].size == out[2].size == out[3].size == (ow, oh), (size, out[0].size, (ow, oh))
89
  print("odd shapes ok")