Skip to content

Commit 287ba20

Browse files
Add spherical coordinate utilities to geo
Needed for virtual fitting algorithm (OpenwaterHealth#147)
1 parent 15a9682 commit 287ba20

4 files changed

Lines changed: 161 additions & 59 deletions

File tree

src/openlifu/geo.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from openlifu.util.units import getunitconversion
1212

1313

14+
# === Tools to work with points ===
1415
@dataclass
1516
class Point:
1617
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0])) # mm
@@ -136,3 +137,81 @@ def to_json(self, compact:bool) -> str:
136137
return json.dumps(self.to_dict(), separators=(',', ':'))
137138
else:
138139
return json.dumps(self.to_dict(), indent=4)
140+
141+
142+
# === Tools to work with spherical coordinate systems ===
143+
144+
def cartesian_to_spherical(x:float,y:float,z:float) -> Tuple[float, float, float]:
145+
"""Convert cartesian coordinates to spherical coordinates
146+
147+
Args: x, y, z are cartesian coordinates
148+
Returns: r, theta, phi, where
149+
r is the radial spherical coordinate, a nonnegative float.
150+
theta is the polar spherical coordinate, aka the angle off the z-axis, aka the non-azimuthal spherical angle.
151+
theta is in the range [0,pi].
152+
phi is the azimuthal spherical coordinate, in the range [-pi,pi]
153+
154+
Angles are in radians.
155+
"""
156+
return (np.sqrt(x**2+y**2+z**2), np.arctan2(np.sqrt(x**2+y**2),z), np.arctan2(y,x))
157+
158+
def spherical_to_cartesian(r: float, th:float, ph:float) -> Tuple[float, float, float]:
159+
"""Convert spherical coordinates to cartesian coordinates
160+
161+
Args:
162+
r: the radial spherical coordinate
163+
th: the polar spherical coordinate theta, aka the angle off the z-axis, aka the non-azimuthal spherical angle
164+
ph: the azimuthal spherical coordinate phi
165+
Returns the cartesian coordinates x,y,z
166+
167+
Angles are in radians.
168+
"""
169+
return (r*np.sin(th)*np.cos(ph), r*np.sin(th)*np.sin(ph), r*np.cos(th))
170+
171+
def cartesian_to_spherical_vectorized(p:np.ndarray) -> np.ndarray:
172+
"""Convert cartesian coordinates to spherical coordinates
173+
174+
Args:
175+
p: an array of shape (...,3), where the last axis describes point cartesian coordinates x,y,z.
176+
Returns: An array of shape (...,3), where the last axis describes point spherical coordinates r, theta, phi, where
177+
r is the radial spherical coordinate, a nonnegative float.
178+
theta is the polar spherical coordinate, aka the angle off the z-axis, aka the non-azimuthal spherical angle.
179+
theta is in the range [0,pi].
180+
phi is the azimuthal spherical coordinate, in the range [-pi,pi]
181+
182+
Angles are in radians.
183+
"""
184+
return np.stack([
185+
np.sqrt((p**2).sum(axis=-1)),
186+
np.arctan2(np.sqrt((p[...,0:2]**2).sum(axis=-1)),p[...,2]),
187+
np.arctan2(p[...,1],p[...,0]),
188+
], axis=-1)
189+
190+
def spherical_to_cartesian_vectorized(p:np.ndarray) -> np.ndarray:
191+
"""Convert spherical coordinates to cartesian coordinates
192+
193+
Args:
194+
p: an array of shape (...,3), where the last axis describes point spherical coordinates r, theta, phi, where:
195+
r is the radial spherical coordinate
196+
theta is the polar spherical coordinate, aka the angle off the z-axis, aka the non-azimuthal spherical angle
197+
phi is the azimuthal spherical coordinate
198+
Returns the cartesian coordinates x,y,z
199+
200+
Angles are in radians.
201+
"""
202+
return np.stack([
203+
p[...,0]*np.sin(p[...,1])*np.cos(p[...,2]),
204+
p[...,0]*np.sin(p[...,1])*np.sin(p[...,2]),
205+
p[...,0]*np.cos(p[...,1]),
206+
], axis=-1)
207+
208+
def spherical_coordinate_basis(th:float, phi:float) -> np.ndarray:
209+
"""Return normalized spherical coordinate basis at a location with spherical polar and azimuthal coordinates (th, phi).
210+
The coordinate basis is returned as an array `basis` of shape (3,3), where the rows are the basis vectors,
211+
in the order r, theta, phi. So `basis[0], basis[1], basis[2]` are the vectors $\\hat{r}$, $\\hat{\\theta}$, $\\hat{\\phi}$.
212+
Angles are assumed to be provided in radians."""
213+
return np.array([
214+
[np.sin(th)*np.cos(phi), np.sin(th)*np.sin(phi), np.cos(th)],
215+
[np.cos(th)*np.cos(phi), np.cos(th)*np.sin(phi), -np.sin(th)],
216+
[-np.sin(phi), np.cos(phi), 0],
217+
])

