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,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
|
+
)
|