Skip to content

Commit a8441b7

Browse files
committed
Use scipy's spatial math instead of hand-rolled implementations
1 parent aa3bd6f commit a8441b7

4 files changed

Lines changed: 41 additions & 77 deletions

File tree

examples/viewer_demo.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
import numpy as np
2323

2424
os.environ.setdefault("JAX_PLATFORMS", "cpu")
25+
os.environ.setdefault("SCIPY_ARRAY_API", "1")
26+
27+
from scipy.spatial.transform import Rotation as R
2528

2629
import splax
2730
from splax.viewer import Viewer
@@ -45,8 +48,8 @@ def main(scene: Path, obj: Path, port: int, radius: float, height: float, freq:
4548
angle = 2 * np.pi * freq * (time.time() - t_start)
4649
pos = (radius * np.cos(angle), radius * np.sin(angle), height)
4750
# Yaw along the direction of travel: rotation of angle + pi/2 around +z (wxyz).
48-
yaw = angle / 2 + np.pi / 4
49-
viewer.update_pose("object", pos, (np.cos(yaw), 0.0, 0.0, np.sin(yaw)))
51+
quat = R.from_euler("z", angle + np.pi / 2).as_quat(scalar_first=True)
52+
viewer.update_pose("object", pos, quat)
5053
time.sleep(1 / 30)
5154
except KeyboardInterrupt:
5255
viewer.close()

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ TORCH_CUDA_ARCH_LIST = "8.9;12.0"
233233
# conda-forge keeps the CUDA headers under targets/, torch's extension builder
234234
# expects them on the host compiler search path.
235235
CPATH = "$CONDA_PREFIX/targets/x86_64-linux/include"
236+
# Required for scipy.spatial.transform's Rotation / RigidTransform to work with JAX
237+
SCIPY_ARRAY_API = "1"
236238

237239
[tool.pixi.feature.tests.pypi-dependencies]
238240
torch = ">=2.4"

scripts/train_colmap.py

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import numpy as np
2828
import optax
2929
from scipy.spatial import KDTree
30+
from scipy.spatial.transform import RigidTransform as TF
31+
from scipy.spatial.transform import Rotation as R
3032

3133
import splax
3234

@@ -134,19 +136,6 @@ def read_points3D(path: str | Path) -> tuple[np.ndarray, np.ndarray, np.ndarray,
134136
)
135137

136138

137-
def quat2mat(q: np.ndarray) -> np.ndarray:
138-
"""COLMAP wxyz quaternion -> 3x3 rotation matrix."""
139-
q = q / np.linalg.norm(q)
140-
w, x, y, z = q
141-
return np.array(
142-
[
143-
[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
144-
[2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
145-
[2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
146-
]
147-
)
148-
149-
150139
def _view_depth_targets(
151140
im: dict,
152141
vm: np.ndarray,
@@ -217,7 +206,9 @@ def load_scene(
217206
# (temporally ordered) center path by more than pose_filter times the median
218207
# consecutive-frame step. The window (15) absorbs excursions up to ~5 frames.
219208
n_all = len(images)
220-
ctr_path = np.array([-quat2mat(im["qvec"]).T @ im["tvec"] for im in images])
209+
tvecs = np.array([im["tvec"] for im in images])
210+
rots = R.from_quat(np.array([im["qvec"] for im in images]), scalar_first=True)
211+
ctr_path = TF.from_components(tvecs, rots).inv().translation
221212
med_step = np.median(np.linalg.norm(np.diff(ctr_path, axis=0), axis=1))
222213
half = 7
223214
keep_mask = np.ones(n_all, bool)
@@ -236,7 +227,9 @@ def load_scene(
236227
# camera centers + similarity normalization. The gauge comes from the full filtered list,
237228
# BEFORE the eval split and any train-view sampling, so runs with different sampling share
238229
# the same normalized world and their eval scores stay comparable.
239-
centers = np.array([-quat2mat(im["qvec"]).T @ im["tvec"] for im in images])
230+
tvecs = np.array([im["tvec"] for im in images])
231+
rots = R.from_quat(np.array([im["qvec"] for im in images]), scalar_first=True)
232+
centers = TF.from_components(tvecs, rots).inv().translation
240233
ctr = np.median(centers, axis=0)
241234
s = 1.0 / np.mean(np.linalg.norm(centers - ctr, axis=1))
242235

@@ -250,11 +243,10 @@ def load_scene(
250243
# motion of the same order). Uniform-in-time sampling underserves fast sections, which
251244
# is where held-out views end up farthest from their training neighbours.
252245
n_all = len(train_images)
253-
ctr_path = np.array([-quat2mat(im["qvec"]).T @ im["tvec"] for im in train_images])
254-
Rs = np.array([quat2mat(im["qvec"]) for im in train_images])
255-
rel = np.einsum("nij,nkj->nik", Rs[1:], Rs[:-1])
256-
tr = np.clip((np.trace(rel, axis1=1, axis2=2) - 1) / 2, -1, 1)
257-
ang = np.arccos(tr)
246+
tvecs = np.array([im["tvec"] for im in train_images])
247+
rots = R.from_quat(np.array([im["qvec"] for im in train_images]), scalar_first=True)
248+
ctr_path = TF.from_components(tvecs, rots).inv().translation
249+
ang = (rots[1:] * rots[:-1].inv()).magnitude()
258250
dist = np.linalg.norm(np.diff(ctr_path, axis=0), axis=1) + ang
259251
arc = np.concatenate([[0.0], np.cumsum(dist)])
260252
targets = np.linspace(0.0, arc[-1], adaptive_views)
@@ -268,10 +260,10 @@ def load_scene(
268260

269261
def normalize_pose(qvec: np.ndarray, tvec: np.ndarray) -> np.ndarray:
270262
"""Similarity-transform a w2c pose: X' = s (X - ctr). R stays, t' = s(t + R ctr)."""
271-
R = quat2mat(qvec)
272-
t_new = s * (tvec + R @ ctr)
263+
rmat = R.from_quat(qvec, scalar_first=True).as_matrix()
264+
t_new = s * (tvec + rmat @ ctr)
273265
vm = np.eye(4, dtype=np.float32)
274-
vm[:3, :3] = R
266+
vm[:3, :3] = rmat
275267
vm[:3, 3] = t_new
276268
return vm
277269

@@ -536,6 +528,8 @@ def apply_pose_delta(vm: jax.Array, delta: jax.Array) -> jax.Array:
536528
zero-rotation init has well-defined gradients.
537529
"""
538530
w, t = delta[:3], delta[3:]
531+
# Not scipy's Rotation.from_rotvec here: it returns NaN gradients at the zero-vector init.
532+
# The smooth A/B form below keeps jax.grad finite at theta = 0.
539533
theta2 = jnp.sum(w * w) + 1e-12
540534
theta = jnp.sqrt(theta2)
541535
A = jnp.sin(theta) / theta

tests/test_transforms.py

Lines changed: 17 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import jax.numpy as jnp
2424
import numpy as np
2525
import pytest
26+
from scipy.spatial.transform import RigidTransform as TF
27+
from scipy.spatial.transform import Rotation as R
2628

2729
import splax
2830
from splax._project import _project_call
@@ -58,57 +60,14 @@ def _kw(H: int, W: int) -> _KW:
5860
}
5961

6062

61-
def _euler_T(rx: float, ry: float, rz: float, t: tuple[float, float, float]) -> np.ndarray:
62-
"""4x4 rigid transform from XYZ Euler angles in radians plus a translation."""
63-
64-
def rot(axis: int, a: float) -> np.ndarray:
65-
ca, sa = np.cos(a), np.sin(a)
66-
m = np.eye(3, dtype=np.float64)
67-
i, j = [(1, 2), (0, 2), (0, 1)][axis]
68-
m[i, i] = ca
69-
m[j, j] = ca
70-
m[i, j] = -sa if axis != 1 else sa
71-
m[j, i] = sa if axis != 1 else -sa
72-
return m
73-
74-
R = rot(2, rz) @ rot(1, ry) @ rot(0, rx)
75-
T = np.eye(4, dtype=np.float32)
76-
T[:3, :3] = R.astype(np.float32)
77-
T[:3, 3] = t
78-
return T
79-
80-
81-
def _rotmat_to_quat_wxyz(R: np.ndarray) -> np.ndarray:
82-
w = np.sqrt(max(0.0, 1.0 + R[0, 0] + R[1, 1] + R[2, 2])) / 2.0
83-
x = (R[2, 1] - R[1, 2]) / (4.0 * w)
84-
y = (R[0, 2] - R[2, 0]) / (4.0 * w)
85-
z = (R[1, 0] - R[0, 1]) / (4.0 * w)
86-
return np.array([w, x, y, z], np.float32)
87-
88-
89-
def _qmul_wxyz(a: jax.Array, b: jax.Array) -> jax.Array:
90-
w1, x1, y1, z1 = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
91-
w2, x2, y2, z2 = b[..., 0], b[..., 1], b[..., 2], b[..., 3]
92-
return jnp.stack(
93-
[
94-
w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
95-
w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
96-
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,
97-
w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,
98-
],
99-
axis=-1,
100-
)
101-
102-
10363
def _manual_move(
10464
means: jax.Array, quats: jax.Array, T: np.ndarray, start: int, stop: int
10565
) -> tuple[jax.Array, jax.Array]:
10666
"""Reference transform of a slice, applied to the splat arrays in JAX."""
107-
R = jnp.asarray(T[:3, :3])
108-
t = jnp.asarray(T[:3, 3])
109-
q_obj = jnp.asarray(_rotmat_to_quat_wxyz(T[:3, :3]))
110-
m2 = means.at[start:stop].set(means[start:stop] @ R.T + t)
111-
q2 = quats.at[start:stop].set(_qmul_wxyz(q_obj[None], quats[start:stop]))
67+
transform = TF.from_matrix(jnp.asarray(T))
68+
rotated = transform.rotation * R.from_quat(quats[start:stop], scalar_first=True)
69+
m2 = means.at[start:stop].set(transform.apply(means[start:stop]))
70+
q2 = quats.at[start:stop].set(rotated.as_quat(scalar_first=True))
11271
return m2, q2
11372

11473

@@ -143,7 +102,8 @@ def test_projection_matches_manual_transform() -> None:
143102
n = 4000
144103
means, scales, quats, _colors, opac = _scene(n, seed=2)
145104
kw = _kw(128, 128)
146-
T = _euler_T(0.26, -0.17, 0.52, (0.3, -0.2, 0.1))
105+
rot = R.from_euler("xyz", [0.26, -0.17, 0.52])
106+
T = TF.from_components((0.3, -0.2, 0.1), rot).as_matrix().astype(np.float32)
147107
args = (n, kw["img_shape"], kw["f"], kw["c"], 1.0, 0.01)
148108
tf_ids = jnp.full((n,), -1, jnp.int32).at[:1000].set(0)
149109
a = _project_call(
@@ -167,7 +127,8 @@ def test_render_matches_manual_transform() -> None:
167127
n = 4000
168128
means, scales, quats, colors, opac = _scene(n, seed=3)
169129
kw = _kw(128, 128)
170-
T = _euler_T(0.26, -0.17, 0.52, (0.3, -0.2, 0.1))
130+
rot = R.from_euler("xyz", [0.26, -0.17, 0.52])
131+
T = TF.from_components((0.3, -0.2, 0.1), rot).as_matrix().astype(np.float32)
171132
moved = np.asarray(
172133
splax.inference.render(
173134
means,
@@ -195,7 +156,9 @@ def test_vmap_over_transforms_matches_sequential() -> None:
195156
n, B = 4000, 3
196157
means, scales, quats, colors, opac = _scene(n, seed=4)
197158
kw = _kw(96, 96)
198-
Ts = np.stack([_euler_T(0.0, 0.0, 0.3 * i, (0.05 * i, -0.03 * i, 0.0)) for i in range(B)])
159+
angles = np.array([[0.0, 0.0, 0.3 * i] for i in range(B)])
160+
trans = np.array([[0.05 * i, -0.03 * i, 0.0] for i in range(B)])
161+
Ts = TF.from_components(trans, R.from_euler("xyz", angles)).as_matrix().astype(np.float32)
199162
tfs = jnp.asarray(Ts)[:, None] # (B, 1, 4, 4)
200163

201164
def render_tf(tf: jax.Array) -> jax.Array:
@@ -222,8 +185,10 @@ def test_two_objects_move_independently() -> None:
222185
n = 4000
223186
means, scales, quats, colors, opac = _scene(n, seed=5)
224187
kw = _kw(128, 128)
225-
Ta = _euler_T(0.0, 0.0, 0.4, (0.2, 0.0, 0.0))
226-
Tb = _euler_T(0.3, 0.0, 0.0, (-0.1, 0.15, 0.0))
188+
rot_a = R.from_euler("xyz", [0.0, 0.0, 0.4])
189+
Ta = TF.from_components((0.2, 0.0, 0.0), rot_a).as_matrix().astype(np.float32)
190+
rot_b = R.from_euler("xyz", [0.3, 0.0, 0.0])
191+
Tb = TF.from_components((-0.1, 0.15, 0.0), rot_b).as_matrix().astype(np.float32)
227192
slices = ((0, 800), (2000, 2600))
228193
both = np.asarray(
229194
splax.inference.render(

0 commit comments

Comments
 (0)