-
Notifications
You must be signed in to change notification settings - Fork 31
Expand file tree
/
Copy pathmodel_analysis.py
More file actions
2287 lines (1798 loc) · 96.5 KB
/
Copy pathmodel_analysis.py
File metadata and controls
2287 lines (1798 loc) · 96.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/python3
# Copyright (c) 2017-2023 California Institute of Technology ("Caltech"). U.S.
# Government sponsorship acknowledged. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
'''Routines for analysis of camera projection
This is largely dealing with uncertainty and projection diff operations.
All functions are exported into the mrcal module. So you can call these via
mrcal.model_analysis.fff() or mrcal.fff(). The latter is preferred.
'''
import numpy as np
import numpysane as nps
import sys
import re
import mrcal
def implied_Rt10__from_unprojections(q0, p0, v1,
*,
weights = None,
atinfinity = True,
focus_center = np.zeros((2,), dtype=float),
focus_radius = 1.0e8):
r'''Compute the implied-by-the-intrinsics transformation to fit two cameras' projections
SYNOPSIS
models = ( mrcal.cameramodel('cam0-dance0.cameramodel'),
mrcal.cameramodel('cam0-dance1.cameramodel') )
lensmodels = [model.intrinsics()[0] for model in models]
intrinsics_data = [model.intrinsics()[1] for model in models]
# v shape (...,Ncameras,Nheight,Nwidth,...)
# q0 shape (..., Nheight,Nwidth,...)
v,q0 = \
mrcal.sample_imager_unproject(60, None,
*models[0].imagersize(),
lensmodels, intrinsics_data,
normalize = True)
implied_Rt10 = \
mrcal.implied_Rt10__from_unprojections(q0, v[0,...], v[1,...])
q1 = mrcal.project( mrcal.transform_point_Rt(implied_Rt10, v[0,...]),
*models[1].intrinsics())
projection_diff = q1 - q0
When comparing projections from two lens models, it is usually necessary to
align the geometry of the two cameras, to cancel out any transformations implied
by the intrinsics of the lenses. This transformation is computed by this
function, used primarily by mrcal.show_projection_diff() and the
mrcal-show-projection-diff tool.
What are we comparing? We project the same world point into the two cameras, and
report the difference in projection. Usually, the lens intrinsics differ a bit,
and the implied origin of the camera coordinate systems and their orientation
differ also. These geometric uncertainties are baked into the intrinsics. So
when we project "the same world point" we must apply a geometric transformation
to compensate for the difference in the geometry of the two cameras. This
transformation is unknown, but we can estimate it by fitting projections across
the imager: the "right" transformation would result in apparent low projection
diffs in a wide area.
The primary inputs are unprojected gridded samples of the two imagers, obtained
with something like mrcal.sample_imager_unproject(). We grid the two imagers,
and produce normalized observation vectors for each grid point. We pass the
pixel grid from camera0 in q0, and the two unprojections in p0, v1. This
function then tries to find a transformation to minimize
norm2( project(camera1, transform(p0)) - q1 )
We return an Rt transformation to map points in the camera0 coordinate system to
the camera1 coordinate system. Some details about this general formulation are
significant:
- The subset of points we use for the optimization
- What kind of transformation we use
In most practical usages, we would not expect a good fit everywhere in the
imager: areas where no chessboards were observed will not fit well, for
instance. From the point of view of the fit we perform, those ill-fitting areas
should be treated as outliers, and they should NOT be a part of the solve. How
do we specify the well-fitting area? The best way is to use the model
uncertainties to pass the weights in the "weights" argument (see
show_projection_diff() for an implementation). If uncertainties aren't
available, or if we want a faster solve, the focus region can be passed in the
focus_center, focus_radius arguments. By default, these are set to encompass the
whole imager, since the uncertainties would take care of everything, but without
uncertainties (weights = None), these should be set more discriminately. It is
possible to pass both a focus region and weights, but it's probably not very
useful.
Unlike the projection operation, the diff operation is NOT invariant under
geometric scaling: if we look at the projection difference for two points at
different locations along a single observation ray, there will be a variation in
the observed diff. This is due to the geometric difference in the two cameras.
If the models differed only in their intrinsics parameters, then this would not
happen. Thus this function needs to know how far from the camera it should look.
By default (atinfinity = True) we look out to infinity. In this case, p0 is
expected to contain unit vectors. To use any other distance, pass atinfinity =
False, and pass POINTS in p0 instead of just observation directions. v1 should
always be normalized. Generally the most confident distance will be where the
chessboards were observed at calibration time.
Practically, it is very easy for the unprojection operation to produce nan or
inf values. And the weights could potentially have some invalid values also.
This function explicitly checks for such illegal data in p0, v1 and weights, and
ignores those points.
ARGUMENTS
- q0: an array of shape (Nh,Nw,2). Gridded pixel coordinates covering the imager
of both cameras
- p0: an array of shape (...,Nh,Nw,3). An unprojection of q0 from camera 0. If
atinfinity, this should contain unit vectors, else it should contain points in
space at the desired distance from the camera. This array may have leading
dimensions that are all used in the fit. These leading dimensions correspond
to those in the "weights" array
- v1: an array of shape (Nh,Nw,3). An unprojection of q0 from camera 1. This
should always contain unit vectors, regardless of the value of atinfinity
- weights: optional array of shape (...,Nh,Nw); None by default. If given, these
are used to weigh each fitted point differently. Usually we use the projection
uncertainties to apply a stronger weight to more confident points. If omitted
or None, we weigh each point equally. This array may have leading dimensions
that are all used in the fit. These leading dimensions correspond to those in
the "p0" array
- atinfinity: optional boolean; True by default. If True, we're looking out to
infinity, and I compute a rotation-only fit; a full Rt transformation is still
returned, but Rt[3,:] is 0; p0 should contain unit vectors. If False, I'm
looking out to a finite distance, and p0 should contain 3D points specifying
the positions of interest.
- focus_center: optional array of shape (2,); (0,0) by default. Used to indicate
that we're interested only in a subset of pixels q0, a distance focus_radius
from focus_center. By default focus_radius is LARGE, so we use all the points.
This is intended to be used if no uncertainties are available, and we need to
manually select the focus region.
- focus_radius: optional value; LARGE by default. Used to indicate that we're
interested only in a subset of pixels q0, a distance focus_radius from
focus_center. By default focus_radius is LARGE, so we use all the points. This
is intended to be used if no uncertainties are available, and we need to
manually select the focus region.
RETURNED VALUE
An array of shape (4,3), representing an Rt transformation from camera0 to
camera1. If atinfinity then we're computing a rotation-fit only, but we still
report a full Rt transformation with the t component set to 0
'''
# This is very similar in spirit to what compute_Rcorrected_dq_dintrinsics() did
# (removed in commit 4240260), but that function worked analytically, while this
# one explicitly computes the rotation by matching up known vectors.
import scipy.optimize
### flatten all the input arrays
# shape (N,2)
q0 = nps.clump(q0, n=q0.ndim-1)
# shape (M,N,3)
p0 = nps.transpose(nps.clump(nps.mv( nps.atleast_dims(p0, -3),
-1,-3),
n=-2))
# shape (N,3)
v1 = nps.clump(v1, n=v1.ndim-1)
if weights is None:
weights = np.ones(p0.shape[:-1], dtype=float)
else:
# shape (..., Nh,Nw) -> (M,N,) where N = Nh*Nw
weights = nps.clump( nps.clump(weights, n=-2),
n = weights.ndim-2)
# Any inf/nan weight or vector are set to 0
weights = weights.copy()
weights[ ~np.isfinite(weights) ] = 0.0
p0 = p0.copy()
v1 = v1.copy()
# p0 had shape (N,3). Collapse all the leading dimensions into one
# And do the same for weights
i_nan_p0 = ~np.isfinite(p0)
p0[i_nan_p0] = 0.
weights[i_nan_p0[...,0]] = 0.0
weights[i_nan_p0[...,1]] = 0.0
weights[i_nan_p0[...,2]] = 0.0
i_nan_v1 = ~np.isfinite(v1)
v1[i_nan_v1] = 0.
weights[..., i_nan_v1[...,0]] = 0.0
weights[..., i_nan_v1[...,1]] = 0.0
weights[..., i_nan_v1[...,2]] = 0.0
# We try to match the geometry in a particular region
q_off_center = q0 - focus_center
i = nps.norm2(q_off_center) < focus_radius*focus_radius
if np.count_nonzero(i)<3:
raise Exception("Focus region contained too few points")
p0_cut = p0 [..., i, :]
v1_cut = v1 [ i, :]
weights = weights[..., i ]
def residual_jacobian_rt(rt):
# rtp0 has shape (M,N,3)
# drtp0_drt has shape (M,N,3,6)
rtp0, drtp0_drt, _ = \
mrcal.transform_point_rt(rt, p0_cut,
get_gradients = True)
# inner(a,b)/(mag(a)*mag(b)) = cos(x) ~ 1 - x^2/2
# Each of these has shape (M,N,)
mag_rtp0 = nps.mag(rtp0)
inner = nps.inner(rtp0, v1_cut)
th2 = 2.* (1.0 - inner / mag_rtp0)
x = th2 * weights
# shape (M,N,6)
dmag_rtp0_drt = nps.matmult( nps.dummy(rtp0, -2), # shape (M,N,1,3)
drtp0_drt # shape (M,N,3,6)
# matmult has shape (M,N,1,6)
)[...,0,:] / \
nps.dummy(mag_rtp0, -1) # shape (M,N,1)
# shape (M,N,6)
dinner_drt = nps.matmult( nps.dummy(v1_cut, -2), # shape (M,N,1,3)
drtp0_drt # shape (M,N,3,6)
# matmult has shape (M,N,1,6)
)[...,0,:]
# dth2 = 2 (inner dmag_rtp0 - dinner mag_rtp0)/ mag_rtp0^2
# shape (M,N,6)
J = 2. * \
(nps.dummy(inner, -1) * dmag_rtp0_drt - \
nps.dummy(mag_rtp0, -1) * dinner_drt) / \
nps.dummy(mag_rtp0*mag_rtp0, -1) * \
nps.dummy(weights,-1)
return x.ravel(), nps.clump(J, n=J.ndim-1)
def residual_jacobian_r(r):
# rp0 has shape (M,N,3)
# drp0_dr has shape (M,N,3,3)
rp0, drp0_dr, _ = \
mrcal.rotate_point_r(r, p0_cut,
get_gradients = True)
# inner(a,b)/(mag(a)*mag(b)) ~ cos(x) ~ 1 - x^2/2
# Each of these has shape (M,N)
inner = nps.inner(rp0, v1_cut)
th2 = 2.* (1.0 - inner)
x = th2 * weights
# shape (M,N,3)
dinner_dr = nps.matmult( nps.dummy(v1_cut, -2), # shape (M,N,1,3)
drp0_dr # shape (M,N,3,3)
# matmult has shape (M,N,1,3)
)[...,0,:]
J = -2. * dinner_dr * nps.dummy(weights,-1)
return x.ravel(), nps.clump(J, n=J.ndim-1)
cache = {'rt': None}
def residual(rt, f):
if cache['rt'] is None or not np.array_equal(rt,cache['rt']):
cache['rt'] = rt
cache['x'],cache['J'] = f(rt)
return cache['x']
def jacobian(rt, f):
if cache['rt'] is None or not np.array_equal(rt,cache['rt']):
cache['rt'] = rt
cache['x'],cache['J'] = f(rt)
return cache['J']
# # gradient check
# import gnuplotlib as gp
# rt0 = np.random.random(6)*1e-3
# x0,J0 = residual_jacobian_rt(rt0)
# drt = np.random.random(6)*1e-7
# rt1 = rt0+drt
# x1,J1 = residual_jacobian_rt(rt1)
# dx_theory = nps.matmult(J0, nps.transpose(drt)).ravel()
# dx_got = x1-x0
# relerr = (dx_theory-dx_got) / ( (np.abs(dx_theory)+np.abs(dx_got))/2. )
# gp.plot(relerr, wait=1, title='rt')
# r0 = np.random.random(3)*1e-3
# x0,J0 = residual_jacobian_r(r0)
# dr = np.random.random(3)*1e-7
# r1 = r0+dr
# x1,J1 = residual_jacobian_r(r1)
# dx_theory = nps.matmult(J0, nps.transpose(dr)).ravel()
# dx_got = x1-x0
# relerr = (dx_theory-dx_got) / ( (np.abs(dx_theory)+np.abs(dx_got))/2. )
# gp.plot(relerr, wait=1, title='r')
# sys.exit()
# I was using loss='soft_l1', but it behaved strangely. For large
# f_scale_deg it should be equivalent to loss='linear', but I was seeing
# large diffs when comparing a model to itself:
#
# ./mrcal-show-projection-diff --gridn 50 28 test/data/cam0.splined.cameramodel{,} --distance 3
#
# f_scale_deg needs to be > 0.1 to make test-projection-diff.py pass, so
# there was an uncomfortably-small usable gap for f_scale_deg. loss='huber'
# should work similar-ish to 'soft_l1', and it works even for high
# f_scale_deg
f_scale_deg = 5
loss = 'huber'
if atinfinity:
# This is similar to a basic procrustes fit, but here we're using an L1
# cost function
r = np.random.random(3) * 1e-5
res = scipy.optimize.least_squares(residual,
r,
jac=jacobian,
method='dogbox',
loss=loss,
f_scale = (f_scale_deg * np.pi/180.)**2.,
# max_nfev=1,
args=(residual_jacobian_r,),
# Without this, the optimization was
# ending too quickly, and I was
# seeing not-quite-optimal solutions.
# Especially for
# very-nearly-identical rotations.
# This is tested by diffing the same
# model in test-projection-diff.py.
# I'd like to set this to None to
# disable the comparison entirely,
# but that requires scipy >= 1.3.0.
# So instead I set the threshold so
# low that it's effectively disabled
gtol = np.finfo(float).eps,
verbose=0)
Rt = np.zeros((4,3), dtype=float)
Rt[:3,:] = mrcal.R_from_r(res.x)
return Rt
else:
rt = np.random.random(6) * 1e-5
res = scipy.optimize.least_squares(residual,
rt,
jac=jacobian,
method='dogbox',
loss=loss,
f_scale = (f_scale_deg * np.pi/180.)**2.,
# max_nfev=1,
args=(residual_jacobian_rt,),
# Without this, the optimization was
# ending too quickly, and I was
# seeing not-quite-optimal solutions.
# Especially for
# very-nearly-identical rotations.
# This is tested by diffing the same
# model in test-projection-diff.py.
# I'd like to set this to None to
# disable the comparison entirely,
# but that requires scipy >= 1.3.0.
# So instead I set the threshold so
# low that it's effectively disabled
gtol = np.finfo(float).eps )
return mrcal.Rt_from_rt(res.x)
def worst_direction_stdev(cov):
r'''Compute the worst-direction standard deviation from a NxN covariance matrix
SYNOPSIS
# A covariance matrix
print(cov)
===>
[[ 1. -0.4]
[-0.4 0.5]]
# Sample 1000 0-mean points using this covariance
x = np.random.multivariate_normal(mean = np.array((0,0)),
cov = cov,
size = (1000,))
# Compute the worst-direction standard deviation of the sampled data
print(np.sqrt(np.max(np.linalg.eig(np.mean(nps.outer(x,x),axis=0))[0])))
===>
1.1102510878087053
# The predicted worst-direction standard deviation
print(mrcal.worst_direction_stdev(cov))
===> 1.105304960905736
The covariance of a (N,) random vector can be described by a (N,N)
positive-definite symmetric matrix. The 1-sigma contour of this random variable
is described by an ellipse with its axes aligned with the eigenvectors of the
covariance, and the semi-major and semi-minor axis lengths specified as the sqrt
of the corresponding eigenvalues. This function returns the worst-case standard
deviation of the given covariance: the sqrt of the largest eigenvalue.
Given the common case of a 2x2 covariance this function computes the result
directly. Otherwise it uses the numpy functions to compute the biggest
eigenvalue.
This function supports broadcasting fully.
DERIVATION
I solve this directly for the 2x2 case.
Let cov = (a b). If l is an eigenvalue of the covariance then
(b c)
(a-l)*(c-l) - b^2 = 0 --> l^2 - (a+c) l + ac-b^2 = 0
--> l = (a+c +- sqrt( a^2 + 2ac + c^2 - 4ac + 4b^2)) / 2 =
= (a+c +- sqrt( a^2 - 2ac + c^2 + 4b^2)) / 2 =
= (a+c)/2 +- sqrt( (a-c)^2/4 + b^2)
So the worst-direction standard deviation is
sqrt((a+c)/2 + sqrt( (a-c)^2/4 + b^2))
ARGUMENTS
- cov: the covariance matrices given as a (..., 2,2) array. Valid covariances
are positive-semi-definite (symmetric with eigenvalues >= 0), but this is not
checked
RETURNED VALUES
The worst-direction standard deviation. This is a scalar or an array, if we're
broadcasting
'''
cov = nps.atleast_dims(cov,-2)
if cov.shape[-2:] == (1,1):
return np.sqrt(cov[...,0,0])
if cov.shape[-2:] == (2,2):
a = cov[..., 0,0]
b = cov[..., 1,0]
c = cov[..., 1,1]
return np.sqrt((a+c)/2 + np.sqrt( (a-c)*(a-c)/4 + b*b))
if cov.shape[-1] != cov.shape[-2]:
raise Exception(f"covariance matrices must be square. Got cov.shape = {cov.shape}")
import scipy.sparse.linalg
@nps.broadcast_define( (('N','N',),),
() )
def largest_eigenvalue(V):
return \
scipy.sparse.linalg.eigsh(V, 1,
which = 'LM',
return_eigenvectors = False)[0]
return np.sqrt(largest_eigenvalue(cov))
def _observed_pixel_uncertainty_from_inputs(optimization_inputs,
x = None):
r'''Estimate the input noise from the solve residuals
Documented here:
https://mrcal.secretsauce.net/formulation.html#estimating-input-noise
'''
if x is None:
x = mrcal.optimizer_callback(**optimization_inputs,
no_jacobian = True,
no_factorization = True)[1]
sum_of_squares_measurements = 0
Nmeasurements = 0
# shape (Nobservations*2)
measurements = mrcal.measurements_board(optimization_inputs, x = x).ravel()
if measurements.size:
sum_of_squares_measurements += np.sum(measurements*measurements)
Nmeasurements += measurements.size
measurements = mrcal.measurements_point(optimization_inputs, x = x).ravel()
if measurements.size:
sum_of_squares_measurements += np.sum(measurements*measurements)
Nmeasurements += measurements.size
if Nmeasurements == 0:
raise Exception("observed_pixel_uncertainty cannot be computed because we don't have any board or point observations")
Nstate = mrcal.num_states(**optimization_inputs)
# The main takeaway from the docs
# https://mrcal.secretsauce.net/formulation.html#estimating-input-noise
# is:
# norm2(x) ~ (Nmeas - Nstate + tr(Jreg inv_JtJ Jreg^T ) ) sigma^2
#
# RMS(x) = sqrt(norm2(x)/Nmeas)
# = sigma sqrt(Nmeas - Nstate + tr(Jreg inv_JtJ Jreg^T ) / Nmeas)
# = sigma sqrt(1 - Nstate/Nmeas + tr(Jreg inv_JtJ Jreg^T ) / Nmeas)
#
# So the correction factor is sqrt(1 - Nstate/Nmeas + tr(Jreg inv_JtJ Jreg^T ) / Nmeas)
# I generally ignore the regularization
if True:
f = np.sqrt(1 - Nstate/Nmeasurements)
else:
# But as an experiment, I want to be able to include it. It should
# mostly not make any difference
Nmeasurements_regularization = mrcal.num_measurements_regularization(**optimization_inputs)
if Nmeasurements_regularization == 0:
f = np.sqrt(1 - Nstate/Nmeasurements)
else:
_,x,Jpacked,factorization = mrcal.optimizer_callback(**optimization_inputs)
# I assume the regularization measurements are at the end
# tr(Jreg inv_JtJ Jreg^T ) = tr(Jreg^T Jreg inv_JtJ )
Jreg = Jpacked[-Nmeasurements_regularization:]
JregtJreg = (Jreg.T @ Jreg).toarray().T
f_extra = np.linalg.trace( factorization.solve_xt_JtJ_bt(JregtJreg) )
f = np.sqrt(1 + (- Nstate + f_extra)/Nmeasurements)
return np.sqrt(sum_of_squares_measurements / Nmeasurements) / f
def _propagate_calibration_uncertainty( what,
*,
# One of these must be given. If it's
# dF_dbunpacked, then
# optimization_inputs must be given too
dF_dbpacked = None,
dF_dbunpacked = None,
# These are partly optional. I need
# everything except optimization_inputs.
# If any of the non-optimization_inputs
# arguments are missing I need
# optimization_inputs to compute them.
x = None,
factorization = None,
Jpacked = None,
Nmeasurements_observations_leading = None,
observed_pixel_uncertainty = None,
# can compute each of the above
optimization_inputs = None):
r'''Helper for uncertainty propagation functions
Propagates the calibration-time uncertainty to compute Var(F) for some arbitrary
vector F. The user specifies the gradient dF/db: the sensitivity of F to noise
in the calibration state. The vector F can have any length: this is inferred
from the dimensions of the given dF/db gradient.
The given factorization uses the packed, unitless state: b*.
The given Jpacked uses the packed, unitless state: b*. Jpacked applies to all
observations.
The leading Nmeasurements_observations_leading rows apply to the observations of
the calibration object, and we use just those for the input noise propagation.
If Nmeasurements_observations_leading is None: we auto-detect this; this is the
default. If Nmeasurements_observations_leading==0: assume that ALL the
measurements come from the calibration object observations; a simplifed
expression can be used in this case. This produces incorrect results today:
test/test-projection-uncertainty.py \
--fixed cam0 \
--model opencv4 \
--do-sample \
--reproject-perturbed cross-reprojection-rrp-Jfp \
--observed-pixel-uncertainty 0.03 \
--Nsamples 400 \
--Ncameras 2 \
--points
The given dF_dbpacked uses the packed, unitless state b*, so it already includes
the multiplication by D in the expressions below. It's usually sparse, but
stored densely.
The uncertainty computation in
https://mrcal.secretsauce.net/uncertainty.html concludes that
Var(b*) = observed_pixel_uncertainty^2 inv(J*tJ*) J*[observations]t J*[observations] inv(J*tJ*)
Where b* and J* are the UNITLESS, packed state and the jacobian respectively:
b = D b*
J = J* inv(D)
In the special case where all the measurements come from observations, this
simplifies to
Var(b*) = observed_pixel_uncertainty^2 inv(J*tJ*)
My factorization is of packed (scaled, unitless) flavors of J (J*). So
Var(b) = D Var(b*) D
I want Var(F) = dF/db Var(b) dF/dbt
So
Var(F) = dF/db D Var(b*) D dF/dbt
In the regularized case I have
Var(F) = dF/db D inv(J*tJ*) J*[observations]t J*[observations] inv(J*tJ*) D dF/dbt observed_pixel_uncertainty^2
I have
J* = [ J*[o] ]
[ J*[r] ]
where J*[r] has MANY FEWER rows than J*[o]. J*tJ* = J*[o]tJ*[o] + J*[r]tJ*[r], so
J*[o]tJ*[o] = J*tJ* - J*[r]tJ*[r]
Var(F) = dF/db D inv(J*tJ*) (J*tJ* - J*[r]tJ*[r]) inv(J*tJ*) D dF/dbt observed_pixel_uncertainty^2
= dF/db D inv(J*tJ*) D dF/dbt observed_pixel_uncertainty^2 -
dF/db D inv(J*tJ*) J*[r]tJ*[r] inv(J*tJ*) D dF/dbt observed_pixel_uncertainty^2
It is more efficient to compute inv(J*tJ*) D dF/dbt than inv(J*tJ*) J*[r]t:
there's less to compute, and the matrices are smaller. Thus I don't compute the
covariances directly.
In the non-regularized case:
Var(F) = dF/db D inv(J*tJ*) D dF/dbt
1. dF/db D inv(J*tJ*) = solve( J*tJ*, D dF/dbt)
The result has shape (Nstate,len(F))
2. pre-multiply by dF/db D
3. multiply by observed_pixel_uncertainty^2
In the regularized case:
Var(F) = dF/db D inv(J*tJ*) J*[observations]t J*[observations] inv(J*tJ*) D dF/dbt
1. solve( J*tJ*, D dF/dbt)
The result has shape (Nstate,len(F))
2. Pre-multiply by J*[observations]
The result has shape (Nmeasurements_observations_leading,2)
3. Compute the sum of the outer products of each row
4. multiply by observed_pixel_uncertainty^2
'''
what_known = set(('covariance', 'worstdirection-stdev', 'rms-stdev', '_covariance-raw'))
if not what in what_known:
raise Exception(f"'what' kwarg must be in {what_known}, but got '{what}'")
if dF_dbpacked is None and \
dF_dbunpacked is None:
raise Exception("Exactly one of dF_dbpacked,dF_dbunpacked must be given")
if dF_dbpacked is not None and \
dF_dbunpacked is not None:
raise Exception("Exactly one of dF_dbpacked,dF_dbunpacked must be given")
if dF_dbunpacked is not None:
if optimization_inputs is None:
raise Exception('dF_dbunpacked is given but optimization_inputs is not. Either pass dF_dbpacked or pass optimization_inputs in as well')
# Make dF_db use the packed state. I call "unpack_state" because the
# state is in the denominator
dF_dbpacked = np.array(dF_dbunpacked) # make a copy
mrcal.unpack_state(dF_dbpacked, **optimization_inputs)
if \
x is None or \
factorization is None or \
Jpacked is None or \
Nmeasurements_observations_leading is None or \
observed_pixel_uncertainty is None:
if optimization_inputs is None:
raise Exception("At least one of (factorization,Jpacked,Nmeasurements_observations_leading,observed_pixel_uncertainty) are None, so optimization_inputs MUST have been given to compute them")
if factorization is None or Jpacked is None or x is None:
_,x,Jpacked,factorization = mrcal.optimizer_callback(**optimization_inputs)
if factorization is None:
raise Exception("Cannot compute the uncertainty: factorization computation failed")
if Nmeasurements_observations_leading is None:
Nmeasurements_boards = mrcal.num_measurements_boards(**optimization_inputs)
Nmeasurements_points = mrcal.num_measurements_points(**optimization_inputs)
Nmeasurements_regularization = mrcal.num_measurements_regularization(**optimization_inputs)
Nmeasurements_all = mrcal.num_measurements(**optimization_inputs)
imeas_regularization = mrcal.measurement_index_regularization(**optimization_inputs)
if Nmeasurements_boards + \
Nmeasurements_points + \
Nmeasurements_regularization != \
Nmeasurements_all:
raise Exception("Some measurements other than boards, points and regularization are present. Don't know what to do")
if imeas_regularization is not None and \
imeas_regularization + Nmeasurements_regularization != Nmeasurements_all:
raise Exception("Regularization measurements are NOT at the end. Don't know what to do")
if Nmeasurements_regularization == 0:
# Note the special-case where I'm using all the observations. No other
# measurements are present other than the chessboard observations
Nmeasurements_observations_leading = 0
else:
Nmeasurements_observations_leading = \
Nmeasurements_all - Nmeasurements_regularization
if Nmeasurements_observations_leading == 0:
raise Exception("No non-regularization measurements. Don't know what to do")
if observed_pixel_uncertainty is None:
observed_pixel_uncertainty = _observed_pixel_uncertainty_from_inputs(optimization_inputs,
x = x)
def process_slice(dF_dbpacked):
if Nmeasurements_observations_leading > 0:
# I have regularization. Use the more complicated expression
# shape (N,Nstate) where N=2 usually
A = factorization.solve_xt_JtJ_bt( dF_dbpacked )
# I see no python way to do matrix multiplication with sparse matrices,
# so I have my own routine in C. AND the C routine does the outer
# product, so there's no big temporary expression. It's much faster
if A.ndim >= 2 and A.shape[-2] == 2:
f = mrcal._mrcal_npsp._A_Jt_J_At__2
else:
f = mrcal._mrcal_npsp._A_Jt_J_At
return \
f(A, Jpacked.indptr, Jpacked.indices, Jpacked.data,
Nleading_rows_J = Nmeasurements_observations_leading)
else:
# No regularization. Use the simplified expression
# The expression I had earlier. Works properly, but is slow:
# # time ./mrcal-show-projection-uncertainty --gridn 120 90 --hardcopy /tmp/tst.gp **/*.cameramodel(OL[1])
# # 21.48s user 0.27s system 99% cpu 21.750 total
#
# # shape (N,Nstate) where N=2 usually
# A = factorization.solve_xt_JtJ_bt( dF_dbpacked )
# Var_dF = nps.matmult(dF_dbpacked, nps.transpose(A))
#
# Instead, I do something smarter. I need to compute
#
# sigma^2 dF/db D inv(J*tJ*) D dF/dbt
#
# I just computed a factorization, so I have
#
# J*tJ* = L Lt
#
# so I can equivalently compute
#
# sigma^2 norm2( inv(L) D dF/dbt )
#
# when cholmod_solve2() tries to solve inv(JtJ) x = b, it solves two
# linear systems in series: one with L and then again with Lt. This new
# expression runs only one solve, which speeds things up dramatically.
# I tried several ways to compute this. All produce the same result, but
# have different speeds. By default cholmod gives me an LDLt
# factorization, not an LLt factorization (this is a different D), so I
# need to handle this extra D in some way.
# Slowest; two different solves.
# time ./mrcal-show-projection-uncertainty --gridn 120 90 --hardcopy /tmp/tst.gp **/*.cameramodel(OL[1])
# 20.64s user 1.30s system 99% cpu 21.938 total
#
# A1 = factorization.solve_xt_JtJ_bt( dF_dbpacked, sys='P' )
# A2 = factorization.solve_xt_JtJ_bt( A1, sys='L' )
# A3 = factorization.solve_xt_JtJ_bt( A1, sys='LD' )
# Var_dF = nps.matmult(A2, nps.transpose(A3))
#
# Fastest, but works only if I have an LLt factorization. Can be
# requested with this patch:
#
# diff --git a/mrcal-pywrap.c b/mrcal-pywrap.c
# index b0d45fcc..b7c87090 100644
# --- a/mrcal-pywrap.c
# +++ b/mrcal-pywrap.c
# @@ -290,3 +290,5 @@
# self->common.supernodal = 0;
#
# + // self->common.final_ll = 1;
# +
# // I want all output to go to STDERR, not STDOUT
#
# I don't know if there are downsides to this, so I don't do this.
#
# time ./mrcal-show-projection-uncertainty --gridn 120 90 --hardcopy /tmp/tst.gp **/*.cameramodel(OL[1])
# 11.80s user 0.38s system 99% cpu 12.187 total
#
# A1 = factorization.solve_xt_JtJ_bt( dF_dbpacked, sys='P' )
# A2 = factorization.solve_xt_JtJ_bt( A1, sys='L' )
# Var_dF = nps.matmult(A2, nps.transpose(A2))
# A bit slower, uses more memory, but works with LDLt. This is what I
# use
#
# time ./mrcal-show-projection-uncertainty --gridn 120 90 --hardcopy /tmp/tst.gp **/*.cameramodel(OL[1])
# 12.29s user 0.96s system 99% cpu 13.253 total
A1 = factorization.solve_xt_JtJ_bt( dF_dbpacked, sys='P' )
del dF_dbpacked
A2 = factorization.solve_xt_JtJ_bt( A1, sys='L' )
del A1
A3 = factorization.solve_xt_JtJ_bt( A2, sys='D' )
return \
nps.matmult(A2, nps.transpose(A3))
scalar = (dF_dbpacked.ndim == 1)
Var_dF = process_slice(dF_dbpacked)
if what == '_covariance-raw':
return Var_dF,observed_pixel_uncertainty
if what == 'covariance':
if scalar:
Var_dF = Var_dF[0,0]
return Var_dF * observed_pixel_uncertainty*observed_pixel_uncertainty
if what == 'worstdirection-stdev':
return worst_direction_stdev(Var_dF) * observed_pixel_uncertainty
if what == 'rms-stdev':
# Compute the RMS of the standard deviations in each direction
# RMS(stdev) =
# = sqrt( mean(stdev^2) )
# = sqrt( mean(var) )
# = sqrt( sum(var)/N )
# = sqrt( trace/N )
return np.sqrt(nps.trace(Var_dF)/Var_dF.shape[-1]) * observed_pixel_uncertainty
else:
raise Exception("Shouldn't have gotten here. There's a bug")
def _dq_db__Kunpacked_rrp(## write output here
# shape (..., 2,Nstate)
dq_db,
## inputs
# shape (..., Ncameras_extrinsics, 3)
p_ref,
# shape (..., 2,3)
dq_dpcam,
# shape (..., Ncameras_extrinsics,3,3)
dpcam_dpref,
# shape (6,Nstate)
Kunpacked_rrp,
*,
atinfinity):
# atinfinity = rotation-only
# K is drt_ref_refperturbed/db
# From https://mrcal.secretsauce.net/docs-3.0/uncertainty-cross-reprojection.html:
# q* = project( pcam*, intrinsics* )
# pcam* = Tc*r* pref*
# pref* = Tr*r pref
#
# So q* depends on Tr*r, Tc*,r*, intrinsics*. The extrinsics and intrinsics
# dependence was handled by the caller. Here I ingest only the dependence on
# Tr*r
Ncameras_extrinsics = p_ref.shape[-2]
if Ncameras_extrinsics != 1:
raise Exception("I only handle stationary cameras for now")
# shape (..., 3)
p_ref = p_ref[...,0,:]
# shape (..., 3,3)
dpcam_dpref = dpcam_dpref[...,0,:,:]
# So
# dq/db[frame_all,calobject_warp] =
# dq_dpcam dpcam__dpref dpref*__drt_ref_ref* Kunpacked_rrp
if dpcam_dpref is not None:
dq_dpref = nps.matmult(dq_dpcam, dpcam_dpref)
else:
dq_dpref = dq_dpcam
# pref = transform(rt_ref_ref*, pref*)
# where rt_ref_ref* is tiny
# -> pref ~ pref* + cross(r, pref) + t
# = pref* - cross(pref, r) + t
# -> 0 = dpref*/dr - skew(pref)
# 0 = dpref*/dt + I
# -> dpref*/dr = skew(pref)
# dpref*/dt = -I
dprefp__dr_ref_refp = mrcal.skew_symmetric(p_ref)
# I don't explicitly store dpref*/dt. I multiply by I implicitly
dq__dr_ref_refp = nps.matmult(dq_dpref, dprefp__dr_ref_refp)
# I apply this to the whole dq_db array. Kunpacked_rrp has 0 rows for
# the unaffected state, so the columns that should be untouched will
# be untouched
dq_db += nps.matmult(dq__dr_ref_refp, Kunpacked_rrp[:3,:])
if not atinfinity:
dq_db -= nps.matmult(dq_dpref, Kunpacked_rrp[3:,:])
def _dq_db__projection_uncertainty( # shape (...,3)
p_cam,
lensmodel,
# The intrinsics for THIS camera. If we
# optimize any of it, it's
# intrinsics_data[slice_intrinsics_arg].
# Partial optimization is possible:
# do_optimize_intrinsics_core can be on/off
intrinsics_data,
# The block of extrinsics for this camera.
rt_cam_ref,
# The block of frame poses for this camera.
# We optimize all of this, or nothing,
# depending on whether istate_frames0 is
# None
rt_ref_frame,
Nstate,
slice_intrinsics_state,
slice_intrinsics_arg,
slice_extrinsics_state,
istate_frames0,
*,
# shape (6,Nstate)
Kunpacked_cross_reprojection, # used iff method ~ "cross-reprojection-..."
method,
atinfinity):
r'''Helper for projection_uncertainty()
This is used for the older uncertainty methods, and NOT used for
cross-reprojection-ccp
See docs for _propagate_calibration_uncertainty() and
projection_uncertainty()
This function does all the work when observing points with a finite range
The underlying math is documented here:
- https://mrcal.secretsauce.net/docs-3.0/uncertainty-mean-pcam.html
- https://mrcal.secretsauce.net/docs-3.0/uncertainty-cross-reprojection.html
The end result for method == "mean-pcam":
q* - q
~ dq_dpcam (pcam* - pcam)
+ dq_dintrinsics db[intrinsics_this]
~ dq_dpcam/Ncam_frame sum(dpcam__drt_camj_ref db[extrinsics_j] +
dpcam__dpref_i (pref_i* - pref_i) )
+ dq_dintrinsics db[intrinsics_this]
~ dq_dpcam/Ncam_frame sum(dpcam__drt_camj_ref db[extrinsics_j] +
dpcam__dpref_i ( dpref__drt_ref_framei db[frame_i] +
dpref__dpframe_i d(pframe_i) )
+ dq_dintrinsics db[intrinsics_this]