machinevision-toolbox-python 2.3.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,1036 +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
- assert len(uv) == 2, "uv must be a 2-vector"
409
-
410
- edge = Observation(viewpoint, landmark, uv.flatten()) # create edge object
411
- e = viewpoint.connect(landmark, edge=edge) # connect nodes with it
412
- e.name = viewpoint.name + "--" + landmark.name
413
-
414
- @classmethod
415
- def load_SBA(
416
- cls,
417
- cameraFile: str,
418
- pointFile: str,
419
- calibFile: str,
420
- imagesize: np.ndarray | None = None,
421
- ) -> "BundleAdjust":
422
- """
423
- Load bundle adjustment data files
424
-
425
- :param cameraFile: name of file with camera view data
426
- :type cameraFile: str
427
- :param pointFile: name of file with landmark data
428
- :type pointFile: str
429
- :param calibFile: name of file with camera intrinsic data
430
- :type calibFile: str
431
- :param imagesize: image plane dimensions in pixels, if not given infer
432
- it from principal point data in ``calibFile``
433
- :type imagesize: array_like(2)
434
-
435
- Provides access to bundle adjustment problems from data files as distributed with the SBA package.
436
- Details of the file format are given in the source code comments.
437
-
438
- Example:
439
-
440
- To solve the 7-point bundle adjustment problem distributed with
441
- SBA 1.6:
442
-
443
- .. code-block:: python
444
-
445
- ba = Bundle.load_SBA('7cams.txt', '7pts.txt', 'calib.txt')
446
- X = ba.optimize()
447
-
448
-
449
- :reference:
450
- - Sparse Bundle Adjustment package by Manolis Lourakis,
451
- http://users.ics.forth.gr/~lourakis/sba
452
-
453
- :seealso: :meth:`add_view` :meth:`add_landmark` :meth:`add_projection`
454
- """
455
- # Adopted from sba-1.6/matlab/eucsbademo.m
456
-
457
- # read calibration parameters
458
- #
459
- # f/rho_u skew u0
460
- # 0 f/rho_v v0
461
- # 0 0 1
462
- K = np.loadtxt(calibFile)
463
-
464
- # create the camera object
465
- if imagesize is None:
466
- # no image plane size given
467
- # infer it from the principal point
468
- imagesize = 2 * K[:2, 2]
469
-
470
- camera = CentralCamera(
471
- 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)"
472
587
  )
473
588
 
