flatpoints 0.1.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.
flatpoints/main.py ADDED
@@ -0,0 +1,290 @@
1
+ """User-facing classes: `Flat` (point cloud -> images), `Resampler` (images -> new points
2
+ on the surface) and `Compressor` (image compression of the points, see compressor.py).
3
+ `Viz` adds the plots to `Flat`.
4
+ """
5
+ from functools import cached_property
6
+
7
+ import numpy as np
8
+ from scipy.ndimage import gaussian_filter, map_coordinates
9
+ from scipy.spatial import cKDTree
10
+
11
+ from .core import conformal_uv, image_cells
12
+ from .mesh import TRIM, closest_on_mesh, disk_cut, hemisphere_cut, poisson_mesh, submesh
13
+ from .utils import axes, fill_empty, Chart, Corners, Mesh, _window, _seeds, ON_SURFACE
14
+
15
+
16
+ class Viz:
17
+ """Plots of a `Flat`, as its methods: `flat.plot()` and `flat.imshow(data)`.
18
+
19
+ Needs matplotlib (`pip install "flatpoints[viz]"`), imported only when a plot is drawn.
20
+ The drawing itself is in `viz.py`."""
21
+
22
+ def plot(self, squares=8, point_size=1.0):
23
+ """The surface in 3D and its images, coloured by RGB = xyz and by a checkerboard
24
+ of `squares` pixels: smooth colours and regular squares mean a good flattening.
25
+ """
26
+ from .viz import plot
27
+ return plot(self, squares, point_size)
28
+
29
+ def imshow(self, data=None, sigma=0.5, cmap="viridis", animate=False, resolution="low"):
30
+ """`data` on the images: per-point (N, *C) or images (n_faces, n, n, *C),
31
+ default the xyz of the points. See `viz.imshow` for the colouring, `viz.morph` for
32
+ `animate` (True, or a .webp path) and `resolution` ("low" or "high").
33
+ """
34
+ from .viz import imshow, morph
35
+ data = self.points if data is None else np.asarray(data)
36
+ if len(data) != len(self.points):
37
+ if data.shape[:3] != (self.n_faces, self.n, self.n):
38
+ raise ValueError(f"data must be per point ({len(self.points)}, *C) or images "
39
+ f"({self.n_faces}, {self.n}, {self.n}, *C), got {data.shape}")
40
+ else:
41
+ data = self.to_image(data)
42
+ if animate is not False:
43
+ return morph(data, self.to_image(self.points), self.normal_axis, sigma, cmap,
44
+ path=None if animate is True else animate, resolution=resolution)
45
+ return imshow(data, sigma, cmap)
46
+
47
+
48
+ class Flat(Viz):
49
+ """Flatten a 3D point cloud into one or two square images, one pixel per point.
50
+
51
+ Neighbour pixels hold neighbour points, so per-point data (N, *C) becomes images
52
+ (n_faces, n, n, *C). See Method.md for how.
53
+
54
+ Args:
55
+ points: (N, 3) points sampled on a surface.
56
+ single_face: True for an open surface (one image), False for a closed one (two
57
+ images, the surface cut in left and right halves).
58
+ normal_axis: "x", "y" or "z". Orients the images (rows follow z, or y if
59
+ normal_axis is "z") and, for a closed surface, sets the direction of the cut.
60
+ verbose: print progress.
61
+
62
+ Attributes:
63
+ n, n_faces: side and number of the images.
64
+ pixels: (N, 3) (face, row, col) of each point.
65
+ faces: {"left": idx, "right": idx}, None if single_face.
66
+ corners: the points copied into the empty pixels (see `to_image`).
67
+ _mesh, _charts, _uv_conformal: intermediate steps, for inspection.
68
+
69
+ Example:
70
+ flat = Flat(points)
71
+ image = flat.to_image(points) # (2, n, n, 3)
72
+ face, row, col = flat.pixels[42]
73
+ new = flat.resample(512).points # (512, 3)
74
+ """
75
+
76
+ def __init__(self, points, single_face=False, normal_axis="x", verbose=False):
77
+ self.points = P = np.asarray(points, float)
78
+ self.normal_axis, self.single_face = normal_axis, single_face
79
+ normal, horiz, vert = axes(normal_axis)
80
+
81
+ # 1. Mesh, cut into charts
82
+ if single_face:
83
+ V, F = poisson_mesh(P, trim=TRIM)
84
+ side = disk_cut(V, F)
85
+ else:
86
+ V, F = poisson_mesh(P)
87
+ side = hemisphere_cut(V, F, normal, np.median(P[:, normal]))
88
+ self._mesh = Mesh(V, F, side)
89
+ if verbose:
90
+ print(f"mesh: {len(V)} vertices, {len(F)} triangles, "
91
+ f"{(side < 0).sum()} in no face")
92
+ in_face = np.flatnonzero(side >= 0)
93
+ tri, bary = closest_on_mesh(V, F[in_face], P) # each point projected on the mesh
94
+ tri = in_face[tri]
95
+ face_of = side[tri]
96
+
97
+ names = ("main",) if single_face else ("left", "right")
98
+ self.n_faces = len(names)
99
+ self.n = n = int(np.ceil(np.sqrt(np.bincount(face_of).max())))
100
+ self.faces = None if single_face else {}
101
+ self._charts = []
102
+ self._uv_conformal = np.zeros((len(P), 2))
103
+ self.pixels = np.zeros((len(P), 3), int)
104
+ pad_idxs, pad_pixels = [], []
105
+
106
+ for f, name in enumerate(names):
107
+ idx = np.flatnonzero(face_of == f)
108
+ if verbose:
109
+ print(f"face {name}: {len(idx)} points on {n} x {n}")
110
+ if not single_face:
111
+ self.faces[name] = idx
112
+
113
+ # 2. Conformal uv of the chart, interpolated at the points
114
+ mine = side == f
115
+ Ff, vertices = submesh(F, mine)
116
+ Vf = V[vertices]
117
+ uv = conformal_uv(Vf, Ff, Vf[:, [horiz, vert]])
118
+ self._charts.append(Chart(vertices, Ff, uv))
119
+ around = Ff[(np.cumsum(mine) - 1)[tri[idx]]] # chart vertices around each point
120
+ self._uv_conformal[idx] = np.einsum("nk,nkd->nd", bary[idx], uv[around])
121
+
122
+ # 3. Image, from the conformal uv
123
+ cells = image_cells(self._uv_conformal[idx], n, verbose) # (n, n), -1: empty
124
+ rows, cols = np.nonzero(cells >= 0)
125
+ self.pixels[idx[cells[rows, cols]]] = np.c_[np.full(len(rows), f), rows, cols]
126
+ rows, cols = np.nonzero(cells < 0)
127
+ pad_idxs.append(idx[fill_empty(cells)[rows, cols]])
128
+ pad_pixels.append(np.c_[np.full(len(rows), f), rows, cols])
129
+
130
+ self.corners = Corners(np.concatenate(pad_idxs), np.concatenate(pad_pixels))
131
+
132
+ def resample(self, n_points=512, seed=42, sigma=0.0):
133
+ """`n_points` new points evenly spread on the surface, see `Resampler`."""
134
+ return Resampler(self, n_points, seed, sigma)
135
+
136
+ def compress(self, factor=0.8, method="auto"):
137
+ """Compress the points, `factor` (e.g. 0.8) of the storage saved, with `method`
138
+ ("auto": the most accurate), see `Compressor`."""
139
+ return Compressor(self, factor, method)
140
+
141
+ @cached_property
142
+ def _lifting(self):
143
+ """Cached for `Resampler`: xyz images, mask of real pixels, k-d tree and local
144
+ spacing of the points.
145
+ """
146
+ image = self.to_image(self.points) # (n_faces, n, n, 3)
147
+ valid = np.ones(image.shape[:3], bool)
148
+ valid[tuple(self.corners.pixels.T)] = False
149
+ tree = cKDTree(self.points)
150
+ spacing = tree.query(self.points, k=7)[0][:, 1:].mean(1)
151
+ return image, valid, tree, spacing
152
+
153
+ def to_image(self, values):
154
+ """Per-point `values` (N, ...) as images (n_faces, n, n, ...), empty pixels
155
+ filled from `corners`. E.g. `to_image(points)`: the xyz images.
156
+ """
157
+ values = np.asarray(values)
158
+ image = np.empty((self.n_faces, self.n, self.n) + values.shape[1:], values.dtype)
159
+ image[tuple(self.pixels.T)] = values
160
+ image[tuple(self.corners.pixels.T)] = values[self.corners.idxs]
161
+ return image
162
+
163
+
164
+ class Resampler:
165
+ """`n_points` new points evenly spread on the surface of a `Flat`
166
+ (`flat.resample(n_points)`).
167
+
168
+ A 2D seed is placed on the images and lifted to 3D by bilinear interpolation of the xyz
169
+ images; points that fall off the surface (across a tear) are replaced by the next ones.
170
+
171
+ Args:
172
+ n_points: split evenly between the faces.
173
+ seed: int (shift of the default hyperuniform seed), (M, 2) points in [0, 1)^2, or
174
+ {face name: (M, 2)}.
175
+ sigma: Gaussian smoothing of the images before lifting, in pixels.
176
+
177
+ Attributes:
178
+ points: (n_points, 3); faces: (n_points,); pixels: (n_points, 3) float
179
+ (face, row, col).
180
+ """
181
+
182
+ def __init__(self, flat, n_points=512, seed=42, sigma=0.0):
183
+ self.flat, self.n_points, self.sigma = flat, n_points, sigma
184
+ names = ("main",) if flat.single_face else ("left", "right")
185
+ if n_points < len(names):
186
+ raise ValueError(f"n_points must be at least {len(names)}, one per face")
187
+ seeds = _seeds(seed, names)
188
+ counts = np.full(len(names), n_points // len(names))
189
+ if n_points % len(names): # odd: one face, drawn from the seed, gets one more
190
+ rng = np.random.default_rng(seed if isinstance(seed, (int, np.integer)) else 0)
191
+ counts[rng.integers(len(names))] += 1
192
+
193
+ image, valid, tree, spacing = flat._lifting
194
+ m = flat.n
195
+ points, pixels = [], []
196
+ for f, (name, n) in enumerate(zip(names, counts)):
197
+ smooth = gaussian_filter(image[f], (sigma, sigma, 0), mode="nearest")
198
+ lift = lambda rc: np.stack([map_coordinates(smooth[..., c], rc.T, order=1, # noqa: E731
199
+ mode="nearest") for c in range(3)], -1)
200
+
201
+ def keep(rc, f=f, lift=lift):
202
+ """Not on a padding pixel, and on the surface (not in the air)."""
203
+ pixel = valid[f][tuple(np.clip(np.rint(rc).astype(int), 0, m - 1).T)]
204
+ d, i = tree.query(lift(rc))
205
+ return pixel & (d <= ON_SURFACE * spacing[i])
206
+
207
+ rc = _window(seeds[name], n, m, keep)
208
+ points.append(lift(rc))
209
+ pixels.append(np.c_[np.full(n, f), rc])
210
+ self.points = np.concatenate(points)
211
+ self.pixels = np.concatenate(pixels)
212
+ self.faces = self.pixels[:, 0].astype(int)
213
+
214
+ def plot(self, ax=None, point_size=1.0, **kwargs):
215
+ """Before (input cloud, left) / after (new points on its mesh, right), in 3D.
216
+ With `ax`, only the after view, drawn in `ax`. See `viz.plot_resampler`."""
217
+ from .viz import plot_resampler
218
+ return plot_resampler(self, ax=ax, point_size=point_size, **kwargs)
219
+
220
+
221
+
222
+ class Compressor:
223
+ """Compress the points of a `Flat` through its xyz images (`flat.compress(factor)`).
224
+
225
+ The images are smooth (neighbour pixels hold neighbour points), so image compression
226
+ applies. Methods (see `compressor.py`): "svd" (truncated SVD), "dct" (quantized DCT,
227
+ as JPEG) and "wavelet" (quantized wavelets, as JPEG2000, needs PyWavelets); "auto"
228
+ runs the available ones and keeps the most accurate.
229
+
230
+ Args:
231
+ factor: fraction of the storage saved, in [0, 1), against the points in float32
232
+ (96 bits per point): 0.8 keeps 20%, about 19 bits per point.
233
+ method: "auto", "svd", "dct" or "wavelet".
234
+
235
+ Attributes:
236
+ method: the method used.
237
+ code: what is stored, arrays (n_faces, 3, ...): {"U", "S", "Vt"} for svd,
238
+ {"coefficients", "step", ...} for dct and wavelet (integers, times step).
239
+ bits: size of the code; ratio: storage actually saved, 1 - bits / (96 N).
240
+ images: (n_faces, n, n, 3) the reconstructed xyz images.
241
+ points: (N, 3) the reconstructed points, in the order of `flat.points`.
242
+ error: RMS distance between the points and their reconstruction.
243
+
244
+ `bits` is a theoretical bound for dct and wavelet (ideal entropy coder). `save` writes a
245
+ real file, in JPEG2000, and `Compressor.read` reads it back.
246
+ """
247
+
248
+ def __init__(self, flat, factor=0.8, method="auto"):
249
+ from . import compressor
250
+ if not 0 <= factor < 1:
251
+ raise ValueError("factor must be in [0, 1), e.g. 0.8 to save 80% of the storage")
252
+ methods = compressor.available() if method == "auto" else [method]
253
+ if method != "auto" and method not in compressor.METHODS:
254
+ raise ValueError(f"method must be 'auto' or one of {list(compressor.METHODS)}")
255
+ self.flat, self.factor = flat, factor
256
+ raw = 32 * flat.points.size
257
+ A = np.moveaxis(flat.to_image(flat.points), -1, 1) # (n_faces, 3, n, n)
258
+ shape = A.shape
259
+ best = None
260
+ for name in methods:
261
+ code, rec, bits = compressor.METHODS[name](A.reshape(-1, *shape[2:]), (1 - factor) * raw)
262
+ images = np.moveaxis(rec.reshape(shape), 1, -1)
263
+ points = images[tuple(flat.pixels.T)]
264
+ error = np.sqrt(((points - flat.points) ** 2).sum(1).mean())
265
+ code = {k: v.reshape(shape[:2] + v.shape[1:]) if np.ndim(v) else v
266
+ for k, v in code.items()} # per face and coordinate
267
+ if best is None or error < best[-1]:
268
+ best = (name, code, bits, images, points, error)
269
+ self.method, self.code, self.bits, self.images, self.points, self.error = best
270
+ self.ratio = 1 - self.bits / raw
271
+
272
+ def save(self, path):
273
+ """Write the points to `path` as a JPEG2000 file (16 bits, any JPEG2000 reader opens
274
+ it), within the size budget of `factor`. The file holds the points, not their order.
275
+ Returns its size in bytes."""
276
+ from .compressor import to_jp2
277
+ flat = self.flat
278
+ n = flat.n
279
+ padding = (flat.corners.pixels * [n * n, n, 1]).sum(1)
280
+ data = to_jp2(flat.to_image(flat.points), padding, (1 - self.factor) * 32 * flat.points.size)
281
+ with open(path, "wb") as file:
282
+ file.write(data)
283
+ return len(data)
284
+
285
+ @staticmethod
286
+ def read(path):
287
+ """(N, 3) points of a file written by `save`, in the order of its pixels."""
288
+ from .compressor import from_jp2
289
+ with open(path, "rb") as file:
290
+ return from_jp2(file.read())
flatpoints/mesh.py ADDED
@@ -0,0 +1,172 @@
1
+ """Meshes: Poisson reconstruction, cut into disks, projection of the points."""
2
+ import heapq
3
+
4
+ import numpy as np
5
+ from scipy.sparse import coo_matrix
6
+ from scipy.sparse.csgraph import dijkstra
7
+ from scipy.spatial import cKDTree
8
+
9
+ POISSON_DEPTH = 8 # octree depth of the Poisson reconstruction
10
+ KNN_NORMALS = 16 # neighbours used to estimate and orient the normals
11
+ TRIM = 2.0 # open surface: mesh kept within TRIM x the point spacing of the cloud
12
+
13
+
14
+ # === Reconstruction ===
15
+
16
+ def poisson_mesh(points, depth=POISSON_DEPTH, trim=None):
17
+ """(V, F) mesh of the surface sampled by `points`, screened Poisson: its largest piece.
18
+
19
+ Poisson always returns a closed surface. For an open surface, `trim` removes what it
20
+ invents away from the samples: the vertices farther from the cloud than `trim` times
21
+ the local point spacing.
22
+ """
23
+ import open3d as o3d
24
+ cloud = o3d.geometry.PointCloud(o3d.utility.Vector3dVector(points))
25
+ cloud.estimate_normals(o3d.geometry.KDTreeSearchParamKNN(KNN_NORMALS))
26
+ try:
27
+ cloud.orient_normals_consistent_tangent_plane(KNN_NORMALS)
28
+ except RuntimeError: # exactly planar cloud: its Delaunay (qhull) is degenerate
29
+ jitter = 1e-6 * np.ptp(points, 0).max() * np.random.default_rng(0).standard_normal(points.shape)
30
+ shaken = o3d.geometry.PointCloud(o3d.utility.Vector3dVector(points + jitter))
31
+ shaken.normals = cloud.normals
32
+ shaken.orient_normals_consistent_tangent_plane(KNN_NORMALS)
33
+ cloud.normals = shaken.normals
34
+ mesh, _ = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(cloud, depth=depth)
35
+ if trim is not None:
36
+ tree = cKDTree(points)
37
+ spacing = tree.query(points, k=7)[0][:, 1:].mean(1)
38
+ d, i = tree.query(np.asarray(mesh.vertices))
39
+ mesh.remove_vertices_by_mask(d > trim * spacing[i])
40
+ mesh.remove_degenerate_triangles()
41
+ mesh.remove_non_manifold_edges()
42
+ piece, sizes, _ = mesh.cluster_connected_triangles()
43
+ mesh.remove_triangles_by_mask(np.asarray(piece) != np.argmax(sizes))
44
+ mesh.remove_unreferenced_vertices()
45
+ return np.asarray(mesh.vertices), np.asarray(mesh.triangles)
46
+
47
+
48
+ # === Cuts ===
49
+
50
+ def disk_cut(V, F):
51
+ """Cut the open mesh (V, F) into one disk: (F,) 0 for the triangles of the disk, -1
52
+ for the others.
53
+
54
+ The disk grows from the triangle farthest from the boundary (along the edges of the
55
+ mesh), the closest triangles first; handles and holes are left open (see `grow`).
56
+ """
57
+ E = np.unique(np.sort(F[:, [0, 1, 1, 2, 2, 0]].reshape(-1, 2), 1), axis=0)
58
+ graph = coo_matrix((np.linalg.norm(V[E[:, 0]] - V[E[:, 1]], axis=1), E.T),
59
+ shape=(len(V),) * 2).tocsr()
60
+ nbr = neighbours(F)
61
+ border = np.unique(F[nbr < 0])
62
+ depth = dijkstra(graph, directed=False, indices=border, min_only=True)
63
+ seed = np.argmax(depth[F].min(1))
64
+ d = dijkstra(graph, directed=False, indices=F[seed], min_only=True)[F].mean(1)
65
+ side = np.full(len(F), -1)
66
+ side[seed] = 0
67
+ side = grow(F, nbr, side, [d, d], [np.ones(len(F), bool), np.zeros(len(F), bool)])
68
+ return drop_ears(nbr, side)
69
+
70
+
71
+ def hemisphere_cut(V, F, normal, threshold):
72
+ """(F,) 0 (left), 1 (right) or -1 (out) for each triangle of the closed mesh:
73
+ two disks, either side of `threshold` on the axis `normal`. Each grows from its extreme
74
+ triangle (see `grow`); triangles of a handle stay out of both.
75
+ """
76
+ x = V[F, normal].mean(1)
77
+ nbr = neighbours(F)
78
+ side = np.full(len(F), -1)
79
+ side[np.argmin(x)], side[np.argmax(x)] = 0, 1
80
+ right = x > threshold
81
+ side = grow(F, nbr, side, [x, -x], [~right, right])
82
+ d = np.abs(x - threshold)
83
+ side = grow(F, nbr, side, [d, d], [side < 0, side < 0])
84
+ return drop_ears(nbr, side)
85
+
86
+
87
+ def grow(F, nbr, side, keys, allowed):
88
+ """Grow faces 0 and 1 from `side` (-1: free), keeping each a topological disk.
89
+
90
+ A free triangle joins face s if it shares two edges with it, or one edge and a third
91
+ vertex not yet in it. Candidates come in increasing `keys[s]`, among `allowed[s]`.
92
+ Returns the new `side`.
93
+ """
94
+ side = side.copy()
95
+ Fl, nbl, sl = F.tolist(), nbr.tolist(), side.tolist()
96
+ inside = [np.zeros(F.max() + 1, bool) for _ in (0, 1)]
97
+ for s in (0, 1):
98
+ inside[s][F[side == s]] = True
99
+ inside = [m.tolist() for m in inside]
100
+ keys = [k.tolist() for k in keys]
101
+ allowed = [a.tolist() for a in allowed]
102
+
103
+ queue = []
104
+ for s in (0, 1): # the free neighbours of each face
105
+ for t in np.flatnonzero(side == s).tolist():
106
+ queue += [(keys[s][u], s, u) for u in nbl[t] if u >= 0 and allowed[s][u]]
107
+ heapq.heapify(queue)
108
+ while queue:
109
+ _, s, t = heapq.heappop(queue)
110
+ if sl[t] >= 0:
111
+ continue
112
+ shared = [k for k, u in enumerate(nbl[t]) if u >= 0 and sl[u] == s]
113
+ if len(shared) == 1:
114
+ if inside[s][Fl[t][(shared[0] + 2) % 3]]: # third vertex already in the face
115
+ continue
116
+ elif len(shared) != 2:
117
+ continue
118
+ sl[t] = s
119
+ for v in Fl[t]:
120
+ inside[s][v] = True
121
+ for u in nbl[t]:
122
+ if u >= 0 and sl[u] < 0 and allowed[s][u]:
123
+ heapq.heappush(queue, (keys[s][u], s, u))
124
+ return np.array(sl)
125
+
126
+
127
+ def drop_ears(nbr, side):
128
+ """Free the triangles with two or three edges on the boundary of their face (their
129
+ tip would be a boundary vertex of a single triangle, which the rectangular conformal
130
+ map does not allow), until there is none."""
131
+ side = side.copy()
132
+ while True:
133
+ across = np.where(nbr >= 0, side[nbr], -2)
134
+ ear = (side >= 0) & ((across != side[:, None]).sum(1) >= 2)
135
+ if not ear.any():
136
+ return side
137
+ side[ear] = -1
138
+
139
+
140
+ def neighbours(F):
141
+ """(nF, 3) the triangle across each edge (k, k+1) of each triangle, -1 on a border."""
142
+ he = np.stack([F, np.roll(F, -1, axis=1)], -1).reshape(-1, 2).astype(np.int64)
143
+ n = he.max() + 1
144
+ key, twin = he[:, 0] * n + he[:, 1], he[:, 1] * n + he[:, 0]
145
+ order = np.argsort(key)
146
+ pos = np.minimum(np.searchsorted(key[order], twin), len(key) - 1)
147
+ found = key[order][pos] == twin
148
+ return np.where(found, order[pos] // 3, -1).reshape(-1, 3)
149
+
150
+
151
+ # === Points on the mesh ===
152
+
153
+ def closest_on_mesh(V, F, X):
154
+ """For each point of X, the triangle of its closest point on (V, F) and the
155
+ barycentric coordinates of that closest point, (M,) and (M, 3)."""
156
+ import open3d as o3d
157
+ mesh = o3d.t.geometry.TriangleMesh()
158
+ mesh.vertex.positions = o3d.core.Tensor(V.astype(np.float32))
159
+ mesh.triangle.indices = o3d.core.Tensor(F.astype(np.int32))
160
+ scene = o3d.t.geometry.RaycastingScene()
161
+ scene.add_triangles(mesh)
162
+ hit = scene.compute_closest_points(o3d.core.Tensor(X.astype(np.float32)))
163
+ w = hit["primitive_uvs"].numpy().astype(float)
164
+ bary = np.clip(np.c_[1 - w.sum(1), w], 0, 1) # float32 round-off
165
+ return hit["primitive_ids"].numpy().astype(int), bary / bary.sum(1, keepdims=True)
166
+
167
+
168
+ def submesh(F, keep):
169
+ """(F', vertices) the triangles F[keep] renumbered on their own vertices, and the
170
+ index of these vertices in the full mesh."""
171
+ vertices, Fk = np.unique(F[keep], return_inverse=True)
172
+ return Fk.reshape(-1, 3), vertices
flatpoints/rcm.py ADDED
@@ -0,0 +1,171 @@
1
+ """Rectangular conformal map of a disk-like triangle mesh.
2
+
3
+ Python port of the MATLAB code https://github.com/garyptchoi/rectangular-conformal-map,
4
+ Copyright (c) 2015-2024 Gary Pui-Tung Choi, licensed under the Apache License 2.0 (see
5
+ LICENSE-rectangular-conformal-map). Modified from the original: translated to
6
+ Python/NumPy/SciPy, vectorised, Tutte fallback also used when the harmonic map folds, and
7
+ the optimal height (step 3) left out, since flatpoints rescales the rectangle to the unit
8
+ square anyway.
9
+
10
+ Reference:
11
+ T. W. Meng, G. P.-T. Choi and L. M. Lui, "TEMPO: Feature-Endowed Teichmüller Extremal
12
+ Mappings of Point Clouds", SIAM Journal on Imaging Sciences, 9(4), 1922-1962, 2016.
13
+
14
+ Steps:
15
+ 1. Disk: harmonic map (cotangent Laplacian) of the mesh onto the unit disk, the
16
+ boundary placed by arc length.
17
+ 2. Square: generalised Laplace equation built from the Beltrami coefficient of the
18
+ disk map, with x = 0 / 1 on the left / right sides and y = 0 / 1 on the bottom / top
19
+ sides, the sides being the boundary arcs between the four corners.
20
+ """
21
+ import numpy as np
22
+ from scipy.sparse import coo_matrix, diags
23
+ from scipy.sparse.linalg import factorized
24
+
25
+
26
+ def rectangular_conformal_map(v, f, corners):
27
+ """(nv, 2) conformal map of the disk mesh (v, f) onto the unit square.
28
+
29
+ Args:
30
+ v: (nv, 3) vertices.
31
+ f: (nf, 3) triangles of a mesh with the topology of a disk (one boundary loop, no
32
+ boundary vertex in a single triangle).
33
+ corners: 4 boundary vertices sent to (0, 0), (1, 0), (1, 1), (0, 1).
34
+ """
35
+ loop = boundary_loop(f)
36
+ where = np.full(len(v), -1)
37
+ where[loop] = np.arange(len(loop))
38
+ if (where[corners] < 0).any():
39
+ raise ValueError("the corners must lie on the boundary")
40
+ loop = np.roll(loop, -where[corners[0]])
41
+ where[loop] = np.arange(len(loop))
42
+ i1, i2, i3 = where[corners[1:]]
43
+ if not i1 < i2 < i3: # the corners turn the other way round: reverse the loop
44
+ loop = np.r_[loop[0], loop[:0:-1]]
45
+ where[loop] = np.arange(len(loop))
46
+ i1, i2, i3 = where[corners[1:]]
47
+
48
+ disk = disk_map(v, f, loop)
49
+
50
+ # Square: bottom = loop[0..i1], right = loop[i1..i2], top = loop[i2..i3], left = rest
51
+ A = generalized_laplacian(disk, f, beltrami_coefficient(disk, f, v))
52
+ left_right = np.r_[loop[i3:], loop[0], loop[i1:i2 + 1]]
53
+ bottom_top = np.r_[loop[:i1 + 1], loop[i2:i3 + 1]]
54
+ x = _dirichlet(A, left_right, loop[i1:i2 + 1], len(v))()
55
+ y = _dirichlet(A, bottom_top, loop[i2:i3 + 1], len(v))()
56
+ return np.c_[x, y]
57
+
58
+
59
+ def disk_map(v, f, loop):
60
+ """(nv, 2) harmonic map onto the unit disk, the boundary `loop` placed by arc length.
61
+ Falls back to the Tutte map (always bijective) if the harmonic map folds."""
62
+ arc = np.linalg.norm(v[loop] - v[np.roll(loop, -1)], axis=1)
63
+ theta = 2 * np.pi * np.r_[0, np.cumsum(arc)[:-1]] / arc.sum()
64
+ target = np.c_[np.cos(theta), np.sin(theta)]
65
+ for laplacian in (cotangent_laplacian(v, f), tutte_laplacian(len(v), f)):
66
+ disk = _dirichlet(laplacian, loop, None, len(v))(target)
67
+ if np.isfinite(disk).all() and _orientation_consistent(disk, f):
68
+ break
69
+ return disk
70
+
71
+
72
+ def boundary_loop(f):
73
+ """Vertices of the boundary of a disk mesh, in order. Raises ValueError if the boundary
74
+ is not a single simple loop."""
75
+ he = np.r_[f[:, [0, 1]], f[:, [1, 2]], f[:, [2, 0]]].astype(np.int64)
76
+ n = he.max() + 1
77
+ bd = he[~np.isin(he[:, 0] * n + he[:, 1], he[:, 1] * n + he[:, 0])]
78
+ if len(bd) == 0:
79
+ raise ValueError("the mesh has no boundary")
80
+ nxt = np.full(n, -1)
81
+ nxt[bd[:, 0]] = bd[:, 1]
82
+ if len(np.unique(bd[:, 0])) != len(bd):
83
+ raise ValueError("pinched boundary (a vertex is met twice)")
84
+ loop = [bd[0, 0]]
85
+ for _ in range(len(bd) - 1):
86
+ loop.append(nxt[loop[-1]])
87
+ if nxt[loop[-1]] != loop[0] or len(set(loop)) != len(bd):
88
+ raise ValueError("the boundary has several loops")
89
+ return np.array(loop)
90
+
91
+
92
+ def cotangent_laplacian(v, f):
93
+ """(nv, nv) cotangent Laplacian (negative semi-definite)."""
94
+ n = len(v)
95
+ L = 0
96
+ for a, b, c in ((0, 1, 2), (1, 2, 0), (2, 0, 1)):
97
+ # cotangent of the angle at c, weight of the edge (a, b)
98
+ e1, e2 = v[f[:, a]] - v[f[:, c]], v[f[:, b]] - v[f[:, c]]
99
+ cot = (e1 * e2).sum(1) / np.maximum(np.linalg.norm(np.cross(e1, e2), axis=1), 1e-30)
100
+ L = L + coo_matrix((cot / 2, (f[:, a], f[:, b])), shape=(n, n))
101
+ L = L + L.T
102
+ return (L - diags(np.asarray(L.sum(1)).ravel())).tocsr()
103
+
104
+
105
+ def tutte_laplacian(n, f):
106
+ """(n, n) graph Laplacian of the mesh edges, uniform weights."""
107
+ e = np.r_[f[:, [0, 1]], f[:, [1, 2]], f[:, [2, 0]]]
108
+ W = coo_matrix((np.ones(len(e)), (e[:, 0], e[:, 1])), shape=(n, n)).tocsr()
109
+ W = ((W + W.T) > 0).astype(float)
110
+ return W - diags(np.asarray(W.sum(1)).ravel())
111
+
112
+
113
+ def beltrami_coefficient(domain, f, image):
114
+ """(nf,) complex Beltrami coefficient of the map domain (2D) -> image (2D or 3D),
115
+ piecewise linear on the triangles."""
116
+ p = domain[f] # (nf, 3, 2)
117
+ e = p[:, [2, 0, 1]] - p[:, [1, 2, 0]] # edge opposite to each vertex
118
+ area2 = e[:, 0, 0] * e[:, 1, 1] - e[:, 0, 1] * e[:, 1, 0]
119
+ # gradient of the hat function of each vertex: rot90(opposite edge) / (2 area)
120
+ gx, gy = e[..., 1] / area2[:, None], -e[..., 0] / area2[:, None] # (nf, 3)
121
+ q = image[f] # (nf, 3, d)
122
+ du, dv = np.einsum("fk,fkd->fd", gx, q), np.einsum("fk,fkd->fd", gy, q)
123
+ E, G, F = (du * du).sum(1), (dv * dv).sum(1), (du * dv).sum(1)
124
+ return (E - G + 2j * F) / (E + G + 2 * np.sqrt(np.maximum(E * G - F ** 2, 0)))
125
+
126
+
127
+ def generalized_laplacian(v, f, mu):
128
+ """(nv, nv) Laplacian of the metric of Beltrami coefficient `mu` on the 2D mesh v."""
129
+ mu = np.where(np.abs(mu) < 0.999, mu, 0.999 * mu / np.abs(mu)) # keep it invertible
130
+ m2 = np.abs(mu) ** 2
131
+ a = (1 - 2 * mu.real + m2) / (1 - m2)
132
+ b = -2 * mu.imag / (1 - m2)
133
+ g = (1 + 2 * mu.real + m2) / (1 - m2)
134
+ p = v[f]
135
+ rot = p[:, [2, 0, 1]] - p[:, [1, 2, 0]] # opposite edges, (nf, 3, 2)
136
+ ux, uy = rot[..., 1], -rot[..., 0] # rotated by 90 degrees
137
+ area2 = np.abs(rot[:, 0, 0] * rot[:, 1, 1] - rot[:, 0, 1] * rot[:, 1, 0])
138
+ n = len(v)
139
+ A = 0
140
+ for i in range(3):
141
+ for j in range(3):
142
+ w = (a * ux[:, i] * ux[:, j] + b * (ux[:, i] * uy[:, j] + ux[:, j] * uy[:, i])
143
+ + g * uy[:, i] * uy[:, j]) / area2
144
+ A = A + coo_matrix((-w, (f[:, i], f[:, j])), shape=(n, n))
145
+ return A.tocsr()
146
+
147
+
148
+ def _dirichlet(M, fixed, ones, n):
149
+ """Solver of M x = b with x fixed on the vertices `fixed`. With `ones`, the returned
150
+ function takes no argument and solves for x = 1 on `ones`, 0 on the rest of `fixed`;
151
+ otherwise it takes the values on `fixed` (k, d) and returns x (n, d)."""
152
+ free = np.ones(n)
153
+ free[fixed] = 0
154
+ solve = factorized((diags(free) @ M + diags(1 - free)).tocsc())
155
+ if ones is not None:
156
+ b = np.zeros(n)
157
+ b[ones] = 1
158
+ return lambda: solve(b)
159
+
160
+ def run(values):
161
+ b = np.zeros((n, values.shape[1]))
162
+ b[fixed] = values
163
+ return np.stack([solve(c) for c in b.T], 1)
164
+ return run
165
+
166
+
167
+ def _orientation_consistent(uv, f):
168
+ """True if no triangle of the 2D map is flipped or degenerate."""
169
+ d1, d2 = uv[f[:, 1]] - uv[f[:, 0]], uv[f[:, 2]] - uv[f[:, 0]]
170
+ s = d1[:, 0] * d2[:, 1] - d1[:, 1] * d2[:, 0]
171
+ return (s > 0).all() or (s < 0).all()