machinevision-toolbox-python 2.4.0__py3-none-any.whl → 2.4.1__py3-none-any.whl
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.
- {machinevision_toolbox_python-2.4.0.dist-info → machinevision_toolbox_python-2.4.1.dist-info}/METADATA +6 -4
- {machinevision_toolbox_python-2.4.0.dist-info → machinevision_toolbox_python-2.4.1.dist-info}/RECORD +10 -8
- {machinevision_toolbox_python-2.4.0.dist-info → machinevision_toolbox_python-2.4.1.dist-info}/WHEEL +1 -1
- {machinevision_toolbox_python-2.4.0.dist-info → machinevision_toolbox_python-2.4.1.dist-info}/entry_points.txt +0 -1
- machinevisiontoolbox/BundleAdjust.py +983 -990
- machinevisiontoolbox/ImageFiducials.py +16 -8
- machinevisiontoolbox/Sources.py +14 -4
- machinevisiontoolbox/blocks/Icons/README.md +28 -0
- machinevisiontoolbox/blocks/Icons/make_icons.py +111 -0
- {machinevision_toolbox_python-2.4.0.dist-info → machinevision_toolbox_python-2.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,17 +5,10 @@ Bundle adjustment for multi-view camera pose and 3D point refinement using spars
|
|
|
5
5
|
import time
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
8
9
|
import numpy as np
|
|
10
|
+
import pgraph
|
|
9
11
|
from scipy import sparse
|
|
10
|
-
|
|
11
|
-
try:
|
|
12
|
-
import pgraph
|
|
13
|
-
|
|
14
|
-
pgraph_installed = True
|
|
15
|
-
except ImportError:
|
|
16
|
-
print("pgraph not installed")
|
|
17
|
-
pgraph_installed = False
|
|
18
|
-
import matplotlib.pyplot as plt
|
|
19
12
|
from spatialmath import SE3, SO3, UnitQuaternion, base
|
|
20
13
|
|
|
21
14
|
from machinevisiontoolbox import CentralCamera
|
|
@@ -27,1046 +20,1046 @@ from machinevisiontoolbox.base.imageio import safe_plt_show
|
|
|
27
20
|
# Each node has an index into the state vector given by its index and index2
|
|
28
21
|
# properties. These are initialized by a call to update_index
|
|
29
22
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
:type fixed: bool, optional
|
|
76
|
-
:param color: color with which to draw camera icon, defaults to None
|
|
77
|
-
:type color: str, optional
|
|
78
|
-
|
|
79
|
-
Represent a camera viewpoint in the bundle adjustment problem. If
|
|
80
|
-
the camera is not ``fixed`` it will be adjusted during the
|
|
81
|
-
optimization.
|
|
82
|
-
|
|
83
|
-
:seealso: :class:`PGraph.UVertex`
|
|
84
|
-
"""
|
|
85
|
-
super().__init__()
|
|
86
|
-
|
|
87
|
-
self.coord = x
|
|
88
|
-
self._fixed = fixed
|
|
89
|
-
self._color = color
|
|
90
|
-
|
|
91
|
-
@property
|
|
92
|
-
def pose(self) -> SE3:
|
|
93
|
-
"""
|
|
94
|
-
Get pose of camera
|
|
95
|
-
|
|
96
|
-
:return: pose as an SE(3)
|
|
97
|
-
:rtype: :class:`~spatialmath.pose3d.SE3`
|
|
98
|
-
"""
|
|
99
|
-
t = self.coord[:3]
|
|
100
|
-
qv = self.coord[3:]
|
|
101
|
-
|
|
102
|
-
return SE3(t) * UnitQuaternion.Vec3(qv).SE3()
|
|
103
|
-
|
|
104
|
-
class Landmark(pgraph.UVertex, _Common):
|
|
105
|
-
def __init__(self, P: np.ndarray, fixed: bool = False) -> None:
|
|
106
|
-
"""
|
|
107
|
-
Create new landmark point
|
|
108
|
-
|
|
109
|
-
:param P: landmark coordinate
|
|
110
|
-
:type P: ndarray(3)
|
|
111
|
-
:param fixed: point is fixed, defaults to False
|
|
112
|
-
:type fixed: bool, optional
|
|
113
|
-
|
|
114
|
-
Represent a world point in the bundle adjustment problem. If the
|
|
115
|
-
point is not ``fixed`` it will be adjusted during the optimization.
|
|
116
|
-
|
|
117
|
-
:seealso: :meth:`pgraph.UVertex`
|
|
118
|
-
"""
|
|
119
|
-
super().__init__()
|
|
120
|
-
self._P = P
|
|
121
|
-
self._fixed = fixed
|
|
122
|
-
|
|
123
|
-
@property
|
|
124
|
-
def P(self) -> np.ndarray:
|
|
125
|
-
"""
|
|
126
|
-
Get landmark position
|
|
127
|
-
|
|
128
|
-
:return: landmark position in 3D
|
|
129
|
-
:rtype: ndarray(3)
|
|
130
|
-
"""
|
|
131
|
-
return self._P
|
|
132
|
-
|
|
133
|
-
class Observation(pgraph.Edge):
|
|
134
|
-
def __init__(
|
|
135
|
-
self, camera: ViewPoint, landmark: Landmark, uv: np.ndarray
|
|
136
|
-
) -> None:
|
|
137
|
-
"""
|
|
138
|
-
Create new landmark observation
|
|
139
|
-
|
|
140
|
-
:param camera: the camera that made the observation
|
|
141
|
-
:type camera: :class:`ViewPoint`
|
|
142
|
-
:param landmark: the observed landmark
|
|
143
|
-
:type landmark: :class:`Landmark`
|
|
144
|
-
:param uv: the image plane coordinates of the observed landmark
|
|
145
|
-
:type uv: ndarray(2)
|
|
146
|
-
|
|
147
|
-
Represent the observation of a point by a camera in the bundle
|
|
148
|
-
adjustment problem.
|
|
149
|
-
|
|
150
|
-
:seealso: :meth:`pgraph.Edge`
|
|
151
|
-
"""
|
|
152
|
-
super().__init__(camera, landmark, cost=0)
|
|
153
|
-
self._p = uv
|
|
154
|
-
|
|
155
|
-
@property
|
|
156
|
-
def p(self) -> np.ndarray:
|
|
157
|
-
"""
|
|
158
|
-
Get image plane projection
|
|
159
|
-
|
|
160
|
-
:return: observed projection of landmark on image plane
|
|
161
|
-
:rtype: ndarray(2)
|
|
162
|
-
"""
|
|
163
|
-
|
|
164
|
-
return self._p
|
|
165
|
-
|
|
166
|
-
class BundleAdjust:
|
|
167
|
-
def __init__(self, camera: CentralCamera) -> None:
|
|
168
|
-
r"""
|
|
169
|
-
Create a bundle adjustment problem
|
|
170
|
-
|
|
171
|
-
:param camera: model of the moving camera
|
|
172
|
-
:type camera: CentralCamera instance
|
|
173
|
-
|
|
174
|
-
Implementation of a workable, easy to follow, bundle
|
|
175
|
-
adjustment algorithm suitable for pedagogical purposes and problems of small to medium size.
|
|
176
|
-
|
|
177
|
-
It uses SciPy sparse linear algebra functions to solve the update
|
|
178
|
-
equation. The state vector comprises, in order:
|
|
179
|
-
|
|
180
|
-
- a 6-vector for every view, the camera pose :math:`(t_x, t_y, t_z,
|
|
181
|
-
q_x, q_y, q_z)` as a translation vector and the vector part of the
|
|
182
|
-
unit quaternion
|
|
183
|
-
- a 3-vector for every landmark, (X, Y, Z)
|
|
184
|
-
|
|
185
|
-
Cameras and landmarks can be fixed in which case we have a variable state
|
|
186
|
-
vector, shorter than the state vector, holding only the states corresponding
|
|
187
|
-
to movable cameras and landmarks.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
.. warning:: This class assumes that all camera views have the same camera
|
|
191
|
-
intrinsics.
|
|
192
|
-
|
|
193
|
-
:references:
|
|
194
|
-
- |RVC3|, Section 14.3.2.
|
|
195
|
-
|
|
196
|
-
:seealso: :meth:`optimize` :class:`~machinevisiontoolbox.Camera.CentralCamera` :class:`pgraph.UGraph`
|
|
197
|
-
"""
|
|
198
|
-
self.camera = camera
|
|
199
|
-
|
|
200
|
-
# we use a PGraph object to represent nodes and edges as an undirected graph:
|
|
201
|
-
# - ViewPoint class for vertices representing camera poses as (tx, ty,
|
|
202
|
-
# tz, qx, qy, qz)
|
|
203
|
-
# - Landmark for vertices representing landmark positions as (x,
|
|
204
|
-
# y, z)
|
|
205
|
-
# - Observation for edges representing the observation of a landmark by
|
|
206
|
-
# a view
|
|
207
|
-
#
|
|
208
|
-
self.g = pgraph.UGraph(6) # initialize the graph, nodes have 6D coordinates
|
|
209
|
-
|
|
210
|
-
self._nviews = 0 # number of cameras
|
|
211
|
-
self._nlandmarks = 0 # number of landmark points
|
|
212
|
-
self._nvarstate = 0
|
|
213
|
-
|
|
214
|
-
self.views = [] # list of view nodes
|
|
215
|
-
self.landmarks = [] # list of landmark nodes
|
|
216
|
-
|
|
217
|
-
self.fixedviews = [] # list of view nodes that are fixed
|
|
218
|
-
self.fixedlandmarks = [] # list of landmark nodes that are fixed
|
|
219
|
-
|
|
220
|
-
self.index_valid = False
|
|
221
|
-
|
|
222
|
-
def update_index(self) -> None:
|
|
223
|
-
if self.index_valid:
|
|
224
|
-
return
|
|
225
|
-
|
|
226
|
-
index = 0
|
|
227
|
-
index2 = 0
|
|
228
|
-
for i, view in enumerate(self.views):
|
|
229
|
-
view.id = i
|
|
230
|
-
view._index = index
|
|
231
|
-
view._index2 = index2
|
|
232
|
-
index += 6
|
|
233
|
-
if not view.isfixed:
|
|
234
|
-
index2 += 6
|
|
235
|
-
|
|
236
|
-
for i, landmark in enumerate(self.landmarks):
|
|
237
|
-
landmark.id = i
|
|
238
|
-
landmark._index = index
|
|
239
|
-
landmark._index2 = index2
|
|
240
|
-
index += 3
|
|
241
|
-
if not landmark.isfixed:
|
|
242
|
-
index2 += 3
|
|
243
|
-
|
|
244
|
-
@property
|
|
245
|
-
def nviews(self) -> int:
|
|
246
|
-
"""
|
|
247
|
-
Number of camera views
|
|
248
|
-
|
|
249
|
-
:return: Number of camera views
|
|
250
|
-
:rtype: int
|
|
251
|
-
|
|
252
|
-
:seealso: :meth:`add_view`
|
|
253
|
-
"""
|
|
254
|
-
return self._nviews
|
|
255
|
-
|
|
256
|
-
@property
|
|
257
|
-
def nlandmarks(self) -> int:
|
|
258
|
-
"""
|
|
259
|
-
Number of landmarks
|
|
260
|
-
|
|
261
|
-
:return: Number of landmarks
|
|
262
|
-
:rtype: int
|
|
263
|
-
|
|
264
|
-
:seealso: :meth:`add_landmark`
|
|
265
|
-
"""
|
|
266
|
-
return self._nlandmarks
|
|
267
|
-
|
|
268
|
-
@property
|
|
269
|
-
def nstates(self) -> int:
|
|
270
|
-
"""
|
|
271
|
-
Length of state vector
|
|
272
|
-
|
|
273
|
-
:return: Length of the state vector
|
|
274
|
-
:rtype: int
|
|
275
|
-
|
|
276
|
-
This includes fixed views and landmarks whose state will not be
|
|
277
|
-
updated in the optimization.
|
|
278
|
-
|
|
279
|
-
:seealso: :meth:`nvarstate`
|
|
280
|
-
"""
|
|
281
|
-
return 6 * self.nviews + 3 * self.nlandmarks
|
|
282
|
-
|
|
283
|
-
@property
|
|
284
|
-
def nvarstates(self) -> int:
|
|
285
|
-
"""
|
|
286
|
-
Length of variable state vector
|
|
287
|
-
|
|
288
|
-
:return: Length of the variable state vector
|
|
289
|
-
:rtype: int
|
|
290
|
-
|
|
291
|
-
This is the length of the subset of the state vector that excludes
|
|
292
|
-
fixed views and landmarks. It only includes cameras and landmarks
|
|
293
|
-
whose state will be updated in the optimization.
|
|
294
|
-
|
|
295
|
-
:seealso: :meth:`nstates`
|
|
296
|
-
"""
|
|
297
|
-
return 6 * (self.nviews - len(self.fixedviews)) + 3 * (
|
|
298
|
-
self.nlandmarks - len(self.fixedlandmarks)
|
|
299
|
-
)
|
|
23
|
+
# pgraph 1.0 has a single Vertex class and deprecates UVertex; 0.6.x has only
|
|
24
|
+
# UVertex. A vertex has no direction of its own in 1.0, so they are equivalent.
|
|
25
|
+
_Vertex = getattr(pgraph, "Vertex", None) or pgraph.UVertex
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Common:
|
|
29
|
+
@property
|
|
30
|
+
def index(self) -> int:
|
|
31
|
+
"""
|
|
32
|
+
Index into the state vector (base method)
|
|
33
|
+
|
|
34
|
+
:return: the index of the start of this object's state in the state vector
|
|
35
|
+
:rtype: int
|
|
36
|
+
"""
|
|
37
|
+
return self._index
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def index2(self) -> int:
|
|
41
|
+
"""
|
|
42
|
+
Index into the variable state vector (base method)
|
|
43
|
+
|
|
44
|
+
:return: the index of the start of this object's state in the variable state vector
|
|
45
|
+
:rtype: int
|
|
46
|
+
"""
|
|
47
|
+
return self._index2
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def isfixed(self) -> bool:
|
|
51
|
+
"""
|
|
52
|
+
Value is fixed (base method)
|
|
53
|
+
|
|
54
|
+
:return: Quantity is fixed
|
|
55
|
+
:rtype: bool
|
|
56
|
+
|
|
57
|
+
This viewpoint or landmark will not be adjusted during optimization.
|
|
58
|
+
"""
|
|
59
|
+
return self._fixed
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ViewPoint(_Vertex, _Common):
|
|
63
|
+
def __init__(
|
|
64
|
+
self, x: np.ndarray, fixed: bool = False, color: str | None = None
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Create new camera viewpoint
|
|
300
68
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
69
|
+
:param x: viewpoint pose as translation + vector part of unit quaternion
|
|
70
|
+
:type x: array_like(6)
|
|
71
|
+
:param fixed: camera is fixed, defaults to False
|
|
72
|
+
:type fixed: bool, optional
|
|
73
|
+
:param color: color with which to draw camera icon, defaults to None
|
|
74
|
+
:type color: str, optional
|
|
306
75
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
:type fixed: bool, optional
|
|
311
|
-
:return: new camera viewpoint
|
|
312
|
-
:rtype: :class:`ViewPoint`
|
|
76
|
+
Represent a camera viewpoint in the bundle adjustment problem. If
|
|
77
|
+
the camera is not ``fixed`` it will be adjusted during the
|
|
78
|
+
optimization.
|
|
313
79
|
|
|
314
|
-
|
|
80
|
+
:seealso: :class:`pgraph.Vertex`
|
|
81
|
+
"""
|
|
82
|
+
super().__init__()
|
|
315
83
|
|
|
316
|
-
|
|
317
|
-
|
|
84
|
+
self.coord = x
|
|
85
|
+
self._fixed = fixed
|
|
86
|
+
self._color = color
|
|
318
87
|
|
|
319
|
-
|
|
320
|
-
|
|
88
|
+
@property
|
|
89
|
+
def pose(self) -> SE3:
|
|
90
|
+
"""
|
|
91
|
+
Get pose of camera
|
|
321
92
|
|
|
322
|
-
|
|
323
|
-
|
|
93
|
+
:return: pose as an SE(3)
|
|
94
|
+
:rtype: :class:`~spatialmath.pose3d.SE3`
|
|
95
|
+
"""
|
|
96
|
+
t = self.coord[:3]
|
|
97
|
+
qv = self.coord[3:]
|
|
324
98
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
99
|
+
return SE3(t) * UnitQuaternion.Vec3(qv).SE3()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Landmark(_Vertex, _Common):
|
|
103
|
+
def __init__(self, P: np.ndarray, fixed: bool = False) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Create new landmark point
|
|
106
|
+
|
|
107
|
+
:param P: landmark coordinate
|
|
108
|
+
:type P: ndarray(3)
|
|
109
|
+
:param fixed: point is fixed, defaults to False
|
|
110
|
+
:type fixed: bool, optional
|
|
111
|
+
|
|
112
|
+
Represent a world point in the bundle adjustment problem. If the
|
|
113
|
+
point is not ``fixed`` it will be adjusted during the optimization.
|
|
114
|
+
|
|
115
|
+
:seealso: :class:`pgraph.Vertex`
|
|
116
|
+
"""
|
|
117
|
+
super().__init__()
|
|
118
|
+
self._P = P
|
|
119
|
+
self._fixed = fixed
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def P(self) -> np.ndarray:
|
|
123
|
+
"""
|
|
124
|
+
Get landmark position
|
|
125
|
+
|
|
126
|
+
:return: landmark position in 3D
|
|
127
|
+
:rtype: ndarray(3)
|
|
128
|
+
"""
|
|
129
|
+
return self._P
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Observation(pgraph.Edge):
|
|
133
|
+
def __init__(self, camera: ViewPoint, landmark: Landmark, uv: np.ndarray) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Create new landmark observation
|
|
136
|
+
|
|
137
|
+
:param camera: the camera that made the observation
|
|
138
|
+
:type camera: :class:`ViewPoint`
|
|
139
|
+
:param landmark: the observed landmark
|
|
140
|
+
:type landmark: :class:`Landmark`
|
|
141
|
+
:param uv: the image plane coordinates of the observed landmark
|
|
142
|
+
:type uv: ndarray(2)
|
|
143
|
+
|
|
144
|
+
Represent the observation of a point by a camera in the bundle
|
|
145
|
+
adjustment problem.
|
|
146
|
+
|
|
147
|
+
:seealso: :meth:`pgraph.Edge`
|
|
148
|
+
"""
|
|
149
|
+
super().__init__(camera, landmark, cost=0)
|
|
150
|
+
self._p = uv
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def p(self) -> np.ndarray:
|
|
154
|
+
"""
|
|
155
|
+
Get image plane projection
|
|
156
|
+
|
|
157
|
+
:return: observed projection of landmark on image plane
|
|
158
|
+
:rtype: ndarray(2)
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
return self._p
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class BundleAdjust:
|
|
165
|
+
def __init__(self, camera: CentralCamera) -> None:
|
|
166
|
+
r"""
|
|
167
|
+
Create a bundle adjustment problem
|
|
168
|
+
|
|
169
|
+
:param camera: model of the moving camera
|
|
170
|
+
:type camera: CentralCamera instance
|
|
171
|
+
|
|
172
|
+
Implementation of a workable, easy to follow, bundle
|
|
173
|
+
adjustment algorithm suitable for pedagogical purposes and problems of small to medium size.
|
|
174
|
+
|
|
175
|
+
It uses SciPy sparse linear algebra functions to solve the update
|
|
176
|
+
equation. The state vector comprises, in order:
|
|
177
|
+
|
|
178
|
+
- a 6-vector for every view, the camera pose :math:`(t_x, t_y, t_z,
|
|
179
|
+
q_x, q_y, q_z)` as a translation vector and the vector part of the
|
|
180
|
+
unit quaternion
|
|
181
|
+
- a 3-vector for every landmark, (X, Y, Z)
|
|
182
|
+
|
|
183
|
+
Cameras and landmarks can be fixed in which case we have a variable state
|
|
184
|
+
vector, shorter than the state vector, holding only the states corresponding
|
|
185
|
+
to movable cameras and landmarks.
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
.. warning:: This class assumes that all camera views have the same camera
|
|
189
|
+
intrinsics.
|
|
190
|
+
|
|
191
|
+
:references:
|
|
192
|
+
- |RVC3|, Section 14.3.2.
|
|
193
|
+
|
|
194
|
+
:seealso: :meth:`optimize` :class:`~machinevisiontoolbox.Camera.CentralCamera` :class:`pgraph.UGraph`
|
|
195
|
+
"""
|
|
196
|
+
self.camera = camera
|
|
197
|
+
|
|
198
|
+
# we use a PGraph object to represent nodes and edges as an undirected graph:
|
|
199
|
+
# - ViewPoint class for vertices representing camera poses as (tx, ty,
|
|
200
|
+
# tz, qx, qy, qz)
|
|
201
|
+
# - Landmark for vertices representing landmark positions as (x,
|
|
202
|
+
# y, z)
|
|
203
|
+
# - Observation for edges representing the observation of a landmark by
|
|
204
|
+
# a view
|
|
205
|
+
#
|
|
206
|
+
self.g = pgraph.UGraph() # views have 6D coordinates, landmarks 3D
|
|
207
|
+
|
|
208
|
+
self._nviews = 0 # number of cameras
|
|
209
|
+
self._nlandmarks = 0 # number of landmark points
|
|
210
|
+
self._nvarstate = 0
|
|
211
|
+
|
|
212
|
+
self.views = [] # list of view nodes
|
|
213
|
+
self.landmarks = [] # list of landmark nodes
|
|
214
|
+
|
|
215
|
+
self.fixedviews = [] # list of view nodes that are fixed
|
|
216
|
+
self.fixedlandmarks = [] # list of landmark nodes that are fixed
|
|
217
|
+
|
|
218
|
+
self.index_valid = False
|
|
219
|
+
|
|
220
|
+
def update_index(self) -> None:
|
|
221
|
+
if self.index_valid:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
index = 0
|
|
225
|
+
index2 = 0
|
|
226
|
+
for i, view in enumerate(self.views):
|
|
227
|
+
view.id = i
|
|
228
|
+
view._index = index
|
|
229
|
+
view._index2 = index2
|
|
230
|
+
index += 6
|
|
231
|
+
if not view.isfixed:
|
|
232
|
+
index2 += 6
|
|
233
|
+
|
|
234
|
+
for i, landmark in enumerate(self.landmarks):
|
|
235
|
+
landmark.id = i
|
|
236
|
+
landmark._index = index
|
|
237
|
+
landmark._index2 = index2
|
|
238
|
+
index += 3
|
|
239
|
+
if not landmark.isfixed:
|
|
240
|
+
index2 += 3
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def nviews(self) -> int:
|
|
244
|
+
"""
|
|
245
|
+
Number of camera views
|
|
246
|
+
|
|
247
|
+
:return: Number of camera views
|
|
248
|
+
:rtype: int
|
|
249
|
+
|
|
250
|
+
:seealso: :meth:`add_view`
|
|
251
|
+
"""
|
|
252
|
+
return self._nviews
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def nlandmarks(self) -> int:
|
|
256
|
+
"""
|
|
257
|
+
Number of landmarks
|
|
258
|
+
|
|
259
|
+
:return: Number of landmarks
|
|
260
|
+
:rtype: int
|
|
261
|
+
|
|
262
|
+
:seealso: :meth:`add_landmark`
|
|
263
|
+
"""
|
|
264
|
+
return self._nlandmarks
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def nstates(self) -> int:
|
|
268
|
+
"""
|
|
269
|
+
Length of state vector
|
|
270
|
+
|
|
271
|
+
:return: Length of the state vector
|
|
272
|
+
:rtype: int
|
|
273
|
+
|
|
274
|
+
This includes fixed views and landmarks whose state will not be
|
|
275
|
+
updated in the optimization.
|
|
276
|
+
|
|
277
|
+
:seealso: :meth:`nvarstate`
|
|
278
|
+
"""
|
|
279
|
+
return 6 * self.nviews + 3 * self.nlandmarks
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def nvarstates(self) -> int:
|
|
283
|
+
"""
|
|
284
|
+
Length of variable state vector
|
|
285
|
+
|
|
286
|
+
:return: Length of the variable state vector
|
|
287
|
+
:rtype: int
|
|
288
|
+
|
|
289
|
+
This is the length of the subset of the state vector that excludes
|
|
290
|
+
fixed views and landmarks. It only includes cameras and landmarks
|
|
291
|
+
whose state will be updated in the optimization.
|
|
292
|
+
|
|
293
|
+
:seealso: :meth:`nstates`
|
|
294
|
+
"""
|
|
295
|
+
return 6 * (self.nviews - len(self.fixedviews)) + 3 * (
|
|
296
|
+
self.nlandmarks - len(self.fixedlandmarks)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def add_view(
|
|
300
|
+
self, pose: SE3 | np.ndarray, fixed: bool = False, color: str = "black"
|
|
301
|
+
) -> ViewPoint:
|
|
302
|
+
"""
|
|
303
|
+
Add camera view to bundle adjustment problem
|
|
304
|
+
|
|
305
|
+
:param pose: camera pose
|
|
306
|
+
:type pose: :class:`~spatialmath.pose3d.SE3`, array_like(7)
|
|
307
|
+
:param fixed: the camera is fixed, defaults to False
|
|
308
|
+
:type fixed: bool, optional
|
|
309
|
+
:return: new camera viewpoint
|
|
310
|
+
:rtype: :class:`ViewPoint`
|
|
311
|
+
|
|
312
|
+
Creates a camera node and adds it to the bundle adjustment problem.
|
|
313
|
+
|
|
314
|
+
The camera ``pose`` can be :class:`~spatialmath.pose3d.SE3` or a vector (1x7)
|
|
315
|
+
comprising translation and unit quaternion in vector form.
|
|
316
|
+
|
|
317
|
+
If the camera is fixed (anchored) it will not be adjusted in the
|
|
318
|
+
optimization process.
|
|
319
|
+
|
|
320
|
+
.. note:: Adds a :class:`ViewPoint` object as a node in the
|
|
321
|
+
underlying scene graph.
|
|
322
|
+
|
|
323
|
+
:seealso: :meth:`add_landmark` :meth:`add_projection`
|
|
324
|
+
"""
|
|
325
|
+
if isinstance(pose, SE3):
|
|
326
|
+
t = pose.t
|
|
327
|
+
q = base.r2q(pose.R)
|
|
328
|
+
else:
|
|
329
|
+
base.assertvector(pose, 7)
|
|
330
|
+
q = pose[:4]
|
|
331
|
+
t = pose[4:]
|
|
332
|
+
if q[0] < 0:
|
|
333
|
+
q = -q
|
|
334
|
+
x = np.r_[t, q[1:]]
|
|
335
|
+
|
|
336
|
+
v = ViewPoint(x, fixed=fixed, color=color)
|
|
337
|
+
v.name = f"view#{self._nviews}"
|
|
338
|
+
self._nviews += 1
|
|
339
|
+
|
|
340
|
+
self.g.add_vertex(v)
|
|
341
|
+
self.views.append(v)
|
|
342
|
+
if fixed:
|
|
343
|
+
self.fixedviews.append(v)
|
|
344
|
+
v.ba = self # back reference to the BA problem
|
|
345
|
+
self.index_valid = False
|
|
346
|
+
return v
|
|
347
|
+
|
|
348
|
+
def add_landmark(self, P: np.ndarray, fixed: bool = False) -> Landmark:
|
|
349
|
+
"""
|
|
350
|
+
Add 3D landmark point to bundle adjustment problem
|
|
351
|
+
|
|
352
|
+
:param P: 3D world point, aka landmark
|
|
353
|
+
:type P: array_like(3)
|
|
354
|
+
:param fixed: the landmark is fixed, defaults to False
|
|
355
|
+
:type fixed: bool, optional
|
|
356
|
+
:return: new landmark
|
|
357
|
+
:rtype: :class:`Landmark` instance
|
|
358
|
+
|
|
359
|
+
Create a landmark node and add it to the bundle adjustment problem.
|
|
360
|
+
|
|
361
|
+
If the landmark is fixed (anchored) it will not be adjusted in the
|
|
362
|
+
optimization process.
|
|
363
|
+
|
|
364
|
+
.. note:: Adds a :class:`Landmark` object as a node in the
|
|
365
|
+
underlying scene graph.
|
|
366
|
+
|
|
367
|
+
:seealso: :meth:`add_view` :meth:`add_projection`
|
|
368
|
+
"""
|
|
369
|
+
base.assertvector(P, 3)
|
|
370
|
+
|
|
371
|
+
# P = np.r_[P, 0, 0, 0]
|
|
372
|
+
|
|
373
|
+
l = Landmark(P, fixed=fixed)
|
|
374
|
+
l.name = f"landmark#{self._nlandmarks}"
|
|
375
|
+
l.coord = P
|
|
376
|
+
self._nlandmarks += 1
|
|
377
|
+
self.g.add_vertex(l)
|
|
378
|
+
self.landmarks.append(l)
|
|
379
|
+
if fixed:
|
|
380
|
+
self.fixedlandmarks.append(l)
|
|
381
|
+
l.ba = self # back reference to the BA problem
|
|
382
|
+
self.index_valid = False
|
|
383
|
+
return l
|
|
384
|
+
|
|
385
|
+
def add_projection(
|
|
386
|
+
self, viewpoint: ViewPoint, landmark: Landmark, uv: np.ndarray
|
|
387
|
+
) -> None:
|
|
388
|
+
"""
|
|
389
|
+
Add camera observation to bundle adjustment problem
|
|
390
|
+
|
|
391
|
+
:param view: camera viewpoint
|
|
392
|
+
:type view: :class:`ViewPoint`
|
|
393
|
+
:param landmark: landmark point
|
|
394
|
+
:type landmark: :class:`Landmark`
|
|
395
|
+
:param uv: image plane coordinate
|
|
396
|
+
:type uv: array_like(2)
|
|
397
|
+
|
|
398
|
+
Add an observation by ``viewpoint`` of a ``landmark`` to the bundle
|
|
399
|
+
adjustment problem.
|
|
400
|
+
|
|
401
|
+
.. note:: Adds a :class:`Observation` object as an edge in the
|
|
402
|
+
underlying scene graph.
|
|
403
|
+
|
|
404
|
+
:seealso: :meth:`add_view` :meth:`add_landmark`
|
|
405
|
+
"""
|
|
406
|
+
if len(uv) != 2:
|
|
407
|
+
raise ValueError("uv must be a 2-vector")
|
|
408
|
+
|
|
409
|
+
edge = Observation(viewpoint, landmark, uv.flatten()) # create edge object
|
|
410
|
+
e = viewpoint.connect(landmark, edge=edge) # connect nodes with it
|
|
411
|
+
e.name = viewpoint.name + "--" + landmark.name
|
|
412
|
+
|
|
413
|
+
@classmethod
|
|
414
|
+
def load_SBA(
|
|
415
|
+
cls,
|
|
416
|
+
cameraFile: str,
|
|
417
|
+
pointFile: str,
|
|
418
|
+
calibFile: str,
|
|
419
|
+
imagesize: np.ndarray | None = None,
|
|
420
|
+
) -> "BundleAdjust":
|
|
421
|
+
"""
|
|
422
|
+
Load bundle adjustment data files
|
|
423
|
+
|
|
424
|
+
:param cameraFile: name of file with camera view data
|
|
425
|
+
:type cameraFile: str
|
|
426
|
+
:param pointFile: name of file with landmark data
|
|
427
|
+
:type pointFile: str
|
|
428
|
+
:param calibFile: name of file with camera intrinsic data
|
|
429
|
+
:type calibFile: str
|
|
430
|
+
:param imagesize: image plane dimensions in pixels, if not given infer
|
|
431
|
+
it from principal point data in ``calibFile``
|
|
432
|
+
:type imagesize: array_like(2)
|
|
433
|
+
|
|
434
|
+
Provides access to bundle adjustment problems from data files as distributed with the SBA package.
|
|
435
|
+
Details of the file format are given in the source code comments.
|
|
436
|
+
|
|
437
|
+
Example:
|
|
438
|
+
|
|
439
|
+
To solve the 7-point bundle adjustment problem distributed with
|
|
440
|
+
SBA 1.6:
|
|
441
|
+
|
|
442
|
+
.. code-block:: python
|
|
443
|
+
|
|
444
|
+
ba = Bundle.load_SBA('7cams.txt', '7pts.txt', 'calib.txt')
|
|
445
|
+
X = ba.optimize()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
:reference:
|
|
449
|
+
- Sparse Bundle Adjustment package by Manolis Lourakis,
|
|
450
|
+
http://users.ics.forth.gr/~lourakis/sba
|
|
451
|
+
|
|
452
|
+
:seealso: :meth:`add_view` :meth:`add_landmark` :meth:`add_projection`
|
|
453
|
+
"""
|
|
454
|
+
# Adopted from sba-1.6/matlab/eucsbademo.m
|
|
455
|
+
|
|
456
|
+
# read calibration parameters
|
|
457
|
+
#
|
|
458
|
+
# f/rho_u skew u0
|
|
459
|
+
# 0 f/rho_v v0
|
|
460
|
+
# 0 0 1
|
|
461
|
+
K = np.loadtxt(calibFile)
|
|
462
|
+
|
|
463
|
+
# create the camera object
|
|
464
|
+
if imagesize is None:
|
|
465
|
+
# no image plane size given
|
|
466
|
+
# infer it from the principal point
|
|
467
|
+
imagesize = 2 * K[:2, 2]
|
|
468
|
+
|
|
469
|
+
camera = CentralCamera(
|
|
470
|
+
f=K[0, 0], rho=[1, K[0, 0] / K[1, 1]], pp=K[:2, 2], imagesize=imagesize
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# create a bundle adjustment instance
|
|
474
|
+
ba = cls(camera)
|
|
475
|
+
|
|
476
|
+
# read camera views
|
|
477
|
+
#
|
|
478
|
+
# each line is: qs qx qy qz tx ty tz
|
|
479
|
+
for pose in np.loadtxt(cameraFile):
|
|
480
|
+
ba.add_view(pose)
|
|
481
|
+
|
|
482
|
+
# read points and projections
|
|
483
|
+
#
|
|
484
|
+
# The lines are of the form:
|
|
485
|
+
#
|
|
486
|
+
# X Y Z NFRAMES FRAME0 x0 y0 FRAME1 x1 y1 ...
|
|
487
|
+
#
|
|
488
|
+
# corresponding to a single 3D point and multiple projections:
|
|
489
|
+
#
|
|
490
|
+
# - X, Y, Z is the points' Euclidean 3D coordinates,
|
|
491
|
+
# - NFRAMES the total number of camera views in which the point is
|
|
492
|
+
# visible and there will follow NFRAMES subsequent triplets
|
|
493
|
+
# - FRAME x y specifies that the 3D point in question projects to pixel
|
|
494
|
+
# (x, y) in view number FRAME.
|
|
495
|
+
#
|
|
496
|
+
# For example, the line:
|
|
497
|
+
#
|
|
498
|
+
# 100.0 200.0 300.0 3 2 270.0 114.1 4 234.2 321.7 5 173.6 425.8
|
|
499
|
+
#
|
|
500
|
+
# describes a world point (100.0, 200.0, 300.0) that is visible in
|
|
501
|
+
# three views: view 2 at (270.0, 114.1), view 4 at (234.2, 321.7) and
|
|
502
|
+
# view 5 at (173.6, 425.8)
|
|
503
|
+
with open(pointFile, "r") as file:
|
|
504
|
+
npts = 0
|
|
505
|
+
for line in file:
|
|
506
|
+
if len(line) == 0 or line[0] == "#":
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
data = line.split()
|
|
510
|
+
|
|
511
|
+
# read X, Y, Z, nframes
|
|
512
|
+
P = np.array([float(x) for x in data[:3]])
|
|
513
|
+
data = data[3:]
|
|
514
|
+
npts += 1
|
|
515
|
+
|
|
516
|
+
# create a node for this point
|
|
517
|
+
landmark = ba.add_landmark(P)
|
|
518
|
+
|
|
519
|
+
# now find which cameras it was seen by
|
|
520
|
+
nframes = int(data.pop(0))
|
|
521
|
+
for i in range(nframes): # read "nframes" id, x, y triplets
|
|
522
|
+
id = int(data.pop(0))
|
|
523
|
+
u = float(data.pop(0))
|
|
524
|
+
v = float(data.pop(0))
|
|
525
|
+
|
|
526
|
+
# add a landmark projection
|
|
527
|
+
ba.add_projection(ba.views[id], landmark, np.r_[u, v])
|
|
528
|
+
return ba
|
|
529
|
+
|
|
530
|
+
# =============== METHODS TO SOLVE PROBLEMS ==================== #
|
|
531
|
+
|
|
532
|
+
def optimize(
|
|
533
|
+
self,
|
|
534
|
+
x: np.ndarray | None = None,
|
|
535
|
+
animate: bool = False,
|
|
536
|
+
lmbda: float = 0.1,
|
|
537
|
+
lmbdamin: float = 1e-8,
|
|
538
|
+
dxmin: float = 1e-4,
|
|
539
|
+
tol: float = 0.5,
|
|
540
|
+
iterations: int = 1000,
|
|
541
|
+
verbose: bool = False,
|
|
542
|
+
) -> tuple[np.ndarray, float]:
|
|
543
|
+
"""
|
|
544
|
+
Perform the bundle adjustment
|
|
545
|
+
|
|
546
|
+
:param x: state vector, defaults to the state vector in the instance
|
|
547
|
+
:type Xx: ndarray(N), optional
|
|
548
|
+
:param animate: graphically animate the updates, defaults to False
|
|
549
|
+
:type animate: bool, optional
|
|
550
|
+
:param lmbda: initial damping term, defaults to 0.1
|
|
551
|
+
:type lmbda: float, optional
|
|
552
|
+
:param lmbdamin: minimum value of ``lmbda``, defaults to 1e-8
|
|
553
|
+
:type lmbdamin: float, optional
|
|
554
|
+
:param dxmin: terminate optimization if state update norm falls below this
|
|
555
|
+
threshold, defaults to 1e-4
|
|
556
|
+
:type dxmin: float, optional
|
|
557
|
+
:param tol: terminate optimization if error total reprojection error
|
|
558
|
+
falls below this threshold, defaults to 0.5 pixels
|
|
559
|
+
:type tol: float, optional
|
|
560
|
+
:param iterations: maximum number of iterations, defaults to 1000
|
|
561
|
+
:type iterations: int, optional
|
|
562
|
+
:param verbose: show Levenberg-Marquadt status, defaults to False
|
|
563
|
+
:type verbose: bool, optional
|
|
564
|
+
:raises ValueError: no landmark projections have been added to
|
|
565
|
+
the graph
|
|
566
|
+
:return: optimized state vector
|
|
567
|
+
:rtype: ndarray(N)
|
|
568
|
+
|
|
569
|
+
Performs a Levenberg-Marquadt style optimization of the bundle
|
|
570
|
+
adjustment problem which repeatedly calls :meth:`solve`. Adjusts
|
|
571
|
+
camera poses and landmark positions in order to minimize the total
|
|
572
|
+
reprojection error.
|
|
573
|
+
|
|
574
|
+
:references:
|
|
575
|
+
- |RVC3|, Section 14.3.2.
|
|
576
|
+
|
|
577
|
+
:seealso: :meth:`nstates` :meth:`solve` :meth:`build_linear_system`
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
self.update_index()
|
|
581
|
+
|
|
582
|
+
if self.g.ne == 0:
|
|
583
|
+
raise ValueError(
|
|
584
|
+
"no landmark projections in the graph -- nothing to "
|
|
585
|
+
"optimize (add_projection() was never called, or every "
|
|
586
|
+
"candidate landmark was discarded)"
|
|
473
587
|
)
|
|
474
588
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
#
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
#
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
#
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
#
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
# add a landmark projection
|
|
529
|
-
ba.add_projection(ba.views[id], landmark, np.r_[u, v])
|
|
530
|
-
return ba
|
|
531
|
-
|
|
532
|
-
# =============== METHODS TO SOLVE PROBLEMS ==================== #
|
|
533
|
-
|
|
534
|
-
def optimize(
|
|
535
|
-
self,
|
|
536
|
-
x: np.ndarray | None = None,
|
|
537
|
-
animate: bool = False,
|
|
538
|
-
lmbda: float = 0.1,
|
|
539
|
-
lmbdamin: float = 1e-8,
|
|
540
|
-
dxmin: float = 1e-4,
|
|
541
|
-
tol: float = 0.5,
|
|
542
|
-
iterations: int = 1000,
|
|
543
|
-
verbose: bool = False,
|
|
544
|
-
) -> tuple[np.ndarray, float]:
|
|
545
|
-
"""
|
|
546
|
-
Perform the bundle adjustment
|
|
547
|
-
|
|
548
|
-
:param x: state vector, defaults to the state vector in the instance
|
|
549
|
-
:type Xx: ndarray(N), optional
|
|
550
|
-
:param animate: graphically animate the updates, defaults to False
|
|
551
|
-
:type animate: bool, optional
|
|
552
|
-
:param lmbda: initial damping term, defaults to 0.1
|
|
553
|
-
:type lmbda: float, optional
|
|
554
|
-
:param lmbdamin: minimum value of ``lmbda``, defaults to 1e-8
|
|
555
|
-
:type lmbdamin: float, optional
|
|
556
|
-
:param dxmin: terminate optimization if state update norm falls below this
|
|
557
|
-
threshold, defaults to 1e-4
|
|
558
|
-
:type dxmin: float, optional
|
|
559
|
-
:param tol: terminate optimization if error total reprojection error
|
|
560
|
-
falls below this threshold, defaults to 0.5 pixels
|
|
561
|
-
:type tol: float, optional
|
|
562
|
-
:param iterations: maximum number of iterations, defaults to 1000
|
|
563
|
-
:type iterations: int, optional
|
|
564
|
-
:param verbose: show Levenberg-Marquadt status, defaults to False
|
|
565
|
-
:type verbose: bool, optional
|
|
566
|
-
:raises ValueError: no landmark projections have been added to
|
|
567
|
-
the graph
|
|
568
|
-
:return: optimized state vector
|
|
569
|
-
:rtype: ndarray(N)
|
|
570
|
-
|
|
571
|
-
Performs a Levenberg-Marquadt style optimization of the bundle
|
|
572
|
-
adjustment problem which repeatedly calls :meth:`solve`. Adjusts
|
|
573
|
-
camera poses and landmark positions in order to minimize the total
|
|
574
|
-
reprojection error.
|
|
575
|
-
|
|
576
|
-
:references:
|
|
577
|
-
- |RVC3|, Section 14.3.2.
|
|
578
|
-
|
|
579
|
-
:seealso: :meth:`nstates` :meth:`solve` :meth:`build_linear_system`
|
|
580
|
-
"""
|
|
581
|
-
|
|
582
|
-
self.update_index()
|
|
583
|
-
|
|
584
|
-
if self.g.ne == 0:
|
|
585
|
-
raise ValueError(
|
|
586
|
-
"no landmark projections in the graph -- nothing to "
|
|
587
|
-
"optimize (add_projection() was never called, or every "
|
|
588
|
-
"candidate landmark was discarded)"
|
|
589
|
-
)
|
|
590
|
-
|
|
591
|
-
if x is None:
|
|
592
|
-
x = self.getstate()
|
|
593
|
-
x0 = x
|
|
594
|
-
|
|
595
|
-
t0 = time.perf_counter()
|
|
596
|
-
|
|
597
|
-
print(f"Bundle adjustment cost {self.errors(x0):.3g} -- initial")
|
|
598
|
-
for i in range(iterations):
|
|
599
|
-
if animate:
|
|
600
|
-
if not retain:
|
|
601
|
-
plt.clf()
|
|
602
|
-
g2.plot()
|
|
603
|
-
plt.pause(0.5)
|
|
604
|
-
|
|
605
|
-
ta = time.perf_counter()
|
|
606
|
-
# solve for the step
|
|
607
|
-
dx, energy = self.solve(x, lmbda)
|
|
608
|
-
|
|
609
|
-
# update the state
|
|
610
|
-
x_new = self.updatestate(x, dx)
|
|
611
|
-
|
|
612
|
-
# compute new value of cost
|
|
613
|
-
enew = self.errors(x_new)
|
|
614
|
-
|
|
615
|
-
dt = time.perf_counter() - ta
|
|
616
|
-
print(f"Bundle adjustment cost {enew:.3g} (solved in {dt:.2f} sec)")
|
|
617
|
-
# are we there yet?
|
|
618
|
-
if enew < tol:
|
|
619
|
-
break
|
|
620
|
-
|
|
621
|
-
# have we stopped moving
|
|
622
|
-
if base.norm(dx) < dxmin:
|
|
623
|
-
break
|
|
624
|
-
|
|
625
|
-
# do the Levenberg-Marquadt thing, was it a good update?
|
|
626
|
-
if enew < energy:
|
|
627
|
-
# step is accepted
|
|
628
|
-
x = x_new
|
|
629
|
-
if lmbda > lmbdamin:
|
|
630
|
-
lmbda /= np.sqrt(2)
|
|
631
|
-
if verbose:
|
|
632
|
-
print(f" -- step accepted: lambda = {lmbda:g}")
|
|
633
|
-
else:
|
|
634
|
-
# step is rejected
|
|
635
|
-
lmbda *= 4
|
|
636
|
-
if verbose:
|
|
637
|
-
print(f" -- step rejected: lambda ={lmbda:g}")
|
|
638
|
-
|
|
639
|
-
tf = time.perf_counter()
|
|
640
|
-
err = np.sqrt(enew / self.g.ne)
|
|
641
|
-
print(f"\n * {i + 1} iterations in {tf - t0:.1f} seconds")
|
|
642
|
-
print(f" * Final RMS error is {err:.2f} pixels")
|
|
643
|
-
|
|
644
|
-
return x_new, err
|
|
645
|
-
|
|
646
|
-
def solve(self, x: np.ndarray, lmbda: float = 0.0) -> tuple[np.ndarray, Any]:
|
|
647
|
-
r"""
|
|
648
|
-
Solve for state update
|
|
649
|
-
|
|
650
|
-
:param x: state vector
|
|
651
|
-
:type x: ndarray(N)
|
|
652
|
-
:param lmbda: damping term, defaults to 0.0
|
|
653
|
-
:type lmbda: float, optional
|
|
654
|
-
:return: :math:`\delta \vec{X}`, update to the variable state vector
|
|
655
|
-
:rtype: ndarray(M)
|
|
656
|
-
|
|
657
|
-
Determines the state update :math:`\delta \vec{x}` by creating and
|
|
658
|
-
solving the linear equation
|
|
659
|
-
|
|
660
|
-
.. math:: \mat{H} \delta \vec{x} = \vec{b}
|
|
661
|
-
|
|
662
|
-
where :math:`\mat{H}` is the Hessian and :math:`\mat{b}` is the the
|
|
663
|
-
projection error.
|
|
664
|
-
|
|
665
|
-
.. note::
|
|
666
|
-
- The damping term ``lmbda`` is added to the diagonal of the
|
|
667
|
-
Hessian to prevent problems when the Hessian is nearly
|
|
668
|
-
singular.
|
|
669
|
-
- If the problem includes fixed cameras or landmarks then
|
|
670
|
-
:math:`\text{len}(\delta \vec{x}) < \text{len}(\vec{x})`
|
|
671
|
-
since fixed elements are omitted from the variable state
|
|
672
|
-
vector used for the optimization.
|
|
673
|
-
|
|
674
|
-
:references:
|
|
675
|
-
- |RVC3|, Section 14.3.2, F.2.4.
|
|
676
|
-
|
|
677
|
-
:seealso: :meth:`build_linear_system`
|
|
678
|
-
"""
|
|
679
|
-
# create the Hessian and error vector
|
|
680
|
-
H, b, e = self.build_linear_system(x)
|
|
681
|
-
|
|
682
|
-
# add damping term to the diagonal
|
|
683
|
-
for i in range(self.nvarstates):
|
|
684
|
-
H[i, i] += lmbda
|
|
685
|
-
|
|
686
|
-
# solve for the state update
|
|
687
|
-
# - could replace this with the Schur complement trick
|
|
688
|
-
deltax = sparse.linalg.spsolve(H.tocsr(), b.tocsr())
|
|
689
|
-
return deltax, e
|
|
690
|
-
|
|
691
|
-
# build the Hessian and measurement vector
|
|
692
|
-
def build_linear_system(self, x: np.ndarray) -> tuple[Any, Any, Any]:
|
|
693
|
-
r"""
|
|
694
|
-
Build the linear system
|
|
695
|
-
|
|
696
|
-
:param x: state vector
|
|
697
|
-
:type x: ndarray(N)
|
|
698
|
-
:return: Hessian :math:`\mat{H}(\vec{x})` and projection error :math:`\vec{b}`
|
|
699
|
-
:rtype: sparse_array(N,N), sparse_ndarray(N,1), float
|
|
700
|
-
|
|
701
|
-
Build the block structured Hessian matrix based on current bundle
|
|
702
|
-
adjustment state and the Jacobians.
|
|
703
|
-
|
|
704
|
-
:references:
|
|
705
|
-
- |RVC3|, Section 14.3.2, F.2.4.
|
|
706
|
-
|
|
707
|
-
:seealso: :meth:`spy` :meth:`~Camera.CentralCamera.derivatives`
|
|
708
|
-
"""
|
|
709
|
-
|
|
710
|
-
# this function is slow. lil matrices have similar speed to dok
|
|
711
|
-
# matrices
|
|
712
|
-
# H += A is slower than H = H + A
|
|
713
|
-
|
|
714
|
-
from scipy.sparse import lil_matrix
|
|
715
|
-
|
|
716
|
-
# allocate sparse matrices
|
|
717
|
-
H = lil_matrix((self.nvarstates, self.nvarstates))
|
|
718
|
-
b = lil_matrix((self.nvarstates, 1))
|
|
719
|
-
|
|
720
|
-
etotal = 0
|
|
721
|
-
|
|
722
|
-
# loop over views
|
|
723
|
-
for view in self.views:
|
|
724
|
-
|
|
725
|
-
# get camera pose
|
|
726
|
-
k = view.index
|
|
727
|
-
X = x[k : k + 6]
|
|
589
|
+
if x is None:
|
|
590
|
+
x = self.getstate()
|
|
591
|
+
x0 = x
|
|
592
|
+
|
|
593
|
+
t0 = time.perf_counter()
|
|
594
|
+
|
|
595
|
+
print(f"Bundle adjustment cost {self.errors(x0):.3g} -- initial")
|
|
596
|
+
for i in range(iterations):
|
|
597
|
+
if animate:
|
|
598
|
+
if not retain:
|
|
599
|
+
plt.clf()
|
|
600
|
+
g2.plot()
|
|
601
|
+
plt.pause(0.5)
|
|
602
|
+
|
|
603
|
+
ta = time.perf_counter()
|
|
604
|
+
# solve for the step
|
|
605
|
+
dx, energy = self.solve(x, lmbda)
|
|
606
|
+
|
|
607
|
+
# update the state
|
|
608
|
+
x_new = self.updatestate(x, dx)
|
|
609
|
+
|
|
610
|
+
# compute new value of cost
|
|
611
|
+
enew = self.errors(x_new)
|
|
612
|
+
|
|
613
|
+
dt = time.perf_counter() - ta
|
|
614
|
+
print(f"Bundle adjustment cost {enew:.3g} (solved in {dt:.2f} sec)")
|
|
615
|
+
# are we there yet?
|
|
616
|
+
if enew < tol:
|
|
617
|
+
break
|
|
618
|
+
|
|
619
|
+
# have we stopped moving
|
|
620
|
+
if base.norm(dx) < dxmin:
|
|
621
|
+
break
|
|
622
|
+
|
|
623
|
+
# do the Levenberg-Marquadt thing, was it a good update?
|
|
624
|
+
if enew < energy:
|
|
625
|
+
# step is accepted
|
|
626
|
+
x = x_new
|
|
627
|
+
if lmbda > lmbdamin:
|
|
628
|
+
lmbda /= np.sqrt(2)
|
|
629
|
+
if verbose:
|
|
630
|
+
print(f" -- step accepted: lambda = {lmbda:g}")
|
|
631
|
+
else:
|
|
632
|
+
# step is rejected
|
|
633
|
+
lmbda *= 4
|
|
634
|
+
if verbose:
|
|
635
|
+
print(f" -- step rejected: lambda ={lmbda:g}")
|
|
636
|
+
|
|
637
|
+
tf = time.perf_counter()
|
|
638
|
+
err = np.sqrt(enew / self.g.ne)
|
|
639
|
+
print(f"\n * {i + 1} iterations in {tf - t0:.1f} seconds")
|
|
640
|
+
print(f" * Final RMS error is {err:.2f} pixels")
|
|
728
641
|
|
|
729
|
-
|
|
730
|
-
for landmark, edge in view.incidences():
|
|
642
|
+
return x_new, err
|
|
731
643
|
|
|
732
|
-
|
|
733
|
-
|
|
644
|
+
def solve(self, x: np.ndarray, lmbda: float = 0.0) -> tuple[np.ndarray, Any]:
|
|
645
|
+
r"""
|
|
646
|
+
Solve for state update
|
|
734
647
|
|
|
735
|
-
|
|
736
|
-
|
|
648
|
+
:param x: state vector
|
|
649
|
+
:type x: ndarray(N)
|
|
650
|
+
:param lmbda: damping term, defaults to 0.0
|
|
651
|
+
:type lmbda: float, optional
|
|
652
|
+
:return: :math:`\delta \vec{X}`, update to the variable state vector
|
|
653
|
+
:rtype: ndarray(M)
|
|
737
654
|
|
|
738
|
-
|
|
739
|
-
|
|
655
|
+
Determines the state update :math:`\delta \vec{x}` by creating and
|
|
656
|
+
solving the linear equation
|
|
740
657
|
|
|
741
|
-
|
|
742
|
-
e = np.c_[uvhat - uv]
|
|
743
|
-
etotal = etotal + e.T @ e
|
|
658
|
+
.. math:: \mat{H} \delta \vec{x} = \vec{b}
|
|
744
659
|
|
|
745
|
-
|
|
746
|
-
|
|
660
|
+
where :math:`\mat{H}` is the Hessian and :math:`\mat{b}` is the the
|
|
661
|
+
projection error.
|
|
747
662
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
663
|
+
.. note::
|
|
664
|
+
- The damping term ``lmbda`` is added to the diagonal of the
|
|
665
|
+
Hessian to prevent problems when the Hessian is nearly
|
|
666
|
+
singular.
|
|
667
|
+
- If the problem includes fixed cameras or landmarks then
|
|
668
|
+
:math:`\text{len}(\delta \vec{x}) < \text{len}(\vec{x})`
|
|
669
|
+
since fixed elements are omitted from the variable state
|
|
670
|
+
vector used for the optimization.
|
|
754
671
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
H[j : j + 3, i : i + 6] = H[j : j + 3, i : i + 6] + H_ij.T
|
|
758
|
-
H[j : j + 3, j : j + 3] = H[j : j + 3, j : j + 3] + H_jj
|
|
672
|
+
:references:
|
|
673
|
+
- |RVC3|, Section 14.3.2, F.2.4.
|
|
759
674
|
|
|
760
|
-
|
|
761
|
-
|
|
675
|
+
:seealso: :meth:`build_linear_system`
|
|
676
|
+
"""
|
|
677
|
+
# create the Hessian and error vector
|
|
678
|
+
H, b, e = self.build_linear_system(x)
|
|
762
679
|
|
|
763
|
-
|
|
764
|
-
|
|
680
|
+
# add damping term to the diagonal
|
|
681
|
+
for i in range(self.nvarstates):
|
|
682
|
+
H[i, i] += lmbda
|
|
765
683
|
|
|
766
|
-
|
|
767
|
-
|
|
684
|
+
# solve for the state update
|
|
685
|
+
# - could replace this with the Schur complement trick
|
|
686
|
+
deltax = sparse.linalg.spsolve(H.tocsr(), b.tocsr())
|
|
687
|
+
return deltax, e
|
|
768
688
|
|
|
769
|
-
|
|
770
|
-
|
|
689
|
+
# build the Hessian and measurement vector
|
|
690
|
+
def build_linear_system(self, x: np.ndarray) -> tuple[Any, Any, Any]:
|
|
691
|
+
r"""
|
|
692
|
+
Build the linear system
|
|
771
693
|
|
|
772
|
-
|
|
773
|
-
|
|
694
|
+
:param x: state vector
|
|
695
|
+
:type x: ndarray(N)
|
|
696
|
+
:return: Hessian :math:`\mat{H}(\vec{x})` and projection error :math:`\vec{b}`
|
|
697
|
+
:rtype: sparse_array(N,N), sparse_ndarray(N,1), float
|
|
774
698
|
|
|
775
|
-
|
|
699
|
+
Build the block structured Hessian matrix based on current bundle
|
|
700
|
+
adjustment state and the Jacobians.
|
|
776
701
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
Display sparsity of Hessian
|
|
702
|
+
:references:
|
|
703
|
+
- |RVC3|, Section 14.3.2, F.2.4.
|
|
780
704
|
|
|
781
|
-
|
|
782
|
-
|
|
705
|
+
:seealso: :meth:`spy` :meth:`~Camera.CentralCamera.derivatives`
|
|
706
|
+
"""
|
|
783
707
|
|
|
784
|
-
|
|
785
|
-
|
|
708
|
+
# this function is slow. lil matrices have similar speed to dok
|
|
709
|
+
# matrices
|
|
710
|
+
# H += A is slower than H = H + A
|
|
786
711
|
|
|
787
|
-
|
|
788
|
-
"""
|
|
789
|
-
H, *_ = self.build_linear_system(x)
|
|
790
|
-
plt.spy(H)
|
|
791
|
-
safe_plt_show(block=True)
|
|
712
|
+
from scipy.sparse import lil_matrix
|
|
792
713
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
714
|
+
# allocate sparse matrices
|
|
715
|
+
H = lil_matrix((self.nvarstates, self.nvarstates))
|
|
716
|
+
b = lil_matrix((self.nvarstates, 1))
|
|
796
717
|
|
|
797
|
-
|
|
798
|
-
:rtype: ndarray(N)
|
|
718
|
+
etotal = 0
|
|
799
719
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
problem initialization by calls to :meth:`add_view` and
|
|
803
|
-
:meth:`add_landmark`.
|
|
720
|
+
# loop over views
|
|
721
|
+
for view in self.views:
|
|
804
722
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
723
|
+
# get camera pose
|
|
724
|
+
k = view.index
|
|
725
|
+
X = x[k : k + 6]
|
|
808
726
|
|
|
809
|
-
|
|
810
|
-
|
|
727
|
+
# loop over all points viewed from this camera
|
|
728
|
+
for landmark, edge in view.incidences():
|
|
811
729
|
|
|
812
|
-
|
|
813
|
-
x
|
|
730
|
+
k = landmark.index
|
|
731
|
+
P = x[k : k + 3] # get landmark position
|
|
814
732
|
|
|
815
|
-
|
|
733
|
+
# for this view and landmark, get observation
|
|
734
|
+
uv = edge.p
|
|
816
735
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
Update camera and landmark state
|
|
736
|
+
# compute Jacobians and predicted projection
|
|
737
|
+
uvhat, JA, JB = self.camera.derivatives(X, P)
|
|
820
738
|
|
|
821
|
-
|
|
822
|
-
|
|
739
|
+
# compute reprojection error as a column vector
|
|
740
|
+
e = np.c_[uvhat - uv]
|
|
741
|
+
etotal = etotal + e.T @ e
|
|
823
742
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
unchanged.
|
|
743
|
+
i = view.index2
|
|
744
|
+
j = landmark.index2
|
|
827
745
|
|
|
828
|
-
|
|
829
|
-
|
|
746
|
+
# compute the block components of H and b for this edge
|
|
747
|
+
if not view.isfixed and not landmark.isfixed:
|
|
748
|
+
# adjustable point and view
|
|
749
|
+
H_ii = JA.T @ JA
|
|
750
|
+
H_ij = JA.T @ JB
|
|
751
|
+
H_jj = JB.T @ JB
|
|
830
752
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
view.coord = X
|
|
753
|
+
H[i : i + 6, i : i + 6] = H[i : i + 6, i : i + 6] + H_ii
|
|
754
|
+
H[i : i + 6, j : j + 3] = H[i : i + 6, j : j + 3] + H_ij
|
|
755
|
+
H[j : j + 3, i : i + 6] = H[j : j + 3, i : i + 6] + H_ij.T
|
|
756
|
+
H[j : j + 3, j : j + 3] = H[j : j + 3, j : j + 3] + H_jj
|
|
836
757
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
x = x[3:]
|
|
840
|
-
if not landmark.isfixed:
|
|
841
|
-
landmark.coord = X
|
|
842
|
-
|
|
843
|
-
def updatestate(self, x: np.ndarray, dx: np.ndarray) -> np.ndarray:
|
|
844
|
-
"""
|
|
845
|
-
Update the state vector
|
|
846
|
-
|
|
847
|
-
:param x: state vector
|
|
848
|
-
:type x: ndarray(N)
|
|
849
|
-
:param dx: variable state update vector
|
|
850
|
-
:type dx: ndarray(M)
|
|
851
|
-
:return: updated state vector
|
|
852
|
-
:rtype: ndarray(N)
|
|
758
|
+
b[i : i + 6, 0] = b[i : i + 6, 0] - JA.T @ e
|
|
759
|
+
b[j : j + 3, 0] = b[j : j + 3, 0] - JB.T @ e
|
|
853
760
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
landmarks are unchanged.
|
|
761
|
+
elif view.isfixed and not landmark.isfixed:
|
|
762
|
+
# fixed camera and adjustable point
|
|
857
763
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
xnew = np.zeros(x.shape)
|
|
764
|
+
H[j : j + 3, j : j + 3] = H[j : j + 3, j : j + 3] + JB.T @ JB
|
|
765
|
+
b[j : j + 3, 0] = b[j : j + 3, 0] - JB.T @ e
|
|
861
766
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
# for each landmark we add the increment to its position
|
|
886
|
-
for landmark in self.landmarks:
|
|
887
|
-
k = landmark.index
|
|
888
|
-
P = x[k : k + 3]
|
|
889
|
-
if landmark.isfixed:
|
|
890
|
-
xnew[k : k + 3] = P
|
|
891
|
-
else:
|
|
892
|
-
k2 = landmark.index2
|
|
893
|
-
dP = dx[k2 : k2 + 3]
|
|
894
|
-
xnew[k : k + 3] = P + dP
|
|
767
|
+
elif not view.isfixed and landmark.isfixed:
|
|
768
|
+
# adjustable camera and fixed point
|
|
769
|
+
|
|
770
|
+
H[i : i + 6, i : i + 6] = H[i : i + 6, i : i + 6] + JA.T @ JA
|
|
771
|
+
b[i : i + 6, 0] = b[i : i + 6, 0] - JA.T @ e
|
|
772
|
+
|
|
773
|
+
return H, b, etotal
|
|
774
|
+
|
|
775
|
+
def spyH(self, x: np.ndarray, block: bool = False) -> None:
|
|
776
|
+
"""
|
|
777
|
+
Display sparsity of Hessian
|
|
778
|
+
|
|
779
|
+
:param x: state vector
|
|
780
|
+
:type x: ndarray(N)
|
|
781
|
+
|
|
782
|
+
Use Matplotlib to display the zero and non-zero elements of the
|
|
783
|
+
Hessian.
|
|
784
|
+
|
|
785
|
+
:seealso: :meth:`build_linear_system`
|
|
786
|
+
"""
|
|
787
|
+
H, *_ = self.build_linear_system(x)
|
|
788
|
+
plt.spy(H)
|
|
789
|
+
safe_plt_show(block=True)
|
|
895
790
|
|
|
896
|
-
|
|
791
|
+
def getstate(self) -> np.ndarray:
|
|
792
|
+
"""
|
|
793
|
+
Get the state vector
|
|
897
794
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
"""
|
|
901
|
-
Total reprojection error
|
|
795
|
+
:return: state vector
|
|
796
|
+
:rtype: ndarray(N)
|
|
902
797
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
798
|
+
Build the state vector by concatenating the pose of all cameras and
|
|
799
|
+
then the position of all landmarks. That information is provided at
|
|
800
|
+
problem initialization by calls to :meth:`add_view` and
|
|
801
|
+
:meth:`add_landmark`.
|
|
907
802
|
|
|
908
|
-
|
|
909
|
-
|
|
803
|
+
:seealso: :meth:`setstate` :meth:`nstates` :meth:`add_view` :meth:`add_landmark`
|
|
804
|
+
"""
|
|
805
|
+
x = []
|
|
910
806
|
|
|
911
|
-
|
|
912
|
-
|
|
807
|
+
for view in self.views: # step through camera nodes
|
|
808
|
+
x.extend(view.coord)
|
|
913
809
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
r = self.getresidual(x)
|
|
810
|
+
for landmark in self.landmarks: # step through landmark nodes
|
|
811
|
+
x.extend(landmark.coord)
|
|
917
812
|
|
|
918
|
-
|
|
813
|
+
return np.array(x)
|
|
919
814
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
815
|
+
def setstate(self, x: np.ndarray) -> None:
|
|
816
|
+
"""
|
|
817
|
+
Update camera and landmark state
|
|
923
818
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
:return: residuals :math:`\mat{R}` for each observation
|
|
927
|
-
:rtype: ndarray(V,L)
|
|
819
|
+
:param x: new state vector
|
|
820
|
+
:type x: ndarray(N)
|
|
928
821
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
822
|
+
Copy new state data into the nodes of the bundle adjustment graph.
|
|
823
|
+
Those nodes corresponding to fixed cameras or landmarks are
|
|
824
|
+
unchanged.
|
|
932
825
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
# this is the squared reprojection errors
|
|
936
|
-
self.update_index()
|
|
826
|
+
:seealso: :meth:`updatestate` :meth:`getstate`
|
|
827
|
+
"""
|
|
937
828
|
|
|
938
|
-
|
|
939
|
-
|
|
829
|
+
for view in self.views: # step through view nodes
|
|
830
|
+
X = x[:6]
|
|
831
|
+
x = x[6:]
|
|
832
|
+
if not view.isfixed:
|
|
833
|
+
view.coord = X
|
|
940
834
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
835
|
+
for landmark in self.landmarks:
|
|
836
|
+
X = x[:3]
|
|
837
|
+
x = x[3:]
|
|
838
|
+
if not landmark.isfixed:
|
|
839
|
+
landmark.coord = X
|
|
944
840
|
|
|
945
|
-
|
|
946
|
-
|
|
841
|
+
def updatestate(self, x: np.ndarray, dx: np.ndarray) -> np.ndarray:
|
|
842
|
+
"""
|
|
843
|
+
Update the state vector
|
|
844
|
+
|
|
845
|
+
:param x: state vector
|
|
846
|
+
:type x: ndarray(N)
|
|
847
|
+
:param dx: variable state update vector
|
|
848
|
+
:type dx: ndarray(M)
|
|
849
|
+
:return: updated state vector
|
|
850
|
+
:rtype: ndarray(N)
|
|
851
|
+
|
|
852
|
+
The elements of the update to the variable state are inserted into
|
|
853
|
+
the state vector. Those elements corresponding to fixed cameras or
|
|
854
|
+
landmarks are unchanged.
|
|
855
|
+
|
|
856
|
+
:seealso: :meth:`setstate` :meth:`nstates`
|
|
857
|
+
"""
|
|
858
|
+
xnew = np.zeros(x.shape)
|
|
859
|
+
|
|
860
|
+
# for each camera we need to compound the camera pose with the
|
|
861
|
+
# incremental relative pose
|
|
862
|
+
for view in self.views:
|
|
863
|
+
k = view.index
|
|
864
|
+
if view.isfixed:
|
|
865
|
+
xnew[k : k + 6] = x[k : k + 6]
|
|
866
|
+
else:
|
|
867
|
+
# current pose
|
|
947
868
|
X = x[k : k + 6]
|
|
869
|
+
t = X[:3]
|
|
870
|
+
qv = X[3:]
|
|
871
|
+
|
|
872
|
+
# incremental pose
|
|
873
|
+
k2 = view.index2
|
|
874
|
+
dX = dx[k2 : k2 + 6]
|
|
875
|
+
dt = dX[:3]
|
|
876
|
+
dqv = dX[3:]
|
|
877
|
+
|
|
878
|
+
tnew = t + dt # assume translation in old frame
|
|
879
|
+
qvnew = UnitQuaternion.qvmul(qv, dqv)
|
|
880
|
+
|
|
881
|
+
xnew[k : k + 6] = np.r_[tnew, qvnew]
|
|
882
|
+
|
|
883
|
+
# for each landmark we add the increment to its position
|
|
884
|
+
for landmark in self.landmarks:
|
|
885
|
+
k = landmark.index
|
|
886
|
+
P = x[k : k + 3]
|
|
887
|
+
if landmark.isfixed:
|
|
888
|
+
xnew[k : k + 3] = P
|
|
889
|
+
else:
|
|
890
|
+
k2 = landmark.index2
|
|
891
|
+
dP = dx[k2 : k2 + 3]
|
|
892
|
+
xnew[k : k + 3] = P + dP
|
|
893
|
+
|
|
894
|
+
return xnew
|
|
895
|
+
|
|
896
|
+
# Compute total squared reprojection error
|
|
897
|
+
def errors(self, x: np.ndarray | None = None) -> Any:
|
|
898
|
+
"""
|
|
899
|
+
Total reprojection error
|
|
900
|
+
|
|
901
|
+
:param x: state vector, defaults to state vector in instance
|
|
902
|
+
:type x: ndarray(N), optional
|
|
903
|
+
:return: total residual
|
|
904
|
+
:rtype: float
|
|
905
|
+
|
|
906
|
+
Compute the total reprojection error, of all projected landmarks
|
|
907
|
+
on all camera viewpoints. Is ideally zero.
|
|
908
|
+
|
|
909
|
+
:seealso: :meth:`getresidual`
|
|
910
|
+
"""
|
|
948
911
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
912
|
+
if x is None:
|
|
913
|
+
x = self.getstate()
|
|
914
|
+
r = self.getresidual(x)
|
|
915
|
+
|
|
916
|
+
return np.sum(r)
|
|
917
|
+
|
|
918
|
+
def getresidual(self, x: np.ndarray | None = None) -> np.ndarray:
|
|
919
|
+
r"""
|
|
920
|
+
Get error residuals
|
|
921
|
+
|
|
922
|
+
:param X: state vector, defaults to state vector in instance
|
|
923
|
+
:type X: ndarray(N), optional
|
|
924
|
+
:return: residuals :math:`\mat{R}` for each observation
|
|
925
|
+
:rtype: ndarray(V,L)
|
|
926
|
+
|
|
927
|
+
Returns a 2D array :math:`\mat{R}` whose elements :math:`r_{ij}`
|
|
928
|
+
represent the Euclidean reprojection error for camera :math:`i`
|
|
929
|
+
observing landmark :math:`j`.
|
|
930
|
+
|
|
931
|
+
:seealso: :meth:`errors`
|
|
932
|
+
"""
|
|
933
|
+
# this is the squared reprojection errors
|
|
934
|
+
self.update_index()
|
|
935
|
+
|
|
936
|
+
if x is None:
|
|
937
|
+
x = self.getstate()
|
|
938
|
+
|
|
939
|
+
residual = np.zeros((self.nviews, self.nlandmarks))
|
|
940
|
+
# loop over views
|
|
941
|
+
for view in self.views:
|
|
942
|
+
|
|
943
|
+
# get view pose
|
|
944
|
+
k = view.index
|
|
945
|
+
X = x[k : k + 6]
|
|
946
|
+
|
|
947
|
+
# loop over all points viewed from this camera
|
|
948
|
+
for landmark, edge in view.incidences():
|
|
949
|
+
|
|
950
|
+
k = landmark.index
|
|
951
|
+
P = x[k : k + 3] # get landmark position
|
|
952
|
+
|
|
953
|
+
uv = edge.p
|
|
954
|
+
|
|
955
|
+
uvhat, *_ = self.camera.derivatives(X, P)
|
|
956
|
+
if np.any(np.isnan(uvhat)):
|
|
957
|
+
print("bad uvhat in residual")
|
|
958
|
+
|
|
959
|
+
# compute reprojection error
|
|
960
|
+
e = uvhat - uv
|
|
961
|
+
residual[view.id, landmark.id] = np.dot(e, e)
|
|
962
|
+
return residual
|
|
963
|
+
|
|
964
|
+
@property
|
|
965
|
+
def graph(self) -> Any:
|
|
966
|
+
"""
|
|
967
|
+
Get the scene graph
|
|
968
|
+
|
|
969
|
+
:return: scene graph
|
|
970
|
+
:rtype: :class:`PGraph`
|
|
971
|
+
|
|
972
|
+
The scene graph has nodes representing camera viewpoints, of type
|
|
973
|
+
:class:`ViewPoint`, and nodes representing landmarks, of type
|
|
974
|
+
:class:`Landmark`. An edge, of type :class:`Observation`, exists
|
|
975
|
+
between a landmark and the viewpoint that observed, and the edge has
|
|
976
|
+
the associated image plane projection.
|
|
977
|
+
"""
|
|
978
|
+
return self.g
|
|
979
|
+
|
|
980
|
+
def plot(
|
|
981
|
+
self,
|
|
982
|
+
camera: dict[str, Any] = {},
|
|
983
|
+
block: bool | None = None,
|
|
984
|
+
ax: Any = None,
|
|
985
|
+
**kwargs,
|
|
986
|
+
) -> None:
|
|
987
|
+
"""
|
|
988
|
+
Plot the scene graph
|
|
989
|
+
|
|
990
|
+
:param camera: options passed to :obj:`CentralCamera.plot`, defaults to {}
|
|
991
|
+
:type camera: dict, optional
|
|
992
|
+
:param ax: axis to plot into, defaults to None
|
|
993
|
+
:type ax: Axes, optional
|
|
994
|
+
:param kwargs: options passed to :obj:`PGraph.plot`
|
|
995
|
+
|
|
996
|
+
Display the nodes and edges of the scene graph as an embedded graph.
|
|
997
|
+
Overlay camera icons to indicate the camera viewpoint nodes.
|
|
998
|
+
|
|
999
|
+
:seealso: :meth:`graph`
|
|
1000
|
+
"""
|
|
1001
|
+
# plt.clf() # causes spurious 2d plot with Jupyter
|
|
1002
|
+
ax = base.plotvol3(ax=ax)
|
|
1003
|
+
self.g.plot(**kwargs) # edge=dict(color=0.8*np.r_[1, 1, 1]), **kwargs)
|
|
1004
|
+
# ax.set_aspect('equal')
|
|
1005
|
+
|
|
1006
|
+
# colorOrder = get(gca, 'ColorOrder')
|
|
1007
|
+
for view in self.views:
|
|
1008
|
+
cam = self.camera.move(view.pose)
|
|
1009
|
+
# cidx = mod(i-1, numrows(colorOrder))+1
|
|
1010
|
+
# color = colorOrder(cidx,:)
|
|
1011
|
+
cam.plot(
|
|
1012
|
+
pose=view.pose, ax=ax, color=view._color, **camera
|
|
1013
|
+
) # 'color', color, 'persist')
|
|
1014
|
+
# ax.set_aspect('equal')
|
|
1015
|
+
ax.set_xlabel("X (m)")
|
|
1016
|
+
ax.set_ylabel("Y (m)")
|
|
1017
|
+
ax.set_zlabel("Z (m)")
|
|
1018
|
+
plt.grid(True)
|
|
1019
|
+
|
|
1020
|
+
safe_plt_show(block=block)
|
|
1021
|
+
|
|
1022
|
+
def __repr__(self) -> str:
|
|
1023
|
+
"""
|
|
1024
|
+
String representation
|
|
1025
|
+
|
|
1026
|
+
:return: multiline string describing key parameters of bundle adjustment problem
|
|
1027
|
+
:rtype: str
|
|
1028
|
+
"""
|
|
1029
|
+
return f"BundleAdjust(camera={repr(self.camera)}, views={self.nviews}, landmarks={self.nlandmarks}, states={self.nstates}, projections={self.g.ne})"
|
|
1030
|
+
|
|
1031
|
+
def __str__(self) -> str:
|
|
1032
|
+
"""
|
|
1033
|
+
String representation
|
|
1034
|
+
|
|
1035
|
+
:return: multiline string describing key parameters of bundle adjustment problem
|
|
1036
|
+
:rtype: str
|
|
1037
|
+
"""
|
|
1038
|
+
s = "Bundle adjustment problem:"
|
|
1039
|
+
s += f" {self.nviews} views\n"
|
|
1040
|
+
fixedcam = [i for i, view in enumerate(self.views) if view.isfixed]
|
|
1041
|
+
if len(fixedcam) > 0:
|
|
1042
|
+
s += f" {len(fixedcam)} locked views: {fixedcam}\n"
|
|
1043
|
+
fixedlandmarks = [
|
|
1044
|
+
i for i, landmark in enumerate(self.landmarks) if landmark.isfixed
|
|
1045
|
+
]
|
|
1046
|
+
if len(fixedlandmarks) > 0:
|
|
1047
|
+
s += f" {len(fixedlandmarks)} locked landmarks: {fixedlandmarks}\n"
|
|
1048
|
+
|
|
1049
|
+
s += f" {self.nlandmarks} landmarks\n"
|
|
1050
|
+
|
|
1051
|
+
s += f" {self.g.ne} projections\n"
|
|
1052
|
+
|
|
1053
|
+
s += f" {self.nstates} total states\n"
|
|
1054
|
+
s += f" {self.nvarstates} variable states\n"
|
|
1055
|
+
s += f" {self.g.ne * 2} equations\n"
|
|
1056
|
+
v = np.array(self.g.connectivity(self.views))
|
|
1057
|
+
if len(self.views) > 0:
|
|
1058
|
+
s += f" landmarks per view: min={v.min():d}, max={v.max():d}, avg={v.mean():.1f}\n"
|
|
1059
|
+
if len(self.landmarks) > 0:
|
|
1060
|
+
l = np.array(self.g.connectivity(self.landmarks))
|
|
1061
|
+
s += f" views per landmark: min={l.min():d}, max={l.max():d}, avg={l.mean():.1f}\n"
|
|
1062
|
+
return s
|
|
1070
1063
|
|
|
1071
1064
|
|
|
1072
1065
|
if __name__ == "__main__":
|