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