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,229 @@
|
|
|
1
|
+
"""Stage 2: which views see which faces, and how well.
|
|
2
|
+
|
|
3
|
+
Per view the mesh is rasterised into a face map with a depth test, so a pixel
|
|
4
|
+
belongs to the nearest face. A face's quality in that view is the blurred
|
|
5
|
+
Sobel gradient magnitude summed over its pixels, scaled by the squared cosine
|
|
6
|
+
between the view direction and the smoothed face normal: large, sharp,
|
|
7
|
+
head-on projections score high. The mean colour of those pixels (YCbCr over
|
|
8
|
+
BGR) feeds the outlier test that drops views disagreeing with the majority,
|
|
9
|
+
which is what removes an object passing in front of the subject in one frame.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Callable, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from texture3d.mapping import imageops, raster
|
|
21
|
+
from texture3d.mapping.topology import Topology
|
|
22
|
+
from texture3d.mapping.types import NO_ID, FaceViews, Mesh, View, rgb2ycbcr
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ViewStats:
|
|
27
|
+
quality: np.ndarray # (F,) float32
|
|
28
|
+
area: np.ndarray # (F,) int64
|
|
29
|
+
color: np.ndarray # (F, 3) float32, YCbCr of the mean BGR
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def face_map(mesh: Mesh, view: View, max_candidates: int = 1 << 24) -> Tuple[np.ndarray, np.ndarray]:
|
|
33
|
+
"""The view's face map and depth map: per pixel the nearest face whose
|
|
34
|
+
three vertices project in front of the camera and at least three pixels
|
|
35
|
+
inside the image, or NO_ID."""
|
|
36
|
+
width, height = view.width, view.height
|
|
37
|
+
cam = view.camera.to_camera(mesh.positions)
|
|
38
|
+
z = cam[:, 2]
|
|
39
|
+
in_front = z > 0
|
|
40
|
+
safe = cam.copy()
|
|
41
|
+
safe[~in_front, 2] = 1.0
|
|
42
|
+
px = view.camera.to_image(safe).astype(np.float32)
|
|
43
|
+
inside_vertex = (
|
|
44
|
+
in_front & (px[:, 0] >= 3) & (px[:, 1] >= 3) & (px[:, 0] <= width - 4) & (px[:, 1] <= height - 4)
|
|
45
|
+
)
|
|
46
|
+
candidates = np.flatnonzero(inside_vertex[mesh.faces].all(axis=1))
|
|
47
|
+
faces = mesh.faces[candidates]
|
|
48
|
+
tri = px[faces]
|
|
49
|
+
zf = z[faces].astype(np.float32)
|
|
50
|
+
|
|
51
|
+
fmap = np.full(width * height, NO_ID, dtype=np.int64)
|
|
52
|
+
dmap = np.zeros(width * height, dtype=np.float32)
|
|
53
|
+
for ti, x, y, bary in raster.triangles_bary(tri, width, height, cull=True, max_candidates=max_candidates):
|
|
54
|
+
zt = zf[ti]
|
|
55
|
+
pbary = raster.perspective_correct(bary, zt)
|
|
56
|
+
depth = (pbary * zt).sum(axis=1).astype(np.float32)
|
|
57
|
+
pix = y * width + x
|
|
58
|
+
# The nearest face per pixel within the chunk; on equal depth the
|
|
59
|
+
# earlier face wins.
|
|
60
|
+
order = np.lexsort((ti, depth, pix))
|
|
61
|
+
pix_sorted = pix[order]
|
|
62
|
+
first = np.ones(len(order), dtype=bool)
|
|
63
|
+
first[1:] = pix_sorted[1:] != pix_sorted[:-1]
|
|
64
|
+
sel = order[first]
|
|
65
|
+
p = pix[sel]
|
|
66
|
+
d = depth[sel]
|
|
67
|
+
current = dmap[p]
|
|
68
|
+
replace = (current == 0) | (current > d)
|
|
69
|
+
dmap[p[replace]] = d[replace]
|
|
70
|
+
fmap[p[replace]] = candidates[ti[sel[replace]]]
|
|
71
|
+
return fmap.reshape(height, width), dmap.reshape(height, width)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def score_view(mesh: Mesh, topology: Topology, view: View, max_candidates: int = 1 << 24) -> ViewStats:
|
|
75
|
+
face_count = mesh.face_count
|
|
76
|
+
gradient = imageops.gradient_magnitude_blurred(imageops.gray_normalized(view)).ravel()
|
|
77
|
+
mask = imageops.invalid_region_mask(view)
|
|
78
|
+
fmap, _ = face_map(mesh, view, max_candidates)
|
|
79
|
+
fmap = fmap.ravel()
|
|
80
|
+
if mask is not None:
|
|
81
|
+
# A face that touched a masked pixel is dropped so none of its
|
|
82
|
+
# remaining pixels contribute.
|
|
83
|
+
bad = np.unique(fmap[(mask.ravel() == 0) & (fmap != NO_ID)])
|
|
84
|
+
if len(bad):
|
|
85
|
+
fmap[np.isin(fmap, bad)] = NO_ID
|
|
86
|
+
valid = fmap != NO_ID
|
|
87
|
+
f = fmap[valid]
|
|
88
|
+
area = np.bincount(f, minlength=face_count)
|
|
89
|
+
quality = np.bincount(f, weights=gradient[valid], minlength=face_count).astype(np.float32)
|
|
90
|
+
bgr = view.bgr.reshape(-1, 3)[valid].astype(np.float64)
|
|
91
|
+
color_sum = np.stack([np.bincount(f, weights=bgr[:, c], minlength=face_count) for c in range(3)], axis=-1)
|
|
92
|
+
|
|
93
|
+
seen = area > 0
|
|
94
|
+
faces = mesh.faces[seen]
|
|
95
|
+
p = mesh.positions
|
|
96
|
+
centre = (p[faces[:, 0]] + p[faces[:, 1]] + p[faces[:, 2]]) / np.float32(3)
|
|
97
|
+
cam_dir = view.camera.C.astype(np.float32) - centre
|
|
98
|
+
n = topology.face_normals[seen]
|
|
99
|
+
dot = (cam_dir * n).sum(axis=1)
|
|
100
|
+
norms = np.sqrt((cam_dir * cam_dir).sum(axis=1) * (n * n).sum(axis=1))
|
|
101
|
+
# A zero-area face has no normal; give it the grazing minimum rather than
|
|
102
|
+
# a NaN that would poison the labelling costs.
|
|
103
|
+
cosine = np.clip(np.where(norms > 0, dot / np.where(norms > 0, norms, 1), 0), -1, 1)
|
|
104
|
+
cosine = np.maximum(np.float32(0.001), np.minimum(np.float32(1), cosine))
|
|
105
|
+
quality[seen] *= cosine * cosine
|
|
106
|
+
color = np.zeros((face_count, 3), dtype=np.float32)
|
|
107
|
+
color[seen] = rgb2ycbcr((color_sum[seen] / area[seen][:, None]).astype(np.float32))
|
|
108
|
+
return ViewStats(quality=quality, area=area, color=color)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def score(
|
|
112
|
+
mesh: Mesh,
|
|
113
|
+
topology: Topology,
|
|
114
|
+
views: List[View],
|
|
115
|
+
outlier_threshold: float = 0.06,
|
|
116
|
+
workers: int = 4,
|
|
117
|
+
log: Optional[Callable[[str], None]] = None,
|
|
118
|
+
) -> FaceViews:
|
|
119
|
+
"""Every view scored, merged per face in view order, then filtered."""
|
|
120
|
+
face_count = mesh.face_count
|
|
121
|
+
stats: List[Optional[ViewStats]] = [None] * len(views)
|
|
122
|
+
|
|
123
|
+
def run(v: int) -> int:
|
|
124
|
+
stats[v] = score_view(mesh, topology, views[v])
|
|
125
|
+
return v
|
|
126
|
+
|
|
127
|
+
done = 0
|
|
128
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
129
|
+
for _ in pool.map(run, range(len(views))):
|
|
130
|
+
done += 1
|
|
131
|
+
if log and (done % 10 == 0 or done == len(views)):
|
|
132
|
+
log(f" scored {done}/{len(views)} views")
|
|
133
|
+
|
|
134
|
+
face_ids, view_ids, qualities, colors = [], [], [], []
|
|
135
|
+
for v, s in enumerate(stats):
|
|
136
|
+
seen = np.flatnonzero(s.area > 0)
|
|
137
|
+
face_ids.append(seen)
|
|
138
|
+
view_ids.append(np.full(len(seen), v, dtype=np.int64))
|
|
139
|
+
qualities.append(s.quality[seen])
|
|
140
|
+
colors.append(s.color[seen])
|
|
141
|
+
face_ids = np.concatenate(face_ids)
|
|
142
|
+
view_ids = np.concatenate(view_ids)
|
|
143
|
+
order = np.lexsort((view_ids, face_ids))
|
|
144
|
+
offsets = np.zeros(face_count + 1, dtype=np.int64)
|
|
145
|
+
np.cumsum(np.bincount(face_ids, minlength=face_count), out=offsets[1:])
|
|
146
|
+
face_views = FaceViews(
|
|
147
|
+
offsets=offsets,
|
|
148
|
+
view=view_ids[order],
|
|
149
|
+
quality=np.concatenate(qualities)[order],
|
|
150
|
+
color=np.concatenate(colors)[order],
|
|
151
|
+
)
|
|
152
|
+
if outlier_threshold > 0:
|
|
153
|
+
face_views = remove_outliers(face_views, outlier_threshold)
|
|
154
|
+
return face_views
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def remove_outliers(face_views: FaceViews, threshold: float) -> FaceViews:
|
|
158
|
+
"""Drop views whose mean face colour is an outlier of the others.
|
|
159
|
+
|
|
160
|
+
Fit a Gaussian to the views' mean colours, drop views whose likelihood
|
|
161
|
+
is below the threshold, refit, up to ten times. A face whose test
|
|
162
|
+
abstains (too few views, or a degenerate covariance) keeps every view.
|
|
163
|
+
"""
|
|
164
|
+
th = 6e-2 if threshold <= 0 else float(threshold)
|
|
165
|
+
min_covariance = 1e-3
|
|
166
|
+
max_iterations = 10
|
|
167
|
+
min_inliers = 4
|
|
168
|
+
eps = np.finfo(np.float64).eps
|
|
169
|
+
|
|
170
|
+
counts = face_views.counts
|
|
171
|
+
keep = np.ones(len(face_views.view), dtype=bool)
|
|
172
|
+
for n in np.unique(counts[counts > min_inliers]):
|
|
173
|
+
n = int(n)
|
|
174
|
+
faces_n = np.flatnonzero(counts == n)
|
|
175
|
+
slots = face_views.offsets[faces_n][:, None] + np.arange(n)[None, :]
|
|
176
|
+
colors = face_views.color[slots].astype(np.float64) # (Fn, n, 3)
|
|
177
|
+
inliers = np.ones((len(faces_n), n), dtype=bool)
|
|
178
|
+
active = np.ones(len(faces_n), dtype=bool)
|
|
179
|
+
remove = np.zeros(len(faces_n), dtype=bool)
|
|
180
|
+
for _ in range(max_iterations):
|
|
181
|
+
a = np.flatnonzero(active)
|
|
182
|
+
if len(a) == 0:
|
|
183
|
+
break
|
|
184
|
+
c = colors[a]
|
|
185
|
+
w = inliers[a][..., None].astype(np.float64)
|
|
186
|
+
num = inliers[a].sum(axis=1)
|
|
187
|
+
mean = (c * w).sum(axis=1) / num[:, None]
|
|
188
|
+
d = (c - mean[:, None, :]) * w
|
|
189
|
+
cov = np.einsum("fni,fnj->fij", d, d) / (num - 1)[:, None, None]
|
|
190
|
+
degenerate = np.abs(cov).reshape(len(a), -1).max(axis=1) < min_covariance
|
|
191
|
+
active[a[degenerate]] = False
|
|
192
|
+
remove[a[degenerate]] = True
|
|
193
|
+
rest = ~degenerate
|
|
194
|
+
a, cov, mean = a[rest], cov[rest], mean[rest]
|
|
195
|
+
if len(a) == 0:
|
|
196
|
+
continue
|
|
197
|
+
# Treat the covariance as singular when the smallest eigenvalue
|
|
198
|
+
# falls below 3 eps of the largest.
|
|
199
|
+
eig = np.linalg.eigvalsh(cov)
|
|
200
|
+
singular = eig[:, 0] <= 3 * eps * eig[:, 2]
|
|
201
|
+
active[a[singular]] = False
|
|
202
|
+
good = ~singular
|
|
203
|
+
a, cov, mean = a[good], cov[good], mean[good]
|
|
204
|
+
if len(a) == 0:
|
|
205
|
+
continue
|
|
206
|
+
inv = np.linalg.inv(cov)
|
|
207
|
+
dd = colors[a] - mean[:, None, :]
|
|
208
|
+
q = np.einsum("fni,fij,fnj->fn", dd, inv, dd)
|
|
209
|
+
new_inliers = np.exp(-0.5 * q) > th
|
|
210
|
+
num_new = new_inliers.sum(axis=1)
|
|
211
|
+
changed = (new_inliers != inliers[a]).any(axis=1)
|
|
212
|
+
inliers[a] = new_inliers
|
|
213
|
+
all_in = num_new == n
|
|
214
|
+
too_few = ~all_in & (num_new < min_inliers)
|
|
215
|
+
stable = ~all_in & ~too_few & ~changed
|
|
216
|
+
active[a[all_in | too_few | stable]] = False
|
|
217
|
+
remove[a[stable]] = True
|
|
218
|
+
remove[active] = True
|
|
219
|
+
drop = remove[:, None] & ~inliers
|
|
220
|
+
keep[slots[drop]] = False
|
|
221
|
+
|
|
222
|
+
if keep.all():
|
|
223
|
+
return face_views
|
|
224
|
+
face_of_slot = np.repeat(np.arange(len(counts)), counts)[keep]
|
|
225
|
+
offsets = np.zeros(len(counts) + 1, dtype=np.int64)
|
|
226
|
+
np.cumsum(np.bincount(face_of_slot, minlength=len(counts)), out=offsets[1:])
|
|
227
|
+
return FaceViews(
|
|
228
|
+
offsets=offsets, view=face_views.view[keep], quality=face_views.quality[keep], color=face_views.color[keep]
|
|
229
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Stage 1: incidence, adjacency and smoothed normals of the mesh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from texture3d.mapping.types import NO_ID, Mesh
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Topology:
|
|
14
|
+
#: CSR: the faces incident to vertex ``v`` are
|
|
15
|
+
#: ``vertex_face_list[vertex_face_offsets[v]:vertex_face_offsets[v + 1]]``, ascending.
|
|
16
|
+
vertex_face_offsets: np.ndarray
|
|
17
|
+
vertex_face_list: np.ndarray
|
|
18
|
+
#: Per face, the face across edge ``(face[v], face[(v + 1) % 3])`` for ``v``
|
|
19
|
+
#: in 0, 1, 2, or NO_ID at a boundary edge.
|
|
20
|
+
face_faces: np.ndarray
|
|
21
|
+
#: Unit face normals after three smoothing passes.
|
|
22
|
+
face_normals: np.ndarray
|
|
23
|
+
|
|
24
|
+
def faces_of_vertex(self, v: int) -> np.ndarray:
|
|
25
|
+
return self.vertex_face_list[self.vertex_face_offsets[v] : self.vertex_face_offsets[v + 1]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cv_normalize(v: np.ndarray) -> np.ndarray:
|
|
29
|
+
"""Unit-length vectors: the norm in double, the zero vector left at
|
|
30
|
+
zero, each component scaled in double and narrowed to float32."""
|
|
31
|
+
d = np.asarray(v, dtype=np.float64)
|
|
32
|
+
n = np.sqrt((d * d).sum(axis=-1, keepdims=True))
|
|
33
|
+
scale = np.where(n != 0, 1.0 / np.where(n != 0, n, 1.0), 0.0)
|
|
34
|
+
return (d * scale).astype(np.float32)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build(mesh: Mesh) -> Topology:
|
|
38
|
+
faces = mesh.faces
|
|
39
|
+
face_count = len(faces)
|
|
40
|
+
vertex_count = len(mesh.positions)
|
|
41
|
+
|
|
42
|
+
# Incident faces, CSR, ascending per vertex.
|
|
43
|
+
flat = faces.ravel()
|
|
44
|
+
order = np.argsort(flat, kind="stable")
|
|
45
|
+
vertex_face_list = (order // 3).astype(np.int64)
|
|
46
|
+
counts = np.bincount(flat, minlength=vertex_count)
|
|
47
|
+
vertex_face_offsets = np.zeros(vertex_count + 1, dtype=np.int64)
|
|
48
|
+
np.cumsum(counts, out=vertex_face_offsets[1:])
|
|
49
|
+
|
|
50
|
+
# Face adjacency across each edge slot. On a manifold edge the neighbour
|
|
51
|
+
# is the one other face; on an edge shared by more faces it is the
|
|
52
|
+
# lowest-numbered other face.
|
|
53
|
+
a = faces
|
|
54
|
+
b = np.roll(faces, -1, axis=1)
|
|
55
|
+
lo = np.minimum(a, b).ravel()
|
|
56
|
+
hi = np.maximum(a, b).ravel()
|
|
57
|
+
key = lo * vertex_count + hi
|
|
58
|
+
slot_face = np.repeat(np.arange(face_count, dtype=np.int64), 3)
|
|
59
|
+
order = np.lexsort((slot_face, key))
|
|
60
|
+
key_sorted = key[order]
|
|
61
|
+
face_sorted = slot_face[order]
|
|
62
|
+
group_start = np.ones(len(order), dtype=bool)
|
|
63
|
+
group_start[1:] = key_sorted[1:] != key_sorted[:-1]
|
|
64
|
+
group_id = np.cumsum(group_start) - 1
|
|
65
|
+
starts = np.flatnonzero(group_start)
|
|
66
|
+
sizes = np.diff(np.append(starts, len(order)))
|
|
67
|
+
first = face_sorted[starts]
|
|
68
|
+
second = np.where(sizes >= 2, face_sorted[np.minimum(starts + 1, len(order) - 1)], NO_ID)
|
|
69
|
+
own = face_sorted
|
|
70
|
+
neighbour = np.where(own == first[group_id], second[group_id], first[group_id])
|
|
71
|
+
neighbour = np.where(sizes[group_id] >= 2, neighbour, NO_ID)
|
|
72
|
+
face_faces = np.full(face_count * 3, NO_ID, dtype=np.int64)
|
|
73
|
+
face_faces[order] = neighbour
|
|
74
|
+
face_faces = face_faces.reshape(face_count, 3)
|
|
75
|
+
|
|
76
|
+
# Face normals, then three passes of averaging each normal with its edge
|
|
77
|
+
# neighbours within 25 degrees, half original and half average.
|
|
78
|
+
p = mesh.positions
|
|
79
|
+
v0 = p[faces[:, 0]]
|
|
80
|
+
v1 = p[faces[:, 1]]
|
|
81
|
+
v2 = p[faces[:, 2]]
|
|
82
|
+
normals = cv_normalize(np.cross(v1 - v0, v2 - v0))
|
|
83
|
+
cos_max_gradient = np.float32(np.cos(np.deg2rad(25.0)))
|
|
84
|
+
valid = face_faces != NO_ID
|
|
85
|
+
safe = np.where(valid, face_faces, 0)
|
|
86
|
+
for _ in range(3):
|
|
87
|
+
neighbour_normals = normals[safe] # (F, 3, 3)
|
|
88
|
+
cosine = np.clip((normals[:, None, :] * neighbour_normals).sum(axis=-1), -1, 1)
|
|
89
|
+
take = valid & (cosine >= cos_max_gradient)
|
|
90
|
+
summed = (neighbour_normals * take[..., None].astype(np.float32)).sum(axis=1)
|
|
91
|
+
average = cv_normalize(summed)
|
|
92
|
+
normals = cv_normalize(normals * np.float32(0.5) + average * np.float32(0.5))
|
|
93
|
+
|
|
94
|
+
return Topology(
|
|
95
|
+
vertex_face_offsets=vertex_face_offsets,
|
|
96
|
+
vertex_face_list=vertex_face_list,
|
|
97
|
+
face_faces=face_faces,
|
|
98
|
+
face_normals=normals,
|
|
99
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Shared types and colour helpers of the texture mapping.
|
|
2
|
+
|
|
3
|
+
One photograph per face, then seams levelled. Stages, each its own module:
|
|
4
|
+
|
|
5
|
+
1. ``topology`` incident faces, face adjacency, smoothed face normals
|
|
6
|
+
2. ``scoring`` per view, rasterise the mesh and score each visible face by the
|
|
7
|
+
image gradient it covers; reject views whose colour disagrees
|
|
8
|
+
with the majority
|
|
9
|
+
3. ``labeling`` one view per face, an MRF solved by loopy belief propagation
|
|
10
|
+
with a Potts smoothness term
|
|
11
|
+
4. ``patches`` connected same-view faces become patches with a rectangle in
|
|
12
|
+
their view; seam vertices between them
|
|
13
|
+
5. ``leveling`` global (least-squares colour offsets per vertex and patch) and
|
|
14
|
+
local (Poisson blend of a border strip)
|
|
15
|
+
6. ``atlas`` pack the patch rectangles into one texture, write the
|
|
16
|
+
per-corner texture coordinates, sharpen
|
|
17
|
+
|
|
18
|
+
Conventions: pixels are BGR, integer pixel coordinates are pixel centres,
|
|
19
|
+
the camera looks down +Z with +Y down, and internal arithmetic is float32.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import List
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
#: An absent face, view, patch or label.
|
|
30
|
+
NO_ID = -1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Mesh:
|
|
35
|
+
positions: np.ndarray # (V, 3) float32
|
|
36
|
+
faces: np.ndarray # (F, 3) int64, counter-clockwise seen from outside
|
|
37
|
+
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
self.positions = np.ascontiguousarray(self.positions, dtype=np.float32)
|
|
40
|
+
self.faces = np.ascontiguousarray(self.faces, dtype=np.int64)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def face_count(self) -> int:
|
|
44
|
+
return len(self.faces)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def vertex_count(self) -> int:
|
|
48
|
+
return len(self.positions)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Camera:
|
|
53
|
+
"""A pinhole camera: ``K`` in pixels of the image it is paired with, ``R``
|
|
54
|
+
rotating world to camera, ``C`` the centre in world coordinates."""
|
|
55
|
+
|
|
56
|
+
fx: float
|
|
57
|
+
fy: float
|
|
58
|
+
cx: float
|
|
59
|
+
cy: float
|
|
60
|
+
R: np.ndarray # (3, 3) float64
|
|
61
|
+
C: np.ndarray # (3,) float64
|
|
62
|
+
|
|
63
|
+
def to_camera(self, world: np.ndarray) -> np.ndarray:
|
|
64
|
+
"""(N, 3) world points to camera space, in double."""
|
|
65
|
+
return (np.asarray(world, dtype=np.float64) - self.C) @ self.R.T
|
|
66
|
+
|
|
67
|
+
def to_image(self, cam: np.ndarray) -> np.ndarray:
|
|
68
|
+
"""(N, 3) camera-space points to (N, 2) pixel coordinates, in double."""
|
|
69
|
+
z = cam[:, 2]
|
|
70
|
+
return np.stack([self.cx + self.fx * (cam[:, 0] / z), self.cy + self.fy * (cam[:, 1] / z)], axis=-1)
|
|
71
|
+
|
|
72
|
+
def project(self, world: np.ndarray) -> np.ndarray:
|
|
73
|
+
"""World points to float32 pixel coordinates."""
|
|
74
|
+
return self.to_image(self.to_camera(world)).astype(np.float32)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class View:
|
|
79
|
+
"""One keyframe: BGR8 pixels and the camera that took it."""
|
|
80
|
+
|
|
81
|
+
bgr: np.ndarray # (H, W, 3) uint8
|
|
82
|
+
camera: Camera
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def width(self) -> int:
|
|
86
|
+
return self.bgr.shape[1]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def height(self) -> int:
|
|
90
|
+
return self.bgr.shape[0]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class FaceViews:
|
|
95
|
+
"""Per face, the views that see it, in view order (CSR)."""
|
|
96
|
+
|
|
97
|
+
offsets: np.ndarray # (F + 1,) int64
|
|
98
|
+
view: np.ndarray # (N,) int64
|
|
99
|
+
quality: np.ndarray # (N,) float32
|
|
100
|
+
color: np.ndarray # (N, 3) float32, YCbCr taken over the BGR channel order
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def counts(self) -> np.ndarray:
|
|
104
|
+
return np.diff(self.offsets)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def visible(self) -> np.ndarray:
|
|
108
|
+
return self.counts > 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Rect:
|
|
113
|
+
"""An integer pixel rectangle (x, y, width, height)."""
|
|
114
|
+
|
|
115
|
+
x: int
|
|
116
|
+
y: int
|
|
117
|
+
width: int
|
|
118
|
+
height: int
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def area(self) -> int:
|
|
122
|
+
return self.width * self.height
|
|
123
|
+
|
|
124
|
+
def is_contained_in(self, other: Rect) -> bool:
|
|
125
|
+
return (
|
|
126
|
+
self.x >= other.x
|
|
127
|
+
and self.y >= other.y
|
|
128
|
+
and self.x + self.width <= other.x + other.width
|
|
129
|
+
and self.y + self.height <= other.y + other.height
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Result:
|
|
135
|
+
#: Per face: the view it is textured from, or NO_ID.
|
|
136
|
+
labels: np.ndarray
|
|
137
|
+
#: Per face: its patch index; ``patch_count`` is the untextured patch.
|
|
138
|
+
face_patch: np.ndarray
|
|
139
|
+
patch_count: int
|
|
140
|
+
#: Square atlas, BGR8.
|
|
141
|
+
atlas_bgr: np.ndarray
|
|
142
|
+
atlas_size: int
|
|
143
|
+
#: (F, 3, 2) atlas pixel coordinates (integer = pixel centre, row 0 top).
|
|
144
|
+
face_texcoords: np.ndarray
|
|
145
|
+
#: Where each textured patch landed in the atlas.
|
|
146
|
+
patch_rects: List[Rect] = field(default_factory=list)
|
|
147
|
+
timings: dict = field(default_factory=dict)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class MappingError(Exception):
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class EmptyMesh(MappingError):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class NoViews(MappingError):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class NoVisibleFace(MappingError):
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class PatchTooLarge(MappingError):
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Colour helpers shared by the stages.
|
|
171
|
+
|
|
172
|
+
_RGB2YCBCR = np.array(
|
|
173
|
+
[[0.299, 0.587, 0.114], [-0.168736, -0.331264, 0.5], [0.5, -0.418688, -0.081312]], dtype=np.float32
|
|
174
|
+
)
|
|
175
|
+
_YCBCR_OFFSET = np.array([0.0, 128.0, 128.0], dtype=np.float32)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def rgb2ycbcr(v: np.ndarray) -> np.ndarray:
|
|
179
|
+
"""RGB-to-YCbCr on the last axis. Views hand this BGR, so the channels
|
|
180
|
+
stay in BGR order through the conversion."""
|
|
181
|
+
v = np.asarray(v, dtype=np.float32)
|
|
182
|
+
return v @ _RGB2YCBCR.T + _YCBCR_OFFSET
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def ycbcr2rgb(v: np.ndarray) -> np.ndarray:
|
|
186
|
+
v = np.asarray(v, dtype=np.float32)
|
|
187
|
+
y = v[..., 0]
|
|
188
|
+
cb = v[..., 1] - 128.0
|
|
189
|
+
cr = v[..., 2] - 128.0
|
|
190
|
+
return np.stack([y + cr * 1.402, y + cb * -0.34414 + cr * -0.71414, y + cb * 1.772], axis=-1)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def round2int(x) -> np.ndarray:
|
|
194
|
+
"""Round half up: ``floor(x + 0.5)``."""
|
|
195
|
+
return np.floor(np.asarray(x, dtype=np.float64) + 0.5).astype(np.int64)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def clamp_byte(x: np.ndarray) -> np.ndarray:
|
|
199
|
+
return np.clip(x, 0, 255).astype(np.uint8)
|