cloudglancer 0.1.6__tar.gz → 0.1.8__tar.gz
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.
- {cloudglancer-0.1.6/cloudglancer.egg-info → cloudglancer-0.1.8}/PKG-INFO +30 -3
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/README.md +29 -2
- cloudglancer-0.1.8/cloudglancer/__init__.py +7 -0
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/cloudglancer/animate.py +72 -42
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/cloudglancer/scatter.py +163 -1
- {cloudglancer-0.1.6 → cloudglancer-0.1.8/cloudglancer.egg-info}/PKG-INFO +30 -3
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/pyproject.toml +8 -1
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/tests/test_animate.py +13 -0
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/tests/test_scatter.py +103 -1
- cloudglancer-0.1.6/cloudglancer/__init__.py +0 -7
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/LICENSE +0 -0
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/MANIFEST.in +0 -0
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/cloudglancer.egg-info/SOURCES.txt +0 -0
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/cloudglancer.egg-info/dependency_links.txt +0 -0
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/cloudglancer.egg-info/requires.txt +0 -0
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/cloudglancer.egg-info/top_level.txt +0 -0
- {cloudglancer-0.1.6 → cloudglancer-0.1.8}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudglancer
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Simple interactive visualization of 3D point clouds
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
License: MIT
|
|
@@ -45,9 +45,8 @@ Simple interactive visualization of 3D point clouds using Plotly.
|
|
|
45
45
|
- Interactive 3D scatter plots with pan, zoom, and rotation
|
|
46
46
|
- Support for categorical and continuous color mapping
|
|
47
47
|
- Combine multiple plots into subplot grids
|
|
48
|
-
- Easy-to-use API with sensible defaults
|
|
49
|
-
- Type hints for better IDE support
|
|
50
48
|
- Plot batched point clouds (B, N, 3)
|
|
49
|
+
- Render a (B, N, 3) batch as a grid of subplots (one cloud per cell)
|
|
51
50
|
- Export a rotating turntable GIF of any figure
|
|
52
51
|
|
|
53
52
|
## Installation
|
|
@@ -76,6 +75,34 @@ fig = cg.plot(points, size=2.0)
|
|
|
76
75
|
cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
|
|
77
76
|
```
|
|
78
77
|
|
|
78
|
+
Frames are rendered in parallel; pass `n_workers` to control how many render
|
|
79
|
+
processes are used (defaults to `min(16, cpu_count)`).
|
|
80
|
+
|
|
81
|
+
Render a batch of point clouds as a grid of subplots (one cloud per cell):
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
batch = np.random.randn(6, 500, 3) # (B, N, 3)
|
|
85
|
+
|
|
86
|
+
# Auto near-square grid, single color for every cloud
|
|
87
|
+
fig = cg.plot_grid(batch, colors="#1f77b4", size=1.5)
|
|
88
|
+
|
|
89
|
+
# Or explicit grid + per-cell colors
|
|
90
|
+
fig = cg.plot_grid(batch, rows=2, cols=3,
|
|
91
|
+
colors=["red", "green", "blue", "orange", "purple", "teal"])
|
|
92
|
+
|
|
93
|
+
# Combine with animate() for a rotating GIF of the whole grid
|
|
94
|
+
cg.animate(fig, "grid.gif", n_frames=60, width=1600, height=1200)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Apply a clean, GIF-friendly style (white backgrounds, hidden tick labels,
|
|
98
|
+
light gray axis grid) to every 3D scene in a figure — works for both
|
|
99
|
+
single plots and grids:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
fig = cg.beautify(cg.plot_grid(batch, colors="#1f77b4"))
|
|
103
|
+
cg.animate(fig, "grid.gif", n_frames=60)
|
|
104
|
+
```
|
|
105
|
+
|
|
79
106
|
More examples are in the `examples` folder.
|
|
80
107
|
|
|
81
108
|
### Development Installation
|
|
@@ -10,9 +10,8 @@ Simple interactive visualization of 3D point clouds using Plotly.
|
|
|
10
10
|
- Interactive 3D scatter plots with pan, zoom, and rotation
|
|
11
11
|
- Support for categorical and continuous color mapping
|
|
12
12
|
- Combine multiple plots into subplot grids
|
|
13
|
-
- Easy-to-use API with sensible defaults
|
|
14
|
-
- Type hints for better IDE support
|
|
15
13
|
- Plot batched point clouds (B, N, 3)
|
|
14
|
+
- Render a (B, N, 3) batch as a grid of subplots (one cloud per cell)
|
|
16
15
|
- Export a rotating turntable GIF of any figure
|
|
17
16
|
|
|
18
17
|
## Installation
|
|
@@ -41,6 +40,34 @@ fig = cg.plot(points, size=2.0)
|
|
|
41
40
|
cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
|
|
42
41
|
```
|
|
43
42
|
|
|
43
|
+
Frames are rendered in parallel; pass `n_workers` to control how many render
|
|
44
|
+
processes are used (defaults to `min(16, cpu_count)`).
|
|
45
|
+
|
|
46
|
+
Render a batch of point clouds as a grid of subplots (one cloud per cell):
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
batch = np.random.randn(6, 500, 3) # (B, N, 3)
|
|
50
|
+
|
|
51
|
+
# Auto near-square grid, single color for every cloud
|
|
52
|
+
fig = cg.plot_grid(batch, colors="#1f77b4", size=1.5)
|
|
53
|
+
|
|
54
|
+
# Or explicit grid + per-cell colors
|
|
55
|
+
fig = cg.plot_grid(batch, rows=2, cols=3,
|
|
56
|
+
colors=["red", "green", "blue", "orange", "purple", "teal"])
|
|
57
|
+
|
|
58
|
+
# Combine with animate() for a rotating GIF of the whole grid
|
|
59
|
+
cg.animate(fig, "grid.gif", n_frames=60, width=1600, height=1200)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Apply a clean, GIF-friendly style (white backgrounds, hidden tick labels,
|
|
63
|
+
light gray axis grid) to every 3D scene in a figure — works for both
|
|
64
|
+
single plots and grids:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
fig = cg.beautify(cg.plot_grid(batch, colors="#1f77b4"))
|
|
68
|
+
cg.animate(fig, "grid.gif", n_frames=60)
|
|
69
|
+
```
|
|
70
|
+
|
|
44
71
|
More examples are in the `examples` folder.
|
|
45
72
|
|
|
46
73
|
### Development Installation
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""cloudglancer: Simple interactive visualization of 3D point clouds"""
|
|
2
|
+
|
|
3
|
+
from cloudglancer.scatter import plot, combine_plots, plot_grid, beautify
|
|
4
|
+
from cloudglancer.animate import animate
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = ["plot", "combine_plots", "plot_grid", "beautify", "animate"]
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import multiprocessing
|
|
4
4
|
import os
|
|
5
|
+
import sys
|
|
5
6
|
from concurrent.futures import ProcessPoolExecutor
|
|
6
7
|
from io import BytesIO
|
|
7
|
-
from typing import Tuple
|
|
8
|
+
from typing import Optional, Tuple
|
|
8
9
|
|
|
9
10
|
import numpy as np
|
|
10
11
|
from plotly.graph_objects import Figure
|
|
@@ -40,25 +41,31 @@ def _scene_keys(fig: Figure) -> list:
|
|
|
40
41
|
return keys
|
|
41
42
|
|
|
42
43
|
|
|
43
|
-
def _eye_to_vec(eye) -> np.ndarray:
|
|
44
|
-
if eye
|
|
44
|
+
def _eye_to_vec(eye: dict) -> np.ndarray:
|
|
45
|
+
if not eye or eye.get("x") is None:
|
|
45
46
|
return np.array([1.25, 1.25, 1.25])
|
|
46
|
-
return np.array([eye
|
|
47
|
+
return np.array([eye["x"], eye["y"], eye["z"]], dtype=float)
|
|
47
48
|
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
_WORKER_FIG_DICT = None
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
def _init_worker(fig_dict: dict) -> None:
|
|
53
|
-
global
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
global _WORKER_FIG_DICT
|
|
55
|
+
import warnings
|
|
56
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning,
|
|
57
|
+
message=r".*[Kk]aleido.*")
|
|
58
|
+
_WORKER_FIG_DICT = fig_dict
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
def _render_frame(args: Tuple[dict, int, int]) -> bytes:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
cameras, width, height = args
|
|
63
|
+
import plotly.io as pio
|
|
64
|
+
layout = _WORKER_FIG_DICT.setdefault("layout", {})
|
|
65
|
+
for key, cam in cameras.items():
|
|
66
|
+
layout.setdefault(key, {})["camera"] = cam
|
|
67
|
+
return pio.to_image(_WORKER_FIG_DICT, format="png",
|
|
68
|
+
width=width, height=height, validate=False)
|
|
62
69
|
|
|
63
70
|
|
|
64
71
|
def animate(
|
|
@@ -71,6 +78,7 @@ def animate(
|
|
|
71
78
|
height: int = 600,
|
|
72
79
|
loop: int = 0,
|
|
73
80
|
progress: bool = True,
|
|
81
|
+
n_workers: Optional[int] = None,
|
|
74
82
|
) -> str:
|
|
75
83
|
"""
|
|
76
84
|
Render a rotating GIF of a 3D Plotly figure.
|
|
@@ -90,12 +98,18 @@ def animate(
|
|
|
90
98
|
height: Frame height in pixels. Defaults to 600.
|
|
91
99
|
loop: Number of times the GIF should loop (0 = infinite). Defaults to 0.
|
|
92
100
|
progress: Show a tqdm progress bar while rendering frames. Defaults to True.
|
|
101
|
+
n_workers: Number of parallel render processes. Defaults to
|
|
102
|
+
``min(16, cpu_count, n_frames)``. Raise it on machines with many
|
|
103
|
+
cores for large frame counts. On non-Linux platforms worker
|
|
104
|
+
processes are spawned, so scripts calling ``animate()`` there must
|
|
105
|
+
be guarded by ``if __name__ == "__main__":``.
|
|
93
106
|
|
|
94
107
|
Returns:
|
|
95
108
|
The ``output_path`` it wrote to.
|
|
96
109
|
|
|
97
110
|
Raises:
|
|
98
|
-
ValueError: If `axis` is not one of ``'x'``, ``'y'``, ``'z'
|
|
111
|
+
ValueError: If `axis` is not one of ``'x'``, ``'y'``, ``'z'``, or
|
|
112
|
+
`n_workers` is less than 1.
|
|
99
113
|
ImportError: If ``kaleido`` or ``Pillow`` is not installed.
|
|
100
114
|
|
|
101
115
|
Examples:
|
|
@@ -116,6 +130,14 @@ def animate(
|
|
|
116
130
|
"animate() requires Pillow. Install it with `pip install Pillow`."
|
|
117
131
|
) from e
|
|
118
132
|
|
|
133
|
+
try:
|
|
134
|
+
import kaleido # noqa: F401
|
|
135
|
+
except ImportError as e:
|
|
136
|
+
raise ImportError(
|
|
137
|
+
"animate() requires kaleido for PNG export. "
|
|
138
|
+
"Install it with `pip install 'kaleido<1.0'`."
|
|
139
|
+
) from e
|
|
140
|
+
|
|
119
141
|
axis_vec = _AXIS_VECTORS[axis]
|
|
120
142
|
up_vec = {"x": dict(x=1, y=0, z=0),
|
|
121
143
|
"y": dict(x=0, y=1, z=0),
|
|
@@ -125,45 +147,53 @@ def animate(
|
|
|
125
147
|
if not scene_keys:
|
|
126
148
|
raise ValueError("figure has no 3D scene to animate")
|
|
127
149
|
|
|
128
|
-
|
|
150
|
+
fig_dict = fig.to_dict()
|
|
151
|
+
layout_dict = fig_dict.setdefault("layout", {})
|
|
152
|
+
|
|
153
|
+
orig_cams = {}
|
|
154
|
+
initial_eyes = {}
|
|
155
|
+
for k in scene_keys:
|
|
156
|
+
cam = dict((layout_dict.get(k) or {}).get("camera") or {})
|
|
157
|
+
orig_cams[k] = cam
|
|
158
|
+
initial_eyes[k] = _eye_to_vec(cam.get("eye"))
|
|
129
159
|
|
|
130
160
|
tasks = []
|
|
131
161
|
for i in range(n_frames):
|
|
132
162
|
theta = 2.0 * np.pi * i / n_frames
|
|
133
163
|
R = _rotation_matrix(axis_vec, theta)
|
|
134
|
-
|
|
164
|
+
cameras = {}
|
|
135
165
|
for k in scene_keys:
|
|
136
166
|
eye = R @ initial_eyes[k]
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
n_workers
|
|
167
|
+
cameras[k] = {
|
|
168
|
+
**orig_cams[k],
|
|
169
|
+
"eye": dict(x=float(eye[0]), y=float(eye[1]), z=float(eye[2])),
|
|
170
|
+
"up": up_vec,
|
|
171
|
+
}
|
|
172
|
+
tasks.append((cameras, width, height))
|
|
173
|
+
|
|
174
|
+
if n_workers is None:
|
|
175
|
+
n_workers = min(16, os.cpu_count() or 2)
|
|
176
|
+
elif n_workers < 1:
|
|
177
|
+
raise ValueError(f"n_workers must be >= 1 (got {n_workers})")
|
|
178
|
+
n_workers = min(n_workers, n_frames)
|
|
179
|
+
|
|
180
|
+
# fork keeps unguarded user scripts working and skips per-worker re-imports;
|
|
181
|
+
# spawn elsewhere (fork is unsafe on macOS, unavailable on Windows).
|
|
182
|
+
start_method = "fork" if sys.platform.startswith("linux") else "spawn"
|
|
147
183
|
|
|
148
184
|
pngs: list = [None] * n_frames
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
pngs[i] = png
|
|
162
|
-
except ValueError as e:
|
|
163
|
-
raise ImportError(
|
|
164
|
-
"animate() requires kaleido for PNG export. "
|
|
165
|
-
"Install it with `pip install 'kaleido<1.0'`."
|
|
166
|
-
) from e
|
|
185
|
+
with ProcessPoolExecutor(
|
|
186
|
+
max_workers=n_workers,
|
|
187
|
+
mp_context=multiprocessing.get_context(start_method),
|
|
188
|
+
initializer=_init_worker,
|
|
189
|
+
initargs=(fig_dict,),
|
|
190
|
+
) as executor:
|
|
191
|
+
iterator = executor.map(_render_frame, tasks, chunksize=1)
|
|
192
|
+
for i, png in enumerate(
|
|
193
|
+
tqdm(iterator, total=n_frames, desc="Rendering frames",
|
|
194
|
+
unit="frame", disable=not progress)
|
|
195
|
+
):
|
|
196
|
+
pngs[i] = png
|
|
167
197
|
|
|
168
198
|
ref_palette = Image.open(BytesIO(pngs[0])).convert("P", palette=Image.ADAPTIVE)
|
|
169
199
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Core scatter plotting functionality for 3D point clouds."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import math
|
|
4
|
+
from typing import Optional, List, Dict, Tuple, Union
|
|
4
5
|
import plotly.express as px
|
|
5
6
|
import pandas as pd
|
|
6
7
|
import numpy as np
|
|
@@ -179,3 +180,164 @@ def combine_plots(figs: List[Figure], rows: int = 1, cols: int = 2, aspectmode:
|
|
|
179
180
|
)
|
|
180
181
|
|
|
181
182
|
return combined_fig
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_DEFAULT_AXIS_STYLE = dict(
|
|
186
|
+
backgroundcolor="white",
|
|
187
|
+
gridcolor="lightgray",
|
|
188
|
+
showbackground=True,
|
|
189
|
+
showticklabels=False,
|
|
190
|
+
title="",
|
|
191
|
+
zerolinecolor="lightgray",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def beautify(
|
|
196
|
+
fig: Figure,
|
|
197
|
+
paper_bgcolor: str = "white",
|
|
198
|
+
scene_bgcolor: str = "white",
|
|
199
|
+
axis_style: Optional[Dict] = None,
|
|
200
|
+
) -> Figure:
|
|
201
|
+
"""
|
|
202
|
+
Apply a clean, GIF-friendly style to every 3D scene in a figure.
|
|
203
|
+
|
|
204
|
+
Hides axis tick labels and titles, gives axes a light gray grid, and
|
|
205
|
+
paints both the figure paper and each 3D scene's background. Works on
|
|
206
|
+
single-scene figures (from :func:`plot`) and multi-scene figures (from
|
|
207
|
+
:func:`plot_grid` / :func:`combine_plots`), which use layout keys
|
|
208
|
+
``scene``, ``scene2``, ``scene3``, .... Mutates and returns ``fig``.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
fig (plotly.graph_objects.Figure): Figure to style in place.
|
|
212
|
+
paper_bgcolor (str, optional): Color of the area outside the 3D
|
|
213
|
+
scenes. Default ``"white"``.
|
|
214
|
+
scene_bgcolor (str, optional): Color of the area inside each 3D
|
|
215
|
+
scene's box. Default ``"white"``.
|
|
216
|
+
axis_style (dict, optional): Override the per-axis style applied
|
|
217
|
+
to ``xaxis``/``yaxis``/``zaxis`` of every scene. When ``None``
|
|
218
|
+
a clean default (light gray grid, hidden tick labels and
|
|
219
|
+
titles) is used.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
plotly.graph_objects.Figure: The same ``fig``, restyled.
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
>>> import numpy as np
|
|
226
|
+
>>> import cloudglancer as cg
|
|
227
|
+
>>> pts = np.random.randn(6, 200, 3)
|
|
228
|
+
>>> fig = cg.beautify(cg.plot_grid(pts, colors="#1f77b4"))
|
|
229
|
+
>>> cg.animate(fig, "grid.gif", n_frames=60)
|
|
230
|
+
"""
|
|
231
|
+
style = dict(_DEFAULT_AXIS_STYLE if axis_style is None else axis_style)
|
|
232
|
+
|
|
233
|
+
fig.update_layout(paper_bgcolor=paper_bgcolor)
|
|
234
|
+
|
|
235
|
+
for key in fig.layout:
|
|
236
|
+
if key == "scene" or (key.startswith("scene") and key[len("scene"):].isdigit()):
|
|
237
|
+
fig.layout[key].update(
|
|
238
|
+
bgcolor=scene_bgcolor,
|
|
239
|
+
xaxis=style,
|
|
240
|
+
yaxis=style,
|
|
241
|
+
zaxis=style,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return fig
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _resolve_grid_shape(n: int, rows: Optional[int], cols: Optional[int]) -> Tuple[int, int]:
|
|
248
|
+
if rows is None and cols is None:
|
|
249
|
+
cols = int(math.ceil(math.sqrt(n)))
|
|
250
|
+
rows = int(math.ceil(n / cols))
|
|
251
|
+
elif rows is None:
|
|
252
|
+
rows = int(math.ceil(n / cols))
|
|
253
|
+
elif cols is None:
|
|
254
|
+
cols = int(math.ceil(n / rows))
|
|
255
|
+
if rows * cols < n:
|
|
256
|
+
raise ValueError(f"rows*cols={rows * cols} < B={n}")
|
|
257
|
+
return rows, cols
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def plot_grid(
|
|
261
|
+
points: np.ndarray,
|
|
262
|
+
rows: Optional[int] = None,
|
|
263
|
+
cols: Optional[int] = None,
|
|
264
|
+
colors: Optional[Union[str, List[str]]] = None,
|
|
265
|
+
size: float = 1.5,
|
|
266
|
+
aspectmode: Optional[str] = 'data',
|
|
267
|
+
showlegend: bool = False,
|
|
268
|
+
) -> Figure:
|
|
269
|
+
"""
|
|
270
|
+
Render a batch of 3D point clouds as a grid of subplots, one cloud per cell.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
points (np.ndarray): Array of shape (B, N, 3). Each batch element is
|
|
274
|
+
rendered into its own subplot.
|
|
275
|
+
rows (int, optional): Number of grid rows. If both ``rows`` and ``cols``
|
|
276
|
+
are ``None``, a near-square grid is chosen automatically. If only
|
|
277
|
+
one is given, the other is derived from ``B``.
|
|
278
|
+
cols (int, optional): Number of grid columns. See ``rows``.
|
|
279
|
+
colors (str or list of str, optional): Color for each subplot. Pass a
|
|
280
|
+
single color string to use it for every cloud, or a list of length
|
|
281
|
+
``B`` to color each cloud individually. Defaults to Plotly's
|
|
282
|
+
qualitative palette.
|
|
283
|
+
size (float, optional): Marker size. Default is 1.5.
|
|
284
|
+
aspectmode (str, optional): Aspect mode for each 3D scene. Default
|
|
285
|
+
``'data'``.
|
|
286
|
+
showlegend (bool, optional): Whether to show the figure legend.
|
|
287
|
+
Default ``False`` (legends are noisy in grids).
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
plotly.graph_objects.Figure: A combined figure with the grid of
|
|
291
|
+
subplots, ready for ``.show()`` or for passing to
|
|
292
|
+
:func:`cloudglancer.animate`.
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
ValueError: If ``points`` is not (B, N, 3), if ``colors`` is a list
|
|
296
|
+
whose length does not match ``B``, or if ``rows*cols < B``.
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
>>> import numpy as np
|
|
300
|
+
>>> import cloudglancer as cg
|
|
301
|
+
>>> pts = np.random.randn(6, 200, 3)
|
|
302
|
+
>>> fig = cg.plot_grid(pts, colors="#1f77b4", size=1.5)
|
|
303
|
+
>>> cg.animate(fig, "grid.gif", n_frames=60)
|
|
304
|
+
"""
|
|
305
|
+
if points.ndim != 3 or points.shape[-1] != 3:
|
|
306
|
+
raise ValueError(f"points must be of shape (B, N, 3); got {points.shape}")
|
|
307
|
+
|
|
308
|
+
B = points.shape[0]
|
|
309
|
+
rows, cols = _resolve_grid_shape(B, rows, cols)
|
|
310
|
+
|
|
311
|
+
if colors is None:
|
|
312
|
+
palette = px.colors.qualitative.Plotly
|
|
313
|
+
cell_colors = [palette[i % len(palette)] for i in range(B)]
|
|
314
|
+
elif isinstance(colors, str):
|
|
315
|
+
cell_colors = [colors] * B
|
|
316
|
+
else:
|
|
317
|
+
if len(colors) != B:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
f"colors must have length B={B}; got {len(colors)}"
|
|
320
|
+
)
|
|
321
|
+
cell_colors = list(colors)
|
|
322
|
+
|
|
323
|
+
figs = []
|
|
324
|
+
for i in range(rows * cols):
|
|
325
|
+
if i < B:
|
|
326
|
+
sub = plot(
|
|
327
|
+
points[i:i + 1],
|
|
328
|
+
batch_colors=[cell_colors[i]],
|
|
329
|
+
size=size,
|
|
330
|
+
aspectmode=aspectmode,
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
sub = plot(
|
|
334
|
+
np.zeros((1, 1, 3), dtype=points.dtype),
|
|
335
|
+
batch_colors=["rgba(0,0,0,0)"],
|
|
336
|
+
size=size,
|
|
337
|
+
aspectmode=aspectmode,
|
|
338
|
+
)
|
|
339
|
+
figs.append(sub)
|
|
340
|
+
|
|
341
|
+
combined = combine_plots(figs, rows=rows, cols=cols, aspectmode=aspectmode)
|
|
342
|
+
combined.update_layout(showlegend=showlegend)
|
|
343
|
+
return combined
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudglancer
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Simple interactive visualization of 3D point clouds
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
License: MIT
|
|
@@ -45,9 +45,8 @@ Simple interactive visualization of 3D point clouds using Plotly.
|
|
|
45
45
|
- Interactive 3D scatter plots with pan, zoom, and rotation
|
|
46
46
|
- Support for categorical and continuous color mapping
|
|
47
47
|
- Combine multiple plots into subplot grids
|
|
48
|
-
- Easy-to-use API with sensible defaults
|
|
49
|
-
- Type hints for better IDE support
|
|
50
48
|
- Plot batched point clouds (B, N, 3)
|
|
49
|
+
- Render a (B, N, 3) batch as a grid of subplots (one cloud per cell)
|
|
51
50
|
- Export a rotating turntable GIF of any figure
|
|
52
51
|
|
|
53
52
|
## Installation
|
|
@@ -76,6 +75,34 @@ fig = cg.plot(points, size=2.0)
|
|
|
76
75
|
cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
|
|
77
76
|
```
|
|
78
77
|
|
|
78
|
+
Frames are rendered in parallel; pass `n_workers` to control how many render
|
|
79
|
+
processes are used (defaults to `min(16, cpu_count)`).
|
|
80
|
+
|
|
81
|
+
Render a batch of point clouds as a grid of subplots (one cloud per cell):
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
batch = np.random.randn(6, 500, 3) # (B, N, 3)
|
|
85
|
+
|
|
86
|
+
# Auto near-square grid, single color for every cloud
|
|
87
|
+
fig = cg.plot_grid(batch, colors="#1f77b4", size=1.5)
|
|
88
|
+
|
|
89
|
+
# Or explicit grid + per-cell colors
|
|
90
|
+
fig = cg.plot_grid(batch, rows=2, cols=3,
|
|
91
|
+
colors=["red", "green", "blue", "orange", "purple", "teal"])
|
|
92
|
+
|
|
93
|
+
# Combine with animate() for a rotating GIF of the whole grid
|
|
94
|
+
cg.animate(fig, "grid.gif", n_frames=60, width=1600, height=1200)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Apply a clean, GIF-friendly style (white backgrounds, hidden tick labels,
|
|
98
|
+
light gray axis grid) to every 3D scene in a figure — works for both
|
|
99
|
+
single plots and grids:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
fig = cg.beautify(cg.plot_grid(batch, colors="#1f77b4"))
|
|
103
|
+
cg.animate(fig, "grid.gif", n_frames=60)
|
|
104
|
+
```
|
|
105
|
+
|
|
79
106
|
More examples are in the `examples` folder.
|
|
80
107
|
|
|
81
108
|
### Development Installation
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cloudglancer"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
8
8
|
description = "Simple interactive visualization of 3D point clouds"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -52,6 +52,13 @@ include = ["cloudglancer*"]
|
|
|
52
52
|
[tool.setuptools.package-data]
|
|
53
53
|
cloudglancer = ["py.typed"]
|
|
54
54
|
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
filterwarnings = [
|
|
57
|
+
# animate() forks render workers after the pool's manager thread starts; the
|
|
58
|
+
# workers never touch the inherited locks, so this CPython warning is benign here.
|
|
59
|
+
"ignore:This process .* is multi-threaded, use of fork:DeprecationWarning",
|
|
60
|
+
]
|
|
61
|
+
|
|
55
62
|
[tool.black]
|
|
56
63
|
line-length = 100
|
|
57
64
|
target-version = ["py39"]
|
|
@@ -54,6 +54,19 @@ def test_animate_combined_figure(tmp_path):
|
|
|
54
54
|
assert out.exists() and _is_gif(out)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def test_animate_n_workers(tmp_path):
|
|
58
|
+
fig = plot(np.random.randn(20, 3))
|
|
59
|
+
out = tmp_path / "w.gif"
|
|
60
|
+
animate(fig, str(out), n_frames=3, width=120, height=120, n_workers=2, progress=False)
|
|
61
|
+
assert _is_gif(out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_animate_invalid_n_workers(tmp_path):
|
|
65
|
+
fig = plot(np.random.randn(10, 3))
|
|
66
|
+
with pytest.raises(ValueError, match="n_workers must be >= 1"):
|
|
67
|
+
animate(fig, str(tmp_path / "x.gif"), n_frames=2, n_workers=0)
|
|
68
|
+
|
|
69
|
+
|
|
57
70
|
@pytest.mark.parametrize("axis", ["x", "y", "z"])
|
|
58
71
|
def test_animate_each_axis(tmp_path, axis):
|
|
59
72
|
fig = plot(np.random.randn(20, 3))
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
import pytest
|
|
5
|
-
from cloudglancer import plot, combine_plots
|
|
5
|
+
from cloudglancer import plot, combine_plots, plot_grid, beautify
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def test_plot_basic():
|
|
@@ -154,3 +154,105 @@ def test_plot_batched_with_labels_raises():
|
|
|
154
154
|
labels = np.zeros(300)
|
|
155
155
|
with pytest.raises(ValueError, match="labels are not supported"):
|
|
156
156
|
plot(points, labels=labels)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _scene_count(fig):
|
|
160
|
+
return sum(
|
|
161
|
+
1 for k in fig.layout
|
|
162
|
+
if k == "scene" or (k.startswith("scene") and k[5:].isdigit())
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_plot_grid_auto_shape():
|
|
167
|
+
"""Auto grid for B=5 → 3 cols x 2 rows = 6 cells."""
|
|
168
|
+
pts = np.random.randn(5, 30, 3)
|
|
169
|
+
fig = plot_grid(pts)
|
|
170
|
+
assert _scene_count(fig) == 6
|
|
171
|
+
assert fig.layout.showlegend is False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_plot_grid_explicit_shape():
|
|
175
|
+
pts = np.random.randn(4, 30, 3)
|
|
176
|
+
fig = plot_grid(pts, rows=2, cols=2)
|
|
177
|
+
assert _scene_count(fig) == 4
|
|
178
|
+
assert len(fig.data) == 4
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_plot_grid_single_color_string():
|
|
182
|
+
pts = np.random.randn(3, 20, 3)
|
|
183
|
+
fig = plot_grid(pts, colors="#1f77b4")
|
|
184
|
+
cloud_traces = [t for t in fig.data if len(t.x) > 1]
|
|
185
|
+
assert len(cloud_traces) == 3
|
|
186
|
+
for t in cloud_traces:
|
|
187
|
+
assert t.marker.color == "#1f77b4"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_plot_grid_color_list():
|
|
191
|
+
pts = np.random.randn(3, 20, 3)
|
|
192
|
+
fig = plot_grid(pts, colors=["red", "green", "blue"])
|
|
193
|
+
cloud_traces = [t for t in fig.data if len(t.x) > 1]
|
|
194
|
+
colors = [t.marker.color for t in cloud_traces]
|
|
195
|
+
assert sorted(colors) == sorted(["red", "green", "blue"])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_plot_grid_invalid_shape():
|
|
199
|
+
with pytest.raises(ValueError, match="points must be of shape"):
|
|
200
|
+
plot_grid(np.random.randn(3, 20, 2))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_plot_grid_grid_too_small():
|
|
204
|
+
pts = np.random.randn(5, 20, 3)
|
|
205
|
+
with pytest.raises(ValueError, match="rows\\*cols"):
|
|
206
|
+
plot_grid(pts, rows=2, cols=2)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_plot_grid_color_list_length_mismatch():
|
|
210
|
+
pts = np.random.randn(3, 20, 3)
|
|
211
|
+
with pytest.raises(ValueError, match="colors must have length"):
|
|
212
|
+
plot_grid(pts, colors=["red", "green"])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_plot_grid_pads_when_grid_larger_than_b():
|
|
216
|
+
"""B=3 in a 2x2 grid → 4 scenes, with the last cell padded."""
|
|
217
|
+
pts = np.random.randn(3, 20, 3)
|
|
218
|
+
fig = plot_grid(pts, rows=2, cols=2)
|
|
219
|
+
assert _scene_count(fig) == 4
|
|
220
|
+
assert len(fig.data) == 4
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_beautify_single_scene():
|
|
224
|
+
pts = np.random.randn(50, 3)
|
|
225
|
+
fig = beautify(plot(pts))
|
|
226
|
+
assert fig.layout.paper_bgcolor == "white"
|
|
227
|
+
assert fig.layout.scene.bgcolor == "white"
|
|
228
|
+
assert fig.layout.scene.xaxis.showticklabels is False
|
|
229
|
+
assert fig.layout.scene.yaxis.gridcolor == "lightgray"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_beautify_applies_to_every_grid_scene():
|
|
233
|
+
"""All scenes (not just `scene`) should get the styling."""
|
|
234
|
+
pts = np.random.randn(4, 30, 3)
|
|
235
|
+
fig = beautify(plot_grid(pts, rows=2, cols=2))
|
|
236
|
+
scene_keys = [
|
|
237
|
+
k for k in fig.layout
|
|
238
|
+
if k == "scene" or (k.startswith("scene") and k[5:].isdigit())
|
|
239
|
+
]
|
|
240
|
+
assert len(scene_keys) == 4
|
|
241
|
+
for k in scene_keys:
|
|
242
|
+
scene = fig.layout[k]
|
|
243
|
+
assert scene.bgcolor == "white"
|
|
244
|
+
assert scene.xaxis.showticklabels is False
|
|
245
|
+
assert scene.yaxis.showticklabels is False
|
|
246
|
+
assert scene.zaxis.showticklabels is False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_beautify_custom_colors():
|
|
250
|
+
fig = beautify(plot(np.random.randn(20, 3)),
|
|
251
|
+
paper_bgcolor="black", scene_bgcolor="black")
|
|
252
|
+
assert fig.layout.paper_bgcolor == "black"
|
|
253
|
+
assert fig.layout.scene.bgcolor == "black"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_beautify_returns_same_figure():
|
|
257
|
+
fig = plot(np.random.randn(20, 3))
|
|
258
|
+
assert beautify(fig) is fig
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|