474
- # create a bundle adjustment instance
475
- ba = cls(camera)
476
-
477
- # read camera views
478
- #
479
- # each line is: qs qx qy qz tx ty tz
480
- for pose in np.loadtxt(cameraFile):
481
- ba.add_view(pose)
482
-
483
- # read points and projections
484
- #
485
- # The lines are of the form:
486
- #
487
- # X Y Z NFRAMES FRAME0 x0 y0 FRAME1 x1 y1 ...
488
- #
489
- # corresponding to a single 3D point and multiple projections:
490
- #
491
- # - X, Y, Z is the points' Euclidean 3D coordinates,
492
- # - NFRAMES the total number of camera views in which the point is
493
- # visible and there will follow NFRAMES subsequent triplets
494
- # - FRAME x y specifies that the 3D point in question projects to pixel
495
- # (x, y) in view number FRAME.
496
- #
497
- # For example, the line:
498
- #
499
- # 100.0 200.0 300.0 3 2 270.0 114.1 4 234.2 321.7 5 173.6 425.8
500
- #
501
- # describes a world point (100.0, 200.0, 300.0) that is visible in
502
- # three views: view 2 at (270.0, 114.1), view 4 at (234.2, 321.7) and
503
- # view 5 at (173.6, 425.8)
504
- with open(pointFile, "r") as file:
505
- npts = 0
506
- for line in file:
507
- if len(line) == 0 or line[0] == "#":
508
- continue
509
-
510
- data = line.split()
511
-
512
- # read X, Y, Z, nframes
513
- P = np.array([float(x) for x in data[:3]])
514
- data = data[3:]
515
- npts += 1
516
-
517
- # create a node for this point
518
- landmark = ba.add_landmark(P)
519
-
520
- # now find which cameras it was seen by
521
- nframes = int(data.pop(0))
522
- for i in range(nframes): # read "nframes" id, x, y triplets
523
- id = int(data.pop(0))
524
- u = float(data.pop(0))
525
- v = float(data.pop(0))
526
-
527
- # add a landmark projection
528
- ba.add_projection(ba.views[id], landmark, np.r_[u, v])
529
- return ba
530
-
531
- # =============== METHODS TO SOLVE PROBLEMS ==================== #
532
-
533
- def optimize(
534
- self,
535
- x: np.ndarray | None = None,
536
- animate: bool = False,
537
- lmbda: float = 0.1,
538
- lmbdamin: float = 1e-8,
539
- dxmin: float = 1e-4,
540
- tol: float = 0.5,
541
- iterations: int = 1000,
542
- verbose: bool = False,
543
- ) -> tuple[np.ndarray, float]:
544
- """
545
- Perform the bundle adjustment
546
-
547
- :param x: state vector, defaults to the state vector in the instance
548
- :type Xx: ndarray(N), optional
549
- :param animate: graphically animate the updates, defaults to False
550
- :type animate: bool, optional
551
- :param lmbda: initial damping term, defaults to 0.1
552
- :type lmbda: float, optional
553
- :param lmbdamin: minimum value of ``lmbda``, defaults to 1e-8
554
- :type lmbdamin: float, optional
555
- :param dxmin: terminate optimization if state update norm falls below this
556
- threshold, defaults to 1e-4
557
- :type dxmin: float, optional
558
- :param tol: terminate optimization if error total reprojection error
559
- falls below this threshold, defaults to 0.5 pixels
560
- :type tol: float, optional
561
- :param iterations: maximum number of iterations, defaults to 1000
562
- :type iterations: int, optional
563
- :param verbose: show Levenberg-Marquadt status, defaults to False
564
- :type verbose: bool, optional
565
- :return: optimized state vector
566
- :rtype: ndarray(N)
567
-
568
- Performs a Levenberg-Marquadt style optimization of the bundle
569
- adjustment problem which repeatedly calls :meth:`solve`. Adjusts
570
- camera poses and landmark positions in order to minimize the total
571
- reprojection error.
572
-
573
- :references:
574
- - |RVC3|, Section 14.3.2.
575
-
576
- :seealso: :meth:`nstates` :meth:`solve` :meth:`build_linear_system`
577
- """
578
-
579
- self.update_index()
580
-
581
- if x is None:
582
- x = self.getstate()
583
- x0 = x
584
-
585
- t0 = time.perf_counter()
586
-
587
- print(f"Bundle adjustment cost {self.errors(x0):.3g} -- initial")
588
- for i in range(iterations):
589
- if animate:
590
- if not retain:
591
- plt.clf()
592
- g2.plot()
593
- plt.pause(0.5)
594
-
595
- ta = time.perf_counter()
596
- # solve for the step
597
- dx, energy = self.solve(x, lmbda)
598
-
599
- # update the state
600
- x_new = self.updatestate(x, dx)
601
-
602
- # compute new value of cost
603
- enew = self.errors(x_new)
604
-
605
- dt = time.perf_counter() - ta
606
- print(f"Bundle adjustment cost {enew:.3g} (solved in {dt:.2f} sec)")
607
- # are we there yet?
608
- if enew < tol:
609
- break
610
-
611
- # have we stopped moving
612
- if base.norm(dx) < dxmin:
613
- break
614
-
615
- # do the Levenberg-Marquadt thing, was it a good update?
616
- if enew < energy:
617
- # step is accepted
618
- x = x_new
619
- if lmbda > lmbdamin:
620
- lmbda /= np.sqrt(2)
621
- if verbose:
622
- print(f" -- step accepted: lambda = {lmbda:g}")
623
- else:
624
- # step is rejected
625
- lmbda *= 4
626
- if verbose:
627
- print(f" -- step rejected: lambda ={lmbda:g}")
628
-
629
- tf = time.perf_counter()
630
- err = np.sqrt(enew / self.g.ne)
631
- print(f"\n * {i + 1} iterations in {tf - t0:.1f} seconds")
632
- print(f" * Final RMS error is {err:.2f} pixels")
633
-
634
- return x_new, err
635
-
636
- def solve(self, x: np.ndarray, lmbda: float = 0.0) -> tuple[np.ndarray, Any]:
637
- r"""
638
- Solve for state update
639
-
640
- :param x: state vector
641
- :type x: ndarray(N)
642
- :param lmbda: damping term, defaults to 0.0
643
- :type lmbda: float, optional
644
- :return: :math:`\delta \vec{X}`, update to the variable state vector
645
- :rtype: ndarray(M)
646
-
647
- Determines the state update :math:`\delta \vec{x}` by creating and
648
- solving the linear equation
649
-
650
- .. math:: \mat{H} \delta \vec{x} = \vec{b}
651
-
652
- where :math:`\mat{H}` is the Hessian and :math:`\mat{b}` is the the
653
- projection error.
654
-
655
- .. note::
656
- - The damping term ``lmbda`` is added to the diagonal of the
657
- Hessian to prevent problems when the Hessian is nearly
658
- singular.
659
- - If the problem includes fixed cameras or landmarks then
660
- :math:`\text{len}(\delta \vec{x}) < \text{len}(\vec{x})`
661
- since fixed elements are omitted from the variable state
662
- vector used for the optimization.
663
-
664
- :references:
665
- - |RVC3|, Section 14.3.2, F.2.4.
666
-
667
- :seealso: :meth:`build_linear_system`
668
- """
669
- # create the Hessian and error vector
670
- H, b, e = self.build_linear_system(x)
671
-
672
- # add damping term to the diagonal
673
- for i in range(self.nvarstates):
674
- H[i, i] += lmbda
675
-
676
- # solve for the state update
677
- # - could replace this with the Schur complement trick
678
- deltax = sparse.linalg.spsolve(H.tocsr(), b.tocsr())
679
- return deltax, e
680
-
681
- # build the Hessian and measurement vector
682
- def build_linear_system(self, x: np.ndarray) -> tuple[Any, Any, Any]:
683
- r"""
684
- Build the linear system
685
-
686
- :param x: state vector
687
- :type x: ndarray(N)
688
- :return: Hessian :math:`\mat{H}(\vec{x})` and projection error :math:`\vec{b}`
689
- :rtype: sparse_array(N,N), sparse_ndarray(N,1), float
690
-
691
- Build the block structured Hessian matrix based on current bundle
692
- adjustment state and the Jacobians.
693
-
694
- :references:
695
- - |RVC3|, Section 14.3.2, F.2.4.
696
-
697
- :seealso: :meth:`spy` :meth:`~Camera.CentralCamera.derivatives`
698
- """
699
-
700
- # this function is slow. lil matrices have similar speed to dok
701
- # matrices
702
- # H += A is slower than H = H + A
703
-
704
- from scipy.sparse import lil_matrix
705
-
706
- # allocate sparse matrices
707
- H = lil_matrix((self.nvarstates, self.nvarstates))
708
- b = lil_matrix((self.nvarstates, 1))
709
-
710
- etotal = 0
711
-
712
- # loop over views
713
- for view in self.views:
714
-
715
- # get camera pose
716
- k = view.index
717
- 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")
718
641
 