src/openlifu/seg/skinseg.py

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from scipy.ndimage import distance_transform_edt
2424
from vtk.util.numpy_support import numpy_to_vtk
2525

26+
from openlifu.geo import cartesian_to_spherical
27+
2628

2729
def take_largest_connected_component(mask: np.ndarray) -> np.ndarray:
2830
"""Given a boolean image array (or any integer numpy array), return a mask of the largest connected component."""
@@ -223,33 +225,6 @@ def create_closed_surface_from_labelmap(
223225

224226
return surface_mesh
225227

226-
def cartesian_to_spherical(x:float,y:float,z:float) -> Tuple[float, float, float]:
227-
"""Convert cartesian coordinates to spherical coordinates
228-
229-
Args: x, y, z are cartesian coordinates
230-
Returns: r, theta, phi, where
231-
r is the radial spherical coordinate, a nonnegative float.
232-
theta is the polar spherical coordinate, aka the angle off the z-axis, aka the non-azimuthal spherical angle.
233-
theta is in the range [0,pi].
234-
phi is the azimuthal spherical coordinate, in the range [-pi,pi]
235-
236-
Angles are in radians.
237-
"""
238-
return (np.sqrt(x**2+y**2+z**2), np.arctan2(np.sqrt(x**2+y**2),z), np.arctan2(y,x))
239-
240-
def spherical_to_cartesian(r: float, th:float, ph:float) -> Tuple[float, float, float]:
241-
"""Convert spherical coordinates to cartesian coordinates
242-
243-
Args:
244-
r: the radial spherical coordinate
245-
th: the polar spherical coordinate theta, aka the angle off the z-axis, aka the non-azimuthal spherical angle
246-
ph: the azimuthal spherical coordinate phi
247-
Returns the cartesian coordinates x,y,z
248-
249-
Angles are in radians.
250-
"""
251-
return (r*np.sin(th)*np.cos(ph), r*np.sin(th)*np.sin(ph), r*np.cos(th))
252-
253228
def spherical_interpolator_from_mesh(
254229
surface_mesh: vtk.vtkPolyData,
255230
origin: Tuple[float, float, float] = (0.,0.,0.),

tests/test_geo.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,88 @@
22

33
import numpy as np
44

5-
from openlifu.geo import Point
5+
from openlifu.geo import (
6+
Point,
7+
cartesian_to_spherical,
8+
cartesian_to_spherical_vectorized,
9+
spherical_coordinate_basis,
10+
spherical_to_cartesian,
11+
spherical_to_cartesian_vectorized,
12+
)
613

714

815
def test_point_from_dict():
916
point = Point.from_dict({'position' : [10,20,30],})
1017
assert (point.position == np.array([10,20,30], dtype=float)).all()
18+
19+
def test_spherical_coordinate_range():
20+
"""Verify that spherical coordinate output is in the prescribed value ranges"""
21+
rng = np.random.default_rng(848)
22+
# try all 8 octants of 3D space
23+
for sign_x in [-1,1]:
24+
for sign_y in [-1,1]:
25+
for sign_z in [-1,1]:
26+
cartesian_coords = np.array([sign_x, sign_y, sign_z]) * rng.random(size=3)
27+
r, th, ph = cartesian_to_spherical(*cartesian_coords)
28+
assert r>=0
29+
assert 0 <= th <= np.pi
30+
assert -np.pi <= ph <= np.pi
31+
32+
def test_spherical_coordinate_conversion_inverse():
33+
"""Verify that the spherical coordinate conversion forward and backward functions are inverses of one another"""
34+
rng = np.random.default_rng(241)
35+
# try all 8 octants of 3D space
36+
for sign_x in [-1,1]:
37+
for sign_y in [-1,1]:
38+
for sign_z in [-1,1]:
39+
cartesian_coords = np.array([sign_x, sign_y, sign_z]) * rng.random(size=3)
40+
np.testing.assert_almost_equal(
41+
spherical_to_cartesian(*cartesian_to_spherical(*cartesian_coords)),
42+
cartesian_coords
43+
)
44+
np.testing.assert_almost_equal(
45+
cartesian_to_spherical(*spherical_to_cartesian(*cartesian_to_spherical(*cartesian_coords))),
46+
cartesian_to_spherical(*cartesian_coords)
47+
)
48+
49+
def test_cartesian_to_spherical_vectorized():
50+
rng = np.random.default_rng(35932)
51+
points_cartesian = rng.normal(size=(10,3), scale=2) # make 10 random cartesian points
52+
points_spherical = cartesian_to_spherical_vectorized(points_cartesian)
53+
# Check individual points against the non-vectorized conversion function:
54+
for point_cartesian, point_spherical in zip(points_cartesian, points_spherical):
55+
assert np.allclose(
56+
point_spherical, # result of vectorized converter
57+
np.array(cartesian_to_spherical(*point_cartesian)), # non-vectorized converter
58+
)
59+
60+
def test_spherical_to_cartesian_vectorized():
61+
rng = np.random.default_rng(85932)
62+
63+
# make 10 random points in spherical coordinates
64+
num_pts = 10
65+
points_spherical = np.zeros(shape=(num_pts,3))
66+
points_spherical[...,0] = rng.random(num_pts)*5 # random r coordinates
67+
points_spherical[...,1] = rng.random(num_pts)*np.pi # random theta coordinates
68+
points_spherical[...,2] = rng.random(num_pts)*2*np.pi-np.pi # random phi coordinates
69+
70+
points_cartesian = spherical_to_cartesian_vectorized(points_spherical)
71+
# Check individual points against the non-vectorized conversion function:
72+
for point_cartesian, point_spherical in zip(points_cartesian, points_spherical):
73+
assert np.allclose(
74+
point_cartesian, # result of vectorized converter
75+
np.array(spherical_to_cartesian(*point_spherical)), # non-vectorized converter
76+
)
77+
78+
def test_spherical_coordinate_basis():
79+
rng = np.random.default_rng(35235)
80+
th = rng.random()*np.pi
81+
phi = rng.random()*2*np.pi-np.pi
82+
r = rng.random()*10
83+
basis = spherical_coordinate_basis(th,phi)
84+
assert np.allclose(basis @ basis.T, np.eye(3)) # verify it is an orthonormal basis
85+
r_hat, theta_hat, phi_hat = basis
86+
point = np.array(spherical_to_cartesian(r, th, phi))
87+
assert np.allclose(np.diff(r_hat / point), 0) # verify that r_hat is a scalar multiple of the cartesian coords
88+
assert cartesian_to_spherical_vectorized(point + 0.01*phi_hat)[2] > phi # verify phi_hat points along increasing phi
89+
assert cartesian_to_spherical_vectorized(point + 0.01*theta_hat)[1] > th # verify theta_hat points along increasing theta

tests/test_skinseg.py

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
compute_foreground_mask,
1414
create_closed_surface_from_labelmap,
1515
spherical_interpolator_from_mesh,
16-
spherical_to_cartesian,
1716
take_largest_connected_component,
1817
vtk_img_from_array_and_affine,
1918
)
@@ -89,36 +88,6 @@ def test_create_closed_surface_from_labelmap():
8988
point_distance_from_sphere_center = np.linalg.norm(point_position - sphere_center, ord=2)
9089
assert np.abs(point_distance_from_sphere_center - sphere_radius) < 1.
9190

92-
def test_spherical_coordinate_range():
93-
"""Verify that spherical coordinate output is in the prescribed value ranges"""
94-
rng = np.random.default_rng(848)
95-
# try all 8 octants of 3D space
96-
for sign_x in [-1,1]:
97-
for sign_y in [-1,1]:
98-
for sign_z in [-1,1]:
99-
cartesian_coords = np.array([sign_x, sign_y, sign_z]) * rng.random(size=3)
100-
r, th, ph = cartesian_to_spherical(*cartesian_coords)
101-
assert r>=0
102-
assert 0 <= th <= np.pi
103-
assert -np.pi <= ph <= np.pi
104-
105-
def test_spherical_coordinate_conversion_inverse():
106-
"""Verify that the spherical coordinate conversion forward and backward functions are inverses of one another"""
107-
rng = np.random.default_rng(241)
108-
# try all 8 octants of 3D space
109-
for sign_x in [-1,1]:
110-
for sign_y in [-1,1]:
111-
for sign_z in [-1,1]:
112-
cartesian_coords = np.array([sign_x, sign_y, sign_z]) * rng.random(size=3)
113-
np.testing.assert_almost_equal(
114-
spherical_to_cartesian(*cartesian_to_spherical(*cartesian_coords)),
115-
cartesian_coords
116-
)
117-
np.testing.assert_almost_equal(
118-
cartesian_to_spherical(*spherical_to_cartesian(*cartesian_to_spherical(*cartesian_coords))),
119-
cartesian_to_spherical(*cartesian_coords)
120-
)
121-
12291
def test_spherical_interpolator_from_mesh():
12392
"""Check using a torus that the spherical interpolator behaves reasonably"""
12493
parametric_torus = vtk.vtkParametricTorus()

0 commit comments

Comments
 (0)