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.
@@ -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
+ )