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.
@@ -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
- if pgraph_installed:
31
-
32
- class _Common:
33
- @property
34
- def index(self) -> int:
35
- """
36
- Index into the state vector (base method)
37
-
38
- :return: the index of the start of this object's state in the state vector
39
- :rtype: int
40
- """
41
- return self._index
42
-
43
- @property
44
- def index2(self) -> int:
45
- """
46
- Index into the variable state vector (base method)
47
-
48
- :return: the index of the start of this object's state in the variable state vector
49
- :rtype: int
50
- """
51
- return self._index2
52
-
53
- @property
54
- def isfixed(self) -> bool:
55
- """
56
- Value is fixed (base method)
57
-
58
- :return: Quantity is fixed
59
- :rtype: bool
60
-
61
- This viewpoint or landmark will not be adjusted during optimization.
62
- """
63
- return self._fixed
64
-
65
- class ViewPoint(pgraph.UVertex, _Common):
66
- def __init__(
67
- self, x: np.ndarray, fixed: bool = False, color: str | None = None
68
- ) -> None:
69
- """
70
- Create new camera viewpoint
71
-
72
- :param x: viewpoint pose as translation + vector part of unit quaternion
73
- :type x: array_like(6)
74
- :param fixed: camera is fixed, defaults to False
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
- def add_view(
302
- self, pose: SE3 | np.ndarray, fixed: bool = False, color: str = "black"
303
- ) -> ViewPoint:
304
- """
305
- Add camera view to bundle adjustment problem
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
- :param pose: camera pose
308
- :type pose: :class:`~spatialmath.pose3d.SE3`, array_like(7)
309
- :param fixed: the camera is fixed, defaults to False
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
- Creates a camera node and adds it to the bundle adjustment problem.
80
+ :seealso: :class:`pgraph.Vertex`
81
+ """
82
+ super().__init__()
315
83
 
316
- The camera ``pose`` can be :class:`~spatialmath.pose3d.SE3` or a vector (1x7)
317
- comprising translation and unit quaternion in vector form.
84
+ self.coord = x
85
+ self._fixed = fixed
86
+ self._color = color
318
87
 
319
- If the camera is fixed (anchored) it will not be adjusted in the
320
- optimization process.
88
+ @property
89
+ def pose(self) -> SE3:
90
+ """
91
+ Get pose of camera
321
92
 
