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/__init__.py +8 -0
- flatpoints/compressor.py +139 -0
- flatpoints/core.py +36 -0
- flatpoints/data/seed.npy +0 -0
- flatpoints/datasets.py +100 -0
- flatpoints/examples.py +171 -0
- flatpoints/main.py +290 -0
- flatpoints/mesh.py +172 -0
- flatpoints/rcm.py +171 -0
- flatpoints/utils.py +143 -0
- flatpoints/viz.py +366 -0
- flatpoints-0.1.0.dist-info/METADATA +250 -0
- flatpoints-0.1.0.dist-info/RECORD +17 -0
- flatpoints-0.1.0.dist-info/WHEEL +5 -0
- flatpoints-0.1.0.dist-info/licenses/LICENSE +33 -0
- flatpoints-0.1.0.dist-info/licenses/LICENSE-rectangular-conformal-map +201 -0
- flatpoints-0.1.0.dist-info/top_level.txt +1 -0
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()
|