cloudglancer 0.1.4__tar.gz → 0.1.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudglancer
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Simple interactive visualization of 3D point clouds
5
5
  Author-email: Your Name <your.email@example.com>
6
6
  License: MIT
@@ -24,6 +24,9 @@ Requires-Dist: plotly>=5.0.0
24
24
  Requires-Dist: pandas>=2.0.0
25
25
  Requires-Dist: numpy>=1.24.0
26
26
  Requires-Dist: nbformat>=4.2.0
27
+ Requires-Dist: kaleido<1.0,>=0.2.1
28
+ Requires-Dist: Pillow>=9.0.0
29
+ Requires-Dist: tqdm>=4.0.0
27
30
  Provides-Extra: dev
28
31
  Requires-Dist: pytest>=7.0.0; extra == "dev"
29
32
  Requires-Dist: black>=23.0.0; extra == "dev"
@@ -45,6 +48,7 @@ Simple interactive visualization of 3D point clouds using Plotly.
45
48
  - Easy-to-use API with sensible defaults
46
49
  - Type hints for better IDE support
47
50
  - Plot batched point clouds (B, N, 3)
51
+ - Export a rotating turntable GIF of any figure
48
52
 
49
53
  ## Installation
50
54
 
@@ -64,6 +68,14 @@ points = np.random.randn(500, 3)
64
68
  # Create and display the plot
65
69
  cg.plot(points, title="My Point Cloud", size=2.0).show()
66
70
  ```
71
+
72
+ Export a rotating GIF of the same figure:
73
+
74
+ ```python
75
+ fig = cg.plot(points, size=2.0)
76
+ cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
77
+ ```
78
+
67
79
  More examples are in the `examples` folder.
68
80
 
69
81
  ### Development Installation
@@ -13,6 +13,7 @@ Simple interactive visualization of 3D point clouds using Plotly.
13
13
  - Easy-to-use API with sensible defaults
14
14
  - Type hints for better IDE support
15
15
  - Plot batched point clouds (B, N, 3)
16
+ - Export a rotating turntable GIF of any figure
16
17
 
17
18
  ## Installation
18
19
 
@@ -32,6 +33,14 @@ points = np.random.randn(500, 3)
32
33
  # Create and display the plot
33
34
  cg.plot(points, title="My Point Cloud", size=2.0).show()
34
35
  ```
36
+
37
+ Export a rotating GIF of the same figure:
38
+
39
+ ```python
40
+ fig = cg.plot(points, size=2.0)
41
+ cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
42
+ ```
43
+
35
44
  More examples are in the `examples` folder.
36
45
 
37
46
  ### Development Installation
@@ -1,6 +1,7 @@
1
1
  """cloudglancer: Simple interactive visualization of 3D point clouds"""
2
2
 
3
3
  from cloudglancer.scatter import plot, combine_plots
4
+ from cloudglancer.animate import animate
4
5
 
5
6
  __version__ = "0.1.0"