322
- .. note:: Adds a :class:`ViewPoint` object as a node in the
323
- underlying scene graph.
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
- :seealso: :meth:`add_landmark` :meth:`add_projection`
326
- """
327
- if isinstance(pose, SE3):
328
- t = pose.t
329
- q = base.r2q(pose.R)
330
- else:
331
- base.assertvector(pose, 7)
332
- q = pose[:4]
333
- t = pose[4:]
334
- if q[0] < 0:
335
- q = -q
336
- x = np.r_[t, q[1:]]
337
-
338
- v = ViewPoint(x, fixed=fixed, color=color)
339
- v.name = f"view#{self._nviews}"
340
- self._nviews += 1
341
-
342
- self.g.add_vertex(v)
343
- self.views.append(v)
344
- if fixed:
345
- self.fixedviews.append(v)
346
- v.ba = self # back reference to the BA problem
347
- self.index_valid = False
348
- return v
349
-
350
- def add_landmark(self, P: np.ndarray, fixed: bool = False) -> Landmark:
351
- """
352
- Add 3D landmark point to bundle adjustment problem
353
-
354
- :param P: 3D world point, aka landmark
355
- :type P: array_like(3)
356
- :param fixed: the landmark is fixed, defaults to False
357
- :type fixed: bool, optional
358
- :return: new landmark
359
- :rtype: :class:`Landmark` instance
360
-
361
- Create a landmark node and add it to the bundle adjustment problem.
362
-
363
- If the landmark is fixed (anchored) it will not be adjusted in the
364
- optimization process.
365
-
366
- .. note:: Adds a :class:`Landmark` object as a node in the
367
- underlying scene graph.
368
-
369
- :seealso: :meth:`add_view` :meth:`add_projection`
370
- """
371
- base.assertvector(P, 3)
372
-
373
- # P = np.r_[P, 0, 0, 0]
374
-
375
- l = Landmark(P, fixed=fixed)
376
- l.name = f"landmark#{self._nlandmarks}"
377
- l.coord = P
378
- self._nlandmarks += 1
379
- self.g.add_vertex(l)
380
- self.landmarks.append(l)
381
- if fixed:
382
- self.fixedlandmarks.append(c)
383
- l.ba = self # back reference to the BA problem
384
- self.index_valid = False
385
- return l
386
-
387
- def add_projection(
388
- self, viewpoint: ViewPoint, landmark: Landmark, uv: np.ndarray
389
- ) -> None:
390
- """
391
- Add camera observation to bundle adjustment problem
392
-
393
- :param view: camera viewpoint
394
- :type view: :class:`ViewPoint`
395
- :param landmark: landmark point
396
- :type landmark: :class:`Landmark`
397
- :param uv: image plane coordinate
398
- :type uv: array_like(2)
399
-
400
- Add an observation by ``viewpoint`` of a ``landmark`` to the bundle
401
- adjustment problem.
402
-
403
- .. note:: Adds a :class:`Observation` object as an edge in the
404
- underlying scene graph.
405
-
406
- :seealso: :meth:`add_view` :meth:`add_landmark`
407
- """
408
- if len(uv) != 2:
409
- raise ValueError("uv must be a 2-vector")
410
-
411
- edge = Observation(viewpoint, landmark, uv.flatten()) # create edge object
412
- e = viewpoint.connect(landmark, edge=edge) # connect nodes with it
413
- e.name = viewpoint.name + "--" + landmark.name
414
-
415
- @classmethod
416
- def load_SBA(
417
- cls,
418
- cameraFile: str,
419
- pointFile: str,
420
- calibFile: str,
421
- imagesize: np.ndarray | None = None,
422
- ) -> "BundleAdjust":
423
- """
424
- Load bundle adjustment data files
425
-
426
- :param cameraFile: name of file with camera view data
427
- :type cameraFile: str
428
- :param pointFile: name of file with landmark data
429
- :type pointFile: str
430
- :param calibFile: name of file with camera intrinsic data
431
- :type calibFile: str
432
- :param imagesize: image plane dimensions in pixels, if not given infer
433
- it from principal point data in ``calibFile``
434
- :type imagesize: array_like(2)
435
-
436
- Provides access to bundle adjustment problems from data files as distributed with the SBA package.
437
- Details of the file format are given in the source code comments.
438
-
439
- Example:
440
-
441
- To solve the 7-point bundle adjustment problem distributed with
442
- SBA 1.6:
443
-
444
- .. code-block:: python
445
-
446
- ba = Bundle.load_SBA('7cams.txt', '7pts.txt', 'calib.txt')
447
- X = ba.optimize()
448
-
449
-
450
- :reference:
451
- - Sparse Bundle Adjustment package by Manolis Lourakis,
452
- http://users.ics.forth.gr/~lourakis/sba
453
-
454
- :seealso: :meth:`add_view` :meth:`add_landmark` :meth:`add_projection`
455
- """
456
- # Adopted from sba-1.6/matlab/eucsbademo.m
457
-
458
- # read calibration parameters
459
- #
460
- # f/rho_u skew u0
461
- # 0 f/rho_v v0
462
- # 0 0 1
463
- K = np.loadtxt(calibFile)
464
-
465
- # create the camera object
466
- if imagesize is None:
467
- # no image plane size given
468
- # infer it from the principal point
469
- imagesize = 2 * K[:2, 2]
470
-
471
- camera = CentralCamera(
472
- f=K[0, 0], rho=[1, K[0, 0] / K[1, 1]], pp=K[:2, 2], imagesize=imagesize
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
- # create a bundle adjustment instance
476
- ba = cls(camera)
477
-
478
- # read camera views
479
- #
480
- # each line is: qs qx qy qz tx ty tz
481
- for pose in np.loadtxt(cameraFile):
482
- ba.add_view(pose)
483
-
484
- # read points and projections
485
- #
486
- # The lines are of the form:
487
- #
488
- # X Y Z NFRAMES FRAME0 x0 y0 FRAME1 x1 y1 ...
489
- #
490
- # corresponding to a single 3D point and multiple projections:
491
- #
492
- # - X, Y, Z is the points' Euclidean 3D coordinates,
493
- # - NFRAMES the total number of camera views in which the point is
494
- # visible and there will follow NFRAMES subsequent triplets
495
- # - FRAME x y specifies that the 3D point in question projects to pixel
496
- # (x, y) in view number FRAME.
497
- #
498
- # For example, the line:
499
- #
500
- # 100.0 200.0 300.0 3 2 270.0 114.1 4 234.2 321.7 5 173.6 425.8
501
- #
502
- # describes a world point (100.0, 200.0, 300.0) that is visible in
503
- # three views: view 2 at (270.0, 114.1), view 4 at (234.2, 321.7) and
504
- # view 5 at (173.6, 425.8)
505
- with open(pointFile, "r") as file:
506
- npts = 0
507
- for line in file:
508
- if len(line) == 0 or line[0] == "#":
509
- continue
510
-
511
- data = line.split()
512
-
513
- # read X, Y, Z, nframes
514
- P = np.array([float(x) for x in data[:3]])
515
- data = data[3:]
516
- npts += 1
517
-
518
- # create a node for this point
519
- landmark = ba.add_landmark(P)
520
-
521
- # now find which cameras it was seen by
522
- nframes = int(data.pop(0))
523
- for i in range(nframes): # read "nframes" id, x, y triplets
524
- id = int(data.pop(0))
525
- u = float(data.pop(0))
526
- v = float(data.pop(0))
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
- # loop over all points viewed from this camera
730
- for landmark, edge in view.incidences():
642
+ return x_new, err
731
643
 
732
- k = landmark.index
733
- P = x[k : k + 3] # get landmark position
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
- # for this view and landmark, get observation
736
- uv = edge.p
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
- # compute Jacobians and predicted projection
739
- uvhat, JA, JB = self.camera.derivatives(X, P)
655
+ Determines the state update :math:`\delta \vec{x}` by creating and
656
+ solving the linear equation
740
657
 
741
- # compute reprojection error as a column vector
742
- e = np.c_[uvhat - uv]
743
- etotal = etotal + e.T @ e
658
+ .. math:: \mat{H} \delta \vec{x} = \vec{b}
744
659
 
745
- i = view.index2
746
- j = landmark.index2
660
+ where :math:`\mat{H}` is the Hessian and :math:`\mat{b}` is the the
661
+ projection error.
747
662
 
748
- # compute the block components of H and b for this edge
749
- if not view.isfixed and not landmark.isfixed:
750
- # adjustable point and view
751
- H_ii = JA.T @ JA
752
- H_ij = JA.T @ JB
753
- H_jj = JB.T @ JB
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
- H[i : i + 6, i : i + 6] = H[i : i + 6, i : i + 6] + H_ii
756
- H[i : i + 6, j : j + 3] = H[i : i + 6, j : j + 3] + H_ij
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
- b[i : i + 6, 0] = b[i : i + 6, 0] - JA.T @ e
761
- b[j : j + 3, 0] = b[j : j + 3, 0] - JB.T @ e
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
- elif view.isfixed and not landmark.isfixed:
764
- # fixed camera and adjustable point
680
+ # add damping term to the diagonal
681
+ for i in range(self.nvarstates):
682
+ H[i, i] += lmbda
765
683
 
766
- H[j : j + 3, j : j + 3] = H[j : j + 3, j : j + 3] + JB.T @ JB
767
- b[j : j + 3, 0] = b[j : j + 3, 0] - JB.T @ e
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
- elif not view.isfixed and landmark.isfixed:
770
- # adjustable camera and fixed point
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
- H[i : i + 6, i : i + 6] = H[i : i + 6, i : i + 6] + JA.T @ JA
773
- b[i : i + 6, 0] = b[i : i + 6, 0] - JA.T @ e
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
- return H, b, etotal
699
+ Build the block structured Hessian matrix based on current bundle
700
+ adjustment state and the Jacobians.
776
701
 
777
- def spyH(self, x: np.ndarray, block: bool = False) -> None:
778
- """
779
- Display sparsity of Hessian
702
+ :references:
703
+ - |RVC3|, Section 14.3.2, F.2.4.
780
704
 
