pyframetree 0.3.0__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.
- pyframetree-0.3.0/LICENSE +21 -0
- pyframetree-0.3.0/MANIFEST.in +3 -0
- pyframetree-0.3.0/PKG-INFO +72 -0
- pyframetree-0.3.0/README.md +55 -0
- pyframetree-0.3.0/examples/geometry.py +150 -0
- pyframetree-0.3.0/examples/geometry_viewer.py +187 -0
- pyframetree-0.3.0/examples/simple_transform.py +38 -0
- pyframetree-0.3.0/performance/__init__.py +1 -0
- pyframetree-0.3.0/performance/benchmark_frames.py +58 -0
- pyframetree-0.3.0/performance/benchmark_operations.py +22 -0
- pyframetree-0.3.0/performance/benchmark_transformations.py +131 -0
- pyframetree-0.3.0/performance/run_benchmarks.py +48 -0
- pyframetree-0.3.0/pyframetree/__init__.py +15 -0
- pyframetree-0.3.0/pyframetree/backend.py +38 -0
- pyframetree-0.3.0/pyframetree/coordinate_systems.py +346 -0
- pyframetree-0.3.0/pyframetree/rotation.py +85 -0
- pyframetree-0.3.0/pyframetree/transformations.py +125 -0
- pyframetree-0.3.0/pyframetree/utils.py +89 -0
- pyframetree-0.3.0/pyframetree.egg-info/PKG-INFO +72 -0
- pyframetree-0.3.0/pyframetree.egg-info/SOURCES.txt +28 -0
- pyframetree-0.3.0/pyframetree.egg-info/dependency_links.txt +1 -0
- pyframetree-0.3.0/pyframetree.egg-info/requires.txt +6 -0
- pyframetree-0.3.0/pyframetree.egg-info/top_level.txt +1 -0
- pyframetree-0.3.0/pyproject.toml +45 -0
- pyframetree-0.3.0/setup.cfg +4 -0
- pyframetree-0.3.0/setup.py +4 -0
- pyframetree-0.3.0/tests/test_coordinate_systems.py +349 -0
- pyframetree-0.3.0/tests/test_examples_benchmarks.py +103 -0
- pyframetree-0.3.0/tests/test_geometry_viewer.py +116 -0
- pyframetree-0.3.0/tests/test_transformations.py +238 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyframetree
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Nested 3D coordinate frames with physical units and vectorized transformations
|
|
5
|
+
Author-email: Gabriel Lefloch <gabriel.lefloch@kit.edu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/gabs1234/QuaternionFrame
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=1.24
|
|
12
|
+
Requires-Dist: quantities>=0.16
|
|
13
|
+
Provides-Extra: visualization
|
|
14
|
+
Requires-Dist: pyvista>=0.43; extra == "visualization"
|
|
15
|
+
Requires-Dist: matplotlib>=3.6; extra == "visualization"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# PyFrameTree
|
|
19
|
+
|
|
20
|
+
Nested 3D coordinate frames and point-cloud transformations, with physical units.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
From the repository:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv sync --extra visualization
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv run --extra visualization python examples/simple_transform.py --show
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pyframetree import CoordinateSystem
|
|
38
|
+
import quantities as pq
|
|
39
|
+
import numpy as np
|
|
40
|
+
import pyvista as pv
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
plotter = pv.Plotter()
|
|
45
|
+
|
|
46
|
+
# Create a random point cloud
|
|
47
|
+
N = 100
|
|
48
|
+
points = np.random.rand(N, 3)
|
|
49
|
+
|
|
50
|
+
# Create a reference coordinate system
|
|
51
|
+
Reference = CoordinateSystem()
|
|
52
|
+
|
|
53
|
+
# Create a main coordinate system
|
|
54
|
+
Main = CoordinateSystem()
|
|
55
|
+
|
|
56
|
+
# Create a child coordinate system
|
|
57
|
+
Child = CoordinateSystem(parent=Main, name='Child')
|
|
58
|
+
Child.add_points(points, 'points')
|
|
59
|
+
Child.translate([2, 2, 0]*pq.m)
|
|
60
|
+
Child.rotate([0, 1, 1], 30*pq.deg)
|
|
61
|
+
|
|
62
|
+
Main.scale(3, inherit=False) # Scale the main coordinate system, but not its children
|
|
63
|
+
Main.rotate([1, 0, 0], 30*pq.deg, inherit=True)
|
|
64
|
+
|
|
65
|
+
Reference.visualize(plotter=plotter, children=False, cmap='gray')
|
|
66
|
+
Main.visualize(plotter=plotter, children=False, cmap='plasma')
|
|
67
|
+
Child.visualize(plotter=plotter, children=False, cmap='viridis')
|
|
68
|
+
|
|
69
|
+
plotter.save_graphic('assets/frames.svg')
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+

