Skip to content

Commit 9464db5

Browse files
authored
Merge branch 'main' into refactor/bundleadjust-require-pgraph
2 parents 8c45579 + 34a730a commit 9464db5

2 files changed

Lines changed: 177 additions & 51 deletions

File tree

‎tests/test_bundle_adjust.py‎

Lines changed: 0 additions & 51 deletions
This file was deleted.

‎tests/test_bundleadjust.py‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
11
#!/usr/bin/env python
22

3+
import os
4+
import tempfile
35
import unittest
46

7+
import matplotlib
8+
9+
matplotlib.use("Agg")
10+
import matplotlib.pyplot as plt
511
import numpy as np
612
from spatialmath import SE3
713

814
from machinevisiontoolbox import BundleAdjust, CentralCamera, mkcube
915

1016

17+
def _problem(poses=(SE3(), SE3.Tx(0.1), SE3.Ty(0.1)), start=None):
18+
"""Multi-view problem on a cube. The first view is fixed; the others are
19+
initialised at ``start`` (identity if None) rather than their true pose."""
20+
cam = CentralCamera.Default()
21+
P = mkcube(0.2, pose=SE3.Tz(1.0))
22+
ba = BundleAdjust(cam)
23+
views = []
24+
for i, pose in enumerate(poses):
25+
views.append(ba.add_view(pose if i == 0 else (start or SE3()), fixed=(i == 0)))
26+
landmarks = []
27+
for k in range(P.shape[1]):
28+
landmark = ba.add_landmark(P[:, k])
29+
landmarks.append(landmark)
30+
for view, pose in zip(views, poses):
31+
ba.add_projection(view, landmark, cam.project_point(P[:, k], pose=pose))
32+
return ba, views, landmarks, P, cam
33+
34+
1135
class TestBundleAdjust(unittest.TestCase):
1236
def test_optimize(self):
1337
"""A two-view problem with a deliberately wrong initial pose guess
@@ -33,6 +57,159 @@ def test_optimize(self):
3357
x_new, err = ba.optimize(iterations=20)
3458
self.assertLess(err, 1.0)
3559

60+
# --- scene graph structure (the pgraph boundary) ------------------------ #
61+
62+
def test_graph_structure(self):
63+
ba, views, landmarks, P, cam = _problem()
64+
g = ba.graph # property or method, both must give the pgraph graph
65+
g = g() if callable(g) else g
66+
self.assertIs(g, ba.g)
67+
self.assertEqual(ba.nviews, 3)
68+
self.assertEqual(ba.nlandmarks, 8)
69+
self.assertEqual(g.n, 3 + 8)
70+
self.assertEqual(g.ne, 3 * 8)
71+
72+
# every edge joins one view and one landmark and carries the observation
73+
for e in g.edges():
74+
self.assertIsInstance(
75+
e,
76+
(
77+
type(g.edges()[0])
78+
if isinstance(g.edges(), list)
79+
else type(next(iter(g.edges())))
80+
),
81+
)
82+
self.assertEqual(len(e.p), 2)
83+
self.assertEqual(
84+
{type(e.v1), type(e.v2)}, {type(views[0]), type(landmarks[0])}
85+
)
86+
87+
def test_vertex_and_edge_names(self):
88+
ba, views, landmarks, P, cam = _problem()
89+
self.assertEqual([v.name for v in views], ["view#0", "view#1", "view#2"])
90+
self.assertEqual(landmarks[0].name, "landmark#0")
91+
names = {e.name for e in ba.g.edges()}
92+
self.assertIn("view#1--landmark#3", names)
93+
self.assertEqual(len(names), ba.g.ne) # all distinct
94+
95+
def test_back_reference_and_connectivity(self):
96+
ba, views, landmarks, P, cam = _problem()
97+
for v in views + landmarks:
98+
self.assertIs(v.ba, ba)
99+
# each view sees all 8 landmarks, each landmark is seen by all 3 views
100+
self.assertEqual(list(ba.g.connectivity(ba.views)), [8, 8, 8])
101+
self.assertEqual(list(ba.g.connectivity(ba.landmarks)), [3] * 8)
102+
103+
def test_observation_values(self):
104+
ba, views, landmarks, P, cam = _problem()
105+
expect = cam.project_point(P[:, 2], pose=SE3.Tx(0.1))
106+
edges = [
107+
e
108+
for e in ba.g.edges()
109+
if landmarks[2] in (e.v1, e.v2) and views[1] in (e.v1, e.v2)
110+
]
111+
self.assertEqual(len(edges), 1)
112+
np.testing.assert_allclose(edges[0].p, expect.flatten())
113+
114+
def test_add_projection_bad_uv(self):
115+
ba, views, landmarks, P, cam = _problem()
116+
with self.assertRaises(ValueError):
117+
ba.add_projection(views[0], landmarks[0], np.r_[1.0, 2.0, 3.0])
118+
119+
# --- state vector and bookkeeping ---------------------------------------- #
120+
121+
def test_state_counts(self):
122+
ba, views, landmarks, P, cam = _problem()
123+
self.assertEqual(ba.nstates, 3 * 6 + 8 * 3)
124+
self.assertEqual(ba.nvarstates, 2 * 6 + 8 * 3) # first view fixed
125+
self.assertEqual(ba.getstate().shape, (ba.nstates,))
126+
127+
def test_setstate_respects_fixed(self):
128+
ba, views, landmarks, P, cam = _problem()
129+
x0 = ba.getstate()
130+
ba.setstate(x0 + 1.0)
131+
x1 = ba.getstate()
132+
np.testing.assert_array_equal(x1[:6], x0[:6]) # fixed view unchanged
133+
np.testing.assert_allclose(x1[6:], x0[6:] + 1.0)
134+
135+
def test_residual_zero_at_truth(self):
136+
ba, views, landmarks, P, cam = _problem(start=None)
137+
# set the free views to their true poses via the state vector
138+
for view, pose in zip(views[1:], (SE3.Tx(0.1), SE3.Ty(0.1))):
139+
view.coord = np.r_[pose.t, np.zeros(3)]
140+
r = ba.getresidual()
141+
self.assertLess(np.abs(r).max(), 1e-6)
142+
143+
# --- optimization, multi-view ------------------------------------------- #
144+
145+
def test_optimize_three_views(self):
146+
ba, views, landmarks, P, cam = _problem()
147+
x, err = ba.optimize(iterations=30)
148+
self.assertLess(err, 1.0)
149+
# optimize() returns the state vector, it does not write it back
150+
np.testing.assert_array_equal(views[1].pose.t, 0)
151+
ba.setstate(x)
152+
np.testing.assert_allclose(views[1].pose.t, [0.1, 0, 0], atol=1e-3)
153+
np.testing.assert_allclose(views[2].pose.t, [0, 0.1, 0], atol=1e-3)
154+
155+
# --- reporting and plotting ---------------------------------------------- #
156+
157+
def test_str_and_repr(self):
158+
ba, views, landmarks, P, cam = _problem()
159+
s = str(ba)
160+
self.assertIn("3 views", s)
161+
self.assertIn("8 landmarks", s)
162+
self.assertIn("24 projections", s)
163+
self.assertIn("48 equations", s)
164+
self.assertIn("landmarks per view", s)
165+
self.assertIn("views per landmark", s)
166+
r = repr(ba)
167+
self.assertIn("views=3", r)
168+
self.assertIn("landmarks=8", r)
169+
self.assertIn("projections=24", r)
170+
171+
def test_str_with_fixed_landmarks(self):
172+
cam = CentralCamera.Default()
173+
ba = BundleAdjust(cam)
174+
ba.add_view(SE3(), fixed=True)
175+
ba.add_landmark(np.r_[0, 0, 1.0], fixed=True)
176+
self.assertIn("1 locked landmarks", str(ba))
177+
self.assertIn("1 locked views", str(ba))
178+
179+
def test_plot(self):
180+
ba, views, landmarks, P, cam = _problem()
181+
try:
182+
ba.plot(block=False)
183+
finally:
184+
plt.close("all")
185+
186+
# --- SBA file loader ------------------------------------------------------ #
187+
188+
def test_load_sba(self):
189+
"""Round trip: write a synthetic SBA problem to disk and read it back."""
190+
cam = CentralCamera.Default()
191+
P = mkcube(0.2, pose=SE3.Tz(1.0))
192+
poses = [SE3(), SE3.Tx(0.1)]
193+
with tempfile.TemporaryDirectory() as d:
194+
cams, pts, calib = (os.path.join(d, n) for n in ("c.txt", "p.txt", "k.txt"))
195+
np.savetxt(cams, np.array([np.r_[1, 0, 0, 0, p.t] for p in poses]))
196+
np.savetxt(calib, cam.K)
197+
with open(pts, "w") as f:
198+
f.write("# comment line\n")
199+
for k in range(P.shape[1]):
200+
row = [*P[:, k], len(poses)]
201+
for i, pose in enumerate(poses):
202+
uv = cam.project_point(P[:, k], pose=pose).flatten()
203+
row += [i, *uv]
204+
f.write(" ".join(str(x) for x in row) + "\n")
205+
206+
ba = BundleAdjust.load_SBA(cams, pts, calib, imagesize=cam.imagesize)
207+
208+
self.assertEqual(ba.nviews, 2)
209+
self.assertEqual(ba.nlandmarks, 8)
210+
self.assertEqual(ba.g.ne, 16)
211+
self.assertEqual(ba.g.n, 10)
212+
36213
def test_optimize_empty_graph_raises(self):
37214
"""optimize() on a graph with no landmark projections must raise a
38215
clear error rather than silently reporting err=nan (previously

0 commit comments

Comments
 (0)