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