6
- __all__ = ["plot", "combine_plots"]
7
+ __all__ = ["plot", "combine_plots", "animate"]
@@ -0,0 +1,184 @@
1
+ """Render a rotating GIF animation of a 3D Plotly figure."""
2
+
3
+ import multiprocessing
4
+ import os
5
+ from concurrent.futures import ProcessPoolExecutor
6
+ from io import BytesIO
7
+ from typing import Tuple
8
+
9
+ import numpy as np
10
+ from plotly.graph_objects import Figure
11
+ from tqdm import tqdm
12
+
13
+
14
+ _AXIS_VECTORS = {
15
+ "x": np.array([1.0, 0.0, 0.0]),
16
+ "y": np.array([0.0, 1.0, 0.0]),
17
+ "z": np.array([0.0, 0.0, 1.0]),
18
+ }
19
+
20
+
21
+ def _rotation_matrix(axis: np.ndarray, theta: float) -> np.ndarray:
22
+ """Rodrigues' rotation matrix for rotation by `theta` around unit `axis`."""
23
+ x, y, z = axis
24
+ c = np.cos(theta)
25
+ s = np.sin(theta)
26
+ C = 1.0 - c
27
+ return np.array([
28
+ [c + x * x * C, x * y * C - z * s, x * z * C + y * s],
29
+ [y * x * C + z * s, c + y * y * C, y * z * C - x * s],
30
+ [z * x * C - y * s, z * y * C + x * s, c + z * z * C],
31
+ ])
32
+
33
+
34
+ def _scene_keys(fig: Figure) -> list:
35
+ """Return all scene-typed layout keys on `fig` (e.g. 'scene', 'scene2', ...)."""
36
+ keys = []
37
+ for key in fig.layout:
38
+ if key == "scene" or (key.startswith("scene") and key[5:].isdigit()):
39
+ keys.append(key)
40
+ return keys
41
+
42
+
43
+ def _eye_to_vec(eye) -> np.ndarray:
44
+ if eye is None or eye.x is None:
45
+ return np.array([1.25, 1.25, 1.25])
46
+ return np.array([eye.x, eye.y, eye.z])
47
+
48
+
49
+ _WORKER_FIG = None
50
+
51
+
52
+ def _init_worker(fig_dict: dict) -> None:
53
+ global _WORKER_FIG
54
+ from plotly.graph_objects import Figure as _Figure
55
+ _WORKER_FIG = _Figure(fig_dict)
56
+
57
+
58
+ def _render_frame(args: Tuple[dict, int, int]) -> bytes:
59
+ scene_updates, width, height = args
60
+ _WORKER_FIG.update_layout(**scene_updates)
61
+ return _WORKER_FIG.to_image(format="png", width=width, height=height)
62
+
63
+
64
+ def animate(
65
+ fig: Figure,
66
+ output_path: str,
67
+ axis: str = "z",
68
+ n_frames: int = 60,
69
+ duration: int = 50,
70
+ width: int = 800,
71
+ height: int = 600,
72
+ loop: int = 0,
73
+ progress: bool = True,
74
+ ) -> str:
75
+ """
76
+ Render a rotating GIF of a 3D Plotly figure.
77
+
78
+ The camera orbits the scene around the chosen axis for one full revolution
79
+ over `n_frames` frames, producing a turntable-style animation. The input
80
+ figure is not mutated.
81
+
82
+ Args:
83
+ fig: A Plotly Figure (e.g. the output of :func:`cloudglancer.plot` or
84
+ :func:`cloudglancer.combine_plots`).
85
+ output_path: Path where the GIF is written.
86
+ axis: Rotation axis, one of ``'x'``, ``'y'``, ``'z'``. Defaults to ``'z'``.
87
+ n_frames: Number of frames in one full revolution. Defaults to 60.
88
+ duration: Per-frame display time in milliseconds. Defaults to 50.
89
+ width: Frame width in pixels. Defaults to 800.
90
+ height: Frame height in pixels. Defaults to 600.
91
+ loop: Number of times the GIF should loop (0 = infinite). Defaults to 0.
92
+ progress: Show a tqdm progress bar while rendering frames. Defaults to True.
93
+
94
+ Returns:
95
+ The ``output_path`` it wrote to.
96
+
97
+ Raises:
98
+ ValueError: If `axis` is not one of ``'x'``, ``'y'``, ``'z'``.
99
+ ImportError: If ``kaleido`` or ``Pillow`` is not installed.
100
+
101
+ Examples:
102
+ >>> import numpy as np
103
+ >>> import cloudglancer as cg
104
+ >>> pts = np.random.randn(500, 3)
105
+ >>> fig = cg.plot(pts, size=2.0)
106
+ >>> cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
107
+ 'rotation.gif'
108
+ """
109
+ if axis not in _AXIS_VECTORS:
110
+ raise ValueError(f"axis must be one of 'x', 'y', 'z' (got {axis!r})")
111
+
112
+ try:
113
+ from PIL import Image
114
+ except ImportError as e:
115
+ raise ImportError(
116
+ "animate() requires Pillow. Install it with `pip install Pillow`."
117
+ ) from e
118
+
119
+ axis_vec = _AXIS_VECTORS[axis]
120
+ up_vec = {"x": dict(x=1, y=0, z=0),
121
+ "y": dict(x=0, y=1, z=0),
122
+ "z": dict(x=0, y=0, z=1)}[axis]
123
+
124
+ scene_keys = _scene_keys(fig)
125
+ if not scene_keys:
126
+ raise ValueError("figure has no 3D scene to animate")
127
+
128
+ initial_eyes = {k: _eye_to_vec(fig.layout[k].camera.eye) for k in scene_keys}
129
+
130
+ tasks = []
131
+ for i in range(n_frames):
132
+ theta = 2.0 * np.pi * i / n_frames
133
+ R = _rotation_matrix(axis_vec, theta)
134
+ scene_updates = {}
135
+ for k in scene_keys:
136
+ eye = R @ initial_eyes[k]
137
+ scene_updates[k] = dict(
138
+ camera=dict(
139
+ eye=dict(x=float(eye[0]), y=float(eye[1]), z=float(eye[2])),
140
+ up=up_vec,
141
+ )
142
+ )
143
+ tasks.append((scene_updates, width, height))
144
+
145
+ fig_dict = fig.to_dict()
146
+ n_workers = min(4, os.cpu_count() or 2, n_frames)
147
+
148
+ pngs: list = [None] * n_frames
149
+ try:
150
+ with ProcessPoolExecutor(
151
+ max_workers=n_workers,
152
+ mp_context=multiprocessing.get_context("spawn"),
153
+ initializer=_init_worker,
154
+ initargs=(fig_dict,),
155
+ ) as executor:
156
+ iterator = executor.map(_render_frame, tasks, chunksize=1)
157
+ for i, png in enumerate(
158
+ tqdm(iterator, total=n_frames, desc="Rendering frames",
159
+ unit="frame", disable=not progress)
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
167
+
168
+ ref_palette = Image.open(BytesIO(pngs[0])).convert("P", palette=Image.ADAPTIVE)
169
+
170
+ frames = [ref_palette]
171
+ for png in pngs[1:]:
172
+ rgb = Image.open(BytesIO(png)).convert("RGB")
173
+ frames.append(rgb.quantize(palette=ref_palette, dither=Image.NONE))
174
+
175
+ frames[0].save(
176
+ output_path,
177
+ save_all=True,
178
+ append_images=frames[1:],
179
+ duration=duration,
180
+ loop=loop,
181
+ disposal=2,
182
+ )
183
+
184
+ return output_path
@@ -11,7 +11,7 @@ def plot(
11
11
  points: np.ndarray,
12
12
  labels: Optional[np.ndarray] = None,
13
13
  label_map: Optional[Dict] = None,
14
- color_map: Optional[Union[List[str], float]] = None,
14
+ color_map: Optional[Union[List[str], Dict[str, str], float]] = None,
15
15
  batch_colors: Optional[List[str]] = None,
16
16
  size: float = 1.5,
17
17
  title: Optional[str] = None,
@@ -29,8 +29,9 @@ def plot(
29
29
  a continuous color scale. Not supported for batched input.
30
30
  label_map (dict, optional): Maps label values to display names. When provided,
31
31
  enables discrete color mapping.
32
- color_map (list or float, optional): When label_map is provided, this should be
33
- a list of color strings for discrete coloring. Without label_map, this can be
32
+ color_map (list, dict, or float, optional): When label_map is provided, a
33
+ {display_name: color} dict (preferred) or a list of color strings aligned
34
+ with label_map values for discrete coloring. Without label_map, this can be
34
35
  a float specifying the continuous color scale midpoint.
35
36
  batch_colors (list, optional): List of color strings, one per batch element.
36
37
  Only used when points is 3D. Defaults to Plotly's qualitative palette.
@@ -91,8 +92,18 @@ def plot(
91
92
  df["label"] = labels
92
93
  if label_map:
93
94
  df["label"] = df["label"].map(label_map).fillna(df["label"])
94
- fig = px.scatter_3d(df, x="x", y="y", z="z", color="label",
95
- color_discrete_sequence=color_map)
95
+ category_orders = {"label": list(label_map.values())}
96
+ if isinstance(color_map, dict):
97
+ discrete_map = color_map
98
+ elif isinstance(color_map, list):
99
+ discrete_map = dict(zip(label_map.values(), color_map))
100
+ else:
101
+ discrete_map = None
102
+ fig = px.scatter_3d(
103
+ df, x="x", y="y", z="z", color="label",
104
+ color_discrete_map=discrete_map,
105
+ category_orders=category_orders,
106
+ )
96
107
  else:
97
108
  fig = px.scatter_3d(df, x="x", y="y", z="z", color="label",
98
109
  color_continuous_midpoint=color_map, range_color=[0, 1])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudglancer
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Simple interactive visualization of 3D point clouds
5
5
  Author-email: Your Name <your.email@example.com>
6
6
  License: MIT
@@ -24,6 +24,9 @@ Requires-Dist: plotly>=5.0.0
24
24
  Requires-Dist: pandas>=2.0.0
25
25
  Requires-Dist: numpy>=1.24.0
26
26
  Requires-Dist: nbformat>=4.2.0
27
+ Requires-Dist: kaleido<1.0,>=0.2.1
28
+ Requires-Dist: Pillow>=9.0.0
29
+ Requires-Dist: tqdm>=4.0.0
27
30
  Provides-Extra: dev
28
31
  Requires-Dist: pytest>=7.0.0; extra == "dev"
29
32
  Requires-Dist: black>=23.0.0; extra == "dev"
@@ -45,6 +48,7 @@ Simple interactive visualization of 3D point clouds using Plotly.
45
48
  - Easy-to-use API with sensible defaults
46
49
  - Type hints for better IDE support
47
50
  - Plot batched point clouds (B, N, 3)
51
+ - Export a rotating turntable GIF of any figure
48
52
 
49
53
  ## Installation
50
54
 
@@ -64,6 +68,14 @@ points = np.random.randn(500, 3)
64
68
  # Create and display the plot
65
69
  cg.plot(points, title="My Point Cloud", size=2.0).show()
66
70
  ```
71
+
72
+ Export a rotating GIF of the same figure:
73
+
74
+ ```python
75
+ fig = cg.plot(points, size=2.0)
76
+ cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
77
+ ```
78
+
67
79
  More examples are in the `examples` folder.
68
80
 
69
81
  ### Development Installation
@@ -3,10 +3,12 @@ MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
5
  cloudglancer/__init__.py
6
+ cloudglancer/animate.py
6
7
  cloudglancer/scatter.py
7
8
  cloudglancer.egg-info/PKG-INFO
8
9
  cloudglancer.egg-info/SOURCES.txt
9
10
  cloudglancer.egg-info/dependency_links.txt
10
11
  cloudglancer.egg-info/requires.txt
11
12
  cloudglancer.egg-info/top_level.txt
13
+ tests/test_animate.py
12
14
  tests/test_scatter.py
@@ -2,6 +2,9 @@ plotly>=5.0.0
2
2
  pandas>=2.0.0
3
3
  numpy>=1.24.0
4
4
  nbformat>=4.2.0
5
+ kaleido<1.0,>=0.2.1
6
+ Pillow>=9.0.0
7
+ tqdm>=4.0.0
5
8
 
6
9
  [dev]
7
10
  pytest>=7.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cloudglancer"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "Simple interactive visualization of 3D point clouds"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -29,6 +29,9 @@ dependencies = [
29
29
  "pandas>=2.0.0",
30
30
  "numpy>=1.24.0",
31
31
  "nbformat>=4.2.0",
32
+ "kaleido>=0.2.1,<1.0",
33
+ "Pillow>=9.0.0",
34
+ "tqdm>=4.0.0",
32
35
  ]
33
36
 
34
37
  [project.optional-dependencies]
@@ -0,0 +1,62 @@
1
+ """Unit tests for cloudglancer.animate module."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from cloudglancer import plot, combine_plots, animate
7
+
8
+
9
+ def _is_gif(path) -> bool:
10
+ with open(path, "rb") as f:
11
+ head = f.read(6)
12
+ return head in (b"GIF87a", b"GIF89a")
13
+
14
+
15
+ def test_animate_writes_file(tmp_path):
16
+ points = np.random.randn(50, 3)
17
+ fig = plot(points)
18
+ out = tmp_path / "out.gif"
19
+ result = animate(fig, str(out), n_frames=4, width=160, height=120, progress=False)
20
+ assert result == str(out)
21
+ assert out.exists() and out.stat().st_size > 0
22
+ assert _is_gif(out)
23
+
24
+
25
+ def test_animate_invalid_axis(tmp_path):
26
+ fig = plot(np.random.randn(10, 3))
27
+ with pytest.raises(ValueError, match="axis must be one of"):
28
+ animate(fig, str(tmp_path / "x.gif"), axis="w", n_frames=2)
29
+
30
+
31
+ def test_animate_does_not_mutate_figure(tmp_path):
32
+ fig = plot(np.random.randn(20, 3))
33
+ fig.update_layout(scene=dict(camera=dict(eye=dict(x=2.0, y=2.0, z=2.0))))
34
+ before = (
35
+ fig.layout.scene.camera.eye.x,
36
+ fig.layout.scene.camera.eye.y,
37
+ fig.layout.scene.camera.eye.z,
38
+ )
39
+ animate(fig, str(tmp_path / "x.gif"), n_frames=3, width=120, height=120, progress=False)
40
+ after = (
41
+ fig.layout.scene.camera.eye.x,
42
+ fig.layout.scene.camera.eye.y,
43
+ fig.layout.scene.camera.eye.z,
44
+ )
45
+ assert before == after
46
+
47
+
48
+ def test_animate_combined_figure(tmp_path):
49
+ f1 = plot(np.random.randn(20, 3))
50
+ f2 = plot(np.random.randn(20, 3))
51
+ combined = combine_plots([f1, f2], rows=1, cols=2)
52
+ out = tmp_path / "combined.gif"
53
+ animate(combined, str(out), axis="y", n_frames=3, width=200, height=120, progress=False)
54
+ assert out.exists() and _is_gif(out)
55
+
56
+
57
+ @pytest.mark.parametrize("axis", ["x", "y", "z"])
58
+ def test_animate_each_axis(tmp_path, axis):
59
+ fig = plot(np.random.randn(20, 3))
60
+ out = tmp_path / f"{axis}.gif"
61
+ animate(fig, str(out), axis=axis, n_frames=3, width=120, height=120, progress=False)
62
+ assert _is_gif(out)
@@ -34,6 +34,40 @@ def test_plot_with_label_map():
34
34
  assert len(fig.data) > 0
35
35
 
36
36
 
37
+ def test_plot_label_map_color_binding_is_order_independent():
38
+ """Colors must bind to labels via label_map, not the point order
39
+ (regression for issue #1)."""
40
+ pts = np.random.randn(200, 3)
41
+ labels_a = np.zeros(200, dtype=int); labels_a[:100] = 1
42
+ labels_b = np.zeros(200, dtype=int); labels_b[100:] = 1
43
+ label_map = {0: "unknown", 1: "known"}
44
+ color_map = ["red", "steelblue"]
45
+
46
+ fig_a = plot(pts, labels=labels_a, label_map=label_map, color_map=color_map)
47
+ fig_b = plot(pts, labels=labels_b, label_map=label_map, color_map=color_map)
48
+
49
+ def color_for(fig, name):
50
+ trace = next(t for t in fig.data if t.name == name)
51
+ return trace.marker.color
52
+
53
+ assert color_for(fig_a, "unknown") == color_for(fig_b, "unknown") == "red"
54
+ assert color_for(fig_a, "known") == color_for(fig_b, "known") == "steelblue"
55
+
56
+
57
+ def test_plot_label_map_accepts_color_dict():
58
+ """color_map may be a {display_name: color} dict."""
59
+ pts = np.random.randn(60, 3)
60
+ labels = np.array([0] * 30 + [1] * 30)
61
+ fig = plot(
62
+ pts, labels=labels,
63
+ label_map={0: "unknown", 1: "known"},
64
+ color_map={"unknown": "red", "known": "steelblue"},
65
+ )
66
+ by_name = {t.name: t.marker.color for t in fig.data}
67
+ assert by_name["unknown"] == "red"
68
+ assert by_name["known"] == "steelblue"
69
+
70
+
37
71
  def test_plot_invalid_shape():
38
72
  """Test that ValueError is raised for incorrect point shape."""
39
73
  points = np.random.randn(100, 2) # Wrong shape
File without changes
File without changes
File without changes