781
- :param x: state vector
782
- :type x: ndarray(N)
705
+ :seealso: :meth:`spy` :meth:`~Camera.CentralCamera.derivatives`
706
+ """
783
707
 
784
- Use Matplotlib to display the zero and non-zero elements of the
785
- Hessian.
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
- :seealso: :meth:`build_linear_system`
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
- def getstate(self) -> np.ndarray:
794
- """
795
- Get the state vector
714
+ # allocate sparse matrices
715
+ H = lil_matrix((self.nvarstates, self.nvarstates))
716
+ b = lil_matrix((self.nvarstates, 1))
796
717
 
797
- :return: state vector
798
- :rtype: ndarray(N)
718
+ etotal = 0
799
719
 
800
- Build the state vector by concatenating the pose of all cameras and
801
- then the position of all landmarks. That information is provided at
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
- :seealso: :meth:`setstate` :meth:`nstates` :meth:`add_view` :meth:`add_landmark`
806
- """
807
- x = []
723
+ # get camera pose
724
+ k = view.index
725
+ X = x[k : k + 6]
808
726
 
809
- for view in self.views: # step through camera nodes
810
- x.extend(view.coord)
727
+ # loop over all points viewed from this camera
728
+ for landmark, edge in view.incidences():
811
729
 
812
- for landmark in self.landmarks: # step through landmark nodes
813
- x.extend(landmark.coord)
730
+ k = landmark.index
731
+ P = x[k : k + 3] # get landmark position
814
732
 
815
- return np.array(x)
733
+ # for this view and landmark, get observation
734
+ uv = edge.p
816
735
 
817
- def setstate(self, x: np.ndarray) -> None:
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
- :param x: new state vector
822
- :type x: ndarray(N)
739
+ # compute reprojection error as a column vector
740
+ e = np.c_[uvhat - uv]
741
+ etotal = etotal + e.T @ e
823
742
 
824
- Copy new state data into the nodes of the bundle adjustment graph.
825
- Those nodes corresponding to fixed cameras or landmarks are
826
- unchanged.
743
+ i = view.index2
744
+ j = landmark.index2
827
745
 
828
- :seealso: :meth:`updatestate` :meth:`getstate`
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
- for view in self.views: # step through view nodes
832
- X = x[:6]
833
- x = x[6:]
834
- if not view.isfixed:
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
- for landmark in self.landmarks:
838
- X = x[:3]
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
- The elements of the update to the variable state are inserted into
855
- the state vector. Those elements corresponding to fixed cameras or
856
- landmarks are unchanged.
761
+ elif view.isfixed and not landmark.isfixed:
762
+ # fixed camera and adjustable point
857
763
 
858
- :seealso: :meth:`setstate` :meth:`nstates`
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
- # for each camera we need to compound the camera pose with the
863
- # incremental relative pose
864
- for view in self.views:
865
- k = view.index
866
- if view.isfixed:
867
- xnew[k : k + 6] = x[k : k + 6]
868
- else:
869
- # current pose
870
- X = x[k : k + 6]
871
- t = X[:3]
872
- qv = X[3:]
873
-
874
- # incremental pose
875
- k2 = view.index2
876
- dX = dx[k2 : k2 + 6]
877
- dt = dX[:3]
878
- dqv = dX[3:]
879
-
880
- tnew = t + dt # assume translation in old frame
881
- qvnew = UnitQuaternion.qvmul(qv, dqv)
882
-
883
- xnew[k : k + 6] = np.r_[tnew, qvnew]
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
- return xnew
791
+ def getstate(self) -> np.ndarray:
792
+ """
793
+ Get the state vector
897
794
 
