cloudglancer 0.1.7__tar.gz → 0.1.9__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.7
3
+ Version: 0.1.9
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,7 +24,7 @@ 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
27
+ Requires-Dist: kaleido>=0.2.1
28
28
  Requires-Dist: Pillow>=9.0.0
29
29
  Requires-Dist: tqdm>=4.0.0
30
30
  Provides-Extra: dev
@@ -75,6 +75,9 @@ fig = cg.plot(points, size=2.0)
75
75
  cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
76
76
  ```
77
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
+
78
81
  Render a batch of point clouds as a grid of subplots (one cloud per cell):
79
82
 
80
83
  ```python
@@ -40,6 +40,9 @@ fig = cg.plot(points, size=2.0)
40
40
  cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
41
41
  ```
42
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
+
43
46
  Render a batch of point clouds as a grid of subplots (one cloud per cell):
44
47
 
45
48
  ```python
@@ -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,36 @@ 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 is None or eye.x is None:
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.x, eye.y, eye.z])
47
+ return np.array([eye["x"], eye["y"], eye["z"]], dtype=float)
47
48
 
48
49
 
49
- _WORKER_FIG = None
50
+ _WORKER_FIG_DICT = None
50
51
 
51
52
 
52
53
  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)
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
59
+ import kaleido
60
+ if hasattr(kaleido, "start_sync_server"):
61
+ # kaleido >= 1 launches a new Chrome per to_image() call unless a
62
+ # server is running; keep one Chrome per worker for all its frames.
63
+ kaleido.start_sync_server(silence_warnings=True)
56
64
 
57
65
 
58
66
  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)
67
+ cameras, width, height = args
68
+ import plotly.io as pio
69
+ layout = _WORKER_FIG_DICT.setdefault("layout", {})
70
+ for key, cam in cameras.items():
71
+ layout.setdefault(key, {})["camera"] = cam
72
+ return pio.to_image(_WORKER_FIG_DICT, format="png",
73
+ width=width, height=height, validate=False)
62
74
 
63
75
 
