basalt/include/basalt/linearization/landmark_block_abs_dynamic.hpp
Nikolaus Demmel 24325f2a06 ICCV'21 square root marginalization paper code release
Major changes:

- New square-root implementation for optimization and
  marginalization, giving faster optimization and numerically
  more stable marginalization. The square root solver is the new
  default, but the Schur complement based implementation is still
  available. (Implements the ICCV'21 paper.)

- The odometry estimator is now fully templetized and you can run
  in float or double. Default is float, which works well with the
  new square-root implementation and gives best runtimes.

- Batch evaluation scripts and documentation to reproduce the
  ICCV'21 experiments.

Additional changes:

- New options in VIO to marginalize lost landmark right away and
  not only when the frame is marginalized (enabled by default).

- small bugfix for keypoint patch extraction bounds

- basalt_vio: more logging for batch evaluation

- basalt_vio: better handling of closing the GUI while estimator is still running

- basalt_vio: new command line argument to limit the number of frames processed

- basalt_vio: new command line argument to save ground truth trajectory

- added unit tests for square root marginalization

- update basalt-headers

- new submodules: gmt, nlohmann/json, magic_enum
2021-10-15 15:09:15 +02:00

586 lines
19 KiB
C++

#pragma once
#include <fstream>
#include <mutex>
#include <basalt/utils/ba_utils.h>
#include <Eigen/Dense>
#include <Eigen/Sparse>
#include <basalt/linearization/landmark_block.hpp>
#include <basalt/utils/sophus_utils.hpp>
namespace basalt {
template <typename Scalar, int POSE_SIZE>
class LandmarkBlockAbsDynamic : public LandmarkBlock<Scalar> {
public:
using Options = typename LandmarkBlock<Scalar>::Options;
using State = typename LandmarkBlock<Scalar>::State;
inline bool isNumericalFailure() const override {
return state == State::NumericalFailure;
}
using Vec2 = Eigen::Matrix<Scalar, 2, 1>;
using Vec3 = Eigen::Matrix<Scalar, 3, 1>;
using Vec4 = Eigen::Matrix<Scalar, 4, 1>;
using VecX = Eigen::Matrix<Scalar, Eigen::Dynamic, 1>;
using Mat36 = Eigen::Matrix<Scalar, 3, 6>;
using MatX = Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic>;
using RowMatX =
Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic, Eigen::RowMajor>;
virtual inline void allocateLandmark(
Keypoint<Scalar>& lm,
const Eigen::aligned_unordered_map<std::pair<TimeCamId, TimeCamId>,
RelPoseLin<Scalar>>& relative_pose_lin,
const Calibration<Scalar>& calib, const AbsOrderMap& aom,
const Options& options,
const std::map<TimeCamId, size_t>* rel_order = nullptr) override {
// some of the logic assumes the members are at their initial values
BASALT_ASSERT(state == State::Uninitialized);
UNUSED(rel_order);
lm_ptr = &lm;
options_ = &options;
calib_ = &calib;
// TODO: consider for VIO that we have a lot of 0 columns if we just use aom
// --> add option to use different AOM with reduced size and/or just
// involved poses --> when accumulating results, check which case we have;
// if both aom are identical, we don't have to do block-wise operations.
aom_ = &aom;
pose_lin_vec.clear();
pose_lin_vec.reserve(lm.obs.size());
pose_tcid_vec.clear();
pose_tcid_vec.reserve(lm.obs.size());
// LMBs without host frame should not be created
BASALT_ASSERT(aom.abs_order_map.count(lm.host_kf_id.frame_id) > 0);
for (const auto& [tcid_t, pos] : lm.obs) {
size_t i = pose_lin_vec.size();
auto it = relative_pose_lin.find(std::make_pair(lm.host_kf_id, tcid_t));
BASALT_ASSERT(it != relative_pose_lin.end());
if (aom.abs_order_map.count(tcid_t.frame_id) > 0) {
pose_lin_vec.push_back(&it->second);
} else {
// Observation droped for marginalization
pose_lin_vec.push_back(nullptr);
}
pose_tcid_vec.push_back(&it->first);
res_idx_by_abs_pose_[it->first.first.frame_id].insert(i); // host
res_idx_by_abs_pose_[it->first.second.frame_id].insert(i); // target
}
// number of pose-jacobian columns is determined by oam
padding_idx = aom_->total_size;
num_rows = pose_lin_vec.size() * 2 + 3; // residuals and lm damping
size_t pad = padding_idx % 4;
if (pad != 0) {
padding_size = 4 - pad;
}
lm_idx = padding_idx + padding_size;
res_idx = lm_idx + 3;
num_cols = res_idx + 1;
// number of columns should now be multiple of 4 for good memory alignment
// TODO: test extending this to 8 --> 32byte alignment for float?
BASALT_ASSERT(num_cols % 4 == 0);
storage.resize(num_rows, num_cols);
damping_rotations.clear();
damping_rotations.reserve(6);
state = State::Allocated;
}
// may set state to NumericalFailure --> linearization at this state is
// unusable. Numeric check is only performed for residuals that were
// considered to be used (valid), which depends on
// use_valid_projections_only setting.
virtual inline Scalar linearizeLandmark() override {
BASALT_ASSERT(state == State::Allocated ||
state == State::NumericalFailure ||
state == State::Linearized || state == State::Marginalized);
// storage.setZero(num_rows, num_cols);
storage.setZero();
damping_rotations.clear();
damping_rotations.reserve(6);
bool numerically_valid = true;
Scalar error_sum = 0;
size_t i = 0;
for (const auto& [tcid_t, obs] : lm_ptr->obs) {
std::visit(
[&, obs = obs](const auto& cam) {
// TODO: The pose_lin_vec[i] == nullptr is intended to deal with
// dropped measurements during marginalization. However, dropped
// measurements should only occur for the remaining frames, not for
// the marginalized frames. Maybe these are observations bewtween
// two marginalized frames, if more than one is marginalized at the
// same time? But those we would not have to drop... Double check if
// and when this happens and possibly resolve by fixing handling
// here, or else updating the measurements in lmdb before calling
// linearization. Otherwise, check where else we need a `if
// (pose_lin_vec[i])` check or `pose_lin_vec[i] != nullptr` assert
// in this class.
if (pose_lin_vec[i]) {
size_t obs_idx = i * 2;
size_t abs_h_idx =
aom_->abs_order_map.at(pose_tcid_vec[i]->first.frame_id)
.first;
size_t abs_t_idx =
aom_->abs_order_map.at(pose_tcid_vec[i]->second.frame_id)
.first;
Vec2 res;
Eigen::Matrix<Scalar, 2, POSE_SIZE> d_res_d_xi;
Eigen::Matrix<Scalar, 2, 3> d_res_d_p;
using CamT = std::decay_t<decltype(cam)>;
bool valid = linearizePoint<Scalar, CamT>(
obs, *lm_ptr, pose_lin_vec[i]->T_t_h, cam, res, &d_res_d_xi,
&d_res_d_p);
if (!options_->use_valid_projections_only || valid) {
numerically_valid = numerically_valid &&
d_res_d_xi.array().isFinite().all() &&
d_res_d_p.array().isFinite().all();
const Scalar res_squared = res.squaredNorm();
const auto [weighted_error, weight] =
compute_error_weight(res_squared);
const Scalar sqrt_weight =
std::sqrt(weight) / options_->obs_std_dev;
error_sum += weighted_error /
(options_->obs_std_dev * options_->obs_std_dev);
storage.template block<2, 3>(obs_idx, lm_idx) =
sqrt_weight * d_res_d_p;
storage.template block<2, 1>(obs_idx, res_idx) =
sqrt_weight * res;
d_res_d_xi *= sqrt_weight;
storage.template block<2, 6>(obs_idx, abs_h_idx) +=
d_res_d_xi * pose_lin_vec[i]->d_rel_d_h;
storage.template block<2, 6>(obs_idx, abs_t_idx) +=
d_res_d_xi * pose_lin_vec[i]->d_rel_d_t;
}
}
i++;
},
calib_->intrinsics[tcid_t.cam_id].variant);
}
if (numerically_valid) {
state = State::Linearized;
} else {
state = State::NumericalFailure;
}
return error_sum;
}
virtual inline void performQR() override {
BASALT_ASSERT(state == State::Linearized);
// Since we use dense matrices Householder QR might be better:
// https://mathoverflow.net/questions/227543/why-householder-reflection-is-better-than-givens-rotation-in-dense-linear-algebr
if (options_->use_householder) {
performQRHouseholder();
} else {
performQRGivens();
}
state = State::Marginalized;
}
// Sets damping and maintains upper triangular matrix for landmarks.
virtual inline void setLandmarkDamping(Scalar lambda) override {
BASALT_ASSERT(state == State::Marginalized);
BASALT_ASSERT(lambda >= 0);
if (hasLandmarkDamping()) {
BASALT_ASSERT(damping_rotations.size() == 6);
// undo dampening
for (int n = 2; n >= 0; n--) {
for (int m = n; m >= 0; m--) {
storage.applyOnTheLeft(num_rows - 3 + n - m, n,
damping_rotations.back().adjoint());
damping_rotations.pop_back();
}
}
}
if (lambda == 0) {
storage.template block<3, 3>(num_rows - 3, lm_idx).diagonal().setZero();
} else {
BASALT_ASSERT(Jl_col_scale.array().isFinite().all());
storage.template block<3, 3>(num_rows - 3, lm_idx)
.diagonal()
.setConstant(sqrt(lambda));
BASALT_ASSERT(damping_rotations.empty());
// apply dampening and remember rotations to undo
for (int n = 0; n < 3; n++) {
for (int m = 0; m <= n; m++) {
damping_rotations.emplace_back();
damping_rotations.back().makeGivens(
storage(n, lm_idx + n),
storage(num_rows - 3 + n - m, lm_idx + n));
storage.applyOnTheLeft(num_rows - 3 + n - m, n,
damping_rotations.back());
}
}
}
}
// lambda < 0 means computing exact model cost change
virtual inline void backSubstitute(const VecX& pose_inc,
Scalar& l_diff) override {
BASALT_ASSERT(state == State::Marginalized);
// For now we include all columns in LMB
BASALT_ASSERT(pose_inc.size() == signed_cast(padding_idx));
const auto Q1Jl = storage.template block<3, 3>(0, lm_idx)
.template triangularView<Eigen::Upper>();
const auto Q1Jr = storage.col(res_idx).template head<3>();
const auto Q1Jp = storage.topLeftCorner(3, padding_idx);
Vec3 inc = -Q1Jl.solve(Q1Jr + Q1Jp * pose_inc);
// We want to compute the model cost change. The model function is
//
// L(inc) = F(x) + inc^T J^T r + 0.5 inc^T J^T J inc
//
// and thus the expected decrease in cost for the computed increment is
//
// l_diff = L(0) - L(inc)
// = - inc^T J^T r - 0.5 inc^T J^T J inc
// = - inc^T J^T (r + 0.5 J inc)
// = - (J inc)^T (r + 0.5 (J inc)).
//
// Here we have J = [Jp, Jl] under the orthogonal projection Q = [Q1, Q2],
// i.e. the linearized system (model cost) is
//
// L(inc) = 0.5 || J inc + r ||^2 = 0.5 || Q^T J inc + Q^T r ||^2
//
// and below we thus compute
//
// l_diff = - (Q^T J inc)^T (Q^T r + 0.5 (Q^T J inc)).
//
// We have
// | Q1^T | | Q1^T Jp Q1^T Jl |
// Q^T J = | | [Jp, Jl] = | |
// | Q2^T | | Q2^T Jp 0 |.
//
// Note that Q2 is the nullspace of Jl, and Q1^T Jl == R. So with inc =
// [incp^T, incl^T]^T we have
//
// | Q1^T Jp incp + Q1^T Jl incl |
// Q^T J inc = | |
// | Q2^T Jp incp |
//
// undo damping before we compute the model cost difference
setLandmarkDamping(0);
// compute "Q^T J incp"
VecX QJinc = storage.topLeftCorner(num_rows - 3, padding_idx) * pose_inc;
// add "Q1^T Jl incl" to the first 3 rows
QJinc.template head<3>() += Q1Jl * inc;
auto Qr = storage.col(res_idx).head(num_rows - 3);
l_diff -= QJinc.transpose() * (Scalar(0.5) * QJinc + Qr);
// TODO: detect and handle case like ceres, allowing a few iterations but
// stopping eventually
if (!inc.array().isFinite().all() ||
!lm_ptr->direction.array().isFinite().all() ||
!std::isfinite(lm_ptr->inv_dist)) {
std::cerr << "Numerical failure in backsubstitution\n";
}
// Note: scale only after computing model cost change
inc.array() *= Jl_col_scale.array();
lm_ptr->direction += inc.template head<2>();
lm_ptr->inv_dist = std::max(Scalar(0), lm_ptr->inv_dist + inc[2]);
}
virtual inline size_t numReducedCams() const override {
BASALT_LOG_FATAL("check what we mean by numReducedCams for absolute poses");
return pose_lin_vec.size();
}
inline void addQ2JpTQ2Jp_mult_x(VecX& res,
const VecX& x_pose) const override {
UNUSED(res);
UNUSED(x_pose);
BASALT_LOG_FATAL("not implemented");
}
virtual inline void addQ2JpTQ2r(VecX& res) const override {
UNUSED(res);
BASALT_LOG_FATAL("not implemented");
}
virtual inline void addJp_diag2(VecX& res) const override {
BASALT_ASSERT(state == State::Linearized);
for (const auto& [frame_id, idx_set] : res_idx_by_abs_pose_) {
const int pose_idx = aom_->abs_order_map.at(frame_id).first;
for (const int i : idx_set) {
const auto block = storage.block(2 * i, pose_idx, 2, POSE_SIZE);
res.template segment<POSE_SIZE>(pose_idx) +=
block.colwise().squaredNorm();
}
}
}
virtual inline void addQ2JpTQ2Jp_blockdiag(
BlockDiagonalAccumulator<Scalar>& accu) const override {
UNUSED(accu);
BASALT_LOG_FATAL("not implemented");
}
virtual inline void scaleJl_cols() override {
BASALT_ASSERT(state == State::Linearized);
// ceres uses 1.0 / (1.0 + sqrt(SquaredColumnNorm))
// we use 1.0 / (eps + sqrt(SquaredColumnNorm))
Jl_col_scale =
(options_->jacobi_scaling_eps +
storage.block(0, lm_idx, num_rows - 3, 3).colwise().norm().array())
.inverse();
storage.block(0, lm_idx, num_rows - 3, 3) *= Jl_col_scale.asDiagonal();
}
virtual inline void scaleJp_cols(const VecX& jacobian_scaling) override {
BASALT_ASSERT(state == State::Marginalized);
// we assume we apply scaling before damping (we exclude the last 3 rows)
BASALT_ASSERT(!hasLandmarkDamping());
storage.topLeftCorner(num_rows - 3, padding_idx) *=
jacobian_scaling.asDiagonal();
}
inline bool hasLandmarkDamping() const { return !damping_rotations.empty(); }
virtual inline void printStorage(const std::string& filename) const override {
std::ofstream f(filename);
Eigen::IOFormat CleanFmt(4, 0, " ", "\n", "", "");
f << "Storage (state: " << state
<< ", damping: " << (hasLandmarkDamping() ? "yes" : "no")
<< " Jl_col_scale: " << Jl_col_scale.transpose() << "):\n"
<< storage.format(CleanFmt) << std::endl;
f.close();
}
#if 0
virtual inline void stage2(
Scalar lambda, const VecX* jacobian_scaling, VecX* precond_diagonal2,
BlockDiagonalAccumulator<Scalar>* precond_block_diagonal,
VecX& bref) override {
// 1. scale jacobian
if (jacobian_scaling) {
scaleJp_cols(*jacobian_scaling);
}
// 2. dampen landmarks
setLandmarkDamping(lambda);
// 3a. compute diagonal preconditioner (SCHUR_JACOBI_DIAGONAL)
if (precond_diagonal2) {
addQ2Jp_diag2(*precond_diagonal2);
}
// 3b. compute block diagonal preconditioner (SCHUR_JACOBI)
if (precond_block_diagonal) {
addQ2JpTQ2Jp_blockdiag(*precond_block_diagonal);
}
// 4. compute rhs of reduced camera normal equations
addQ2JpTQ2r(bref);
}
#endif
inline State getState() const override { return state; }
virtual inline size_t numQ2rows() const override { return num_rows - 3; }
protected:
inline void performQRGivens() {
// Based on "Matrix Computations 4th Edition by Golub and Van Loan"
// See page 252, Algorithm 5.2.4 for how these two loops work
Eigen::JacobiRotation<Scalar> gr;
for (size_t n = 0; n < 3; n++) {
for (size_t m = num_rows - 4; m > n; m--) {
gr.makeGivens(storage(m - 1, lm_idx + n), storage(m, lm_idx + n));
storage.applyOnTheLeft(m, m - 1, gr);
}
}
}
inline void performQRHouseholder() {
VecX tempVector1(num_cols);
VecX tempVector2(num_rows - 3);
for (size_t k = 0; k < 3; ++k) {
size_t remainingRows = num_rows - k - 3;
Scalar beta;
Scalar tau;
storage.col(lm_idx + k)
.segment(k, remainingRows)
.makeHouseholder(tempVector2, tau, beta);
storage.block(k, 0, remainingRows, num_cols)
.applyHouseholderOnTheLeft(tempVector2, tau, tempVector1.data());
}
}
inline std::tuple<Scalar, Scalar> compute_error_weight(
Scalar res_squared) const {
// Note: Definition of cost is 0.5 ||r(x)||^2 to be in line with ceres
if (options_->huber_parameter > 0) {
// use huber norm
const Scalar huber_weight =
res_squared <= options_->huber_parameter * options_->huber_parameter
? Scalar(1)
: options_->huber_parameter / std::sqrt(res_squared);
const Scalar error =
Scalar(0.5) * (2 - huber_weight) * huber_weight * res_squared;
return {error, huber_weight};
} else {
// use squared norm
return {Scalar(0.5) * res_squared, Scalar(1)};
}
}
void get_dense_Q2Jp_Q2r(MatX& Q2Jp, VecX& Q2r,
size_t start_idx) const override {
Q2r.segment(start_idx, num_rows - 3) =
storage.col(res_idx).tail(num_rows - 3);
BASALT_ASSERT(Q2Jp.cols() == signed_cast(padding_idx));
Q2Jp.block(start_idx, 0, num_rows - 3, padding_idx) =
storage.block(3, 0, num_rows - 3, padding_idx);
}
void get_dense_Q2Jp_Q2r_rel(
MatX& Q2Jp, VecX& Q2r, size_t start_idx,
const std::map<TimeCamId, size_t>& rel_order) const override {
UNUSED(Q2Jp);
UNUSED(Q2r);
UNUSED(start_idx);
UNUSED(rel_order);
BASALT_LOG_FATAL("not implemented");
}
void add_dense_H_b(DenseAccumulator<Scalar>& accum) const override {
UNUSED(accum);
BASALT_LOG_FATAL("not implemented");
}
void add_dense_H_b(MatX& H, VecX& b) const override {
const auto r = storage.col(res_idx).tail(num_rows - 3);
const auto J = storage.block(3, 0, num_rows - 3, padding_idx);
H.noalias() += J.transpose() * J;
b.noalias() += J.transpose() * r;
}
void add_dense_H_b_rel(
MatX& H_rel, VecX& b_rel,
const std::map<TimeCamId, size_t>& rel_order) const override {
UNUSED(H_rel);
UNUSED(b_rel);
UNUSED(rel_order);
BASALT_LOG_FATAL("not implemented");
}
const Eigen::PermutationMatrix<Eigen::Dynamic>& get_rel_permutation()
const override {
BASALT_LOG_FATAL("not implemented");
}
Eigen::PermutationMatrix<Eigen::Dynamic> compute_rel_permutation(
const std::map<TimeCamId, size_t>& rel_order) const override {
UNUSED(rel_order);
BASALT_LOG_FATAL("not implemented");
}
void add_dense_H_b_rel_2(MatX& H_rel, VecX& b_rel) const override {
UNUSED(H_rel);
UNUSED(b_rel);
BASALT_LOG_FATAL("not implemented");
}
virtual TimeCamId getHostKf() const override { return lm_ptr->host_kf_id; }
private:
// Dense storage for pose Jacobians, padding, landmark Jacobians and
// residuals [J_p | pad | J_l | res]
Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic, Eigen::RowMajor>
storage;
Vec3 Jl_col_scale = Vec3::Ones();
std::vector<Eigen::JacobiRotation<Scalar>> damping_rotations;
std::vector<const RelPoseLin<Scalar>*> pose_lin_vec;
std::vector<const std::pair<TimeCamId, TimeCamId>*> pose_tcid_vec;
size_t padding_idx = 0;
size_t padding_size = 0;
size_t lm_idx = 0;
size_t res_idx = 0;
size_t num_cols = 0;
size_t num_rows = 0;
const Options* options_ = nullptr;
State state = State::Uninitialized;
Keypoint<Scalar>* lm_ptr = nullptr;
const Calibration<Scalar>* calib_ = nullptr;
const AbsOrderMap* aom_ = nullptr;
std::map<int64_t, std::set<int>> res_idx_by_abs_pose_;
};
} // namespace basalt