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/__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"
|
flatpoints/compressor.py
ADDED
|
@@ -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]
|
flatpoints/data/seed.npy
ADDED
|
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"]
|