719
- # loop over all points viewed from this camera
720
- for landmark, edge in view.incidences():
642
+ return x_new, err
721
643
 
722
- k = landmark.index
723
- 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
724
647
 
725
- # for this view and landmark, get observation
726
- 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)
727
654
 
728
- # compute Jacobians and predicted projection
729
- 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
730
657
 
731
- # compute reprojection error as a column vector
732
- e = np.c_[uvhat - uv]
733
- etotal = etotal + e.T @ e
658
+ .. math:: \mat{H} \delta \vec{x} = \vec{b}
734
659
 
735
- i = view.index2
736
- j = landmark.index2
660
+ where :math:`\mat{H}` is the Hessian and :math:`\mat{b}` is the the
661
+ projection error.
737
662
 
738
- # compute the block components of H and b for this edge
739
- if not view.isfixed and not landmark.isfixed:
740
- # adjustable point and view
741
- H_ii = JA.T @ JA
742
- H_ij = JA.T @ JB
743
- 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.
744
671
 
745
- H[i : i + 6, i : i + 6] = H[i : i + 6, i : i + 6] + H_ii
746
- H[i : i + 6, j : j + 3] = H[i : i + 6, j : j + 3] + H_ij
747
- H[j : j + 3, i : i + 6] = H[j : j + 3, i : i + 6] + H_ij.T
748
- 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.
749
674
 
750
- b[i : i + 6, 0] = b[i : i + 6, 0] - JA.T @ e
751
- 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)
752
679
 
753
- elif view.isfixed and not landmark.isfixed:
754
- # fixed camera and adjustable point
680
+ # add damping term to the diagonal
681
+ for i in range(self.nvarstates):
682
+ H[i, i] += lmbda
755
683
 
756
- H[j : j + 3, j : j + 3] = H[j : j + 3, j : j + 3] + JB.T @ JB
757
- 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
758
688
 
