Skip to content

Conversation

@savuor
Copy link
Contributor

@savuor savuor commented Nov 7, 2021

Merge with extra: opencv/opencv_extra#942
Merge with contrib: opencv/opencv_contrib#3127

TODOs:

  • new interface: constructors, run(params) for linear, various factories, return report after optimization
  • (optional) implement geodesic acceleration
  • play with the params on existing code to find better combinations
  • add perf reports
  • remove old impl
  • remove all DEBUG code and fulfill all TODO statements

What?

New Levenberg-Marquadt algorithm implementation replaces the old one.

Why?

Because the old impl isn't as good as we want:

  • No sparse matrix support
  • No SO(3), SE(3) or any other manifold type supported for optimized params
  • No flexibility: fixed up/down coefficients and some deltas, a few options for a termination criteria

This stops it from using in some complex 3d-related problems such as pose graph optimization.

An algorithm termination deserves its own list of features:

  • Iteration calculation isn't fair: only successful ones are counted, a real amount of iterations elapsed can be several times bigger than reported
  • The optimization can stop and be considered successful even regardless of successful iterations elapsed count, cost function value drop or NaNs in a gradient
  • Step norm threshold is tied up to energy threshold

This is not OK even for a basic LevMarq solver.

How?

A new code fixes everything:

  • A base class does not depend on a type, layout or a group structure of a param vector or an objective function jacobian. A child class should provide a storage for that data and implement all virtual member functions that process it. This lets a user to use sparse matrices, exponential increments or fixed variables.
  • The algorithm is highly tunable:
    • Lambda initial/up/down values can be changed by a user, diagonal clamping or upFactor doubling can be turned on/off
    • A termination criteria can be composed of the following ones, each threshold is independent and tunable:
      • relative energy delta < threshold
      • gradient max value < threshold
      • step norm (L2 or Inf) < threshold
      • cost function < threshold
      • iterations count < maxIterations
  • Jacobian scaling, step quality metric and geodesic acceleration supported, they can improve the algorithm's speed/stability sometimes
  • A dense linear implementation converges at least as good as the old code does

This code is based on a current pose graph optimization routine. As a result, the pose graph has been rewritten too, now it uses the same LevMarq impl as other OpenCV functions.

Any numbers?

TLDR: new implementation converges more often in less iterations to approximately the same cost function values.

A convergence comparison across various use cases:

Convergence by function
function old good old total new good new total
calibrateCameraInternal 15 15 15 15
estimateAffine2D 11493 12111 12172 12172
solvePnPRefine 5 5 5 5
stereoCalibrateImpl 2 3 2 3
estimateAffinePartial2D 4300 6096 6858 6858
findExtrinsicCameraParams2 6018 6341 6238 6329
BundleAdjusterBase::estimate 783 783 709 709
findHomography 25044 30957 31351 31719
Final energy by function
function old min new min old max new max old avg new avg old med new med
calibrateCameraInternal 0.008321 0.008317 558.9 558.9 73.67 73.67 4.726 4.726
estimateAffine2D 0 0 3.267e+07 3.267e+07 7.014e+04 5.368e+05 0.009058 0.009339
solvePnPRefine 1.05e-26 2.985e-12 0.004971 0.004971 0.0009941 0.0009941 3.506e-17 2.179e-11
stereoCalibrateImpl 6.788 6.788 318.8 315.1 110.8 109.5 6.788 6.788
estimateAffinePartial2D 0 0 1209 1209 115.8 108.4 6.931e-09 3.247e-09
findExtrinsicCameraParams2 1.883e-29 2.958e-31 2.729e+42 2.729e+42 4.304e+38 4.313e+38 2.898e-10 4.199e-10
BundleAdjusterBase::estimate 26 26 4.598e+04 4.598e+04 1869 2274 266.2 764.6
findHomography 4.645e-27 0 2304 2304 34 30.6 0.003876 3.122e-05
Iterations elapsed till convergence by function
function old min new min old max new max old avg new avg old med new med
calibrateCameraInternal 5 4 157 54 60.07 24.73 70 26
estimateAffine2D 1 1 21 3 10.39 1.838 6 2
solvePnPRefine 3 2 7 4 5 3.6 5 4
stereoCalibrateImpl 45 1 71 1 58 1 58 1
estimateAffinePartial2D 1 1 21 6 4.561 1.655 4 1
findExtrinsicCameraParams2 1 1 42 20 3.015 1.158 2 1
BundleAdjusterBase::estimate 19 1 2100 38 64.32 3.224 55 4
findHomography 1 1 21 10 7.553 2.267 5 1