|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# PyFrameTree
|
|
2
|
+
|
|
3
|
+
Nested 3D coordinate frames and point-cloud transformations, with physical units.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
From the repository:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv sync --extra visualization
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv run --extra visualization python examples/simple_transform.py --show
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from pyframetree import CoordinateSystem
|
|
21
|
+
import quantities as pq
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pyvista as pv
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
plotter = pv.Plotter()
|
|
28
|
+
|
|
29
|
+
# Create a random point cloud
|
|
30
|
+
N = 100
|
|
31
|
+
points = np.random.rand(N, 3)
|
|
32
|
+
|
|
33
|
+
# Create a reference coordinate system
|
|
34
|
+
Reference = CoordinateSystem()
|
|
35
|
+
|
|
36
|
+
# Create a main coordinate system
|
|
37
|
+
Main = CoordinateSystem()
|
|
38
|
+
|
|
39
|
+
# Create a child coordinate system
|
|
40
|
+
Child = CoordinateSystem(parent=Main, name='Child')
|
|
41
|
+
Child.add_points(points, 'points')
|
|
42
|
+
Child.translate([2, 2, 0]*pq.m)
|
|
43
|
+
Child.rotate([0, 1, 1], 30*pq.deg)
|
|
44
|
+
|
|
45
|
+
Main.scale(3, inherit=False) # Scale the main coordinate system, but not its children
|
|
46
|
+
Main.rotate([1, 0, 0], 30*pq.deg, inherit=True)
|
|
47
|
+
|
|
48
|
+
Reference.visualize(plotter=plotter, children=False, cmap='gray')
|
|
49
|
+
Main.visualize(plotter=plotter, children=False, cmap='plasma')
|
|
50
|
+
Child.visualize(plotter=plotter, children=False, cmap='viridis')
|
|
51
|
+
|
|
52
|
+
plotter.save_graphic('assets/frames.svg')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+

