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/utils.py ADDED
@@ -0,0 +1,143 @@
1
+ """Helpers of flatpoints: axes, grid sorting, padding, containers, resampling seeds.
2
+
3
+ Everything here is plumbing around `core` and `mesh`: no geometry.
4
+ """
5
+ import gridpoints
6
+ import numpy as np
7
+ from typing import NamedTuple
8
+ from pathlib import Path
9
+
10
+ # === Grids ====
11
+
12
+ def axes(normal_axis):
13
+ """Indices (0, 1 or 2) of the normal, horizontal and up axes. Up (followed by v, the
14
+ rows of the images) is z, or y if the normal axis is z; horizontal is the third one."""
15
+ if normal_axis not in ("x", "y", "z"):
16
+ raise ValueError('normal_axis must be "x", "y" or "z"')
17
+ normal = "xyz".index(normal_axis)
18
+ up = 1 if normal == 2 else 2
19
+ return normal, 3 - normal - up, up
20
+
21
+
22
+ def grid_sort(X, n, verbose=False):
23
+ """Assign N <= n*n 2D points to the cells of an n x n grid, with `gridpoints`.
24
+
25
+ Returns:
26
+ (n*n,) for each cell (row-major order), the index of its point in X, or -1 if empty.
27
+ """
28
+ N = len(X)
29
+ X = gridpoints.pad(np.c_[X], n * n, value="inf")
30
+ order = np.asarray(gridpoints.argsort(X, (n, n), verbose=int(verbose), n_iter = 100))
31
+ return np.where(order < N, order, -1)
32
+
33
+
34
+ def walk_to_real(real, a, b):
35
+ """(row, col) of the cell at fractions (a, b) of an n x n grid: if it is empty, walk
36
+ from it (t = 0) towards the centre (t = 1) until a non-empty cell.
37
+
38
+ Args:
39
+ real: (n, n) True for cells holding a point.
40
+ """
41
+ n = len(real)
42
+ m = n - 1
43
+ for t in np.linspace(0, 1, n):
44
+ i, j = (int(round(x * m + t * (m / 2 - x * m))) for x in (a, b))
45
+ if real[i, j]:
46
+ break
47
+ return i, j
48
+
49
+
50
+ def fill_empty(cells):
51
+ """`cells` (n, n), -1 for empty, with each empty cell taking the point nearest
52
+ the corner of its quadrant (see `walk_to_real`).
53
+ """
54
+ n = len(cells)
55
+ real = cells >= 0
56
+ out = cells.copy()
57
+ i, j = np.nonzero(~real)
58
+ # quadrant of each empty cell: 0 = top / left half, 1 = bottom / right half
59
+ qi, qj = (2 * i > n - 1).astype(int), (2 * j > n - 1).astype(int)
60
+ for a in (0, 1):
61
+ for b in (0, 1):
62
+ sel = (qi == a) & (qj == b)
63
+ if sel.any():
64
+ out[i[sel], j[sel]] = cells[walk_to_real(real, a, b)]
65
+ return out
66
+
67
+
68
+ class Mesh(NamedTuple):
69
+ """Triangle mesh of the surface, cut into faces."""
70
+ vertices: np.ndarray # (V, 3) xyz
71
+ triangles: np.ndarray # (T, 3) vertex indices
72
+ face: np.ndarray # (T,) face of each triangle: 0, 1, or -1 if in no face
73
+
74
+
75
+ class Chart(NamedTuple):
76
+ """One face of the mesh, a topological disk, with the conformal uv of its vertices."""
77
+ vertices: np.ndarray # (Vf,) indices in Mesh.vertices
78
+ triangles: np.ndarray # (Tf, 3) indices in Chart.vertices
79
+ uv_conformal: np.ndarray # (Vf, 2) rectangular conformal map onto [0, 1]^2
80
+
81
+
82
+ class Corners(NamedTuple):
83
+ """Padding of the grids: copy points[idxs[k]] into pixel pixels[k]."""
84
+ idxs: np.ndarray # (Npadding,) index in the point cloud of the point to copy
85
+ pixels: np.ndarray # (Npadding, 3) (face, row, col) where to copy it
86
+
87
+ # === Resampling ====
88
+
89
+ SEED = Path(__file__).resolve().parent / "data" / "seed.npy" # (2**17, 2) hyperuniform, shipped
90
+
91
+ # A new point is on the surface if it is closer to the input cloud than ON_SURFACE times
92
+ # the local spacing of the cloud (mean distance of the nearest input point to its 6
93
+ # neighbours). Bilinear interpolation across a tear of the images (two pixels side by side
94
+ # from far apart parts of the surface) gives points in the air, far from the cloud.
95
+ ON_SURFACE = 1.5
96
+
97
+
98
+ def _window(seed, n, m, keep):
99
+ """The `n` points of `seed` closest to the origin (square windows [0, side)^2) that
100
+ `keep`, as (row, col) on an m x m grid: [0, side)^2 is rescaled to [0, m - 1]^2.
101
+
102
+ Args:
103
+ keep: (k, 2) row, col -> (k,) True for the points to keep.
104
+
105
+ Returns:
106
+ (n, 2) row, col.
107
+ """
108
+ order = np.argsort(seed.max(1))
109
+ x, d = seed[order], seed.max(1)[order]
110
+ k = n
111
+ while True:
112
+ if k > len(x):
113
+ raise ValueError(f"the seed is too small for {n} points on this surface")
114
+ side = (d[k - 1] + d[k]) / 2 if k < len(x) else 1.0
115
+ rc = x[:k] / side * (m - 1)
116
+ ok = keep(rc)
117
+ if ok.sum() >= n:
118
+ return rc[ok][:n]
119
+ k += n - ok.sum()
120
+
121
+
122
+ def _seeds(seed, names):
123
+ """{face name: (M, 2) points in [0, 1)^2} from the `seed` argument of `Resampler`."""
124
+ if isinstance(seed, (int, np.integer)) and not isinstance(seed, bool):
125
+ shifts = np.random.default_rng(seed).random((len(names), 2))
126
+ hyperuniform = np.load(SEED).astype(float)
127
+ return {name: (hyperuniform + s) % 1.0 for name, s in zip(names, shifts)}
128
+ if isinstance(seed, dict):
129
+ if set(seed) != set(names):
130
+ raise ValueError(f"seed: expected the keys {list(names)}, got {list(seed)}")
131
+ return {name: _unit_square(seed[name]) for name in names}
132
+ return {name: _unit_square(seed) for name in names}
133
+
134
+ def _unit_square(cloud):
135
+ """`cloud` as an (M, 2) float array, checked to lie in [0, 1)^2."""
136
+ try:
137
+ cloud = np.asarray(cloud, float)
138
+ except (TypeError, ValueError):
139
+ cloud = None
140
+ if cloud is None or cloud.ndim != 2 or cloud.shape[1] != 2 or not (
141
+ (cloud >= 0).all() and (cloud < 1).all()):
142
+ raise ValueError("seed: an int, an (M, 2) array in [0, 1)^2, or a dict of them")
143
+ return cloud
flatpoints/viz.py ADDED
@@ -0,0 +1,366 @@
1
+ """Plots of a `Flat` and a `Resampler`, used as methods: `flat.plot()`,
2
+ `flat.imshow(data)`, `flat.imshow(data, animate=...)` (see `main.Viz`), `sampler.plot()`.
3
+ `imshow` also works on its own, on any (n, n, *C) or (n_faces, n, n, *C) array.
4
+
5
+ The surfaces are drawn as the quads between neighbouring pixels of the images, opaque and
6
+ shaded by their orientation, far first (occlusion). Colourings: RGB = normalised x, y, z,
7
+ or a checkerboard on the pixels; a good flattening gives smooth colours and regular squares.
8
+ """
9
+ import warnings
10
+
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+ from matplotlib.collections import PolyCollection
14
+ from scipy.ndimage import gaussian_filter
15
+ from scipy.spatial import cKDTree
16
+
17
+ from .utils import axes
18
+
19
+ CHECKER = np.array([[0.15, 0.25, 0.45], [0.98, 0.75, 0.45]]) # dark blue, light orange
20
+ BIVARIATE = np.array([[[0.91, 0.91, 0.91], [0.35, 0.65, 0.85]], # (0, 0) grey, (0, 1) blue
21
+ [[0.85, 0.45, 0.35], [0.25, 0.15, 0.40]]]) # (1, 0) red, (1, 1) purple
22
+ SUN = np.array([-0.3, 0.5, 1]) / np.linalg.norm([-0.3, 0.5, 1]) # light direction (camera)
23
+ # morph, per resolution: grid size, frames per second, inches per panel, supersampling,
24
+ # WebP quality and method
25
+ RESOLUTIONS = {"low": (40, 15, 3.0, 1, 75, 0), "high": (None, 25, 4.5, 2, 90, 4)}
26
+
27
+
28
+ # ==== Plots ============================================================================
29
+
30
+ def plot(flat, squares=8, point_size=1.0):
31
+ """First column: all the faces in 3D. Then one column per face: its image. First row
32
+ coloured by RGB = normalised x, y, z, second row by a checkerboard of `squares` x
33
+ `squares` pixels of the images; the 3D views and the images share the colours.
34
+ `point_size` scales the points in 3D (1 = default). Returns the figure."""
35
+ s = _step(flat)
36
+ grids = _grids(flat)
37
+ images = flat.to_image(rgb(flat.points)) # (n_faces, n, n, 3)
38
+ checker = _checker(flat.n, squares) # (n, n, 3), the same for every face
39
+ k, m = len(grids), len(grids[0]["xyz"]) # m: side of the drawn grids
40
+ proj = camera(flat.normal_axis)
41
+ fig = plt.figure(figsize=(4 + 3 * k, 6.5), layout="constrained")
42
+ grid = fig.add_gridspec(2, 1 + k, width_ratios=[1.5] + [1] * k)
43
+ names = ["left", "right"] if k == 2 else [""]
44
+ for row, colouring in enumerate(("RGB", "checkerboard")):
45
+ full = [checker if row else im for im in images]
46
+ cols = [c[s, s] for c in full]
47
+ # 3D: the quads, and on each pixel a disc in full colour, one grid step towards the
48
+ # viewer so that it covers the quads of its own surface, not a surface in front
49
+ p = [g["xyz"] @ proj.T for g in grids]
50
+ step = np.median(np.concatenate([np.linalg.norm(np.diff(q, axis=a), axis=-1).ravel()
51
+ for q in p for a in (0, 1)]))
52
+ layers = []
53
+ for q, col in zip(p, cols):
54
+ quads, shade = quad_layer(q, col)
55
+ sh = np.pad(shade, 1, mode="edge") # shade of a pixel: mean of the quads around
56
+ sh = (sh[:-1, :-1] + sh[1:, :-1] + sh[:-1, 1:] + sh[1:, 1:]) / 4
57
+ layers += [quads, disc_layer(q, col, sh, 0.4 * point_size * step, push=step)]
58
+ render(fig.add_subplot(grid[row, 0], title=f"xyz, {colouring}"), layers)
59
+ for c, (im, name) in enumerate(zip(full, names), start=1):
60
+ ax = fig.add_subplot(grid[row, c], title=f"{name} image".strip())
61
+ ax.imshow(im, interpolation="nearest")
62
+ ax.set_axis_off()
63
+ return fig
64
+
65
+
66
+ def plot_resampler(sampler, ax=None, point_size=1.0, light=0.45, radius=None):
67
+ """Before / after, in 3D. Left: the input point cloud (black dots). Right: the
68
+ resampled points (discs, RGB colours) on the mesh of the input cloud (quads of
69
+ `sampler.flat`, washed towards white by `light`). With `ax`, only the right view,
70
+ drawn in `ax`. `radius` of the discs: default from the mean area per point, so that
71
+ two resamplers with as many points get the same discs. Returns the figure."""
72
+ flat, points = sampler.flat, sampler.points
73
+ proj = camera(flat.normal_axis)
74
+ if ax is None:
75
+ fig, (before, ax) = plt.subplots(1, 2, figsize=(14, 7), sharex=True, sharey=True,
76
+ layout="constrained")
77
+ p = flat.points @ proj.T
78
+ before.scatter(p[:, 0], p[:, 1], s=1, c="k", linewidths=0)
79
+ before.set(aspect="equal", title=f"original ({len(p)} points)")
80
+ before.set_axis_off()
81
+ ax.set_title(f"resampled ({len(points)} points)")
82
+ if radius is None:
83
+ radius = 0.19 * point_size * np.sqrt(surface_area(flat.points) / len(points))
84
+ layers = []
85
+ for f, g in enumerate(_grids(flat)):
86
+ quads, shade = quad_layer(g["xyz"] @ proj.T, g["rgb"], light)
87
+ layers.append(quads)
88
+ mine = points[sampler.faces == f]
89
+ if len(mine): # shade of a point: the one of the nearest quad
90
+ _, near = cKDTree(_quads(g["xyz"]).mean(2).reshape(-1, 3)).query(mine)
91
+ layers.append(disc_layer(mine @ proj.T, rgb(mine, flat.points),
92
+ shade.ravel()[near], radius, push=radius, sides=10))
93
+ render(ax, layers)
94
+ return ax.figure
95
+
96
+
97
+ def imshow(image, sigma=0.5, cmap="viridis"):
98
+ """Grids as images, one per face: `image` (n, n, *C) or (1, n, n, *C) for one face,
99
+ (2, n, n, *C) for two (left, right), e.g. `flat.to_image(points)`. The C = prod(*C)
100
+ channels are smoothed by a Gaussian of `sigma` pixels, then coloured with a range
101
+ shared by the faces:
102
+ C = 1: colormap `cmap`, with a colorbar;
103
+ C = 2: bivariate colormap (one channel per axis of a colour square), with its key;
104
+ C = 3: RGB = the channels normalised (e.g. x, y, z);
105
+ C > 3: RGB = the 3 first principal components (PCA over all the pixels).
106
+ NaN pixels are left transparent. Returns the figure."""
107
+ a = gaussian_filter(_faces(np.asarray(image, float)), (0, sigma, sigma, 0))
108
+ k, c = len(a), a.shape[-1]
109
+ side = {1: 0.07, 2: 0.3}.get(c) # width of the colorbar / key, relative to a face
110
+ fig = plt.figure(figsize=(4 * k + 4 * (side or 0) + 0.6 * (c <= 2), 4),
111
+ layout="constrained")
112
+ grid = fig.add_gridspec(1, k + (c <= 2), width_ratios=[1] * k + [side] * (c <= 2))
113
+ rgba = _colour(a, cmap)
114
+ for f, name in zip(range(k), ["left", "right"] if k == 2 else [""]):
115
+ ax = fig.add_subplot(grid[0, f], title=name + (" (PCA)" if c > 3 else ""))
116
+ ax.imshow(rgba[f], interpolation="nearest")
117
+ ax.set_axis_off()
118
+ lo, hi = np.nanmin(a, (0, 1, 2)), np.nanmax(a, (0, 1, 2))
119
+ if c == 1:
120
+ fig.colorbar(plt.cm.ScalarMappable(plt.Normalize(lo[0], hi[0]), cmap),
121
+ cax=fig.add_subplot(grid[0, k]))
122
+ elif c == 2: # key: the colour square, axes in the units of the channels
123
+ key = fig.add_subplot(grid[0, k], xlabel="channel 0", ylabel="channel 1")
124
+ s = np.linspace(0, 1, 64)
125
+ key.imshow(_bivariate(np.stack(np.meshgrid(s, s), -1)), origin="lower",
126
+ extent=(lo[0], hi[0], lo[1], hi[1]), aspect="auto")
127
+ key.set_box_aspect(1)
128
+ key.set_xticks([lo[0], hi[0]], [f"{lo[0]:.3g}", f"{hi[0]:.3g}"])
129
+ key.set_yticks([lo[1], hi[1]], [f"{lo[1]:.3g}", f"{hi[1]:.3g}"])
130
+ key.tick_params(labelsize=7)
131
+ return fig
132
+
133
+
134
+ def morph(image, xyz, normal_axis="x", sigma=0.5, cmap="viridis", path=None,
135
+ resolution="low", seconds=1.2, pause=0.5):
136
+ """Animation: the images fold into the surface (`xyz`, (n_faces, n, n, 3)) and
137
+ back, one panel per face; colours as `imshow`. `resolution`: "low" (downsampled, fast)
138
+ or "high" (every pixel, up to a minute). Saved as .webp to `path` if given. Returns the
139
+ matplotlib animation.
140
+ """
141
+ from matplotlib.animation import FuncAnimation
142
+ if resolution not in RESOLUTIONS:
143
+ raise ValueError(f"resolution must be one of {list(RESOLUTIONS)}, got {resolution!r}")
144
+ if path is not None and not str(path).lower().endswith(".webp"):
145
+ raise ValueError(f"the animation is saved as an animated WebP: path must end with "
146
+ f".webp, got {path!r}")
147
+ size, fps, inches, scale, quality, method = RESOLUTIONS[resolution]
148
+ if resolution == "high":
149
+ warnings.warn("high resolution animation activated, writing the webp will take up "
150
+ "to a minute...", stacklevel=3)
151
+ a = _faces(np.asarray(image, float))
152
+ k, n = len(a), a.shape[1]
153
+ st = 1 if size is None else max(1, int(np.ceil(n / size))) # every st-th pixel
154
+ a = gaussian_filter(a, (0, max(sigma, st / 2), max(sigma, st / 2), 0))
155
+ rgba = _colour(a, cmap)[:, ::st, ::st]
156
+ end = (np.asarray(xyz, float).reshape(k, n, n, 3) @ camera(normal_axis).T)[:, ::st, ::st]
157
+ m = end.shape[1]
158
+ step = max(np.ptp(e[..., 1]) for e in end) / (m - 1) # images as tall as the tallest face
159
+ i, j = np.indices((m, m))
160
+ flat = np.stack([j - (m - 1) / 2, (m - 1) / 2 - i, np.zeros((m, m))], -1) * step
161
+ mids = [(e.reshape(-1, 3).min(0) + e.reshape(-1, 3).max(0)) / 2 for e in end]
162
+ start = [flat + mid * [1, 1, 0] for mid in mids] # each image centred on its face
163
+ half = step + np.max([np.abs(np.concatenate([s, e]).reshape(-1, 3) - mid)[:, :2].max(0)
164
+ for s, e, mid in zip(start, end, mids)], 0) # same window for all
165
+
166
+ fig, panels = plt.subplots(1, k, figsize=(inches * k, inches * (half[1] / half[0] + 0.1)),
167
+ dpi=100 * scale, squeeze=False)
168
+ fig.subplots_adjust(0, 0, 1, 0.92, wspace=0.02)
169
+ collections = []
170
+ for ax, mid, name in zip(panels[0], mids, ["left", "right"] if k == 2 else [""]):
171
+ ax.set(xlim=(mid[0] - half[0], mid[0] + half[0]), title=name,
172
+ ylim=(mid[1] - half[1], mid[1] + half[1]), aspect="equal")
173
+ ax.set_axis_off()
174
+ collections.append(ax.add_collection(PolyCollection([], linewidths=0.3)))
175
+
176
+ def frame(t):
177
+ for s, e, c, quads in zip(start, end, rgba, collections):
178
+ layer, _ = quad_layer((1 - t) * s + t * e, c)
179
+ order = np.argsort(layer[2], kind="stable") # far first
180
+ quads.set_verts([layer[0][o] for o in order])
181
+ quads.set_facecolors(layer[1][order])
182
+ quads.set_edgecolors(_edges(layer[1][order]))
183
+ return collections
184
+
185
+ hold, move = round(pause * fps), round(seconds * fps)
186
+ ease = np.linspace(0, 1, move) ** 2 * (3 - 2 * np.linspace(0, 1, move)) # smoothstep
187
+ times = np.concatenate([np.zeros(hold), ease, np.ones(hold), ease[::-1]])
188
+ if path is not None: # before the animation, which hides its artists from fig.draw
189
+ _save_webp(fig, frame, times, path, fps, scale, quality, method)
190
+ anim = FuncAnimation(fig, frame, frames=times, interval=1000 / fps, blit=True)
191
+ plt.close(fig)
192
+ return anim
193
+
194
+
195
+ # ==== Building blocks ==================================================================
196
+
197
+ def camera(normal_axis, elev=15, azim=30):
198
+ """(3, 3) rotation from xyz to the camera axes (right, up, towards the viewer): a view
199
+ along `normal_axis`, with the up axis of the images up (z, or y if normal_axis is z)
200
+ and the third axis to the right (as u in the 2D panels), turned by `azim` degrees
201
+ around the up axis and raised by `elev` degrees."""
202
+ e = np.eye(3)
203
+ l, _, u = axes(normal_axis)
204
+ side = e[l] * np.sign(np.cross(e[u], e[l]) @ e[3 - l - u])
205
+ az, el = np.radians(azim), np.radians(elev)
206
+ eye = np.cos(el) * (np.cos(az) * side + np.sin(az) * np.cross(e[u], side)) \
207
+ + np.sin(el) * e[u]
208
+ right = np.cross(e[u], eye) / np.linalg.norm(np.cross(e[u], eye))
209
+ return np.stack([right, np.cross(eye, right), eye])
210
+
211
+
212
+ def quad_layer(g, col, light=1.0):
213
+ """Layer (polygons, rgba, depth) of the quads of a projected grid `g` (n, n, 3),
214
+ coloured by the mean of the cell colours `col` (n, n, 3 or 4) washed towards white
215
+ (`light`: 0 = white, 1 = full colour), shaded by their orientation; opaque, unless
216
+ `col` has an alpha channel (a quad takes the lowest of its cells).
217
+ Returns (layer, shade (n-1, n-1))."""
218
+ q, c = _quads(g), _quads(col)
219
+ n = np.cross(q[..., 2, :] - q[..., 0, :], q[..., 3, :] - q[..., 1, :])
220
+ shade = 0.55 + 0.45 * abs(n @ SUN) / np.maximum(np.linalg.norm(n, axis=-1), 1e-12)
221
+ rgb = (1 - light * (1 - c[..., :3].mean(2))) * shade[..., None]
222
+ alpha = c[..., 3].min(2) if col.shape[-1] == 4 else np.ones(shade.shape)
223
+ layer = (list(q[..., :2].reshape(-1, 4, 2)), _rgba(rgb, alpha).reshape(-1, 4),
224
+ q[..., 2].mean(-1).ravel())
225
+ return layer, shade
226
+
227
+
228
+ def disc_layer(p, col, shade, radius, push=0.0, sides=8):
229
+ """Layer of discs (polygons of `sides` sides, facing the camera) at the projected
230
+ points `p` (m, 3), colours `col` (m, 3) times `shade` (m,), opaque. Each disc is moved
231
+ `push` towards the viewer, to cover the quads of its own surface."""
232
+ p, col = p.reshape(-1, 3), col.reshape(-1, 3)
233
+ t = np.linspace(0, 2 * np.pi, sides, endpoint=False)
234
+ disc = radius * np.stack([np.cos(t), np.sin(t)], -1)
235
+ return (list(p[:, None, :2] + disc), _rgba(col * np.reshape(shade, (-1, 1)),
236
+ np.ones(len(p))), p[:, 2] + push)
237
+
238
+
239
+ def render(ax, layers):
240
+ """Draw the layers [(polygons, rgba, depth)], all sorted together, far first."""
241
+ polys = [poly for layer in layers for poly in layer[0]]
242
+ order = np.argsort(np.concatenate([layer[2] for layer in layers]), kind="stable")
243
+ _add(ax, [polys[i] for i in order], np.concatenate([layer[1] for layer in layers])[order])
244
+ ax.autoscale_view()
245
+ ax.set_aspect("equal")
246
+ ax.set_axis_off()
247
+
248
+
249
+ def rgb(points, reference=None):
250
+ """(N, 3) colours: x, y, z normalised to [0, 1] by the bounding box of `reference`."""
251
+ ref = points if reference is None else reference
252
+ lo, hi = ref.min(0), ref.max(0)
253
+ return np.clip((points - lo) / np.where(hi > lo, hi - lo, 1), 0, 1)
254
+
255
+
256
+ def surface_area(points, k=8):
257
+ """Area of the surface, estimated from points sampled uniformly on it: the disc reaching
258
+ the k-th nearest neighbour of a point holds about k points, i.e. an area k * A / N."""
259
+ d = cKDTree(points).query(points, k=k + 1)[0][:, k]
260
+ return len(points) * np.mean(np.pi * d ** 2) / k
261
+
262
+
263
+ def _step(flat):
264
+ """Slice taking every st-th pixel of the images, st: about 110 pixels per side at most."""
265
+ return slice(None, None, max(1, flat.n // 110))
266
+
267
+
268
+ def _grids(flat):
269
+ """Per face, every st-th pixel of the images (see `_step`): {"xyz", "rgb"} (m, m, 3)
270
+ grids of the points and their RGB."""
271
+ s = _step(flat)
272
+ norm = rgb(flat.points)
273
+ return [{"xyz": flat.points[g[s, s]], "rgb": norm[g[s, s]]}
274
+ for g in flat.to_image(np.arange(len(flat.points)))]
275
+
276
+
277
+ def _save_webp(fig, frame, times, path, fps, scale=1, quality=80, method=0):
278
+ """Save the frames as an animated WebP. Frames
279
+ rendered at `scale` times the size are reduced (supersampling: no aliasing).
280
+ `quality` (0-100) and `method` (0 fast - 6 small) of the lossy WebP encoder."""
281
+ from PIL import Image
282
+ frames = []
283
+ for t in times:
284
+ frame(t)
285
+ fig.canvas.draw()
286
+ f = Image.fromarray(np.array(fig.canvas.buffer_rgba())[..., :3])
287
+ frames.append(f.resize((f.width // scale, f.height // scale), Image.Resampling.LANCZOS)
288
+ if scale > 1 else f)
289
+ frames[0].save(path, save_all=True, append_images=frames[1:],
290
+ duration=round(1000 / fps), quality=quality, method=method)
291
+
292
+
293
+ def _faces(a):
294
+ """(F, n, n, C) from (n, n, *C) or (F, n, n, *C), F = 1 or 2."""
295
+ if a.ndim >= 2 and a.shape[0] == a.shape[1]:
296
+ a = a[None]
297
+ elif not (a.ndim >= 3 and a.shape[0] in (1, 2) and a.shape[1] == a.shape[2]):
298
+ raise ValueError(f"expected (n, n, *C), (1, n, n, *C) or (2, n, n, *C), got {a.shape}")
299
+ return a.reshape(*a.shape[:3], -1)
300
+
301
+
302
+ def _colour(a, cmap="viridis"):
303
+ """(F, n, n, 4) RGBA of the (F, n, n, C) channels, as in `imshow`; NaN: transparent."""
304
+ c = a.shape[-1]
305
+ if c == 1:
306
+ colours = plt.get_cmap(cmap)(_unit(a)[..., 0])[..., :3]
307
+ elif c == 2:
308
+ colours = _bivariate(_unit(a))
309
+ else:
310
+ colours = _unit(a if c == 3 else _pca(a, 3))
311
+ ok = np.isfinite(a).all(-1, keepdims=True)
312
+ return np.concatenate([np.where(ok, colours, 1), ok], -1)
313
+
314
+
315
+ def _unit(a):
316
+ """Each channel (last axis) of `a` to [0, 1] by its range over all the pixels."""
317
+ lo, hi = np.nanmin(a, tuple(range(a.ndim - 1))), np.nanmax(a, tuple(range(a.ndim - 1)))
318
+ return np.clip((a - lo) / np.where(hi > lo, hi - lo, 1), 0, 1)
319
+
320
+
321
+ def _bivariate(t):
322
+ """(..., 3) colours of (..., 2) values in [0, 1]^2: bilinear between the 4 corners of
323
+ BIVARIATE."""
324
+ x, y = t[..., :1], t[..., 1:2]
325
+ return ((1 - x) * ((1 - y) * BIVARIATE[0, 0] + y * BIVARIATE[0, 1])
326
+ + x * ((1 - y) * BIVARIATE[1, 0] + y * BIVARIATE[1, 1]))
327
+
328
+
329
+ def _pca(a, k):
330
+ """(..., k) projection of the channels (last axis) of `a` on their k first principal
331
+ components, fitted on the finite pixels."""
332
+ x = a.reshape(-1, a.shape[-1])
333
+ ok = np.isfinite(x).all(1)
334
+ mean = x[ok].mean(0)
335
+ vt = np.linalg.svd(x[ok] - mean, full_matrices=False)[2][:k]
336
+ vt *= np.sign(vt[np.arange(len(vt)), np.abs(vt).argmax(1)])[:, None] # stable signs
337
+ return ((x - mean) @ vt.T).reshape(*a.shape[:-1], len(vt))
338
+
339
+
340
+ def _checker(n, squares):
341
+ """(n, n, 3) colours of the cells of an n x n grid: `squares` x `squares` squares."""
342
+ i, j = np.indices((n, n)) // int(np.ceil(n / squares))
343
+ return CHECKER[(i + j) % 2]
344
+
345
+
346
+ def _quads(grid):
347
+ """(n-1, n-1, 4, d) the quads between neighbouring cells of an (n, n, d) grid."""
348
+ return np.stack([grid[:-1, :-1], grid[:-1, 1:], grid[1:, 1:], grid[1:, :-1]], axis=2)
349
+
350
+
351
+ def _rgba(rgb, alpha):
352
+ return np.concatenate([rgb, alpha[..., None]], -1)
353
+
354
+
355
+ def _edges(rgba):
356
+ """Edge colours: the face colour (no hairline gaps), none on see-through polygons."""
357
+ rgba = np.asarray(rgba, float)
358
+ if rgba.shape[-1] == 3:
359
+ return rgba
360
+ return np.where(rgba[:, 3:] < 1, 0, rgba)
361
+
362
+
363
+ def _add(ax, polys, rgba):
364
+ """Draw the polygons, edges in the face colour."""
365
+ ax.add_collection(PolyCollection(polys, facecolors=rgba, edgecolors=_edges(rgba),
366
+ linewidths=0.3))