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,548 @@
1
+ """Stage 5: hide the seams between patches by editing the view images the
2
+ patches are cut from, in place, so the atlas assembled afterwards already
3
+ agrees across patch borders.
4
+
5
+ Global leveling solves one colour offset per (vertex, patch): along every seam
6
+ the two patches' colours should agree after the offset, and adjacent vertices
7
+ in a patch should have similar offsets. The offsets are interpolated over each
8
+ patch and added in YCbCr.
9
+
10
+ Local leveling then takes a strip along each patch's border, sets the border
11
+ pixels to the mean of the two sides, and solves a Poisson problem in the strip
12
+ so the gradients of the original are kept while the border values are met.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Callable, List, Optional
18
+
19
+ import cv2
20
+ import numpy as np
21
+ from scipy import sparse
22
+ from scipy.sparse.linalg import cg
23
+
24
+ from texture3d.mapping import imageops, raster
25
+ from texture3d.mapping.patches import PatchSet, SeamData
26
+ from texture3d.mapping.topology import Topology
27
+ from texture3d.mapping.types import Mesh, View, clamp_byte, rgb2ycbcr, round2int, ycbcr2rgb
28
+
29
+ MASK_EMPTY = 0
30
+ MASK_BORDER = 128
31
+ MASK_INTERIOR = 255
32
+ TIKHONOV_LAMBDA = np.float32(0.1)
33
+
34
+
35
+ def conjugate_gradient(A: sparse.csr_matrix, b: np.ndarray, tolerance: float, max_iterations: int) -> np.ndarray:
36
+ """Jacobi-preconditioned conjugate gradient from a zero start, stopping
37
+ when the residual falls below ``tolerance`` relative to the right-hand
38
+ side or after ``max_iterations``."""
39
+ b = np.asarray(b, dtype=np.float64)
40
+ if not np.any(b):
41
+ return np.zeros_like(b)
42
+ diag = A.diagonal()
43
+ inv_diag = np.where(diag != 0, 1.0 / np.where(diag != 0, diag, 1.0), 1.0)
44
+ M = sparse.diags(inv_diag)
45
+ try:
46
+ x, _ = cg(A, b, rtol=tolerance, atol=0.0, maxiter=max_iterations, M=M)
47
+ except TypeError: # scipy before 1.12 spelt the relative tolerance ``tol``
48
+ x, _ = cg(A, b, tol=tolerance, atol=0.0, maxiter=max_iterations, M=M)
49
+ return x
50
+
51
+
52
+ def _assign_last(target: np.ndarray, x: np.ndarray, y: np.ndarray, values: np.ndarray) -> None:
53
+ """``target[y, x] = values`` in sequence, later entries overwriting earlier
54
+ ones, as a sequential rasteriser would."""
55
+ if len(x) == 0:
56
+ return
57
+ pix = y * target.shape[1] + x
58
+ _, first_from_end = np.unique(pix[::-1], return_index=True)
59
+ sel = len(pix) - 1 - first_from_end
60
+ target[y[sel], x[sel]] = values[sel]
61
+
62
+
63
+ def _lookup_table(keys: np.ndarray):
64
+ """A vectorised ``dict`` over integer keys: returns a function mapping
65
+ query keys to their position in ``keys`` or -1."""
66
+ order = np.argsort(keys)
67
+ sorted_keys = keys[order]
68
+
69
+ def find(query: np.ndarray) -> np.ndarray:
70
+ query = np.asarray(query, dtype=np.int64)
71
+ pos = np.searchsorted(sorted_keys, query)
72
+ pos_c = np.minimum(pos, max(len(sorted_keys) - 1, 0))
73
+ found = (len(sorted_keys) > 0) & (sorted_keys[pos_c] == query)
74
+ return np.where(found, order[pos_c], -1)
75
+
76
+ return find
77
+
78
+
79
+ # ── Global ──────────────────────────────────────────────────────────────────
80
+
81
+
82
+ def _vertex_colors(patch_set: PatchSet, seams: SeamData, views: List[View], images: List[np.ndarray]) -> np.ndarray:
83
+ """Per seam-vertex entry, the accumulated colour along leaving seam edges.
84
+
85
+ Samples along each seam edge leaving the vertex in the entry's view,
86
+ weighted towards the vertex, then the edges weighted by length.
87
+ """
88
+ entry_count = len(seams.entry_patch)
89
+ colors = np.zeros((entry_count, 3), dtype=np.float32)
90
+ if entry_count == 0 or len(seams.edge_other) == 0:
91
+ return colors
92
+ num_patches = patch_set.patch_count
93
+ entry_key = seams.entry_sv * (num_patches + 1) + seams.entry_patch
94
+ find_entry = _lookup_table(entry_key)
95
+ q_entry = np.repeat(np.arange(entry_count), np.diff(seams.edge_offsets))
96
+ q_patch = seams.entry_patch[q_entry]
97
+ other = find_entry(seams.edge_other * (num_patches + 1) + q_patch)
98
+ valid = other >= 0
99
+ p0 = seams.entry_proj[q_entry]
100
+ p1 = seams.entry_proj[np.maximum(other, 0)]
101
+ d = p1 - p0
102
+ length = np.sqrt((d * d).sum(axis=1)).astype(np.float32)
103
+ valid &= length > 0
104
+ q = np.flatnonzero(valid)
105
+ if len(q) == 0:
106
+ return colors
107
+ samples = round2int(np.maximum(length[q], 1) * 2) - 1
108
+ rep = np.repeat(q, samples)
109
+ local = np.arange(int(samples.sum())) - np.repeat(np.cumsum(samples) - samples, samples)
110
+ frac = (local.astype(np.float32) / samples[np.searchsorted(q, rep)].astype(np.float32)).astype(np.float32)
111
+ pos = p0[rep] + d[rep] * frac[:, None]
112
+ weight = np.float32(1) - frac
113
+ labels = np.array([p.label for p in patch_set.patches[:num_patches]], dtype=np.int64)
114
+ view_of_sample = labels[q_patch[rep]]
115
+ sampled = np.zeros((len(rep), 3), dtype=np.float32)
116
+ for v in np.unique(view_of_sample):
117
+ m = view_of_sample == v
118
+ sampled[m] = rgb2ycbcr(imageops.sample_linear(images[int(v)], pos[m]))
119
+ edge_sum = np.stack([np.bincount(rep, weights=sampled[:, c] * weight, minlength=len(q_entry)) for c in range(3)], -1)
120
+ edge_weight = np.bincount(rep, weights=weight, minlength=len(q_entry))
121
+ edge_color = np.where(edge_weight[:, None] != 0, edge_sum / np.where(edge_weight[:, None] != 0, edge_weight[:, None], 1), 0)
122
+ entry_sum = np.stack(
123
+ [np.bincount(q_entry[q], weights=edge_color[q, c] * length[q], minlength=entry_count) for c in range(3)], -1
124
+ )
125
+ entry_weight = np.bincount(q_entry[q], weights=length[q], minlength=entry_count)
126
+ nonzero = entry_weight != 0
127
+ colors[nonzero] = (entry_sum[nonzero] / entry_weight[nonzero][:, None]).astype(np.float32)
128
+ return colors
129
+
130
+
131
+ def dilate_mean(adjust: np.ndarray) -> np.ndarray:
132
+ """An interior zero pixel takes the mean of its non-zero 8-neighbours."""
133
+ h, w = adjust.shape[:2]
134
+ out = adjust.copy()
135
+ if w < 3 or h < 3:
136
+ return out
137
+ nonzero = np.any(adjust != 0, axis=2)
138
+ weight = nonzero.astype(np.float32)
139
+ padded = np.pad(adjust, ((1, 1), (1, 1), (0, 0)))
140
+ padded_w = np.pad(weight, 1)
141
+ total = np.zeros_like(adjust)
142
+ count = np.zeros((h, w), dtype=np.float32)
143
+ for dy in (-1, 0, 1):
144
+ for dx in (-1, 0, 1):
145
+ if dx == 0 and dy == 0:
146
+ continue
147
+ total += padded[1 + dy : 1 + dy + h, 1 + dx : 1 + dx + w]
148
+ count += padded_w[1 + dy : 1 + dy + h, 1 + dx : 1 + dx + w]
149
+ fill = ~nonzero & (count > 0)
150
+ fill[0, :] = fill[-1, :] = False
151
+ fill[:, 0] = fill[:, -1] = False
152
+ out[fill] = total[fill] / count[fill][:, None]
153
+ return out
154
+
155
+
156
+ def global_leveling(
157
+ mesh: Mesh,
158
+ topology: Topology,
159
+ patch_set: PatchSet,
160
+ seams: SeamData,
161
+ views: List[View],
162
+ images: List[np.ndarray],
163
+ log: Optional[Callable[[str], None]] = None,
164
+ ) -> None:
165
+ if seams.count == 0:
166
+ return
167
+ num_patches = patch_set.patch_count
168
+ vertex_count = mesh.vertex_count
169
+ faces = mesh.faces
170
+ face_patch = patch_set.face_patch
171
+
172
+ # Which patch each vertex is in (the last face containing it decides),
173
+ # or which seam vertex it is.
174
+ flat = faces.ravel()
175
+ order = np.argsort(flat, kind="stable")
176
+ flat_sorted = flat[order]
177
+ last = np.searchsorted(flat_sorted, np.arange(vertex_count), side="right") - 1
178
+ first = np.searchsorted(flat_sorted, np.arange(vertex_count), side="left")
179
+ has_face = first <= last
180
+ vertex_patch = np.full(vertex_count, num_patches, dtype=np.int64)
181
+ vertex_patch[has_face] = face_patch[order[last[has_face]] // 3]
182
+ is_seam = np.zeros(vertex_count, dtype=bool)
183
+ is_seam[seams.vertex] = True
184
+
185
+ # One unknown per (vertex, patch), in vertex order.
186
+ entry_sv = seams.entry_sv
187
+ plain = np.flatnonzero(~is_seam & (vertex_patch < num_patches))
188
+ row_vertex = np.concatenate([plain, seams.vertex[entry_sv]])
189
+ row_patch = np.concatenate([vertex_patch[plain], seams.entry_patch])
190
+ perm = np.argsort(row_vertex, kind="stable")
191
+ row_vertex, row_patch = row_vertex[perm], row_patch[perm]
192
+ rows_x = len(row_vertex)
193
+ find_row = _lookup_table(row_vertex * (num_patches + 1) + row_patch)
194
+ inverse_perm = np.empty(rows_x, dtype=np.int64)
195
+ inverse_perm[perm] = np.arange(rows_x)
196
+ entry_row = inverse_perm[len(plain) + np.arange(len(entry_sv))]
197
+
198
+ # Tikhonov rows: adjacent vertices in the same patch should agree.
199
+ a = faces
200
+ b = np.roll(faces, -1, axis=1)
201
+ lo = np.minimum(a, b).ravel()
202
+ hi = np.maximum(a, b).ravel()
203
+ vertex_edges = np.unique(lo * vertex_count + hi)
204
+ ev = vertex_edges // vertex_count
205
+ ew = vertex_edges % vertex_count
206
+ rows_per_vertex = np.bincount(row_vertex, minlength=vertex_count)
207
+ row_start = np.concatenate([[0], np.cumsum(rows_per_vertex)])
208
+ rep = np.repeat(np.arange(len(ev)), rows_per_vertex[ev])
209
+ local = np.arange(len(rep)) - np.repeat(np.cumsum(rows_per_vertex[ev]) - rows_per_vertex[ev], rows_per_vertex[ev])
210
+ col_v = row_start[ev[rep]] + local
211
+ patch_v = row_patch[col_v]
212
+ col_w = find_row(ew[rep] * (num_patches + 1) + patch_v)
213
+ ok = col_w >= 0
214
+ col_v, col_w = col_v[ok], col_w[ok]
215
+ rows_gamma = len(col_v)
216
+ gamma = sparse.coo_matrix(
217
+ (
218
+ np.concatenate([np.full(rows_gamma, TIKHONOV_LAMBDA), np.full(rows_gamma, -TIKHONOV_LAMBDA)]).astype(np.float64),
219
+ (np.concatenate([np.arange(rows_gamma), np.arange(rows_gamma)]), np.concatenate([col_v, col_w])),
220
+ ),
221
+ shape=(rows_gamma, rows_x),
222
+ ).tocsr()
223
+
224
+ # Seam rows: the two patches' colours at a seam vertex should agree.
225
+ vertex_colors = _vertex_colors(patch_set, seams, views, images)
226
+ entry_counts = np.diff(seams.entry_offsets)
227
+ a_rows, a_cols, a_vals, coeff_b = [], [], [], []
228
+ row_base = 0
229
+ for c in np.unique(entry_counts[entry_counts >= 2]):
230
+ c = int(c)
231
+ svs = np.flatnonzero(entry_counts == c)
232
+ entries = seams.entry_offsets[svs][:, None] + np.arange(c)[None, :]
233
+ by_patch = np.argsort(seams.entry_patch[entries], axis=1, kind="stable")
234
+ entries = np.take_along_axis(entries, by_patch, axis=1)
235
+ i_idx, j_idx = np.triu_indices(c, 1)
236
+ e_i = entries[:, i_idx].ravel()
237
+ e_j = entries[:, j_idx].ravel()
238
+ n = len(e_i)
239
+ rows = row_base + np.arange(n)
240
+ a_rows.append(np.concatenate([rows, rows]))
241
+ a_cols.append(np.concatenate([entry_row[e_i], entry_row[e_j]]))
242
+ a_vals.append(np.concatenate([np.ones(n), -np.ones(n)]))
243
+ coeff_b.append(vertex_colors[e_j] - vertex_colors[e_i])
244
+ row_base += n
245
+ rows_a = row_base
246
+ if rows_a == 0:
247
+ return
248
+ A = sparse.coo_matrix(
249
+ (np.concatenate(a_vals), (np.concatenate(a_rows), np.concatenate(a_cols))), shape=(rows_a, rows_x)
250
+ ).tocsr()
251
+ coeff_b = np.concatenate(coeff_b).astype(np.float64)
252
+
253
+ lhs = (A.T @ A + gamma.T @ gamma).tocsr()
254
+ adjustments = np.zeros((rows_x, 3), dtype=np.float32)
255
+ for channel in range(3):
256
+ rhs = A.T @ coeff_b[:, channel]
257
+ x = conjugate_gradient(lhs, rhs, tolerance=1e-4, max_iterations=1000)
258
+ adjustments[:, channel] = (x - x.sum() / max(1, rows_x)).astype(np.float32)
259
+ if log:
260
+ log(f" global leveling: {rows_x} unknowns, {rows_a} seam rows, {rows_gamma} smoothness rows")
261
+
262
+ # Interpolate the offsets over every patch and add them in YCbCr.
263
+ tex = patch_set.face_texcoords
264
+ for p in range(num_patches):
265
+ patch = patch_set.patches[p]
266
+ rect = patch.rect
267
+ if rect.width <= 0 or rect.height <= 0:
268
+ continue
269
+ view = views[patch.label]
270
+ image = images[patch.label]
271
+ corner_rows = find_row(faces[patch.faces].ravel() * (num_patches + 1) + p)
272
+ corner = np.where(corner_rows[:, None] >= 0, adjustments[np.maximum(corner_rows, 0)], 0).reshape(-1, 3, 3)
273
+ adjust = np.zeros((rect.height, rect.width, 3), dtype=np.float32)
274
+ for ti, x, y, bary in raster.triangles_bary(tex[patch.faces], rect.width, rect.height, cull=True):
275
+ values = (corner[ti] * bary[:, :, None]).sum(axis=1)
276
+ _assign_last(adjust, x, y, values)
277
+ adjust = dilate_mean(adjust)
278
+ ys, xs = np.nonzero(np.any(adjust != 0, axis=2))
279
+ iy = ys + rect.y
280
+ ix = xs + rect.x
281
+ inside = (ix >= 0) & (iy >= 0) & (ix < view.width) & (iy < view.height)
282
+ ys, xs, iy, ix = ys[inside], xs[inside], iy[inside], ix[inside]
283
+ if len(ys) == 0:
284
+ continue
285
+ pixel = image[iy, ix].astype(np.float32)
286
+ adjusted = ycbcr2rgb(rgb2ycbcr(pixel) + adjust[ys, xs])
287
+ image[iy, ix] = clamp_byte(round2int(adjusted))
288
+
289
+
290
+ # ── Local ───────────────────────────────────────────────────────────────────
291
+
292
+
293
+ def _shift(mask: np.ndarray, dr: int, dc: int) -> np.ndarray:
294
+ """``mask`` read at ``(r + dr, c + dc)``, empty outside the image."""
295
+ h, w = mask.shape
296
+ out = np.zeros_like(mask)
297
+ rs, re = max(0, -dr), min(h, h - dr)
298
+ cs, ce = max(0, -dc), min(w, w - dc)
299
+ if re > rs and ce > cs:
300
+ out[rs:re, cs:ce] = mask[rs + dr : re + dr, cs + dc : ce + dc]
301
+ return out
302
+
303
+
304
+ def process_mask(mask: np.ndarray, strip_width: int) -> None:
305
+ """Close gaps between the drawn border and the rasterised interior.
306
+
307
+ Keep only a strip ``strip_width`` deep inside the patch border as the
308
+ region to blend, its outer ring marked border.
309
+ """
310
+ h, w = mask.shape
311
+ # Dilate border pixels (interior ones) into their four neighbours.
312
+ if h > 2 and w > 2:
313
+ border = np.zeros_like(mask, dtype=bool)
314
+ border[1:-1, 1:-1] = mask[1:-1, 1:-1] == MASK_BORDER
315
+ touched = _shift(border, 1, 0) | _shift(border, -1, 0) | _shift(border, 0, 1) | _shift(border, 0, -1)
316
+ mask[touched & (mask != MASK_BORDER)] = MASK_INTERIOR
317
+ # Erode interior pixels caught between a border and emptiness.
318
+ interior = mask == MASK_INTERIOR
319
+ kill = np.zeros_like(interior)
320
+ for dr, dc in ((0, 1), (1, 0), (1, 1), (-1, 1)):
321
+ vl = _shift(mask, -dr, -dc)
322
+ vr = _shift(mask, dr, dc)
323
+ kill |= ((vl == MASK_BORDER) & (vr == MASK_EMPTY)) | ((vr == MASK_BORDER) & (vl == MASK_EMPTY))
324
+ mask[interior & kill] = MASK_EMPTY
325
+ # Interior pixels with an empty 4-neighbour become border.
326
+ interior = mask == MASK_INTERIOR
327
+ empty4 = (
328
+ (_shift(mask, -1, 0) == MASK_EMPTY)
329
+ | (_shift(mask, 0, -1) == MASK_EMPTY)
330
+ | (_shift(mask, 1, 0) == MASK_EMPTY)
331
+ | (_shift(mask, 0, 1) == MASK_EMPTY)
332
+ )
333
+ mask[interior & empty4] = MASK_BORDER
334
+ # Erode ``strip_width`` rings inward from the patch's outer edge; what is
335
+ # left is the core that stays as it is, its boundary ring the fixed border.
336
+ kernel = np.ones((3, 3), dtype=np.uint8)
337
+ valid = (mask != MASK_EMPTY).astype(np.uint8)
338
+ core = valid
339
+ for _ in range(strip_width):
340
+ core = cv2.erode(core, kernel, borderType=cv2.BORDER_CONSTANT, borderValue=0)
341
+ ring = core.astype(bool) & ~cv2.erode(core, kernel, borderType=cv2.BORDER_CONSTANT, borderValue=0).astype(bool)
342
+ mask[core != 0] = MASK_EMPTY
343
+ mask[ring] = MASK_BORDER
344
+
345
+
346
+ def poisson_blend(source: np.ndarray, dst: np.ndarray, mask: np.ndarray) -> None:
347
+ """Poisson blend: inside the mask the result has the source's Laplacian,
348
+ on the border it keeps the current values. Solved as the SPD system in
349
+ the interior unknowns."""
350
+ h, w = mask.shape
351
+ interior = mask == MASK_INTERIOR
352
+ interior[0, :] = interior[-1, :] = False
353
+ interior[:, 0] = interior[:, -1] = False
354
+ ids = np.flatnonzero(interior.ravel())
355
+ n = len(ids)
356
+ if n == 0:
357
+ return
358
+ index = np.full(h * w, -1, dtype=np.int64)
359
+ index[ids] = np.arange(n)
360
+ src = source.reshape(-1, 3).astype(np.float64)
361
+ d = dst.reshape(-1, 3)
362
+ m = mask.ravel()
363
+ rhs = 4 * src[ids]
364
+ rows = [np.arange(n)]
365
+ cols = [np.arange(n)]
366
+ vals = [np.full(n, 4.0)]
367
+ for off in (-w, -1, 1, w):
368
+ nb = ids + off
369
+ rhs -= src[nb]
370
+ col = index[nb]
371
+ unknown = col >= 0
372
+ known = ~unknown & (m[nb] != MASK_EMPTY)
373
+ rhs[known] += d[nb[known]].astype(np.float64)
374
+ rows.append(np.flatnonzero(unknown))
375
+ cols.append(col[unknown])
376
+ vals.append(np.full(int(unknown.sum()), -1.0))
377
+ A = sparse.coo_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n)).tocsr()
378
+ for channel in range(3):
379
+ x = conjugate_gradient(A, rhs[:, channel], tolerance=1e-6, max_iterations=4000)
380
+ d[ids, channel] = x.astype(np.float32)
381
+
382
+
383
+ def local_leveling(
384
+ mesh: Mesh,
385
+ patch_set: PatchSet,
386
+ seams: SeamData,
387
+ views: List[View],
388
+ images: List[np.ndarray],
389
+ strip_width: int = 20,
390
+ log: Optional[Callable[[str], None]] = None,
391
+ ) -> None:
392
+ """Patch by patch, in patch order: each patch reads the other views as
393
+ levelled so far and writes only its own view's image."""
394
+ if seams.count == 0:
395
+ return
396
+ num_patches = patch_set.patch_count
397
+ entry_sv = seams.entry_sv
398
+ entry_counts = np.diff(seams.entry_offsets)
399
+ labels = np.array([p.label for p in patch_set.patches[:num_patches]], dtype=np.int64)
400
+ by_patch = np.argsort(seams.entry_patch, kind="stable")
401
+ patch_counts = np.bincount(seams.entry_patch, minlength=num_patches)
402
+ patch_start = np.concatenate([[0], np.cumsum(patch_counts)])
403
+ for p in range(num_patches):
404
+ entries = by_patch[patch_start[p] : patch_start[p + 1]]
405
+ _level_patch(p, entries, mesh, patch_set, seams, entry_sv, entry_counts, labels, views, images, strip_width)
406
+ if log and ((p + 1) % 500 == 0 or p + 1 == num_patches):
407
+ log(f" levelled {p + 1}/{num_patches} patches")
408
+
409
+
410
+ def _level_patch(
411
+ p: int,
412
+ entries: np.ndarray,
413
+ mesh: Mesh,
414
+ patch_set: PatchSet,
415
+ seams: SeamData,
416
+ entry_sv: np.ndarray,
417
+ entry_counts: np.ndarray,
418
+ labels: np.ndarray,
419
+ views: List[View],
420
+ images: List[np.ndarray],
421
+ strip_width: int,
422
+ ) -> None:
423
+ patch = patch_set.patches[p]
424
+ rect = patch.rect
425
+ w, h = rect.width, rect.height
426
+ if w <= 0 or h <= 0:
427
+ return
428
+ view_index = patch.label
429
+ view = views[view_index]
430
+ target = images[view_index]
431
+ inv255 = np.float32(1.0 / 255.0)
432
+
433
+ # The patch as floats, and an untouched copy.
434
+ ys = rect.y + np.arange(h)
435
+ xs = rect.x + np.arange(w)
436
+ ok_y = (ys >= 0) & (ys < view.height)
437
+ ok_x = (xs >= 0) & (xs < view.width)
438
+ image = np.zeros((h, w, 3), dtype=np.float32)
439
+ image[np.ix_(ok_y, ok_x)] = target[np.ix_(ys[ok_y], xs[ok_x])].astype(np.float32) * inv255
440
+ image_org = image.copy()
441
+
442
+ # Coverage mask.
443
+ mask = np.zeros((h, w), dtype=np.uint8)
444
+ _, mx, my = raster.triangles_fixed(patch_set.face_texcoords[patch.faces], w, h)
445
+ mask[my, mx] = MASK_INTERIOR
446
+
447
+ # Seams with neighbouring patches, drawn as the mean of both sides, then
448
+ # each seam vertex as the mean over every patch meeting there. Collected
449
+ # first, drawn in one pass so later strokes overwrite earlier ones.
450
+ offset = np.array([rect.x, rect.y], dtype=np.float32)
451
+ line_p0, line_p0_adj, line_p1, line_p1_adj, line_view, line_seq = [], [], [], [], [], []
452
+ vertex_px, vertex_py, vertex_color, vertex_seq = [], [], [], []
453
+ seq = 0
454
+ proj = seams.entry_proj
455
+ entry_patch = seams.entry_patch
456
+ for e0 in entries:
457
+ sv0 = int(entry_sv[e0])
458
+ if entry_counts[sv0] < 2:
459
+ continue
460
+ p0 = proj[e0] - offset
461
+ for q in seams.edges_of(int(e0)):
462
+ sv1 = int(seams.edge_other[q])
463
+ e0_adj = seams.lookup.get((sv1, p))
464
+ if e0_adj is None:
465
+ continue
466
+ p0_adj = proj[e0_adj] - offset
467
+ for e1 in seams.entries_of(sv0):
468
+ patch1 = int(entry_patch[e1])
469
+ if patch1 == p:
470
+ continue
471
+ edges1 = seams.edges_of(e1)
472
+ if not np.any(seams.edge_other[edges1.start : edges1.stop] == sv1):
473
+ continue
474
+ e1_adj = seams.lookup.get((sv1, patch1))
475
+ if e1_adj is None:
476
+ continue
477
+ line_p0.append(p0)
478
+ line_p0_adj.append(p0_adj)
479
+ line_p1.append(proj[e1])
480
+ line_p1_adj.append(proj[e1_adj])
481
+ line_view.append(int(labels[patch1]))
482
+ line_seq.append(seq)
483
+ seq += 1
484
+ break
485
+ # The vertex itself: the mean over every patch meeting there.
486
+ acc = np.zeros(3, dtype=np.float32)
487
+ count = 0
488
+ for e in seams.entries_of(sv0):
489
+ vi = int(labels[entry_patch[e]])
490
+ acc += imageops.sample_linear(images[vi], proj[e][None, :])[0] * inv255
491
+ count += 1
492
+ px, py = int(round2int(p0[0])), int(round2int(p0[1]))
493
+ if 0 <= px < w and 0 <= py < h:
494
+ vertex_px.append(px)
495
+ vertex_py.append(py)
496
+ vertex_color.append(acc / count)
497
+ vertex_seq.append(seq)
498
+ seq += 1
499
+
500
+ draw_x, draw_y, draw_c, draw_seq = [], [], [], []
501
+ if line_p0:
502
+ P0 = np.array(line_p0, dtype=np.float32)
503
+ P0A = np.array(line_p0_adj, dtype=np.float32)
504
+ P1 = np.array(line_p1, dtype=np.float32)
505
+ P1A = np.array(line_p1_adj, dtype=np.float32)
506
+ views_of_line = np.array(line_view)
507
+ li, lx, ly = raster.lines(P0, P0A)
508
+ inside = (lx >= 0) & (ly >= 0) & (lx < w) & (ly < h)
509
+ li, lx, ly = li[inside], lx[inside], ly[inside]
510
+ if len(li):
511
+ p0_dir = P0A - P0
512
+ p1_dir = P1A - P1
513
+ length = np.sqrt((p0_dir * p0_dir).sum(axis=1)).astype(np.float32)
514
+ pt = np.stack([lx, ly], axis=-1).astype(np.float32)
515
+ frac = (np.sqrt(((pt - P0[li]) ** 2).sum(axis=1)) / length[li]).astype(np.float32)
516
+ color0 = imageops.sample_linear(image_org, P0[li] + p0_dir[li] * frac[:, None])
517
+ pos1 = P1[li] + p1_dir[li] * frac[:, None]
518
+ color1 = np.zeros_like(color0)
519
+ vol = views_of_line[li]
520
+ for v in np.unique(vol):
521
+ m = vol == v
522
+ color1[m] = imageops.sample_linear(images[int(v)], pos1[m]) * inv255
523
+ draw_x.append(lx)
524
+ draw_y.append(ly)
525
+ draw_c.append((color0 + color1) * np.float32(0.5))
526
+ draw_seq.append(np.array(line_seq)[li])
527
+ if vertex_px:
528
+ draw_x.append(np.array(vertex_px))
529
+ draw_y.append(np.array(vertex_py))
530
+ draw_c.append(np.array(vertex_color, dtype=np.float32))
531
+ draw_seq.append(np.array(vertex_seq))
532
+ if draw_x:
533
+ dx = np.concatenate(draw_x)
534
+ dy = np.concatenate(draw_y)
535
+ dc = np.concatenate(draw_c)
536
+ ds = np.concatenate(draw_seq)
537
+ order = np.argsort(ds, kind="stable")
538
+ _assign_last(image, dx[order], dy[order], dc[order])
539
+ mask[dy, dx] = MASK_BORDER
540
+
541
+ process_mask(mask, strip_width)
542
+ poisson_blend(image_org, image, mask)
543
+
544
+ ys_, xs_ = np.nonzero(mask != MASK_EMPTY)
545
+ iy = ys_ + rect.y
546
+ ix = xs_ + rect.x
547
+ inside = (ix >= 0) & (iy >= 0) & (ix < view.width) & (iy < view.height)
548
+ target[iy[inside], ix[inside]] = clamp_byte(round2int(image[ys_[inside], xs_[inside]] * 255))
@@ -0,0 +1,114 @@
1
+ """The whole pipeline, in stage order."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Callable, List, Optional, Tuple
8
+
9
+ from texture3d.mapping import atlas as atlas_stage
10
+ from texture3d.mapping import labeling, leveling, patches, scoring, topology
11
+ from texture3d.mapping.types import EmptyMesh, Mesh, NoViews, NoVisibleFace, Result, View
12
+
13
+
14
+ @dataclass
15
+ class Tuning:
16
+ """Tunable arguments of the mapping pipeline."""
17
+
18
+ #: Views whose mean face colour falls below this Gaussian likelihood
19
+ #: against the other views' colours are dropped for that face. 0 disables.
20
+ outlier_threshold: float = 0.06
21
+ #: Data cost scale relative to the Potts smoothness cost. Lower makes
22
+ #: larger, fewer patches.
23
+ data_smoothness_ratio: float = 0.1
24
+ global_seam_leveling: bool = True
25
+ local_seam_leveling: bool = True
26
+ #: 0 packs into a power-of-two atlas.
27
+ texture_size_multiple: int = 0
28
+ #: Colour of atlas texels no patch covers, BGR.
29
+ empty_color_bgr: Tuple[int, int, int] = (39, 127, 255)
30
+ #: Unsharp-mask weight applied to the finished atlas. 0 disables it.
31
+ sharpness_weight: float = 0.5
32
+ max_texture_size: int = 8192
33
+ #: Width in pixels of the border strip the local leveling blends.
34
+ local_leveling_strip_width: int = 20
35
+ #: Views scored at once; a memory budget, each holds its face and depth maps.
36
+ scoring_workers: int = 4
37
+ #: Give a face no view rasterised (sub-pixel in every keyframe) the view of
38
+ #: a textured neighbour instead of the empty colour. See ``labeling.fill_unseen``.
39
+ fill_unseen: bool = True
40
+ #: Patch count up to which the atlas is packed with MaxRects; beyond it a
41
+ #: shelf packer runs, since MaxRects grows quadratically with the patch
42
+ #: count. 0 = always MaxRects.
43
+ exact_packing_limit: int = 4000
44
+
45
+
46
+ def run(mesh: Mesh, views: List[View], tuning: Optional[Tuning] = None, log: Optional[Callable[[str], None]] = None) -> Result:
47
+ tuning = tuning or Tuning()
48
+ if mesh.face_count == 0 or mesh.vertex_count == 0:
49
+ raise EmptyMesh("the mesh has no faces")
50
+ if not views:
51
+ raise NoViews("no views to texture from")
52
+ timings = {}
53
+ start = time.time()
54
+ phase = start
55
+
56
+ def lap(name: str) -> None:
57
+ nonlocal phase
58
+ now = time.time()
59
+ timings[name] = round(now - phase, 2)
60
+ phase = now
61
+
62
+ topo = topology.build(mesh)
63
+ lap("topology")
64
+
65
+ face_views = scoring.score(mesh, topo, views, tuning.outlier_threshold, tuning.scoring_workers, log)
66
+ if not face_views.visible.any():
67
+ raise NoVisibleFace("no view sees any face")
68
+ lap("scoring")
69
+ if log:
70
+ log(f" {int(face_views.visible.sum())}/{mesh.face_count} faces seen, {len(face_views.view)} face-view pairs")
71
+
72
+ outcome = labeling.assign(mesh, topo, face_views, tuning.data_smoothness_ratio, log, views, tuning.fill_unseen)
73
+ lap("labeling")
74
+ if log:
75
+ log(f" labelled in {outcome.iterations} sweeps, energy {outcome.energy:.0f}")
76
+
77
+ patch_set = patches.build(mesh, topo, face_views, outcome, views)
78
+ seams = patches.create_seam_vertices(mesh, patch_set)
79
+ lap("patches")
80
+ if log:
81
+ log(f" {patch_set.patch_count} patches, {seams.count} seam vertices")
82
+
83
+ images = [view.bgr.copy() for view in views]
84
+ if patch_set.patch_count > 1:
85
+ if tuning.global_seam_leveling:
86
+ leveling.global_leveling(mesh, topo, patch_set, seams, views, images, log)
87
+ lap("global_leveling")
88
+ if tuning.local_seam_leveling:
89
+ leveling.local_leveling(mesh, patch_set, seams, views, images, tuning.local_leveling_strip_width, log)
90
+ lap("local_leveling")
91
+
92
+ patches.merge_contained(patch_set)
93
+ out = atlas_stage.generate(
94
+ patch_set,
95
+ images,
96
+ views,
97
+ texture_size_multiple=tuning.texture_size_multiple,
98
+ max_texture_size=tuning.max_texture_size,
99
+ empty_color_bgr=tuning.empty_color_bgr,
100
+ sharpness_weight=tuning.sharpness_weight,
101
+ exact_packing_limit=tuning.exact_packing_limit,
102
+ )
103
+ lap("atlas")
104
+ timings["total"] = round(time.time() - start, 2)
105
+ return Result(
106
+ labels=outcome.labels,
107
+ face_patch=patch_set.face_patch,
108
+ patch_count=patch_set.patch_count,
109
+ atlas_bgr=out.pixels,
110
+ atlas_size=out.size,
111
+ face_texcoords=out.face_texcoords,
112
+ patch_rects=out.patch_rects,
113
+ timings=timings,
114
+ )