texture3d 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- texture3d/__init__.py +31 -0
- texture3d/bake.py +159 -0
- texture3d/blur.py +54 -0
- texture3d/camera.py +108 -0
- texture3d/cli.py +74 -0
- texture3d/config.py +57 -0
- texture3d/io.py +52 -0
- texture3d/keyframes.py +42 -0
- texture3d/mapping/__init__.py +31 -0
- texture3d/mapping/atlas.py +246 -0
- texture3d/mapping/imageops.py +80 -0
- texture3d/mapping/labeling.py +298 -0
- texture3d/mapping/leveling.py +548 -0
- texture3d/mapping/mapper.py +114 -0
- texture3d/mapping/patches.py +287 -0
- texture3d/mapping/raster.py +185 -0
- texture3d/mapping/scoring.py +229 -0
- texture3d/mapping/topology.py +99 -0
- texture3d/mapping/types.py +199 -0
- texture3d-0.2.0.dist-info/METADATA +362 -0
- texture3d-0.2.0.dist-info/RECORD +24 -0
- texture3d-0.2.0.dist-info/WHEEL +5 -0
- texture3d-0.2.0.dist-info/entry_points.txt +2 -0
- texture3d-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Stage 6: pack the patch rectangles into one square texture, copy the
|
|
2
|
+
(levelled) pixels, write the per-corner texture coordinates, sharpen."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import List, Sequence, Tuple
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from texture3d.mapping import imageops
|
|
12
|
+
from texture3d.mapping.patches import PatchSet
|
|
13
|
+
from texture3d.mapping.types import NO_ID, PatchTooLarge, Rect, View
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compute_texture_size(rects: Sequence[Rect], multiple: int) -> int:
|
|
17
|
+
"""The square that would hold the rectangles at a 0.9 fill, at least the
|
|
18
|
+
largest side, rounded up to a power of two or a multiple."""
|
|
19
|
+
area = sum(r.area for r in rects)
|
|
20
|
+
max_side = max([max(r.width, r.height) for r in rects] + [0])
|
|
21
|
+
scaled = int(np.ceil(np.float32(area) * np.float32(1.0 / 0.9)))
|
|
22
|
+
side = max(int(np.ceil(np.sqrt(np.float32(scaled)))), max_side)
|
|
23
|
+
if multiple > 0:
|
|
24
|
+
return ((side + multiple - 1) // multiple) * multiple
|
|
25
|
+
size = 1
|
|
26
|
+
while size < side:
|
|
27
|
+
size *= 2
|
|
28
|
+
return size
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MaxRectsBinPack:
|
|
32
|
+
"""MaxRects bin packing with the bottom-left rule, rotation allowed.
|
|
33
|
+
|
|
34
|
+
Every step places, of all remaining rectangles, the one whose best
|
|
35
|
+
position is lowest (then leftmost).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, width: int, height: int):
|
|
39
|
+
self.width = width
|
|
40
|
+
self.height = height
|
|
41
|
+
self.free = np.array([[0, 0, width, height]], dtype=np.int64)
|
|
42
|
+
|
|
43
|
+
def insert(self, sizes: np.ndarray) -> Tuple[List[Tuple[Rect, int]], np.ndarray]:
|
|
44
|
+
"""Places as many of the (N, 2) ``sizes`` as fit. Returns the placed
|
|
45
|
+
rectangles with their input index, and the indices left unplaced."""
|
|
46
|
+
remaining = np.arange(len(sizes))
|
|
47
|
+
placed: List[Tuple[Rect, int]] = []
|
|
48
|
+
big = np.int64(1) << np.int64(62)
|
|
49
|
+
while len(remaining):
|
|
50
|
+
w = sizes[remaining, 0][:, None]
|
|
51
|
+
h = sizes[remaining, 1][:, None]
|
|
52
|
+
fx, fy, fw, fh = (self.free[:, i][None, :] for i in range(4))
|
|
53
|
+
fr = np.arange(len(self.free), dtype=np.int64)[None, :]
|
|
54
|
+
# Candidate keys order (top, x, free rect, orientation).
|
|
55
|
+
fit0 = (fw >= w) & (fh >= h)
|
|
56
|
+
fit1 = (fw >= h) & (fh >= w)
|
|
57
|
+
key0 = np.where(fit0, ((fy + h) << 39) | (fx << 25) | (fr << 1), big)
|
|
58
|
+
key1 = np.where(fit1, ((fy + w) << 39) | (fx << 25) | (fr << 1) | 1, big)
|
|
59
|
+
best = np.minimum(key0.min(axis=1), key1.min(axis=1))
|
|
60
|
+
feasible = best < big
|
|
61
|
+
if not feasible.any():
|
|
62
|
+
break
|
|
63
|
+
top = best >> 39
|
|
64
|
+
x = (best >> 25) & ((1 << 14) - 1)
|
|
65
|
+
top = np.where(feasible, top, big)
|
|
66
|
+
x = np.where(feasible, x, big)
|
|
67
|
+
u = int(np.lexsort((x, top))[0])
|
|
68
|
+
k = int(best[u])
|
|
69
|
+
free_index = (k >> 1) & ((1 << 24) - 1)
|
|
70
|
+
rotated = bool(k & 1)
|
|
71
|
+
fxu, fyu = int(self.free[free_index, 0]), int(self.free[free_index, 1])
|
|
72
|
+
pw, ph = int(sizes[remaining[u], 0]), int(sizes[remaining[u], 1])
|
|
73
|
+
node = Rect(fxu, fyu, ph if rotated else pw, pw if rotated else ph)
|
|
74
|
+
self._place(node)
|
|
75
|
+
placed.append((node, int(remaining[u])))
|
|
76
|
+
remaining = np.delete(remaining, u)
|
|
77
|
+
return placed, remaining
|
|
78
|
+
|
|
79
|
+
def _place(self, node: Rect) -> None:
|
|
80
|
+
fx, fy, fw, fh = (self.free[:, i] for i in range(4))
|
|
81
|
+
ux, uy, uw, uh = node.x, node.y, node.width, node.height
|
|
82
|
+
hit = ~((ux >= fx + fw) | (ux + uw <= fx) | (uy >= fy + fh) | (uy + uh <= fy))
|
|
83
|
+
pieces = []
|
|
84
|
+
for i in np.flatnonzero(hit):
|
|
85
|
+
x, y, w, h = self.free[i]
|
|
86
|
+
if ux < x + w and ux + uw > x:
|
|
87
|
+
if y < uy < y + h:
|
|
88
|
+
pieces.append((x, y, w, uy - y))
|
|
89
|
+
if uy + uh < y + h:
|
|
90
|
+
pieces.append((x, uy + uh, w, y + h - (uy + uh)))
|
|
91
|
+
if uy < y + h and uy + uh > y:
|
|
92
|
+
if x < ux < x + w:
|
|
93
|
+
pieces.append((x, y, ux - x, h))
|
|
94
|
+
if ux + uw < x + w:
|
|
95
|
+
pieces.append((ux + uw, y, x + w - (ux + uw), h))
|
|
96
|
+
kept = self.free[~hit]
|
|
97
|
+
if pieces:
|
|
98
|
+
kept = np.concatenate([kept, np.array(pieces, dtype=np.int64).reshape(-1, 4)])
|
|
99
|
+
self.free = kept
|
|
100
|
+
self._prune()
|
|
101
|
+
|
|
102
|
+
def _prune(self) -> None:
|
|
103
|
+
"""Drop free rectangles contained in another; of two identical ones
|
|
104
|
+
the earlier goes."""
|
|
105
|
+
n = len(self.free)
|
|
106
|
+
if n < 2:
|
|
107
|
+
return
|
|
108
|
+
x, y, w, h = (self.free[:, i] for i in range(4))
|
|
109
|
+
contained = (
|
|
110
|
+
(x[:, None] >= x[None, :])
|
|
111
|
+
& (y[:, None] >= y[None, :])
|
|
112
|
+
& (x[:, None] + w[:, None] <= x[None, :] + w[None, :])
|
|
113
|
+
& (y[:, None] + h[:, None] <= y[None, :] + h[None, :])
|
|
114
|
+
)
|
|
115
|
+
identical = contained & contained.T
|
|
116
|
+
np.fill_diagonal(contained, False)
|
|
117
|
+
earlier = np.arange(n)[None, :] < np.arange(n)[:, None]
|
|
118
|
+
contained &= ~(identical & earlier)
|
|
119
|
+
self.free = self.free[~contained.any(axis=1)]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def shelf_pack(sizes: np.ndarray, width: int, height: int) -> Tuple[List[Tuple[Rect, int]], np.ndarray]:
|
|
123
|
+
"""Next-fit decreasing-height shelf packing, rotation allowed: every
|
|
124
|
+
rectangle is laid so it is wider than tall, sorted by height, and placed
|
|
125
|
+
left to right on shelves stacked from the top. O(N log N), for the tens of
|
|
126
|
+
thousands of tiny patches a dense mesh seen by many views produces, where
|
|
127
|
+
MaxRects is quadratic. Returns the placed rectangles with their input
|
|
128
|
+
index, and the indices left unplaced."""
|
|
129
|
+
n = len(sizes)
|
|
130
|
+
if n == 0:
|
|
131
|
+
return [], np.zeros(0, dtype=np.int64)
|
|
132
|
+
w = sizes[:, 0].astype(np.int64)
|
|
133
|
+
h = sizes[:, 1].astype(np.int64)
|
|
134
|
+
rotated = h > w
|
|
135
|
+
pw = np.where(rotated, h, w)
|
|
136
|
+
ph = np.where(rotated, w, h)
|
|
137
|
+
order = np.lexsort((np.arange(n), -pw, -ph))
|
|
138
|
+
placed: List[Tuple[Rect, int]] = []
|
|
139
|
+
unplaced: List[int] = []
|
|
140
|
+
y = 0
|
|
141
|
+
i = 0
|
|
142
|
+
while i < n:
|
|
143
|
+
shelf_h = int(ph[order[i]])
|
|
144
|
+
if y + shelf_h > height:
|
|
145
|
+
unplaced.extend(order[i:].tolist())
|
|
146
|
+
break
|
|
147
|
+
# Take rectangles while they fit on this shelf's width; those wider
|
|
148
|
+
# than the texture can never be placed.
|
|
149
|
+
widths = pw[order[i:]]
|
|
150
|
+
x_end = np.cumsum(widths)
|
|
151
|
+
fits = x_end <= width
|
|
152
|
+
take = int(np.argmin(fits)) if not fits.all() else len(widths)
|
|
153
|
+
if take == 0:
|
|
154
|
+
unplaced.append(int(order[i]))
|
|
155
|
+
i += 1
|
|
156
|
+
continue
|
|
157
|
+
xs = x_end[:take] - widths[:take]
|
|
158
|
+
for k in range(take):
|
|
159
|
+
idx = int(order[i + k])
|
|
160
|
+
placed.append((Rect(int(xs[k]), y, int(pw[idx]), int(ph[idx])), idx))
|
|
161
|
+
y += shelf_h
|
|
162
|
+
i += take
|
|
163
|
+
return placed, np.array(unplaced, dtype=np.int64)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def pack_rects(sizes: np.ndarray, size: int, exact_limit: int) -> Tuple[List[Tuple[Rect, int]], np.ndarray]:
|
|
167
|
+
"""MaxRects up to ``exact_limit`` rectangles, the shelf packer beyond it
|
|
168
|
+
(0 = always MaxRects)."""
|
|
169
|
+
if exact_limit <= 0 or len(sizes) <= exact_limit:
|
|
170
|
+
return MaxRectsBinPack(size, size).insert(sizes)
|
|
171
|
+
return shelf_pack(sizes, size, size)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class AtlasOutput:
|
|
176
|
+
pixels: np.ndarray # (S, S, 3) BGR8
|
|
177
|
+
size: int
|
|
178
|
+
face_texcoords: np.ndarray # (F, 3, 2)
|
|
179
|
+
#: Where each textured patch landed in the atlas.
|
|
180
|
+
patch_rects: List[Rect] = field(default_factory=list)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def generate(
|
|
184
|
+
patch_set: PatchSet,
|
|
185
|
+
images: List[np.ndarray],
|
|
186
|
+
views: List[View],
|
|
187
|
+
texture_size_multiple: int = 0,
|
|
188
|
+
max_texture_size: int = 8192,
|
|
189
|
+
empty_color_bgr: Tuple[int, int, int] = (39, 127, 255),
|
|
190
|
+
sharpness_weight: float = 0.5,
|
|
191
|
+
exact_packing_limit: int = 4000,
|
|
192
|
+
) -> AtlasOutput:
|
|
193
|
+
"""Packs, copies, and writes the final texture coordinates. Raises
|
|
194
|
+
``PatchTooLarge`` when a patch does not fit the largest texture allowed.
|
|
195
|
+
Up to ``exact_packing_limit`` patches are packed with MaxRects; more
|
|
196
|
+
take the shelf packer (see ``shelf_pack``)."""
|
|
197
|
+
patches = patch_set.patches
|
|
198
|
+
for patch in patches:
|
|
199
|
+
if max_texture_size > 0 and (patch.rect.width > max_texture_size or patch.rect.height > max_texture_size):
|
|
200
|
+
raise PatchTooLarge(f"patch {patch.rect.width}x{patch.rect.height} exceeds {max_texture_size}")
|
|
201
|
+
sizes = np.array([[p.rect.width, p.rect.height] for p in patches], dtype=np.int64)
|
|
202
|
+
texture_size = compute_texture_size([p.rect for p in patches], texture_size_multiple)
|
|
203
|
+
if max_texture_size > 0:
|
|
204
|
+
texture_size = min(texture_size, max_texture_size)
|
|
205
|
+
while True:
|
|
206
|
+
placed, remaining = pack_rects(sizes, texture_size, exact_packing_limit)
|
|
207
|
+
if len(remaining) == 0 or texture_size == max_texture_size:
|
|
208
|
+
if len(remaining):
|
|
209
|
+
r = patches[int(remaining[0])].rect
|
|
210
|
+
raise PatchTooLarge(f"patch {r.width}x{r.height} does not fit a {texture_size} texture")
|
|
211
|
+
break
|
|
212
|
+
texture_size *= 2
|
|
213
|
+
if max_texture_size > 0:
|
|
214
|
+
texture_size = min(texture_size, max_texture_size)
|
|
215
|
+
|
|
216
|
+
atlas = np.empty((texture_size, texture_size, 3), dtype=np.uint8)
|
|
217
|
+
atlas[:] = np.array(empty_color_bgr, dtype=np.uint8)
|
|
218
|
+
texcoords = patch_set.face_texcoords.copy()
|
|
219
|
+
patch_rects: List[Rect] = []
|
|
220
|
+
for rect, p in placed:
|
|
221
|
+
patch = patches[p]
|
|
222
|
+
rotated = rect.width != patch.rect.width
|
|
223
|
+
if patch.label != NO_ID:
|
|
224
|
+
patch_rects.append(rect)
|
|
225
|
+
view = views[patch.label]
|
|
226
|
+
source = images[patch.label]
|
|
227
|
+
r0 = max(0, -patch.rect.y)
|
|
228
|
+
r1 = min(patch.rect.height, view.height - patch.rect.y)
|
|
229
|
+
c0 = max(0, -patch.rect.x)
|
|
230
|
+
c1 = min(patch.rect.width, view.width - patch.rect.x)
|
|
231
|
+
if r1 > r0 and c1 > c0:
|
|
232
|
+
block = source[patch.rect.y + r0 : patch.rect.y + r1, patch.rect.x + c0 : patch.rect.x + c1]
|
|
233
|
+
if rotated:
|
|
234
|
+
# A rotated placement is the transpose of the patch.
|
|
235
|
+
atlas[rect.y + c0 : rect.y + c1, rect.x + r0 : rect.x + r1] = block.transpose(1, 0, 2)
|
|
236
|
+
else:
|
|
237
|
+
atlas[rect.y + r0 : rect.y + r1, rect.x + c0 : rect.x + c1] = block
|
|
238
|
+
t = texcoords[patch.faces]
|
|
239
|
+
if rotated:
|
|
240
|
+
texcoords[patch.faces] = np.stack([t[..., 1] + rect.x, t[..., 0] + rect.y], axis=-1)
|
|
241
|
+
else:
|
|
242
|
+
texcoords[patch.faces] = t + np.array([rect.x, rect.y], dtype=np.float32)
|
|
243
|
+
|
|
244
|
+
if sharpness_weight > 0:
|
|
245
|
+
atlas = imageops.sharpen(atlas, 1.5, sharpness_weight)
|
|
246
|
+
return AtlasOutput(pixels=atlas, size=texture_size, face_texcoords=texcoords, patch_rects=patch_rects)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The image arithmetic the stages share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from texture3d.mapping.types import View
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def gray_normalized(view: View) -> np.ndarray:
|
|
14
|
+
"""Grey in 0...1: ``0.114 B + 0.587 G + 0.299 R`` over normalised bytes."""
|
|
15
|
+
bgr = view.bgr.astype(np.float32) * np.float32(1.0 / 255.0)
|
|
16
|
+
return bgr[..., 0] * np.float32(0.114) + bgr[..., 1] * np.float32(0.587) + bgr[..., 2] * np.float32(0.299)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def gradient_magnitude_blurred(gray: np.ndarray) -> np.ndarray:
|
|
20
|
+
"""Sobel 3x3 gradient magnitude at scale 1/8, then a 15x15 Gaussian blur."""
|
|
21
|
+
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3, scale=1.0 / 8.0, borderType=cv2.BORDER_REFLECT_101)
|
|
22
|
+
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3, scale=1.0 / 8.0, borderType=cv2.BORDER_REFLECT_101)
|
|
23
|
+
magnitude = cv2.magnitude(gx, gy)
|
|
24
|
+
return cv2.GaussianBlur(magnitude, (15, 15), 0, borderType=cv2.BORDER_REFLECT_101)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def invalid_region_mask(view: View) -> Optional[np.ndarray]:
|
|
28
|
+
"""Black regions reached by flood fill from eight border seeds.
|
|
29
|
+
|
|
30
|
+
Those regions are the padding left by lens undistortion and must not
|
|
31
|
+
texture anything. None when no seed is black.
|
|
32
|
+
"""
|
|
33
|
+
gray = cv2.cvtColor(view.bgr, cv2.COLOR_BGR2GRAY)
|
|
34
|
+
h, w = gray.shape
|
|
35
|
+
seeds = [(0, 0), (0, h // 2), (0, h - 1), (w // 2, h - 1), (w - 1, h - 1), (w - 1, h // 2), (w - 1, 0), (w // 2, 0)]
|
|
36
|
+
if not any(gray[y, x] == 0 for x, y in seeds):
|
|
37
|
+
return None
|
|
38
|
+
filled = np.zeros((h + 2, w + 2), dtype=np.uint8)
|
|
39
|
+
flags = 8 | cv2.FLOODFILL_MASK_ONLY | (255 << 8)
|
|
40
|
+
for x, y in seeds:
|
|
41
|
+
if gray[y, x] == 0 and filled[y + 1, x + 1] == 0:
|
|
42
|
+
cv2.floodFill(gray.copy(), filled, (x, y), 255, 0, 3, flags)
|
|
43
|
+
mask = np.full((h, w), 255, dtype=np.uint8)
|
|
44
|
+
mask[filled[1:-1, 1:-1] != 0] = 0
|
|
45
|
+
return mask
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sample_linear(image: np.ndarray, pts: np.ndarray) -> np.ndarray:
|
|
49
|
+
"""Bilinear sample between the four pixels around ``floor(pt)``.
|
|
50
|
+
|
|
51
|
+
Integer coordinates are pixel centres; pixels outside the image
|
|
52
|
+
contribute nothing. ``image`` is (H, W, 3) of any numeric type; the
|
|
53
|
+
result is float32.
|
|
54
|
+
"""
|
|
55
|
+
h, w = image.shape[:2]
|
|
56
|
+
pts = np.asarray(pts, dtype=np.float32)
|
|
57
|
+
gx = np.floor(pts[:, 0]).astype(np.int64)
|
|
58
|
+
gy = np.floor(pts[:, 1]).astype(np.int64)
|
|
59
|
+
dx = pts[:, 0] - gx.astype(np.float32)
|
|
60
|
+
dy = pts[:, 1] - gy.astype(np.float32)
|
|
61
|
+
result = np.zeros((len(pts), 3), dtype=np.float32)
|
|
62
|
+
for i, wy in ((0, 1 - dy), (1, dy)):
|
|
63
|
+
y = gy + i
|
|
64
|
+
y_ok = (y >= 0) & (y < h)
|
|
65
|
+
for j, wx in ((0, 1 - dx), (1, dx)):
|
|
66
|
+
x = gx + j
|
|
67
|
+
ok = y_ok & (x >= 0) & (x < w)
|
|
68
|
+
weight = (wx * wy)[ok]
|
|
69
|
+
result[ok] += image[y[ok], x[ok]].astype(np.float32) * weight[:, None]
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def gaussian_blur_bgr8(image: np.ndarray, sigma: float) -> np.ndarray:
|
|
74
|
+
return cv2.GaussianBlur(image, (0, 0), sigma)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sharpen(image: np.ndarray, sigma: float, weight: float) -> np.ndarray:
|
|
78
|
+
"""Unsharp mask: ``img (1 + w) - blur(img) w``."""
|
|
79
|
+
blurred = gaussian_blur_bgr8(image, sigma)
|
|
80
|
+
return cv2.addWeighted(image, 1.0 + weight, blurred, -weight, 0.0)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Stage 3: one view per face.
|
|
2
|
+
|
|
3
|
+
The face graph (faces adjacent across an edge, both seen by at least one view)
|
|
4
|
+
is an MRF whose labels are the views plus "none". A face's data cost for a
|
|
5
|
+
view falls with the view's quality, normalised by the 95th percentile of all
|
|
6
|
+
qualities so a few very sharp faces do not flatten the rest; the Potts
|
|
7
|
+
smoothness term makes neighbours prefer the same view, which is what grows
|
|
8
|
+
the patches. Solved by synchronous loopy belief propagation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Callable, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from texture3d.mapping.topology import Topology
|
|
19
|
+
from texture3d.mapping.types import NO_ID, FaceViews, Mesh, View
|
|
20
|
+
|
|
21
|
+
MAX_ENERGY = np.float32(1000)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Outcome:
|
|
26
|
+
#: Per face: view index, or NO_ID for no view.
|
|
27
|
+
labels: np.ndarray
|
|
28
|
+
#: The 95th-percentile quality the data costs were normalised by.
|
|
29
|
+
norm_quality: float
|
|
30
|
+
energy: float
|
|
31
|
+
iterations: int
|
|
32
|
+
#: Face pairs across an edge where exactly one side has no view, in face
|
|
33
|
+
#: order, then pairs whose labels differ, in the graph's edge order. The
|
|
34
|
+
#: seam leveling walks these.
|
|
35
|
+
seam_edges: np.ndarray
|
|
36
|
+
#: The face graph's undirected edges, smaller face first, in face order.
|
|
37
|
+
graph_edges: np.ndarray
|
|
38
|
+
#: Faces no view rasterised that took a neighbour's view (see ``fill_unseen``).
|
|
39
|
+
filled: Optional[np.ndarray] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def approximate_permille(values: np.ndarray, permille: float = 0.95) -> float:
|
|
43
|
+
"""Approximate ``permille`` of ``values`` with a 1000-bin histogram.
|
|
44
|
+
|
|
45
|
+
Zero when every quality is zero: the data costs then read every view as
|
|
46
|
+
as good as the best and the smoothness term decides.
|
|
47
|
+
"""
|
|
48
|
+
v = np.asarray(values, dtype=np.float32)
|
|
49
|
+
v = v[np.isfinite(v)]
|
|
50
|
+
if len(v) == 0:
|
|
51
|
+
return 0.0
|
|
52
|
+
max_quality = float(v.max())
|
|
53
|
+
if max_quality <= 0:
|
|
54
|
+
return 0.0
|
|
55
|
+
bins = 1000
|
|
56
|
+
bin_interval = np.float32(bins) / np.float32(max_quality)
|
|
57
|
+
i = (v * bin_interval).astype(np.int64)
|
|
58
|
+
freq = np.bincount(i[(i >= 0) & (i < bins)], minlength=bins)
|
|
59
|
+
total = int(freq.sum())
|
|
60
|
+
num = 0
|
|
61
|
+
upper = 0.0
|
|
62
|
+
for b in range(bins):
|
|
63
|
+
if num / total > permille:
|
|
64
|
+
return upper
|
|
65
|
+
num += int(freq[b])
|
|
66
|
+
upper = float((np.float32(b) * np.float32(max_quality)) / np.float32(bins - 1))
|
|
67
|
+
return max_quality
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BeliefPropagation:
|
|
71
|
+
"""The flattened MRF and its synchronous sweeps.
|
|
72
|
+
|
|
73
|
+
Labels per node in the order they were set; messages per directed edge
|
|
74
|
+
with one entry per label of the receiving node.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
label_offset: np.ndarray,
|
|
80
|
+
labels_flat: np.ndarray,
|
|
81
|
+
costs_flat: np.ndarray,
|
|
82
|
+
edge_from: np.ndarray,
|
|
83
|
+
edge_to: np.ndarray,
|
|
84
|
+
):
|
|
85
|
+
self.label_offset = label_offset
|
|
86
|
+
self.labels_flat = labels_flat
|
|
87
|
+
self.costs_flat = costs_flat.astype(np.float32)
|
|
88
|
+
self.edge_from = edge_from
|
|
89
|
+
self.edge_to = edge_to
|
|
90
|
+
node_count = len(label_offset) - 1
|
|
91
|
+
n_labels = np.diff(label_offset)
|
|
92
|
+
self.node_of_label = np.repeat(np.arange(node_count), n_labels)
|
|
93
|
+
|
|
94
|
+
edge_count = len(edge_from)
|
|
95
|
+
per_edge = n_labels[edge_to]
|
|
96
|
+
self.msg_offset = np.zeros(edge_count + 1, dtype=np.int64)
|
|
97
|
+
np.cumsum(per_edge, out=self.msg_offset[1:])
|
|
98
|
+
total = int(self.msg_offset[-1])
|
|
99
|
+
self.slot_edge = np.repeat(np.arange(edge_count), per_edge)
|
|
100
|
+
slot_j = np.arange(total) - self.msg_offset[self.slot_edge]
|
|
101
|
+
# Message slot -> the (node, label) it speaks about.
|
|
102
|
+
self.s_idx = label_offset[edge_to[self.slot_edge]] + slot_j
|
|
103
|
+
# A message on edge e (n1 -> n2) for n2's label j takes the minimum over
|
|
104
|
+
# n1's labels k of n1's belief without n2's message plus the Potts term.
|
|
105
|
+
# That belief lives in the slots of the reverse edge (its messages are
|
|
106
|
+
# indexed by n1's labels), so the matching label's slot is found once.
|
|
107
|
+
label_max = int(labels_flat.max()) + 1 if len(labels_flat) else 1
|
|
108
|
+
node_key = self.node_of_label * label_max + labels_flat
|
|
109
|
+
n1 = edge_from[self.slot_edge]
|
|
110
|
+
label_j = labels_flat[self.s_idx]
|
|
111
|
+
query = n1 * label_max + label_j
|
|
112
|
+
pos = np.searchsorted(node_key, query)
|
|
113
|
+
pos_c = np.minimum(pos, len(node_key) - 1)
|
|
114
|
+
found = (node_key[pos_c] == query) & (label_j != 0)
|
|
115
|
+
k = pos_c - label_offset[n1]
|
|
116
|
+
self.match = np.where(found, self.msg_offset[self.slot_edge ^ 1] + k, -1)
|
|
117
|
+
self.reverse_of_slot = self.slot_edge ^ 1
|
|
118
|
+
self.old = np.zeros(total, dtype=np.float32)
|
|
119
|
+
self.new = np.zeros(total, dtype=np.float32)
|
|
120
|
+
self.label, self.data_cost = self._first_min(self.costs_flat)
|
|
121
|
+
|
|
122
|
+
def _first_min(self, energy: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
123
|
+
"""Per node the first label of least energy, as (label, cost)."""
|
|
124
|
+
node_min = np.minimum.reduceat(energy, self.label_offset[:-1])
|
|
125
|
+
candidates = np.flatnonzero(energy == node_min[self.node_of_label])
|
|
126
|
+
_, first = np.unique(self.node_of_label[candidates], return_index=True)
|
|
127
|
+
chosen = candidates[first]
|
|
128
|
+
return self.labels_flat[chosen], self.costs_flat[chosen]
|
|
129
|
+
|
|
130
|
+
def compute_energy(self) -> float:
|
|
131
|
+
lf = self.label[self.edge_from]
|
|
132
|
+
lt = self.label[self.edge_to]
|
|
133
|
+
agree = (lf == lt) & (lf != 0)
|
|
134
|
+
return float(self.data_cost.sum(dtype=np.float64)) + float(MAX_ENERGY) * float((~agree).sum())
|
|
135
|
+
|
|
136
|
+
def sweep(self) -> None:
|
|
137
|
+
total_labels = len(self.labels_flat)
|
|
138
|
+
incoming = np.bincount(self.s_idx, weights=self.old, minlength=total_labels).astype(np.float32)
|
|
139
|
+
belief = self.costs_flat[self.s_idx] + incoming[self.s_idx] - self.old
|
|
140
|
+
segment_min = np.minimum.reduceat(belief, self.msg_offset[:-1])
|
|
141
|
+
floor = segment_min[self.reverse_of_slot] + MAX_ENERGY
|
|
142
|
+
matched = np.where(self.match >= 0, belief[np.maximum(self.match, 0)], np.float32(np.inf))
|
|
143
|
+
np.minimum(floor, matched, out=self.new)
|
|
144
|
+
# Normalise each edge's messages to a minimum of zero.
|
|
145
|
+
edge_min = np.minimum.reduceat(self.new, self.msg_offset[:-1])
|
|
146
|
+
self.new -= edge_min[self.slot_edge]
|
|
147
|
+
self.old, self.new = self.new, self.old
|
|
148
|
+
incoming = np.bincount(self.s_idx, weights=self.old, minlength=total_labels).astype(np.float32)
|
|
149
|
+
self.label, self.data_cost = self._first_min(self.costs_flat + incoming)
|
|
150
|
+
|
|
151
|
+
def optimize(self, log: Optional[Callable[[str], None]] = None) -> Tuple[float, int]:
|
|
152
|
+
"""Sweep until the energy stops falling, tolerating a few increases,
|
|
153
|
+
at most 100 sweeps."""
|
|
154
|
+
energy = self.compute_energy()
|
|
155
|
+
i = 0
|
|
156
|
+
increases = 0
|
|
157
|
+
total_increases = 0
|
|
158
|
+
while True:
|
|
159
|
+
last = energy
|
|
160
|
+
self.sweep()
|
|
161
|
+
energy = self.compute_energy()
|
|
162
|
+
diff = last - energy
|
|
163
|
+
i += 1
|
|
164
|
+
if log:
|
|
165
|
+
log(f" sweep {i}: energy {energy:.0f}")
|
|
166
|
+
if i > 100 or diff == 0:
|
|
167
|
+
break
|
|
168
|
+
if diff < 0:
|
|
169
|
+
total_increases += 1
|
|
170
|
+
increases += 1
|
|
171
|
+
if increases > 3:
|
|
172
|
+
break
|
|
173
|
+
else:
|
|
174
|
+
if total_increases > 5:
|
|
175
|
+
break
|
|
176
|
+
increases = 0
|
|
177
|
+
return energy, i
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def fill_unseen(
|
|
181
|
+
mesh: Mesh, topology: Topology, labels: np.ndarray, views: List[View], passes: int = 3
|
|
182
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
183
|
+
"""Give a face no view rasterised the view of a textured neighbour.
|
|
184
|
+
|
|
185
|
+
On a dense mesh a face smaller than a pixel in every keyframe is never
|
|
186
|
+
scored. It takes the view of the first edge neighbour that has one,
|
|
187
|
+
provided its three vertices project in front of that view's camera and
|
|
188
|
+
inside its image. Repeated ``passes`` times so small clusters fill from
|
|
189
|
+
their rim. Without this such faces land in the empty patch and speckle
|
|
190
|
+
the surface with the empty colour. Returns the new labels and the mask
|
|
191
|
+
of faces that were filled.
|
|
192
|
+
"""
|
|
193
|
+
labels = labels.copy()
|
|
194
|
+
filled = np.zeros(len(labels), dtype=bool)
|
|
195
|
+
ff = topology.face_faces
|
|
196
|
+
for _ in range(passes):
|
|
197
|
+
unlabelled = labels == NO_ID
|
|
198
|
+
neighbour_label = np.where(ff != NO_ID, labels[np.maximum(ff, 0)], NO_ID)
|
|
199
|
+
has = neighbour_label != NO_ID
|
|
200
|
+
candidates = np.flatnonzero(unlabelled & has.any(axis=1))
|
|
201
|
+
if len(candidates) == 0:
|
|
202
|
+
break
|
|
203
|
+
chosen = neighbour_label[candidates, np.argmax(has[candidates], axis=1)]
|
|
204
|
+
changed = False
|
|
205
|
+
for v in np.unique(chosen):
|
|
206
|
+
faces = candidates[chosen == v]
|
|
207
|
+
view = views[int(v)]
|
|
208
|
+
cam = view.camera.to_camera(mesh.positions[mesh.faces[faces].ravel()])
|
|
209
|
+
z = cam[:, 2]
|
|
210
|
+
safe = cam.copy()
|
|
211
|
+
safe[z <= 0, 2] = 1.0
|
|
212
|
+
px = view.camera.to_image(safe)
|
|
213
|
+
ok = (z > 0) & (px[:, 0] >= 0) & (px[:, 1] >= 0) & (px[:, 0] <= view.width - 1) & (px[:, 1] <= view.height - 1)
|
|
214
|
+
accept = faces[ok.reshape(-1, 3).all(axis=1)]
|
|
215
|
+
if len(accept):
|
|
216
|
+
labels[accept] = v
|
|
217
|
+
filled[accept] = True
|
|
218
|
+
changed = True
|
|
219
|
+
if not changed:
|
|
220
|
+
break
|
|
221
|
+
return labels, filled
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def assign(
|
|
225
|
+
mesh: Mesh,
|
|
226
|
+
topology: Topology,
|
|
227
|
+
face_views: FaceViews,
|
|
228
|
+
data_smoothness_ratio: float = 0.1,
|
|
229
|
+
log: Optional[Callable[[str], None]] = None,
|
|
230
|
+
views: Optional[List[View]] = None,
|
|
231
|
+
fill: bool = False,
|
|
232
|
+
) -> Outcome:
|
|
233
|
+
face_count = mesh.face_count
|
|
234
|
+
ff = topology.face_faces
|
|
235
|
+
f_idx = np.broadcast_to(np.arange(face_count)[:, None], ff.shape)
|
|
236
|
+
has = (ff != NO_ID) & (f_idx < ff)
|
|
237
|
+
visible = face_views.visible
|
|
238
|
+
vis_f = visible[f_idx]
|
|
239
|
+
vis_g = visible[np.where(ff != NO_ID, ff, 0)]
|
|
240
|
+
graph_mask = has & vis_f & vis_g
|
|
241
|
+
seam_mask = has & (vis_f != vis_g)
|
|
242
|
+
graph_edges = np.stack([f_idx[graph_mask], ff[graph_mask]], axis=-1)
|
|
243
|
+
seam_first = np.stack([f_idx[seam_mask], ff[seam_mask]], axis=-1)
|
|
244
|
+
|
|
245
|
+
norm_quality = approximate_permille(face_views.quality)
|
|
246
|
+
max_energy = np.float32(data_smoothness_ratio) * MAX_ENERGY
|
|
247
|
+
|
|
248
|
+
# Labels per node: 0 ("no view") first, then the face's views plus one.
|
|
249
|
+
counts = face_views.counts
|
|
250
|
+
n_labels = counts + 1
|
|
251
|
+
label_offset = np.zeros(face_count + 1, dtype=np.int64)
|
|
252
|
+
np.cumsum(n_labels, out=label_offset[1:])
|
|
253
|
+
total = int(label_offset[-1])
|
|
254
|
+
labels_flat = np.zeros(total, dtype=np.int64)
|
|
255
|
+
costs_flat = np.full(total, max_energy, dtype=np.float32)
|
|
256
|
+
is_view = np.ones(total, dtype=bool)
|
|
257
|
+
is_view[label_offset[:-1]] = False
|
|
258
|
+
labels_flat[is_view] = face_views.view + 1
|
|
259
|
+
q = face_views.quality
|
|
260
|
+
if norm_quality > 0:
|
|
261
|
+
normalized = np.where(q >= norm_quality, np.float32(1), q / np.float32(norm_quality))
|
|
262
|
+
else:
|
|
263
|
+
normalized = np.ones_like(q)
|
|
264
|
+
costs_flat[is_view] = (np.float32(1) - normalized) * max_energy
|
|
265
|
+
|
|
266
|
+
edge_from = np.stack([graph_edges[:, 0], graph_edges[:, 1]], axis=-1).ravel()
|
|
267
|
+
edge_to = np.stack([graph_edges[:, 1], graph_edges[:, 0]], axis=-1).ravel()
|
|
268
|
+
inference = BeliefPropagation(label_offset, labels_flat, costs_flat, edge_from, edge_to)
|
|
269
|
+
energy, iterations = inference.optimize(log)
|
|
270
|
+
|
|
271
|
+
labels = np.where(inference.label > 0, inference.label - 1, NO_ID)
|
|
272
|
+
filled = np.zeros(face_count, dtype=bool)
|
|
273
|
+
if fill and views is not None:
|
|
274
|
+
labels, filled = fill_unseen(mesh, topology, labels, views)
|
|
275
|
+
if log and filled.any():
|
|
276
|
+
log(f" {int(filled.sum())} unseen faces took a neighbour's view")
|
|
277
|
+
# Filled faces count as seen for the seams, which are then recomputed
|
|
278
|
+
# over every edge: the same two blocks as below, in the same order.
|
|
279
|
+
seen = visible | filled
|
|
280
|
+
seen_f = seen[f_idx]
|
|
281
|
+
seen_g = seen[np.where(ff != NO_ID, ff, 0)]
|
|
282
|
+
seam_mask = has & (seen_f != seen_g)
|
|
283
|
+
seam_first = np.stack([f_idx[seam_mask], ff[seam_mask]], axis=-1)
|
|
284
|
+
both = has & seen_f & seen_g
|
|
285
|
+
differ = both & (labels[f_idx] != labels[np.where(ff != NO_ID, ff, 0)])
|
|
286
|
+
seam_second = np.stack([f_idx[differ], ff[differ]], axis=-1)
|
|
287
|
+
else:
|
|
288
|
+
seam_second = graph_edges[labels[graph_edges[:, 0]] != labels[graph_edges[:, 1]]]
|
|
289
|
+
seam_edges = np.concatenate([seam_first, seam_second]) if len(seam_first) or len(seam_second) else np.zeros((0, 2), np.int64)
|
|
290
|
+
return Outcome(
|
|
291
|
+
labels=labels,
|
|
292
|
+
norm_quality=norm_quality,
|
|
293
|
+
energy=energy,
|
|
294
|
+
iterations=iterations,
|
|
295
|
+
seam_edges=seam_edges,
|
|
296
|
+
graph_edges=graph_edges,
|
|
297
|
+
filled=filled,
|
|
298
|
+
)
|