Other changes in this PR

  • Stereo calibration was broken during 4.x to 5.x porting, fixing it
  • Temporary fixes for Submap class and related stuff (anyway it'll be done using updated PoseGraph class)
  • minor changes

Pull Request Readiness Checklist

See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request

  • I agree to contribute to the project under Apache 2 License.
  • To the best of my knowledge, the proposed patch is not based on a code under GPL or other license that is incompatible with OpenCV
  • The PR is proposed to proper branch
  • There is reference to original bug report and related work
  • There is accuracy test, performance test and test data in opencv_extra repository, if applicable
    Patch to opencv_extra has the same branch name.
  • The feature is well documented and sample code can be built with the project CMake

@savuor savuor changed the base branch from next to 5.x November 8, 2021 10:04
@savuor savuor marked this pull request as ready for review December 9, 2021 16:25
@savuor savuor requested a review from alalek December 10, 2021 11:19
Copy link
Member

@alalek alalek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should revise public API.
Keep minimal part which is necessary for user application (tests).

Internal interfaces like "BaseLevMarq::Backend" should be hidden.

For more details, please refer to Wikipedia page (https://en.wikipedia.org/wiki/Levenberg%E2%80%93Marquardt_algorithm).
*/
class CV_EXPORTS LMSolver : public Algorithm
class CV_EXPORTS BaseLevMarq
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseLevMarq -> LevMarqBase or LevMarqSolver

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


Ptr<Backend> pBackend;

BaseLevMarq(Ptr<Backend> backend_) :
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, const reference for all input parameter.


no underscores in names if we don't need them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines 545 to 577
// normalize jacobian columns for better conditioning
// slows down sparse solver, but maybe this'd be useful for some other solver
bool jacobiScaling;
// double upFactor until the probe is successful
bool upDouble;
// use stepQuality metrics for steps down
bool useStepQuality;
// clamp diagonal values added to J^T*J to pre-defined range of values
bool clampDiagonal;
// to use squared L2 norm or Inf norm for step size estimation
bool stepNormInf;
// to use relEnergyDeltaTolerance or not
bool checkRelEnergyChange;
// to use minGradientTolerance or not
bool checkMinGradient;
// to use stepNormTolerance or not
bool checkStepNorm;
// to use geodesic acceleration or not
bool geodesic;
// second directional derivative approximation step for geodesic acceleration
double hGeo;
// how much of geodesic acceleration is used
double geoScale;
// optimization stops when norm2(dx) drops below this value
double stepNormTolerance;
// optimization stops when relative energy change drops below this value
double relEnergyDeltaTolerance;
// optimization stops when max gradient value (J^T*b vector) drops below this value
double minGradientTolerance;
// optimization stops when energy drops below this value
double smallEnergyTolerance;
// optimization stops after a number of iterations performed
unsigned int maxIterations;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No fields in public API.
This is the primary requirement of public OpenCV API.
getter/setter should be exposed instead.

Copy link
Contributor

@vpisarev vpisarev Dec 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we try to utilize https://github.com/opencv/opencv/wiki/OE-34.-Named-Parameters? It lets to define very nice API for C++ and Python (and maybe Swift after the corresponding binding generator is revised)

Copy link
Contributor Author

@savuor savuor Dec 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented it as a Settings structure

double energy;
};

class Backend
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it is not used by user code directly. Move to src or details.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation to leave the Backend in public was to let a user to construct solvers that use sparse matrices, params of SE(3) and of more exotic groups, with fixed variables and so on.
I will try to move it to details to keep this possibility.
(However the interface is very complicated for a user)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backend is moved to details header

Comment on lines 497 to 499
bool found;
int iters;
double energy;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing documentation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added docs for Report structure

inline Vec<T, 3> DualQuat<T>::getTranslation(QuatAssumeType assumeUnit) const
{
Quat<T> trans = 2.0 * (getDualPart() * getRealPart().inv(assumeUnit));
Quat<T> trans = T(2.0) * (getDualPart() * getRealPart().inv(assumeUnit));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such changes should be backported to 4.x

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backported to #21319 and #3137@contrib

Comment on lines 1067 to 1069
solver.maxIterations = (unsigned int)(termCrit.maxCount * 2.1);
solver.stepNormTolerance = termCrit.epsilon;
solver.smallEnergyTolerance = termCrit.epsilon * termCrit.epsilon;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have setter with TermCriteria parameter.

maxIterations = (unsigned int)(termCrit.maxCount * 2.1);
2.1

Why?

Copy link
Contributor Author

@savuor savuor Dec 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2.1 is a compatibility hack. Old impl counts successful iterations only, the new one counts all iterations.
The proportion between them for the same run is different per use case but in average it is 2.1: newIters = oldIters*2.1

I propose three solutions here:

  • to leave this hack as is (adding a comment about it in src)
  • to find true multiplier for each use case based on iterations elapsed statistics
  • remove the multiplier: it's 5.x now, we don't have to maintain such parameters compatibility

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, having such a hack is fine.

  1. If the problem solved is more or less well-defined (not ill-posed), the solver should converge faster than it will reach the maximum.
  2. If the problem solved is ill-posed then we will do at least termCrit.maxCount iterations, so this hack may affect the speed, but not the quality. But, I believe, because the new solver uses the sparse structure of matrices, it should run even faster than the previous "dense" implementation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiplier removed, statistics recalculated.
Regarding TermCriteria usage: I'm against it, it has only one epsilon parameter and it's not obvious to what threshold in this LevMarq impl it corresponds to.

initialLmDownFactor(3.0)
{ }

bool operator==(const Settings& other) const
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If users don't need this functionality, then it is better to create internal function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed
(was made to compare a passed arg with default settings constant, default arg is used instead)

Settings() :
jacobiScaling(false),
upDouble(true),
useStepQuality(true),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move ctor implementation to .cpp file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


Settings& jacobiScalingS (bool v) { jacobiScaling = v; return *this; }
Settings& upDoubleS (bool v) { upDouble = v; return *this; }
Settings& useStepQualityS (bool v) { useStepQuality = v; return *this; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is here 'S' suffix?
Where are you find that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

S stands for "set" but I didn't want to give them names like setEpsilon to make method names more compact.
Since you recommended not to use underscores, I seek other ways to name them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided to use setValue names instead, looks more natural

return ok;
}

Settings& jacobiScalingS (bool v) { jacobiScaling = v; return *this; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline for all setters

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Settings& relEnergyDeltaToleranceS(double v) { relEnergyDeltaTolerance = v; return *this; }
Settings& minGradientToleranceS (double v) { minGradientTolerance = v; return *this; }
Settings& smallEnergyToleranceS (double v) { smallEnergyTolerance = v; return *this; }
Settings& maxIterationsS (unsigned int v) { maxIterations = v; return *this; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unsigned int

This type may be non-friendly with bindings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced by int

Comment on lines 636 to 642
/*
Defined in details header
*/
class CV_EXPORTS Backend
{
public:
virtual ~Callback() {}
/**
computes error and Jacobian for the specified vector of parameters
@param param the current vector of parameters
@param err output vector of errors: err_i = actual_f_i - ideal_f_i
@param J output Jacobian: J_ij = d(err_i)/d(param_j)
when J=noArray(), it means that it does not need to be computed.
Dimensionality of error vector and param vector can be different.
The callback should explicitly allocate (with "create" method) each output array
(unless it's noArray()).
*/
virtual bool compute(InputArray param, OutputArray err, OutputArray J) const = 0;
virtual ~Backend() { }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defined in

Is it not a definition?

Why does forward declaration not work here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backend class removed

The final vector of parameters (whether the algorithm converged or not) is stored at the same
vector. The method returns the number of iterations used. If it's equal to the previously specified
maxIters, there is a big chance the algorithm did not converge.
LevMarqBase(const Ptr<Backend>& backend, const Settings& settings);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users don't really need this constructor. As they can't use it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, see the comment below

…, no Backend class, Settings() => .cpp, Settings==() removed, Settings.set...() inlines
@savuor
Copy link
Contributor Author

savuor commented Dec 26, 2021

  1. LevMarqBase was moved to detail headers
  2. LevMarqDenseLinear was replaced by a class LevMarq which takes enum args responsible for "dense" and "linear" properties respectively; other types of solvers are not implemented now and reserved for future.

@savuor savuor requested a review from alalek December 27, 2021 11:27
Copy link
Member

@alalek alalek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me 👍

@alalek alalek merged commit 9d6f388 into opencv:5.x Dec 27, 2021
@savuor savuor deleted the levmarqfromscratch branch December 27, 2021 21:57
@mshabunin mshabunin mentioned this pull request Jun 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants