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 ADDED
@@ -0,0 +1,8 @@
1
+ """Flatten a point cloud into square images: one pixel per point, neighbour pixels hold neighbour points."""
2
+ from .datasets import datasets, load
3
+ from .examples import exemple_compression, exemple_flat, exemple_sampler
4
+ from .main import Compressor, Flat, Resampler
5
+
6
+ __all__ = ["Flat", "Resampler", "Compressor", "load", "datasets",
7
+ "exemple_flat", "exemple_sampler", "exemple_compression"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,139 @@
1
+ """Compression of the images of a `Flat`: three methods, used by `main.Compressor`.
2
+
3
+ Each method takes the images A (m, n, n), m = n_faces x channels, and a budget in bits, and
4
+ returns (code, reconstructed A, bits): what would be stored, what it decodes to, its size.
5
+
6
+ - svd: truncated SVD of each image, U, S, Vt stored as float32.
7
+ - dct: 2D DCT of each image, uniformly quantized, entropy coded per frequency band (as JPEG,
8
+ with one transform over the whole image).
9
+ - wavelet: 2D wavelet transform (biorthogonal 4.4, as JPEG2000), uniformly quantized,
10
+ entropy coded per subband. Needs PyWavelets (`pip install "flatpoints[compress]"`).
11
+
12
+ dct and wavelet use the step of the quantization to fit the budget. Their size is the one of
13
+ an ideal entropy coder per band (zeroth-order entropy): a theoretical bound, a real arithmetic
14
+ coder is a few percent larger.
15
+
16
+ `to_jp2` / `from_jp2` write and read a real file instead: the images in JPEG2000 (16 bits),
17
+ a standard format that any JPEG2000 reader opens. Needs Pillow.
18
+ """
19
+ import io
20
+ import json
21
+ from importlib.util import find_spec
22
+
23
+ import numpy as np
24
+ from scipy.fft import dctn, idctn
25
+
26
+ FLOAT = 32 # bits of a stored float (svd, quantization step)
27
+ BANDS = 12 # dct: number of frequency bands, each with its own entropy coder
28
+
29
+
30
+ def svd(A, budget):
31
+ """Keep the k largest singular values of each image, k (2n + 1) floats per image."""
32
+ m, n = A.shape[:2]
33
+ k = int(np.clip(budget // (FLOAT * m * (2 * n + 1)), 1, n))
34
+ U, S, Vt = np.linalg.svd(A, full_matrices=False)
35
+ code = {"U": U[..., :k], "S": S[..., :k], "Vt": Vt[..., :k, :]}
36
+ rec = (code["U"] * code["S"][..., None, :]) @ code["Vt"]
37
+ return code, rec, FLOAT * m * k * (2 * n + 1)
38
+
39
+
40
+ def dct(A, budget):
41
+ """Quantized 2D DCT, the frequencies grouped in bands of u + v."""
42
+ n = A.shape[1]
43
+ u, v = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
44
+ bands = np.minimum((u + v) * BANDS // (2 * n - 1), BANDS - 1)
45
+ C = dctn(A, axes=(1, 2), norm="ortho")
46
+ q, step, bits = _quantize(C, [bands == b for b in range(BANDS)], budget)
47
+ code = {"coefficients": q, "step": step}
48
+ return code, idctn(q * step, axes=(1, 2), norm="ortho"), bits
49
+
50
+
51
+ def wavelet(A, budget, name="bior4.4"):
52
+ """Quantized 2D wavelet transform (periodic), one entropy coder per subband."""
53
+ import pywt
54
+ n = A.shape[1]
55
+ level = min(4, pywt.dwt_max_level(n, pywt.Wavelet(name).dec_len))
56
+ arrays = [pywt.coeffs_to_array(pywt.wavedec2(a, name, mode="periodization", level=level))
57
+ for a in A]
58
+ C, slices = np.stack([a for a, _ in arrays]), arrays[0][1]
59
+ bands = [np.zeros(C.shape[1:], bool) for _ in range(1 + 3 * level)]
60
+ bands[0][slices[0]] = True
61
+ for j, details in enumerate(slices[1:]):
62
+ for i, key in enumerate(("ad", "da", "dd")):
63
+ bands[1 + 3 * j + i][details[key]] = True
64
+ q, step, bits = _quantize(C, bands, budget)
65
+
66
+ def inverse(c):
67
+ coeffs = pywt.array_to_coeffs(c, slices, output_format="wavedec2")
68
+ return pywt.waverec2(coeffs, name, mode="periodization")[:n, :n]
69
+
70
+ code = {"coefficients": q, "step": step, "wavelet": name, "level": level}
71
+ return code, np.stack([inverse(c) for c in q * step]), bits
72
+
73
+
74
+ METHODS = {"svd": svd, "dct": dct, "wavelet": wavelet}
75
+
76
+
77
+ def available():
78
+ """The methods that can run here: wavelet only if PyWavelets is installed."""
79
+ return list(METHODS) if find_spec("pywt") else ["svd", "dct"]
80
+
81
+
82
+ # === Quantization ===
83
+
84
+ def _entropy(q):
85
+ """Bits of an ideal entropy coder (zeroth order) for the integers q."""
86
+ _, counts = np.unique(q, return_counts=True)
87
+ return -(counts * np.log2(counts / counts.sum())).sum()
88
+
89
+
90
+ def _quantize(C, bands, budget):
91
+ """(q, step, bits): C / step rounded, with the finest step whose size fits the
92
+ budget (bisection on log(step)); each band of coefficients is coded on its own."""
93
+ def size(step):
94
+ q = np.round(C / step)
95
+ return q, FLOAT + sum(_entropy(q[:, b]) for b in bands)
96
+
97
+ lo, hi = np.log(np.abs(C).max() * 1e-7 + 1e-30), np.log(np.abs(C).max() * 2 + 1e-30)
98
+ for _ in range(40):
99
+ mid = (lo + hi) / 2
100
+ lo, hi = (lo, mid) if size(np.exp(mid))[1] <= budget else (mid, hi)
101
+ q, bits = size(np.exp(hi))
102
+ return q.astype(np.int32), float(np.exp(hi)), bits
103
+
104
+
105
+ # === JPEG2000 file ===
106
+
107
+ def to_jp2(images, padding, budget):
108
+ """Bytes of a JPEG2000 file of the xyz images (n_faces, n, n, 3), at most `budget` bits.
109
+
110
+ The images are quantized to 16 bits within their bounding box and laid out in one
111
+ grayscale image: one row of images per face, x, y and z side by side. A JSON comment in
112
+ the file holds what the decoder needs: the number of faces, the bounding box and the
113
+ flat indices of the `padding` pixels (copies of other points, dropped when reading).
114
+ """
115
+ from PIL import Image
116
+ faces, n = images.shape[:2]
117
+ lo, hi = images.reshape(-1, 3).min(0), images.reshape(-1, 3).max(0)
118
+ q = np.round((images - lo) / np.maximum(hi - lo, 1e-30) * 65535).astype(np.uint16)
119
+ mosaic = q.transpose(0, 1, 3, 2).reshape(faces * n, 3 * n) # face rows, x | y | z
120
+ header = json.dumps({"faces": faces, "lo": lo.tolist(), "hi": hi.tolist(),
121
+ "padding": np.sort(padding).tolist()}, separators=(",", ":")).encode()
122
+ rate = 16 * mosaic.size / max(budget - 8 * len(header), 1) # compression ratio asked
123
+ buffer = io.BytesIO()
124
+ Image.fromarray(mosaic).save(buffer, "JPEG2000", quality_mode="rates", irreversible=True,
125
+ quality_layers=[max(rate, 1.0)], comment=header)
126
+ return buffer.getvalue()
127
+
128
+
129
+ def from_jp2(data):
130
+ """(N, 3) points of a file written by `to_jp2`, in the order of the pixels."""
131
+ from PIL import Image
132
+ image = Image.open(io.BytesIO(data))
133
+ header = json.loads(image.info["comment"])
134
+ faces = header["faces"]
135
+ n = image.size[1] // faces
136
+ q = np.asarray(image, float).reshape(faces, n, 3, n).transpose(0, 1, 3, 2)
137
+ lo, hi = np.array(header["lo"]), np.array(header["hi"])
138
+ points = (q / 65535 * (hi - lo) + lo).reshape(-1, 3)
139
+ return np.delete(points, header["padding"], axis=0)
flatpoints/core.py ADDED
@@ -0,0 +1,36 @@
1
+ """Core of flatpoints: the image of one face (a chart of the cut surface).
2
+
3
+ 1. rectangular conformal map of the mesh of the face onto the unit square (`conformal_uv`);
4
+ 2. the image: the points sorted on an n x n grid by their conformal uv (`image_cells`).
5
+ """
6
+ import numpy as np
7
+
8
+ from .rcm import boundary_loop, rectangular_conformal_map
9
+ from .utils import grid_sort
10
+
11
+
12
+ # === Conformal uv and image ===
13
+
14
+ def conformal_uv(V, F, ab):
15
+ """(nv, 2) conformal map of the disk mesh (V, F) onto the unit square.
16
+
17
+ Args:
18
+ ab: (nv, 2) horizontal and up coordinates of the vertices: the corners of the
19
+ square go to the boundary vertices closest to the corners of the silhouette.
20
+ """
21
+ loop = boundary_loop(F)
22
+ x = (ab[loop] - ab[loop].mean(0)) / np.maximum(ab[loop].std(0), 1e-12)
23
+ corner = [np.argmax(sa * x[:, 0] + sb * x[:, 1]) # position on the loop
24
+ for sa, sb in ((-1, -1), (1, -1), (1, 1), (-1, 1))]
25
+ # the corners must follow each other along the loop, in one direction or the other
26
+ turn = np.argsort((np.array(corner) - corner[0]) % len(loop))
27
+ ordered = np.array_equal(turn, [0, 1, 2, 3]) or np.array_equal(turn, [0, 3, 2, 1])
28
+ if not ordered or len(set(corner)) < 4:
29
+ corner = corner[0] + np.arange(4) * len(loop) // 4 # odd silhouette: quarters
30
+ return rectangular_conformal_map(V, F, loop[np.mod(corner, len(loop))])
31
+
32
+
33
+ def image_cells(uv, n, verbose=False):
34
+ """(n, n) index of the point of each pixel (-1: empty), from the uv of the points.
35
+ Image layout: u along the columns, v up the rows."""
36
+ return grid_sort(uv, n, verbose).reshape(n, n).T[::-1]
Binary file
flatpoints/datasets.py ADDED
@@ -0,0 +1,100 @@
1
+ """Example point clouds, downloaded on demand from the GitHub repository of flatpoints.
2
+
3
+ The clouds are derived from third-party 3D models and are NOT covered by the licence of
4
+ flatpoints: they are not shipped in the package, but fetched from
5
+ https://github.com/neighborhood-grid/flatpoints/tree/main/data the first time they are
6
+ used, then cached on disk. Each one keeps the terms of its original model (see `MODELS`,
7
+ or data/README.md in the repository): check them before any use, and credit the sources.
8
+
9
+ points = fp.load("bunny") # (10000, 3) float32
10
+ fp.datasets() # names, sources and terms
11
+ """
12
+ import hashlib
13
+ import os
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ URL = "https://raw.githubusercontent.com/neighborhood-grid/flatpoints/main/data/{}_points.npy"
20
+
21
+ _STANFORD = "Stanford 3D Scanning Repository terms: research use, no commercial use without permission"
22
+ _RESEARCH = "research and non-commercial use, copyright of the creator"
23
+ _AIMATSHAPE = ("AIM@SHAPE licence: research and non-commercial use; credit \"Elephant model is "
24
+ "provided courtesy of INRIA by the AIM@SHAPE Shape Repository\"")
25
+
26
+ # name: (sha256 of the .npy, source, terms)
27
+ MODELS = {
28
+ "airplane": ("3e2f639783dfbe25f20eb8efdab8dad71cf1a49c2c8f8ad7236d024638e18f1f",
29
+ "PLY format sample file, shipped with PyVista", "origin and licence not documented"),
30
+ "armadillo": ("ce922b4a83772bbe3ecc903acf5dfcec1a567923cc42d6ac748e5d4376cfac9b",
31
+ "Stanford Computer Graphics Laboratory", _STANFORD),
32
+ "bunny": ("aa3cdba2c957156b197d8e0937587a98281dbd87bd542f601dc06c5973d09a84",
33
+ "Stanford Computer Graphics Laboratory", _STANFORD),
34
+ "cow": ("5cfd15b81d98813ce5e5bab9ccbdc57da31d6f3f03fc4e4d9aea18a47a537dcf",
35
+ "Viewpoint Animation Engineering / Sun Microsystems", _RESEARCH),
36
+ "dragon": ("427a14245f5340a73cad43e85d9133ff0d3fb60e98bac989352bad306670e13d",
37
+ "Stanford Computer Graphics Laboratory", _STANFORD),
38
+ "elephant": ("24649adc1293b018faa4c3cdb2a20b7b7c5fa272280924954dcbb73eacc826d6",
39
+ "INRIA, via the AIM@SHAPE Shape Repository", _AIMATSHAPE),
40
+ "everest": ("cc9dec8654673f60cef75490d493bb600eeb967f5695e7a2e45d145ce99ea97c",
41
+ "Copernicus DEM GLO-90 (ESA)",
42
+ "free licence; credit \"produced using Copernicus WorldDEM-90 (c) DLR e.V. 2010-2014 and "
43
+ "(c) Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the "
44
+ "European Union and ESA; all rights reserved\""),
45
+ "horse": ("bb760ad2ed22ca8639c81ce121abf87791ffa50d78af5dec7c311526287f523f",
46
+ "Cyberware, Georgia Tech Large Geometric Models Archive", _RESEARCH),
47
+ "max_planck": ("2cae575f579f426656c25ae8bbb01535a7848d829e684d87e638a03e0ca81f5b",
48
+ "Max-Planck-Institut für Informatik", _RESEARCH),
49
+ "spot": ("3f0a01a5c628cf112799db085dd0e279ce2c52b7e78a8843514087d689626d2f",
50
+ "Keenan Crane", "CC0 1.0 (public domain)"),
51
+ }
52
+
53
+
54
+ def cache_dir():
55
+ """Where the downloaded clouds are kept: $FLATPOINTS_DATA, else the user cache."""
56
+ if os.environ.get("FLATPOINTS_DATA"):
57
+ return Path(os.environ["FLATPOINTS_DATA"])
58
+ if os.name == "nt":
59
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
60
+ else:
61
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
62
+ return base / "flatpoints"
63
+
64
+
65
+ def datasets():
66
+ """{name: (source, terms)} of the example point clouds."""
67
+ return {name: (source, terms) for name, (_, source, terms) in MODELS.items()}
68
+
69
+
70
+ def load(name, verbose=True):
71
+ """(N, 3) float32 example point cloud `name` (see `datasets()`), downloaded on first use.
72
+
73
+ The cloud is derived from a third-party model and keeps its terms, which are printed
74
+ at download: they are not those of flatpoints.
75
+ """
76
+ if name not in MODELS:
77
+ raise ValueError(f"unknown point cloud {name!r}, available: {', '.join(MODELS)}")
78
+ sha, source, terms = MODELS[name]
79
+ path = cache_dir() / f"{name}_points.npy"
80
+ if not path.exists() or _sha256(path.read_bytes()) != sha:
81
+ url = URL.format(name)
82
+ if verbose:
83
+ print(f"flatpoints: downloading {name} from {url}\n"
84
+ f" source: {source}\n terms: {terms} (not the flatpoints licence)")
85
+ with urllib.request.urlopen(url, timeout=60) as response:
86
+ data = response.read()
87
+ if _sha256(data) != sha:
88
+ raise OSError(f"{url}: checksum mismatch, the file may have changed upstream")
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ tmp = path.with_suffix(".part")
91
+ tmp.write_bytes(data)
92
+ tmp.replace(path)
93
+ return np.load(path)
94
+
95
+
96
+ def _sha256(data):
97
+ return hashlib.sha256(data).hexdigest()
98
+
99
+
100
+ __all__ = ["load", "datasets", "cache_dir"]
flatpoints/examples.py ADDED
@@ -0,0 +1,171 @@
1
+ """Ready-to-run examples on the point clouds of `fp.datasets()`, straight from the package:
2
+
3
+ import flatpoints as fp
4
+ flat = fp.exemple_flat("bunny") # flattening: surface and images
5
+ sampler = fp.exemple_sampler("bunny") # resampling with a hyperuniform seed
6
+ comp = fp.exemple_compression("bunny") # compression of the xyz images
7
+
8
+ Each one prints what it computes, shows its figure (needs `flatpoints[viz]`,
9
+ `flatpoints[compress]` for the compression) and returns its result. `save="plots"`
10
+ also writes the figure in that folder (plots/, plots/resampling/, plots/compression/), and
11
+ `exemple_flat(..., animate=True)` the animation in plots/animation/.
12
+ """
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ from .datasets import load
19
+ from .main import Compressor, Flat
20
+
21
+
22
+ def flatten(model):
23
+ """`Flat` of the example point cloud `model`. A closed object (cow, bunny...) is cut in two
24
+ faces, left and right, across the normal axis x. A terrain (everest) is an open surface:
25
+ a single face, seen from above (normal axis z)."""
26
+ points = load(model)
27
+ everest = model == "everest"
28
+ return Flat(points, single_face=everest, normal_axis="z" if everest else "x")
29
+
30
+
31
+ def exemple_flat(model="bunny", save=None, show=True, animate=False):
32
+ """Flatten `model` and plot the surface in 3D with its images; with `animate`, also the
33
+ animation of the images folding into the surface (saved as save/animation/<model>.webp
34
+ if `save`). Returns the `Flat`."""
35
+ flat = flatten(model)
36
+ points = flat.points
37
+ print("points ", points.shape) # (N, 3)
38
+ print("pixels ", flat.pixels.shape) # (N, 3) (face, row, col), face 0 if single face
39
+ print("images ", (flat.n_faces, flat.n, flat.n))
40
+ print("padding ", flat.corners.idxs.shape) # (Npadding,) points repeated in the empty pixels
41
+ if flat.faces is not None:
42
+ print("faces ", {f: len(i) for f, i in flat.faces.items()})
43
+
44
+ # The point cloud as an image: (n_faces, n, n, 3), n_faces = 2 (left, right) or 1
45
+ image = np.zeros((flat.n_faces, flat.n, flat.n, 3))
46
+ image[tuple(flat.pixels.T)] = points # each point on its pixel
47
+ image[tuple(flat.corners.pixels.T)] = points[flat.corners.idxs] # fill the empty pixels
48
+ assert np.array_equal(image, flat.to_image(points)) # the same, in one call
49
+
50
+ # The flattening at a glance: the surface in 3D and its images, RGB and checkerboard
51
+ fig = flat.plot()
52
+ _finish(fig, save, f"{model}.png", show)
53
+ # Any per-point data (N, *C) as images: flat.imshow(points[:, 2])
54
+
55
+ # The same, animated: the images fold into the surface in 3D, and back
56
+ if animate:
57
+ path = None
58
+ if save:
59
+ path = Path(save) / "animation" / f"{model}.webp"
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ anim = flat.imshow(animate=path or True) # resolution="high": cleaner, slower
62
+ if path:
63
+ print("saved ", path)
64
+ if show:
65
+ import matplotlib.pyplot as plt
66
+ plt.show()
67
+ del anim
68
+ return flat
69
+
70
+
71
+ def exemple_sampler(model="bunny", n_points=512, save=None, show=True):
72
+ """Resample `model` with `n_points` new points, transported from a window of the
73
+ hyperuniform seed. Returns the `Resampler`."""
74
+ flat = flatten(model)
75
+ sampler = flat.resample(n_points=n_points)
76
+ print("flat ", flat.n_faces, "faces of", flat.n, "x", flat.n)
77
+ print("points ", sampler.points.shape) # (n_points, 3) the new points
78
+ print("faces ", sampler.faces.shape) # (n_points,) face of each new point
79
+ print("pixels ", sampler.pixels.shape) # (n_points, 3) float (face, row, col)
80
+ # Another, independent resampling, without recomputing the flat: flat.resample(n_points, seed=1)
81
+
82
+ # The new points (discs) on the mesh of the full cloud (quads of the flat)
83
+ fig = sampler.plot()
84
+ _finish(fig, save and Path(save) / "resampling", f"{model}_resampled.png", show)
85
+ return sampler
86
+
87
+
88
+ def exemple_compression(model="bunny", factor=0.8, save=None, show=True):
89
+ """Compress `model`, compare the methods, and write / read a JPEG2000 file.
90
+ Returns the `Compressor` of `factor`."""
91
+ import matplotlib.pyplot as plt
92
+ from scipy.spatial import cKDTree
93
+
94
+ from .compressor import available
95
+ from .viz import camera
96
+
97
+ flat = flatten(model)
98
+ points = flat.points
99
+
100
+ # `factor` of the storage saved (against float32 points), the most accurate method
101
+ comp = flat.compress(factor=factor)
102
+ print("method ", comp.method) # "dct", "wavelet" or "svd"
103
+ print("saved ", f"{comp.ratio:.1%}", f"({comp.bits / len(points):.1f} bits per point)")
104
+ print("code ", {k: getattr(v, "shape", v) for k, v in comp.code.items()})
105
+ print("points ", comp.points.shape) # (N, 3) reconstructed, same order as points
106
+
107
+ # Error of each point, in point spacings (mean distance to the nearest neighbour)
108
+ spacing = cKDTree(points).query(points, k=2)[0][:, 1].mean()
109
+ for method in available():
110
+ c = flat.compress(factor, method=method)
111
+ print(f"{method:8s} {factor:.0%}: RMS error {c.error / spacing:.3f} spacings")
112
+
113
+ # Strong compressions (default method), coloured by the error of each point; then the
114
+ # RMS error of each method against the storage saved
115
+ factors = [0.9, 0.95, 0.98, 0.99]
116
+ comps = [flat.compress(f) for f in factors]
117
+ errors = [np.linalg.norm(c.points - points, axis=1) / spacing for c in comps]
118
+ proj = camera(flat.normal_axis)
119
+ fig, axs = plt.subplots(1, len(factors) + 2, figsize=(4 * (len(factors) + 2), 4),
120
+ layout="constrained")
121
+ vmax = np.percentile(errors[-1], 95)
122
+ panels = [("original", points, None)] + [
123
+ (f"{c.ratio:.0%} saved, {c.method}", c.points, e) for c, e in zip(comps, errors)]
124
+ for ax, (title, xyz, err) in zip(axs, panels):
125
+ p = xyz @ proj.T
126
+ order = np.argsort(p[:, 2]) # far first
127
+ if err is None:
128
+ ax.scatter(p[order, 0], p[order, 1], s=1, c="0.4")
129
+ else:
130
+ sc = ax.scatter(p[order, 0], p[order, 1], s=1, c=err[order], cmap="magma_r",
131
+ vmin=0, vmax=vmax)
132
+ ax.set_title(title)
133
+ ax.set_aspect("equal")
134
+ ax.axis("off")
135
+ fig.colorbar(sc, ax=axs[-2], label="error (point spacings)")
136
+ grid = np.linspace(0.5, 0.995, 30)
137
+ for method in available():
138
+ curve = [flat.compress(f, method=method) for f in grid]
139
+ axs[-1].plot([100 * c.ratio for c in curve], [c.error / spacing for c in curve],
140
+ label=method)
141
+ axs[-1].set_xlabel("storage saved (%)")
142
+ axs[-1].set_ylabel("RMS error (point spacings)")
143
+ axs[-1].set_yscale("log")
144
+ axs[-1].set_xlim(50, 100)
145
+ axs[-1].grid(alpha=0.3)
146
+ axs[-1].legend()
147
+
148
+ # A real file: the points in JPEG2000 (any JPEG2000 reader opens it), and back
149
+ with tempfile.TemporaryDirectory() as tmp:
150
+ path = Path(tmp) / f"{model}.jp2"
151
+ size = comp.save(path)
152
+ print("file ", f"{path.name}: {size} bytes ({8 * size / len(points):.1f} bits per point)")
153
+ print("read ", Compressor.read(path).shape) # (N, 3)
154
+
155
+ _finish(fig, save and Path(save) / "compression", f"{model}.png", show, dpi=100)
156
+ return comp
157
+
158
+
159
+ def _finish(fig, folder, name, show, **kwargs):
160
+ """Save `fig` as folder/name if `folder`, then show it if `show`."""
161
+ import matplotlib.pyplot as plt
162
+
163
+ if folder:
164
+ Path(folder).mkdir(parents=True, exist_ok=True)
165
+ fig.savefig(Path(folder) / name, **kwargs)
166
+ print("saved ", Path(folder) / name)
167
+ if show:
168
+ plt.show()
169
+
170
+
171
+ __all__ = ["exemple_flat", "exemple_sampler", "exemple_compression"]