898
- # Compute total squared reprojection error
899
- def errors(self, x: np.ndarray | None = None) -> Any:
900
- """
901
- Total reprojection error
795
+ :return: state vector
796
+ :rtype: ndarray(N)
902
797
 
903
- :param x: state vector, defaults to state vector in instance
904
- :type x: ndarray(N), optional
905
- :return: total residual
906
- :rtype: float
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
- Compute the total reprojection error, of all projected landmarks
909
- on all camera viewpoints. Is ideally zero.
803
+ :seealso: :meth:`setstate` :meth:`nstates` :meth:`add_view` :meth:`add_landmark`
804
+ """
805
+ x = []
910
806
 
911
- :seealso: :meth:`getresidual`
912
- """
807
+ for view in self.views: # step through camera nodes
808
+ x.extend(view.coord)
913
809
 
914
- if x is None:
915
- x = self.getstate()
916
- r = self.getresidual(x)
810
+ for landmark in self.landmarks: # step through landmark nodes
811
+ x.extend(landmark.coord)
917
812
 
918
- return np.sum(r)
813
+ return np.array(x)
919
814
 
920
- def getresidual(self, x: np.ndarray | None = None) -> np.ndarray:
921
- r"""
922
- Get error residuals
815
+ def setstate(self, x: np.ndarray) -> None:
816
+ """
817
+ Update camera and landmark state
923
818
 
924
- :param X: state vector, defaults to state vector in instance
925
- :type X: ndarray(N), optional
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
- Returns a 2D array :math:`\mat{R}` whose elements :math:`r_{ij}`
930
- represent the Euclidean reprojection error for camera :math:`i`
931
- observing landmark :math:`j`.
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
- :seealso: :meth:`errors`
934
- """
935
- # this is the squared reprojection errors
936
- self.update_index()
826
+ :seealso: :meth:`updatestate` :meth:`getstate`
827
+ """
937
828
 
938
- if x is None:
939
- x = self.getstate()
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
- residual = np.zeros((self.nviews, self.nlandmarks))
942
- # loop over views
943
- for view in self.views:
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
- # get view pose
946
- k = view.index
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
- # loop over all points viewed from this camera
950
- for landmark, edge in view.incidences():
951
-
952
- k = landmark.index
953
- P = x[k : k + 3] # get landmark position
954
-
955
- uv = edge.p
956
-
957
- uvhat, *_ = self.camera.derivatives(X, P)
958
- if np.any(np.isnan(uvhat)):
959
- print("bad uvhat in residual")
960
-
961
- # compute reprojection error
962
- e = uvhat - uv
963
- residual[view.id, landmark.id] = np.dot(e, e)
964
- return residual
965
-
966
- @property
967
- def graph(self) -> Any:
968
- """
969
- Get the scene graph
970
-
971
- :return: scene graph
972
- :rtype: :class:`PGraph`
973
-
974
- The scene graph has nodes representing camera viewpoints, of type
975
- :class:`ViewPoint`, and nodes representing landmarks, of type
976
- :class:`Landmark`. An edge, of type :class:`Observation`, exists
977
- between a landmark and the viewpoint that observed, and the edge has
978
- the associated image plane projection.
979
- """
980
- return self.g
981
-
982
- def plot(
983
- self,
984
- camera: dict[str, Any] = {},
985
- block: bool | None = None,
986
- ax: Any = None,
987
- **kwargs,
988
- ) -> None:
989
- """
990
- Plot the scene graph
991
-
992
- :param camera: options passed to :obj:`CentralCamera.plot`, defaults to {}
993
- :type camera: dict, optional
994
- :param ax: axis to plot into, defaults to None
995
- :type ax: Axes, optional
996
- :param kwargs: options passed to :obj:`PGraph.plot`
997
-
998
- Display the nodes and edges of the scene graph as an embedded graph.
999
- Overlay camera icons to indicate the camera viewpoint nodes.
1000
-
1001
- :seealso: :meth:`graph`
1002
- """
1003
- # plt.clf() # causes spurious 2d plot with Jupyter
1004
- ax = base.plotvol3(ax=ax)
1005
- self.g.plot(**kwargs) # edge=dict(color=0.8*np.r_[1, 1, 1]), **kwargs)
1006
- # ax.set_aspect('equal')
1007
-
1008
- # colorOrder = get(gca, 'ColorOrder')
1009
- for view in self.views:
1010
- cam = self.camera.move(view.pose)
1011
- # cidx = mod(i-1, numrows(colorOrder))+1
1012
- # color = colorOrder(cidx,:)
1013
- cam.plot(
1014
- pose=view.pose, ax=ax, color=view._color, **camera
1015
- ) # 'color', color, 'persist')
1016
- # ax.set_aspect('equal')
1017
- ax.set_xlabel("X (m)")
1018
- ax.set_ylabel("Y (m)")
1019
- ax.set_zlabel("Z (m)")
1020
- plt.grid(True)
1021
-
1022
- safe_plt_show(block=block)
1023
-
1024
- def __repr__(self) -> str:
1025
- """
1026
- String representation
1027
-
1028
- :return: multiline string describing key parameters of bundle adjustment problem
1029
- :rtype: str
1030
- """
1031
- return f"BundleAdjust(camera={repr(self.camera)}, views={self.nviews}, landmarks={self.nlandmarks}, states={self.nstates}, projections={self.g.ne})"
1032
-
1033
- def __str__(self) -> str:
1034
- """
1035
- String representation
1036
-
1037
- :return: multiline string describing key parameters of bundle adjustment problem
1038
- :rtype: str
1039
- """
1040
- s = "Bundle adjustment problem:"
1041
- s += f" {self.nviews} views\n"
1042
- fixedcam = [i for i, view in enumerate(self.views) if view.isfixed]
1043
- if len(fixedcam) > 0:
1044
- s += f" {len(fixedcam)} locked views: {fixedcam}\n"
1045
- fixedlandmarks = [
1046
- i for i, landmark in enumerate(self.landmarks) if landmark.isfixed
1047
- ]
1048
- if len(fixedlandmarks) > 0:
1049
- s += f" {len(fixedlandmarks)} locked landmarks: {fixedlandmarks}\n"
1050
-
1051
- s += f" {self.nlandmarks} landmarks\n"
1052
-
1053
- s += f" {self.g.ne} projections\n"
1054
-
1055
- s += f" {self.nstates} total states\n"
1056
- s += f" {self.nvarstates} variable states\n"
1057
- s += f" {self.g.ne * 2} equations\n"
1058
- v = np.array(self.g.connectivity(self.views))
1059
- if len(self.views) > 0:
1060
- s += f" landmarks per view: min={v.min():d}, max={v.max():d}, avg={v.mean():.1f}\n"
1061
- if len(self.landmarks) > 0:
1062
- l = np.array(self.g.connectivity(self.landmarks))
1063
- s += f" views per landmark: min={l.min():d}, max={l.max():d}, avg={l.mean():.1f}\n"
1064
- return s
1065
-
1066
- else:
1067
-
1068
- class BundleAdjust:
1069
- pass
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__":