759
- elif not view.isfixed and landmark.isfixed:
760
- # 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
761
693
 
762
- H[i : i + 6, i : i + 6] = H[i : i + 6, i : i + 6] + JA.T @ JA
763
- 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
764
698
 
765
- return H, b, etotal
699
+ Build the block structured Hessian matrix based on current bundle
700
+ adjustment state and the Jacobians.
766
701
 
767
- def spyH(self, x: np.ndarray, block: bool = False) -> None:
768
- """
769
- Display sparsity of Hessian
702
+ :references:
703
+ - |RVC3|, Section 14.3.2, F.2.4.
770
704
 
771
- :param x: state vector
772
- :type x: ndarray(N)
705
+ :seealso: :meth:`spy` :meth:`~Camera.CentralCamera.derivatives`
706
+ """
773
707
 
774
- Use Matplotlib to display the zero and non-zero elements of the
775
- Hessian.
708
+ # this function is slow. lil matrices have similar speed to dok
709
+ # matrices
710
+ # H += A is slower than H = H + A
776
711
 
777
- :seealso: :meth:`build_linear_system`
778
- """
779
- H, *_ = self.build_linear_system(x)
780
- plt.spy(H)
781
- safe_plt_show(block=True)
712
+ from scipy.sparse import lil_matrix
782
713
 
783
- def getstate(self) -> np.ndarray:
784
- """
785
- Get the state vector
714
+ # allocate sparse matrices
715
+ H = lil_matrix((self.nvarstates, self.nvarstates))
716
+ b = lil_matrix((self.nvarstates, 1))
786
717
 
787
- :return: state vector
788
- :rtype: ndarray(N)
718
+ etotal = 0
789
719
 
790
- Build the state vector by concatenating the pose of all cameras and
791
- then the position of all landmarks. That information is provided at
792
- problem initialization by calls to :meth:`add_view` and
793
- :meth:`add_landmark`.
720
+ # loop over views
721
+ for view in self.views:
794
722
 
795
- :seealso: :meth:`setstate` :meth:`nstates` :meth:`add_view` :meth:`add_landmark`
796
- """
797
- x = []
723
+ # get camera pose
724
+ k = view.index
725
+ X = x[k : k + 6]
798
726
 
799
- for view in self.views: # step through camera nodes
800
- x.extend(view.coord)
727
+ # loop over all points viewed from this camera
728
+ for landmark, edge in view.incidences():
801
729
 
802
- for landmark in self.landmarks: # step through landmark nodes
803
- x.extend(landmark.coord)
730
+ k = landmark.index
731
+ P = x[k : k + 3] # get landmark position
804
732
 
805
- return np.array(x)
733
+ # for this view and landmark, get observation
734
+ uv = edge.p
806
735
 
807
- def setstate(self, x: np.ndarray) -> None:
808
- """
809
- Update camera and landmark state
736
+ # compute Jacobians and predicted projection
737
+ uvhat, JA, JB = self.camera.derivatives(X, P)
810
738
 
811
- :param x: new state vector
812
- :type x: ndarray(N)
739
+ # compute reprojection error as a column vector
740
+ e = np.c_[uvhat - uv]
741
+ etotal = etotal + e.T @ e
813
742
 
814
- Copy new state data into the nodes of the bundle adjustment graph.
815
- Those nodes corresponding to fixed cameras or landmarks are
816
- unchanged.
743
+ i = view.index2
744
+ j = landmark.index2
817
745
 
818
- :seealso: :meth:`updatestate` :meth:`getstate`
819
- """
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
820
752
 
821
- for view in self.views: # step through view nodes
822
- X = x[:6]
823
- x = x[6:]
824
- if not view.isfixed:
825
- 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
826
757
 
827
- for landmark in self.landmarks:
828
- X = x[:3]
829
- x = x[3:]
830
- if not landmark.isfixed:
831
- landmark.coord = X
832
-
833
- def updatestate(self, x: np.ndarray, dx: np.ndarray) -> np.ndarray:
834
- """
835
- Update the state vector
836
-
837
- :param x: state vector
838
- :type x: ndarray(N)
839
- :param dx: variable state update vector
840
- :type dx: ndarray(M)
841
- :return: updated state vector
842
- :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
843
760
 
844
- The elements of the update to the variable state are inserted into
845
- the state vector. Those elements corresponding to fixed cameras or
846
- landmarks are unchanged.
761
+ elif view.isfixed and not landmark.isfixed:
762
+ # fixed camera and adjustable point
847
763
 