|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import marimo
|
|
2
|
+
|
|
3
|
+
__generated_with = '0.25.2'
|
|
4
|
+
app = marimo.App(width='medium')
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@app.cell
|
|
8
|
+
def _():
|
|
9
|
+
import marimo as mo
|
|
10
|
+
import numpy as np
|
|
11
|
+
import quantities as pq
|
|
12
|
+
from geometry_viewer import GeometryViewer
|
|
13
|
+
|
|
14
|
+
from pyframetree import CoordinateSystem
|
|
15
|
+
|
|
16
|
+
mo.output.clear() # Hide fastplotlib's optional Jupyter logo/device banner.
|
|
17
|
+
return CoordinateSystem, GeometryViewer, mo, np, pq
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.cell(hide_code=True)
|
|
21
|
+
def _(mo):
|
|
22
|
+
mo.md(r"""
|
|
23
|
+
# See how a parent frame moves its child
|
|
24
|
+
|
|
25
|
+
The **Reference** frame stays fixed. **Main** is the parent of **Child**,
|
|
26
|
+
which carries a point cloud. Axis labels identify each frame and its local
|
|
27
|
+
**u**, **v**, and **w** directions. Labels stay upright as the camera moves.
|
|
28
|
+
All positions are in metres.
|
|
29
|
+
|
|
30
|
+
Drag the 3D view to rotate it; scroll to zoom. Change the angles below to
|
|
31
|
+
update the geometry without resetting your camera. The defaults reproduce
|
|
32
|
+
the original Jupyter example.
|
|
33
|
+
|
|
34
|
+
Open this notebook with `uv run --group notebook marimo edit examples/geometry.py`.
|
|
35
|
+
""")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.cell(hide_code=True)
|
|
40
|
+
def _(mo):
|
|
41
|
+
parent_angle = mo.ui.slider(
|
|
42
|
+
-180,
|
|
43
|
+
180,
|
|
44
|
+
value=30,
|
|
45
|
+
step=5,
|
|
46
|
+
show_value=True,
|
|
47
|
+
label='Parent rotation (deg)',
|
|
48
|
+
)
|
|
49
|
+
child_angle = mo.ui.slider(
|
|
50
|
+
-180,
|
|
51
|
+
180,
|
|
52
|
+
value=30,
|
|
53
|
+
step=5,
|
|
54
|
+
show_value=True,
|
|
55
|
+
label='Child rotation (deg)',
|
|
56
|
+
)
|
|
57
|
+
parent_scale = mo.ui.slider(
|
|
58
|
+
1,
|
|
59
|
+
4,
|
|
60
|
+
value=3,
|
|
61
|
+
step=0.25,
|
|
62
|
+
show_value=True,
|
|
63
|
+
label='Parent axis scale',
|
|
64
|
+
)
|
|
65
|
+
inherit_scale = mo.ui.checkbox(value=False, label='Scale the child with its parent')
|
|
66
|
+
mo.vstack([parent_angle, child_angle, parent_scale, inherit_scale])
|
|
67
|
+
return child_angle, inherit_scale, parent_angle, parent_scale
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.cell
|
|
71
|
+
def _(np):
|
|
72
|
+
# Keep the same cloud when a control changes, so only the geometry moves.
|
|
73
|
+
points = np.random.default_rng(0).random((100, 3))
|
|
74
|
+
return (points,)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.cell
|
|
78
|
+
def _(CoordinateSystem, child_angle, inherit_scale, parent_angle, parent_scale, points, pq):
|
|
79
|
+
reference = CoordinateSystem(name='Reference')
|
|
80
|
+
main = CoordinateSystem(name='Main')
|
|
81
|
+
child = CoordinateSystem(parent=main, name='Child')
|
|
82
|
+
child.add_points(points, 'cloud')
|
|
83
|
+
child.translate([2, 2, 0] * pq.m)
|
|
84
|
+
child.rotate([0, 1, 1], child_angle.value * pq.deg)
|
|
85
|
+
|
|
86
|
+
main.scale(parent_scale.value, inherit=inherit_scale.value)
|
|
87
|
+
_ = main.rotate([1, 0, 0], parent_angle.value * pq.deg)
|
|
88
|
+
return child, main, reference
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.cell
|
|
92
|
+
def _(GeometryViewer, points):
|
|
93
|
+
viewer = GeometryViewer(points)
|
|
94
|
+
viewer.canvas
|
|
95
|
+
return (viewer,)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.cell
|
|
99
|
+
def _(child, main, reference, viewer):
|
|
100
|
+
viewer.update(reference, main, child)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.cell(hide_code=True)
|
|
105
|
+
def _(mo):
|
|
106
|
+
reset_view = mo.ui.run_button(label='Reset view')
|
|
107
|
+
reset_view
|
|
108
|
+
return (reset_view,)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.cell
|
|
112
|
+
def _(reset_view, viewer):
|
|
113
|
+
if reset_view.value:
|
|
114
|
+
viewer.reset_view()
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.cell(hide_code=True)
|
|
119
|
+
def _(child, main, mo, pq, reference):
|
|
120
|
+
_rows = []
|
|
121
|
+
for _frame in (reference, main, child):
|
|
122
|
+
_position = _frame.origin.rescale(pq.m).magnitude
|
|
123
|
+
_rows.append(
|
|
124
|
+
{
|
|
125
|
+
'Frame': _frame.name,
|
|
126
|
+
'Parent': _frame.parent.name if _frame.parent else '—',
|
|
127
|
+
'x (m)': round(float(_position[0]), 4),
|
|
128
|
+
'y (m)': round(float(_position[1]), 4),
|
|
129
|
+
'z (m)': round(float(_position[2]), 4),
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
mo.vstack(
|
|
133
|
+
[
|
|
134
|
+
mo.md('### World positions'),
|
|
135
|
+
mo.ui.table(_rows, selection=None, pagination=False, show_column_summaries=False),
|
|
136
|
+
mo.md("""
|
|
137
|
+
The parent rotates about the world x-axis. The child rotates about its
|
|
138
|
+
own origin, around its parent's axis `(0, 1, 1)`.
|
|
139
|
+
|
|
140
|
+
With **Scale the child with its parent** unchecked, `inherit=False`
|
|
141
|
+
changes the parent's axes while holding the child and its points fixed.
|
|
142
|
+
Rotation keeps the default `inherit=True`, so the child follows its parent.
|
|
143
|
+
"""),
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == '__main__':
|
|
150
|
+
app.run()
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Fastplotlib rendering for the coordinate-frame notebook."""
|
|
2
|
+
|
|
3
|
+
import weakref
|
|
4
|
+
|
|
5
|
+
import fastplotlib as fpl
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pygfx
|
|
8
|
+
import quantities as pq
|
|
9
|
+
from rendercanvas.anywidget import RenderCanvas
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _unit_arrow_geometry():
|
|
13
|
+
"""A unit arrow along +z, with a short head joined to a slender shaft."""
|
|
14
|
+
# Fastplotlib 0.7 misplaces the shaft when its height differs from the cone's.
|
|
15
|
+
# Assemble the two standard primitives explicitly, with tail=0 and tip=1.
|
|
16
|
+
shaft = pygfx.cylinder_geometry(
|
|
17
|
+
radius_bottom=0.012,
|
|
18
|
+
radius_top=0.012,
|
|
19
|
+
height=0.82,
|
|
20
|
+
radial_segments=16,
|
|
21
|
+
)
|
|
22
|
+
head = pygfx.cone_geometry(radius=0.045, height=0.18, radial_segments=16)
|
|
23
|
+
shaft.positions.data[:, 2] += 0.41
|
|
24
|
+
head.positions.data[:, 2] += 0.91
|
|
25
|
+
return pygfx.Geometry(
|
|
26
|
+
positions=np.concatenate([shaft.positions.data, head.positions.data]),
|
|
27
|
+
normals=np.concatenate([shaft.normals.data, head.normals.data]),
|
|
28
|
+
indices=np.concatenate(
|
|
29
|
+
[
|
|
30
|
+
shaft.indices.data,
|
|
31
|
+
head.indices.data + len(shaft.positions.data),
|
|
32
|
+
]
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _arrow_transform(origin, axis):
|
|
38
|
+
"""Place a unit arrow along ``axis``, keeping its proportions for any axis length."""
|
|
39
|
+
length = np.linalg.norm(axis)
|
|
40
|
+
direction = axis / length
|
|
41
|
+
helper = np.eye(3)[np.argmin(np.abs(direction))]
|
|
42
|
+
side = np.cross(helper, direction)
|
|
43
|
+
side /= np.linalg.norm(side)
|
|
44
|
+
matrix = np.eye(4)
|
|
45
|
+
matrix[:3, :3] = np.column_stack([side, np.cross(direction, side), direction]) * length
|
|
46
|
+
matrix[:3, 3] = origin
|
|
47
|
+
return matrix
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotebookCanvas(RenderCanvas):
|
|
51
|
+
"""Guard rendercanvas 2.7's recursive close notification, without patching it globally."""
|
|
52
|
+
|
|
53
|
+
def close(self):
|
|
54
|
+
if getattr(self, '_closing', False) or getattr(self, '_is_closed', False):
|
|
55
|
+
return
|
|
56
|
+
self._closing = True
|
|
57
|
+
try:
|
|
58
|
+
self._rfb_cancel_lossless_draw()
|
|
59
|
+
super().close()
|
|
60
|
+
finally:
|
|
61
|
+
self._closing = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GeometryViewer:
|
|
65
|
+
"""Keep the notebook's three frames and child point cloud in one live scene.
|
|
66
|
+
|
|
67
|
+
Render positions are metres. Only the display buffers use float32;
|
|
68
|
+
CoordinateSystem continues to calculate its transforms at its own precision.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, points, *, size=(900, 600), canvas=None):
|
|
72
|
+
self.canvas = NotebookCanvas(size=size, max_fps=60) if canvas is None else canvas
|
|
73
|
+
camera = pygfx.OrthographicCamera(8, 6)
|
|
74
|
+
camera.local.position = (6, 6, 6)
|
|
75
|
+
camera.look_at((0, 0, 0))
|
|
76
|
+
|
|
77
|
+
# Scope the frame styling to this figure, then restore the caller's defaults.
|
|
78
|
+
config = fpl.layouts.Subplot.config.init
|
|
79
|
+
previous = config.frame_kwargs
|
|
80
|
+
config.frame_kwargs = {
|
|
81
|
+
'spacing': dict(x0=0, sides=0, title_flanks=0, resize_handle_space=0, bottom=0),
|
|
82
|
+
'title_kwargs': dict(font_size=0),
|
|
83
|
+
}
|
|
84
|
+
try:
|
|
85
|
+
self.figure = fpl.Figure(
|
|
86
|
+
cameras=camera,
|
|
87
|
+
controller_types='orbit',
|
|
88
|
+
canvas=self.canvas,
|
|
89
|
+
size=size,
|
|
90
|
+
)
|
|
91
|
+
finally:
|
|
92
|
+
config.frame_kwargs = previous
|
|
93
|
+
|
|
94
|
+
self.plot = self.figure[0, 0]
|
|
95
|
+
self.plot.background_color = ('white',)
|
|
96
|
+
self.arrows, self.labels = [], []
|
|
97
|
+
arrow_geometry = _unit_arrow_geometry()
|
|
98
|
+
for name, color in [
|
|
99
|
+
('Reference', '#666666'),
|
|
100
|
+
('Main', '#4e79a7'),
|
|
101
|
+
('Child', '#b86b16'),
|
|
102
|
+
]:
|
|
103
|
+
arrows = self.plot.add_vectors(
|
|
104
|
+
np.zeros((3, 3), dtype=np.float32),
|
|
105
|
+
np.eye(3, dtype=np.float32),
|
|
106
|
+
color='white',
|
|
107
|
+
size=1,
|
|
108
|
+
)
|
|
109
|
+
arrows.world_object.geometry = arrow_geometry
|
|
110
|
+
arrows.world_object.material.color = color
|
|
111
|
+
self.arrows.append(arrows)
|
|
112
|
+
labels = [
|
|
113
|
+
self.plot.add_text(
|
|
114
|
+
f'{name}.{axis}',
|
|
115
|
+
font_size=14,
|
|
116
|
+
face_color='#222222',
|
|
117
|
+
screen_space=True,
|
|
118
|
+
outline_color='white',
|
|
119
|
+
outline_thickness=0.15,
|
|
120
|
+
)
|
|
121
|
+
for axis in ('u', 'v', 'w')
|
|
122
|
+
]
|
|
123
|
+
for label in labels:
|
|
124
|
+
label.world_object.material.depth_test = False
|
|
125
|
+
self.labels.append(labels)
|
|
126
|
+
|
|
127
|
+
self.cloud = self.plot.add_scatter(
|
|
128
|
+
np.asarray(points, dtype=np.float32),
|
|
129
|
+
colors='#b86b16',
|
|
130
|
+
sizes=7,
|
|
131
|
+
edge_width=0,
|
|
132
|
+
)
|
|
133
|
+
self.link = self.plot.add_line(
|
|
134
|
+
np.zeros((2, 3), dtype=np.float32),
|
|
135
|
+
colors='#666666',
|
|
136
|
+
thickness=1,
|
|
137
|
+
)
|
|
138
|
+
self.figure.show(autoscale=False, axes_visible=False)
|
|
139
|
+
self._bounds = None
|
|
140
|
+
self._cleanup = weakref.finalize(self, self.canvas.close)
|
|
141
|
+
|
|
142
|
+
def update(self, reference, main, child):
|
|
143
|
+
"""Update poses and local geometry while preserving the user's camera."""
|
|
144
|
+
bounds = []
|
|
145
|
+
for frame, arrows, labels in zip((reference, main, child), self.arrows, self.labels):
|
|
146
|
+
matrix = frame.world_transform.as_unit(pq.m).matrix
|
|
147
|
+
origin, basis = matrix[:3, 3], matrix[:3, :3].T
|
|
148
|
+
bounds.extend([origin, *(origin + 1.12 * basis)])
|
|
149
|
+
for i, (label, axis) in enumerate(zip(labels, basis)):
|
|
150
|
+
arrows.world_object.set_matrix_at(i, _arrow_transform(origin, axis))
|
|
151
|
+
label.offset = origin + 1.12 * axis
|
|
152
|
+
|
|
153
|
+
local = (child.get_points('cloud') * child.unit).rescale(pq.m).magnitude
|
|
154
|
+
local = np.asarray(local, dtype=np.float32).reshape(-1, 3)
|
|
155
|
+
if not np.array_equal(local, self.cloud.data[:]):
|
|
156
|
+
self.cloud.data = local
|
|
157
|
+
matrix = child.world_transform.as_unit(pq.m).matrix
|
|
158
|
+
self.cloud.world_object.local.matrix = matrix
|
|
159
|
+
self.link.data[:] = np.array(
|
|
160
|
+
[main.origin.rescale(pq.m).magnitude, child.origin.rescale(pq.m).magnitude],
|
|
161
|
+
dtype=np.float32,
|
|
162
|
+
)
|
|
163
|
+
if local.size:
|
|
164
|
+
bounds.extend(local @ matrix[:3, :3].T + matrix[:3, 3])
|
|
165
|
+
first_update = self._bounds is None
|
|
166
|
+
self._bounds = np.array([np.min(bounds, axis=0), np.max(bounds, axis=0)])
|
|
167
|
+
if first_update:
|
|
168
|
+
self.reset_view()
|
|
169
|
+
self.canvas.request_draw()
|
|
170
|
+
|
|
171
|
+
def reset_view(self):
|
|
172
|
+
"""Fit the current geometry and restore the initial viewing angle."""
|
|
173
|
+
if self._bounds is None:
|
|
174
|
+
return
|
|
175
|
+
center = self._bounds.mean(axis=0)
|
|
176
|
+
radius = max(float(np.linalg.norm(self._bounds[1] - center)), 1e-6)
|
|
177
|
+
self.plot.camera.show_object(
|
|
178
|
+
(*center, radius),
|
|
179
|
+
view_dir=(-1, 1, -0.8),
|
|
180
|
+
up=(0, 0, 1),
|
|
181
|
+
scale=1.15,
|
|
182
|
+
)
|
|
183
|
+
self.plot.camera.zoom = 1
|
|
184
|
+
self.canvas.request_draw()
|
|
185
|
+
|
|
186
|
+
def close(self):
|
|
187
|
+
self._cleanup()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""A CPU example; pass --show to visualize with the optional plotting extra."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
if __package__ in (None, ''):
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import quantities as q
|
|
12
|
+
|
|
13
|
+
from pyframetree import CoordinateSystem
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_scene():
|
|
17
|
+
root = CoordinateSystem(name='root')
|
|
18
|
+
child = CoordinateSystem(parent=root, name='cloud')
|
|
19
|
+
child.add_points(np.random.default_rng(0).random((1000, 3)), 'points')
|
|
20
|
+
child.translate([2, 2, 0] * q.m)
|
|
21
|
+
child.rotate([0, 1, 1], 30 * q.deg)
|
|
22
|
+
root.rotate([1, 0, 0], 30 * q.deg)
|
|
23
|
+
return root, child
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv=None):
|
|
27
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
28
|
+
parser.add_argument('--show', action='store_true')
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
root, child = make_scene()
|
|
31
|
+
print(child)
|
|
32
|
+
print(f'World point array: {child.get_points("points", space="world").shape}')
|
|
33
|
+
if args.show:
|
|
34
|
+
root.visualize().show()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == '__main__':
|
|
38
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Tests package initialization
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Per-call cost of frame operations, in microseconds."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from timeit import Timer
|
|
6
|
+
|
|
7
|
+
if __package__ in (None, ''):
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import quantities as q
|
|
12
|
+
|
|
13
|
+
from pyframetree import CoordinateSystem
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_chain(depth):
|
|
17
|
+
root = leaf = CoordinateSystem()
|
|
18
|
+
for _ in range(depth):
|
|
19
|
+
leaf = CoordinateSystem([1, 0, 0], parent=leaf)
|
|
20
|
+
leaf.rotate([0, 0, 1], 0.1)
|
|
21
|
+
return root, leaf
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def microseconds(operation, min_time=0.05):
|
|
25
|
+
timer = Timer(operation)
|
|
26
|
+
count, _ = timer.autorange()
|
|
27
|
+
count = max(1, int(count * min_time / 0.2))
|
|
28
|
+
return min(timer.repeat(repeat=3, number=count)) / count * 1e6
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_benchmarks(depths=(1, 10, 100), min_time=0.05):
|
|
32
|
+
"""Return rows of (operation, depth, microseconds per call)."""
|
|
33
|
+
point = np.array([1.0, 2.0, 3.0])
|
|
34
|
+
rows = [('create a frame', 0, microseconds(CoordinateSystem, min_time))]
|
|
35
|
+
for depth in depths:
|
|
36
|
+
root, leaf = make_chain(depth)
|
|
37
|
+
operations = {
|
|
38
|
+
'origin (cached)': lambda: leaf.origin,
|
|
39
|
+
'origin after root moved': lambda: (root.translate([0, 0, 0]), leaf.origin),
|
|
40
|
+
'translate': lambda: leaf.translate([0, 0, 0]),
|
|
41
|
+
'translate (quantity)': lambda: leaf.translate([0, 0, 0] * q.cm),
|
|
42
|
+
'translate (world)': lambda: leaf.translate([0, 0, 0], relative_to='world'),
|
|
43
|
+
'rotate': lambda: leaf.rotate([0, 0, 1], 0.0),
|
|
44
|
+
'to_world (one point)': lambda: leaf.to_world(point),
|
|
45
|
+
}
|
|
46
|
+
for name, operation in operations.items():
|
|
47
|
+
rows.append((name, depth, microseconds(operation, min_time)))
|
|
48
|
+
return rows
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def print_results(rows):
|
|
52
|
+
print(f'{"operation":<26} {"depth":>5} {"µs/call":>9}')
|
|
53
|
+
for name, depth, time in rows:
|
|
54
|
+
print(f'{name:<26} {depth:>5} {time:9.2f}')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == '__main__':
|
|
58
|
+
print_results(run_benchmarks())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Translation, rotation and scaling, using the shared benchmark methodology."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
if __package__ in (None, ''):
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
8
|
+
|
|
9
|
+
from performance.benchmark_transformations import print_results, run_benchmarks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def benchmark_operations(nmin=100, nmax=1000000, num_runs=5, *, cpu_only=False):
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
counts = np.unique(np.geomspace(nmin, nmax, num=6, dtype=int))
|
|
16
|
+
return run_benchmarks(
|
|
17
|
+
counts, num_runs, cpu_only=cpu_only, operations=('Translation', 'Rotation', 'Scaling')
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == '__main__':
|
|
22
|
+
print_results(benchmark_operations())
|