11#!/usr/bin/env python
22
3+ import os
4+ import tempfile
35import unittest
46
7+ import matplotlib
8+
9+ matplotlib .use ("Agg" )
10+ import matplotlib .pyplot as plt
511import numpy as np
612from spatialmath import SE3
713
814from 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+
1135class 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