848
- :seealso: :meth:`setstate` :meth:`nstates`
849
- """
850
- 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
851
766
 
852
- # for each camera we need to compound the camera pose with the
853
- # incremental relative pose
854
- for view in self.views:
855
- k = view.index
856
- if view.isfixed:
857
- xnew[k : k + 6] = x[k : k + 6]
858
- else:
859
- # current pose
860
- X = x[k : k + 6]
861
- t = X[:3]
862
- qv = X[3:]
863
-
864
- # incremental pose
865
- k2 = view.index2
866
- dX = dx[k2 : k2 + 6]
867
- dt = dX[:3]
868
- dqv = dX[3:]
869
-
870
- tnew = t + dt # assume translation in old frame
871
- qvnew = UnitQuaternion.qvmul(qv, dqv)
872
-
873
- xnew[k : k + 6] = np.r_[tnew, qvnew]
874
-
875
- # for each landmark we add the increment to its position
876
- for landmark in self.landmarks:
877
- k = landmark.index
878
- P = x[k : k + 3]
879
- if landmark.isfixed:
880
- xnew[k : k + 3] = P
881
- else:
882
- k2 = landmark.index2
883
- dP = dx[k2 : k2 + 3]
884
- 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)
885
790
 
886
- return xnew
791
+ def getstate(self) -> np.ndarray:
792
+ """
793
+ Get the state vector
887
794
 
888
- # Compute total squared reprojection error
889
- def errors(self, x: np.ndarray | None = None) -> Any:
890
- """
891
- Total reprojection error
795
+ :return: state vector
796
+ :rtype: ndarray(N)
892
797
 
893
- :param x: state vector, defaults to state vector in instance
894
- :type x: ndarray(N), optional
895
- :return: total residual
896
- :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`.
897
802
 
898
- Compute the total reprojection error, of all projected landmarks
899
- on all camera viewpoints. Is ideally zero.
803
+ :seealso: :meth:`setstate` :meth:`nstates` :meth:`add_view` :meth:`add_landmark`
804
+ """
805
+ x = []
900
806
 
901
- :seealso: :meth:`getresidual`
902
- """
807
+ for view in self.views: # step through camera nodes
808
+ x.extend(view.coord)
903
809
 
904
- if x is None:
905
- x = self.getstate()
906
- r = self.getresidual(x)
810
+ for landmark in self.landmarks: # step through landmark nodes
811
+ x.extend(landmark.coord)
907
812
 
908
- return np.sum(r)
813
+ return np.array(x)
909
814
 
910
- def getresidual(self, x: np.ndarray | None = None) -> np.ndarray:
911
- r"""
912
- Get error residuals
815
+ def setstate(self, x: np.ndarray) -> None:
816
+ """
817
+ Update camera and landmark state
913
818
 
914
- :param X: state vector, defaults to state vector in instance
915
- :type X: ndarray(N), optional
916
- :return: residuals :math:`\mat{R}` for each observation
917
- :rtype: ndarray(V,L)
819
+ :param x: new state vector
820
+ :type x: ndarray(N)
918
821
 
919
- Returns a 2D array :math:`\mat{R}` whose elements :math:`r_{ij}`
920
- represent the Euclidean reprojection error for camera :math:`i`
921
- 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.
922
825
 
923
- :seealso: :meth:`errors`
924
- """
925
- # this is the squared reprojection errors
926
- self.update_index()
826
+ :seealso: :meth:`updatestate` :meth:`getstate`
827
+ """
927
828
 
928
- if x is None:
929
- 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
930
834
 
931
- residual = np.zeros((self.nviews, self.nlandmarks))
932
- # loop over views
933
- 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
934
840
 
935
- # get view pose
936
- 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
937
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
+ """
938
911
 