64
76
  def animate(
@@ -71,6 +83,7 @@ def animate(
71
83
  height: int = 600,
72
84
  loop: int = 0,
73
85
  progress: bool = True,
86
+ n_workers: Optional[int] = None,
74
87
  ) -> str:
75
88
  """
76
89
  Render a rotating GIF of a 3D Plotly figure.
@@ -90,12 +103,18 @@ def animate(
90
103
  height: Frame height in pixels. Defaults to 600.
91
104
  loop: Number of times the GIF should loop (0 = infinite). Defaults to 0.
92
105
  progress: Show a tqdm progress bar while rendering frames. Defaults to True.
106
+ n_workers: Number of parallel render processes. Defaults to
107
+ ``min(16, cpu_count, n_frames)``. Raise it on machines with many
108
+ cores for large frame counts. On non-Linux platforms worker
109
+ processes are spawned, so scripts calling ``animate()`` there must
110
+ be guarded by ``if __name__ == "__main__":``.
93
111
 
94
112
  Returns:
95
113
  The ``output_path`` it wrote to.
96
114
 
97
115
  Raises:
98
- ValueError: If `axis` is not one of ``'x'``, ``'y'``, ``'z'``.
116
+ ValueError: If `axis` is not one of ``'x'``, ``'y'``, ``'z'``, or
117
+ `n_workers` is less than 1.
99
118
  ImportError: If ``kaleido`` or ``Pillow`` is not installed.
100
119
 
101
120
  Examples:
@@ -116,6 +135,14 @@ def animate(
116
135
  "animate() requires Pillow. Install it with `pip install Pillow`."
117
136
  ) from e
118
137
 
138
+ try:
139
+ import kaleido # noqa: F401
140
+ except ImportError as e:
141
+ raise ImportError(
142
+ "animate() requires kaleido for PNG export. "
143
+ "Install it with `pip install kaleido`."
144
+ ) from e
145
+
119
146
  axis_vec = _AXIS_VECTORS[axis]
120
147
  up_vec = {"x": dict(x=1, y=0, z=0),
121
148
  "y": dict(x=0, y=1, z=0),
@@ -125,45 +152,53 @@ def animate(
125
152
  if not scene_keys:
126
153
  raise ValueError("figure has no 3D scene to animate")
127
154
 
128
- initial_eyes = {k: _eye_to_vec(fig.layout[k].camera.eye) for k in scene_keys}
155
+ fig_dict = fig.to_dict()
156
+ layout_dict = fig_dict.setdefault("layout", {})
157
+
158
+ orig_cams = {}
159
+ initial_eyes = {}
160
+ for k in scene_keys:
161
+ cam = dict((layout_dict.get(k) or {}).get("camera") or {})
162
+ orig_cams[k] = cam
163
+ initial_eyes[k] = _eye_to_vec(cam.get("eye"))
129
164
 
130
165
  tasks = []
131
166
  for i in range(n_frames):
132
167
  theta = 2.0 * np.pi * i / n_frames
133
168
  R = _rotation_matrix(axis_vec, theta)
134
- scene_updates = {}
169
+ cameras = {}
135
170
  for k in scene_keys:
136
171
  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)
172
+ cameras[k] = {
173
+ **orig_cams[k],
174
+ "eye": dict(x=float(eye[0]), y=float(eye[1]), z=float(eye[2])),
175
+ "up": up_vec,
176
+ }
177
+ tasks.append((cameras, width, height))
178
+
179
+ if n_workers is None:
180
+ n_workers = min(16, os.cpu_count() or 2)
181
+ elif n_workers < 1:
182
+ raise ValueError(f"n_workers must be >= 1 (got {n_workers})")
183
+ n_workers = min(n_workers, n_frames)
184
+
185
+ # fork keeps unguarded user scripts working and skips per-worker re-imports;
186
+ # spawn elsewhere (fork is unsafe on macOS, unavailable on Windows).
187
+ start_method = "fork" if sys.platform.startswith("linux") else "spawn"
147
188
 
148
189
  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
190
+ with ProcessPoolExecutor(
191
+ max_workers=n_workers,
192
+ mp_context=multiprocessing.get_context(start_method),
193
+ initializer=_init_worker,
194
+ initargs=(fig_dict,),
195
+ ) as executor:
196
+ iterator = executor.map(_render_frame, tasks, chunksize=1)
197
+ for i, png in enumerate(
198
+ tqdm(iterator, total=n_frames, desc="Rendering frames",
199
+ unit="frame", disable=not progress)
200
+ ):
201
+ pngs[i] = png
167
202
 
168
203
  ref_palette = Image.open(BytesIO(pngs[0])).convert("P", palette=Image.ADAPTIVE)
169
204
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudglancer
3
- Version: 0.1.7
3
+ Version: 0.1.9
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,7 +24,7 @@ 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
27
+ Requires-Dist: kaleido>=0.2.1
28
28
  Requires-Dist: Pillow>=9.0.0
29
29
  Requires-Dist: tqdm>=4.0.0
30
30
  Provides-Extra: dev
@@ -75,6 +75,9 @@ fig = cg.plot(points, size=2.0)
75
75
  cg.animate(fig, "rotation.gif", axis="z", n_frames=60)
76
76
  ```
77
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
+
78
81
  Render a batch of point clouds as a grid of subplots (one cloud per cell):
79
82
 
80
83
  ```python
@@ -2,7 +2,7 @@ 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
5
+ kaleido>=0.2.1
6
6
  Pillow>=9.0.0
7
7
  tqdm>=4.0.0
8
8
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cloudglancer"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  description = "Simple interactive visualization of 3D point clouds"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -29,7 +29,7 @@ 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",
32
+ "kaleido>=0.2.1",
33
33
  "Pillow>=9.0.0",
34
34
  "tqdm>=4.0.0",
35
35
  ]
@@ -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))
File without changes
File without changes
File without changes