939
- # loop over all points viewed from this camera
940
- for landmark, edge in view.incidences():
941
-
942
- k = landmark.index
943
- P = x[k : k + 3] # get landmark position
944
-
945
- uv = edge.p
946
-
947
- uvhat, *_ = self.camera.derivatives(X, P)
948
- if np.any(np.isnan(uvhat)):
949
- print("bad uvhat in residual")
950
-
951
- # compute reprojection error
952
- e = uvhat - uv
953
- residual[view.id, landmark.id] = np.dot(e, e)
954
- return residual
955
-
956
- @property
957
- def graph(self) -> Any:
958
- """
959
- Get the scene graph
960
-
961
- :return: scene graph
962
- :rtype: :class:`PGraph`
963
-
964
- The scene graph has nodes representing camera viewpoints, of type
965
- :class:`ViewPoint`, and nodes representing landmarks, of type
966
- :class:`Landmark`. An edge, of type :class:`Observation`, exists
967
- between a landmark and the viewpoint that observed, and the edge has
968
- the associated image plane projection.
969
- """
970
- return self.g
971
-
972
- def plot(
973
- self,
974
- camera: dict[str, Any] = {},
975
- block: bool | None = None,
976
- ax: Any = None,
977
- **kwargs,
978
- ) -> None:
979
- """
980
- Plot the scene graph
981
-
982
- :param camera: options passed to :obj:`CentralCamera.plot`, defaults to {}
983
- :type camera: dict, optional
984
- :param ax: axis to plot into, defaults to None
985
- :type ax: Axes, optional
986
- :param kwargs: options passed to :obj:`PGraph.plot`
987
-
988
- Display the nodes and edges of the scene graph as an embedded graph.
989
- Overlay camera icons to indicate the camera viewpoint nodes.
990
-
991
- :seealso: :meth:`graph`
992
- """
993
- # plt.clf() # causes spurious 2d plot with Jupyter
994
- ax = base.plotvol3(ax=ax)
995
- self.g.plot(**kwargs) # edge=dict(color=0.8*np.r_[1, 1, 1]), **kwargs)
996
- # ax.set_aspect('equal')
997
-
998
- # colorOrder = get(gca, 'ColorOrder')
999
- for view in self.views:
1000
- cam = self.camera.move(view.pose)
1001
- # cidx = mod(i-1, numrows(colorOrder))+1
1002
- # color = colorOrder(cidx,:)
1003
- cam.plot(
1004
- pose=view.pose, ax=ax, color=view._color, **camera
1005
- ) # 'color', color, 'persist')
1006
- # ax.set_aspect('equal')
1007
- ax.set_xlabel("X (m)")
1008
- ax.set_ylabel("Y (m)")
1009
- ax.set_zlabel("Z (m)")
1010
- plt.grid(True)
1011
-
1012
- safe_plt_show(block=block)
1013
-
1014
- def __repr__(self) -> str:
1015
- """
1016
- String representation
1017
-
1018
- :return: multiline string describing key parameters of bundle adjustment problem
1019
- :rtype: str
1020
- """
1021
- return f"BundleAdjust(camera={repr(self.camera)}, views={self.nviews}, landmarks={self.nlandmarks}, states={self.nstates}, projections={self.g.ne})"
1022
-
1023
- def __str__(self) -> str:
1024
- """
1025
- String representation
1026
-
1027
- :return: multiline string describing key parameters of bundle adjustment problem
1028
- :rtype: str
1029
- """
1030
- s = "Bundle adjustment problem:"
1031
- s += f" {self.nviews} views\n"
1032
- fixedcam = [i for i, view in enumerate(self.views) if view.isfixed]
1033
- if len(fixedcam) > 0:
1034
- s += f" {len(fixedcam)} locked views: {fixedcam}\n"
1035
- fixedlandmarks = [
1036
- i for i, landmark in enumerate(self.landmarks) if landmark.isfixed
1037
- ]
1038
- if len(fixedlandmarks) > 0:
1039
- s += f" {len(fixedlandmarks)} locked landmarks: {fixedlandmarks}\n"
1040
-
1041
- s += f" {self.nlandmarks} landmarks\n"
1042
-
1043
- s += f" {self.g.ne} projections\n"
1044
-
1045
- s += f" {self.nstates} total states\n"
1046
- s += f" {self.nvarstates} variable states\n"
1047
- s += f" {self.g.ne * 2} equations\n"
1048
- v = np.array(self.g.connectivity(self.views))
1049
- if len(self.views) > 0:
1050
- s += f" landmarks per view: min={v.min():d}, max={v.max():d}, avg={v.mean():.1f}\n"
1051
- if len(self.landmarks) > 0:
1052
- l = np.array(self.g.connectivity(self.landmarks))
1053
- s += f" views per landmark: min={l.min():d}, max={l.max():d}, avg={l.mean():.1f}\n"
1054
- return s
1055
-
1056
- else:
1057
-
1058
- class BundleAdjust:
1059
- 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
1060
1063
 
1061
1064
 
1062
1065
  if __name__ == "__main__":