Avoid SVD to compute optimal rotation between point sets

Draft note

Original article:

 https://zalo.github.io/blog/kabsch/

And here the javascript code for this article that finds the best transformation (rotation and translation) to match two sets of point clouds.

/*
Original author:
https://zalo.github.io/blog/kabsch/

*/

import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.114/build/three.module.js";
import {
    OrbitControls
}
from "https://cdn.jsdelivr.net/npm/three@0.114/examples/jsm/controls/OrbitControls.js";
import {
    DragControls
}
from "https://cdn.jsdelivr.net/npm/three@0.114/examples/jsm/controls/DragControls.js";
("use strict");

var g_camera, g_scene, g_renderer;
var g_controls;

var g_pointsRef = []; // blue boxes (The aim)
var g_pointsToMatch = []; // white boxes (to be transformed to match the aim)

var g_avg;
var g_curQuaternion = new THREE.Quaternion(0, 0, 0, 1);
var g_numPoints = 6;
var g_nb_iterations = 1;

// set to "true" to make the blue cubes rotates 
// and observe we are able to find back the rotation
var g_animate = false;
var g_do_matching = true;

// ===============================================================

init();
animate();

// ===============================================================

function pseudo_rand_1(i){ return ((i * 212435 + 2312  ) % 255) / 255.0; }
function pseudo_rand_2(i){ return ((i * 878923 + 123342  ) % 255) / 255.0; }
function pseudo_rand_3(i){ return ((i * 18464 + 79874  ) % 255) / 255.0; }

function pseudo_rand_4(i){ return ((i * 896559 + 626  ) % 255) / 255.0; }
function pseudo_rand_5(i){ return ((i * 3645 + 1666) % 255) / 255.0; }
function pseudo_rand_6(i){ return ((i * 85236 + 375961  ) % 255) / 255.0; }

function initPoints(draggableObjects) {
    let boxGeometry = new THREE.BoxBufferGeometry(100, 100, 100);
    let blue = new THREE.MeshPhongMaterial({
        color: 0x3399dd
    });
    let white = new THREE.MeshLambertMaterial({
        color: 0x888888
    });

    let scl = new THREE.Vector3(100, 100, 100);
    for (let i = 0; i < g_numPoints; i++) {
        let box = new THREE.Mesh(boxGeometry, blue);
        g_scene.add(box);
        box.scale.set(0.075, 0.075, 0.075);        
        if(true){
        box.position.set(
            pseudo_rand_1(i+628) * scl.x - scl.x / 2.0,
            pseudo_rand_2(i+923) * scl.y - scl.y / 2.0 + 100.0,
            pseudo_rand_3(i+123) * scl.z - scl.z / 2.0
            );      
        }else{
        box.position.set(
            Math.random() * scl.x - scl.x / 2,
            Math.random() * scl.y - scl.y / 2 + 100,
            Math.random() * scl.z - scl.z / 2
            );
        }
        box.castShadow = true;
        draggableObjects.push(box);
        g_pointsRef.push(box);
    }

    let rcl = new THREE.Vector3(20, 20, 20);
    for (let i = 0; i < g_numPoints; i++) {
        let box = new THREE.Mesh(boxGeometry, white);
        g_scene.add(box);
        box.scale.set(0.05, 0.05, 0.05);
        let randomOffset;
        if(true) {        
        randomOffset = new THREE.Vector3(
                pseudo_rand_4(i+1535) * rcl.x - rcl.x / 2 + pseudo_rand_1(i+628) * rcl.x * 3,
                pseudo_rand_5(i+6389) * rcl.y - rcl.y / 2 + 93,                
                pseudo_rand_6(i+1453) * rcl.z - rcl.z / 2 + 3);
        }else{ 
        randomOffset = new THREE.Vector3(
                Math.random() * rcl.x - rcl.x / 2,
                Math.random() * rcl.y - rcl.y / 2 + 100,
                Math.random() * rcl.z - rcl.z / 2);
        }

        box.position.copy(randomOffset.add(g_pointsRef[i].position));
        box.castShadow = true;
        g_pointsToMatch.push(box);
    }

    let average = getAverage(g_pointsRef);

    g_camera.position.y = 150;
    g_camera.lookAt(average);
    if (g_controls) {
        g_controls.target.set(average.x, average.y, average.z);
        g_controls.update();
    }

    g_avg = new THREE.Mesh(
            boxGeometry,
            new THREE.MeshPhongMaterial({
                color: 0xdd3333
            }));
    g_scene.add(g_avg);
    g_avg.scale.set(0.075, 0.075, 0.075);
    g_avg.position.set(average.x, average.y, average.z);
    g_avg.castShadow = true;
}

function getAverage(points) {
    let average = new THREE.Vector3(0, 0, 0);
    for (let i = 0; i < points.length; i++) {
        average.add(points[i].position);
    }
    average.divideScalar(points.length);
    return average;
}

//https://animation.rwth-aachen.de/media/papers/2016-MIG-StableRotation.pdf
//Iteratively apply torque to the basis using Cross products (in place of SVD)
function quaternionTorqueDecomposition(A, curQuaternion, iterations = 9) { 
    // Cancels out the momentum from the prior frame
    let resQuaternion = new THREE.Quaternion(0, 0, 0, 1);
    // or take int account previous frame for faster convergence:
    //resQuaternion.copy(curQuaternion);     
    
    let QuatBasis = [ 
        new THREE.Vector3(1, 0, 0),
        new THREE.Vector3(0, 1, 0),
        new THREE.Vector3(0, 0, 1)
    ];
  
    let quatMatrix = new THREE.Matrix4().makeRotationFromQuaternion(resQuaternion);

    for (let iter = 0; iter < iterations; iter++) {
        quatMatrix.makeRotationFromQuaternion(resQuaternion);
        quatMatrix.extractBasis(QuatBasis[0], QuatBasis[1], QuatBasis[2]);

        let omegaDenom = Math.abs(
                QuatBasis[0].dot(A[0]) +
                QuatBasis[1].dot(A[1]) +
                QuatBasis[2].dot(A[2]) +
                0.00000001);

        let omega = QuatBasis[0]
            .clone()
            .cross(A[0])
            .add(QuatBasis[1].clone().cross(A[1]))
            .add(QuatBasis[2].clone().cross(A[2]))
            .divideScalar(omegaDenom);

        let w = omega.length();
        if (w < 0.00000001) {
            break;
        }
        resQuaternion.premultiply(
            new THREE.Quaternion().setFromAxisAngle(omega.normalize(), w));
        resQuaternion.normalize(); //Normalizes the Quaternion; critical for error suppression
    }
  
  return resQuaternion;
}

function transposeMult(vec1, vec2) {
    let covariance = [
        // Initialize Cross Covariance Matrix
        new THREE.Vector3(0, 0, 0),
        new THREE.Vector3(0, 0, 0),
        new THREE.Vector3(0, 0, 0)
    ];

    for (let i = 0; i < 3; i++) {
        //i is the row in this matrix
        for (let j = 0; j < 3; j++) {
            //j is the column in the other matrix
            for (let k = 0; k < vec1.length; k++) {
                //k is the column in this matrix
                covariance[i].setComponent(
                    j,
                    covariance[i].getComponent(j) +
                    vec1[k].getComponent(i) * vec2[k].getComponent(j));
            }
        }
    }
    return covariance;
}

function kabschPoints(
            pointsIn /*To be transformed to match the aim (white*/,
            pointsRef /*Base position / aim (blue)*/) 
{
    let workingRef = [];
    let workingIn = [];
    let refAverage = getAverage(pointsRef);
    let inAverage = getAverage(pointsIn);

    // Mean-center the points for the optimal translation
    for (let i = 0; i < pointsRef.length; i++) {
        // p_i - avg      
        workingRef.push(pointsRef[i].position.clone().sub(refAverage));        
        workingIn .push(pointsIn[i] .position.clone().sub(inAverage ));
    }

    // Calculate the optimal rotation
    let crossCovarianceMatrix = transposeMult(/*const*/workingIn, /*const*/workingRef);
    g_curQuaternion = quaternionTorqueDecomposition(crossCovarianceMatrix, g_curQuaternion, g_nb_iterations);
     

    // Apply the optimal translation and rotation
    for (let i = 0; i < workingIn.length; i++) {
        workingIn[i].applyQuaternion(g_curQuaternion);
        pointsIn[i].position.copy(workingIn[i].add(refAverage));
    }
}

function init() {
    let container = document.createElement("div");
    document.body.appendChild(container);

    g_camera = new THREE.PerspectiveCamera(
            60,
            window.innerWidth / window.innerHeight,
            1,
            2000);
    g_camera.position.set(50, 100, 150);

    g_scene = new THREE.Scene();
    g_scene.background = new THREE.Color(0xa0a0a0); //0xffffff
    g_scene.fog = new THREE.Fog(0xa0a0a0, 200, 800); //0xffffff

    let light1 = new THREE.HemisphereLight(0xffffff, 0x444444);
    light1.position.set(0, 200, 0);
    g_scene.add(light1);

    let light2 = new THREE.DirectionalLight(0xbbbbbb);
    light2.position.set(0, 200, 100);
    light2.castShadow = true;
    light2.shadow.camera.top = 180;
    light2.shadow.camera.bottom = -100;
    light2.shadow.camera.left = -120;
    light2.shadow.camera.right = 120;
    g_scene.add(light2);
    //scene.add(new THREE.CameraHelper(light.shadow.camera));

    // ground
    let mesh = new THREE.Mesh(
            new THREE.PlaneBufferGeometry(2000, 2000),
            new THREE.MeshPhongMaterial({
                color: 0x999999,
                depthWrite: false
            }));
    mesh.rotation.x = -Math.PI / 2;
    mesh.receiveShadow = true;
    g_scene.add(mesh);

    let grid = new THREE.GridHelper(2000, 20, 0x000000, 0x000000);
    grid.material.opacity = 0.2;
    grid.material.transparent = true;
    g_scene.add(grid);

    g_renderer = new THREE.WebGLRenderer({
        antialias: true
    });
    g_renderer.setPixelRatio(window.devicePixelRatio);
    g_renderer.setSize(window.innerWidth, window.innerHeight);
    g_renderer.shadowMap.enabled = true;
    document.body.style.margin = 0;
    document.body.style.padding = 0;
    document.body.style.overflow = "hidden";
    document.body.style.position = "fixed";
    container.appendChild(g_renderer.domElement);
    window.addEventListener("resize", onWindowResize, false);

    g_controls = new /*THREE.*/ OrbitControls(g_camera, g_renderer.domElement);
    g_controls.target.set(0, 45, 0);
    g_controls.update();

    var draggableObjects = [];

    //draggableObjects.push(mesh);
    //g_scene.add(g_cogPointMesh);

    initPoints(draggableObjects);

    var dragControls = new /*THREE.*/ DragControls(
            draggableObjects,
            g_camera,
            g_renderer.domElement);

    dragControls.addEventListener("dragstart", function () {
        g_controls.enabled = false;
    });
    dragControls.addEventListener("dragend", function () {
        g_controls.enabled = true;
    });
}

function onWindowResize() {
    g_camera.aspect = window.innerWidth / window.innerHeight;
    g_camera.updateProjectionMatrix();
    g_renderer.setSize(window.innerWidth, window.innerHeight);
}

function animate() {
  
    if(g_animate){    
    for (let i = 0; i < g_pointsRef.length; i++) {            
        var axis = new THREE.Vector3(0, 1, 0 );
        var angle = Math.PI / 100;        
        g_pointsRef[i].position.applyAxisAngle( axis, angle );
    }
    }
  
    if(g_do_matching)
    {
        kabschPoints(g_pointsToMatch, g_pointsRef);

        let averageRef = getAverage(g_pointsRef);
        g_avg.position.set(averageRef.x, averageRef.y, averageRef.z);
    }
  
    requestAnimationFrame(animate);
    g_renderer.render(g_scene, g_camera);
}

You can play with it using this code pen: https://codepen.io/brainexcerpts/pen/QWzvxPm





More notes:

 A Robust Method to Extract the Rotational Part of Deformations

Best used in an incremental system where you can re-use the rotation q from the previous frame (faster convergence) max iter should probably be set around 10-30

Eigen code:

// A: covariance matrix, q: shape matching rotation
void extractRotation(const Matrix3d& A, Quaterniond& q,const unsigned int maxIter)
{
    for (unsigned int iter = 0; iter < maxIter; iter++){
        Matrix3d R = q.matrix();
        Vector3d omega = (R.col(0).cross(A.col(0)) +
                          R.col(1).cross(A.col(1)) +
                          R.col(2).cross(A.col(2)))
                * (1.0 / fabs(R.col(0).dot(A.col(0)) +
                              R.col(1).dot(A.col(1)) +
                              R.col(2).dot(A.col(2))) +1.0e-9);
        double w = omega.norm();
        if (w < 1.0e-9)
            break;

        q = Quaterniond(AngleAxisd(w, (1.0 / w)*omega))*q;
        q.normalize();
    }
}


#include <Eigen/Dense>
#include <Eigen/SVD>
#include <toolbox_maths/eigen_lib_interop.hpp>
// From Mathias Muller optimized svd:
// https://animation.rwth-aachen.de/media/papers/2016-MIG-StableRotation.pdf
void extract_rotation(const Mat3& A, Quat& q,const unsigned int max_iter = 30)
{
    for (unsigned int iter = 0; iter < max_iter; iter++){
        Mat3 R = q.to_matrix3();

        Vec3 omega = (
                (R.x().cross( (A.x()) ) +
                 R.y().cross( (A.y()) ) +
                 R.z().cross( (A.z()) ) )
                * (1.0 / fabs(R.x().dot( (A.x()) ) +
                              R.y().dot( (A.y()) ) +
                              R.z().dot( (A.z()) )) + 1.0e-9)
                    );

        float w = omega.norm();
        if (w < 1.0e-9)
            break;

        q = Quat::from_axis_angle((1.0f / w)*omega, w ) * q;
        q.normalize();
    }
}

// -----------------------------------------------------------------------------

void compute_SVD_rotations_with_quats(
        std::vector<tbx::Mat3>& svd_rotations,
        std::vector<tbx::Quat>& quat_rotations,
        const std::vector<std::map<int, float> >& weights,
        const First_ring_it& topo,
        const std::vector<Vec3>& vertices, // rest pose / start vertices
        const Sub_mesh& sub_mesh,
        const std::vector< std::vector<float> >& per_edge_weights, // I think this is optional and could be set to 1.0f

        const Skin_deformer& dfm)
{
    Eigen::Matrix3f eye = Eigen::Matrix3f::Identity();

    for(unsigned i : sub_mesh)// Loop over mesh vertices.
    {
        unsigned valence = topo.nb_neighbors(i); 

        Eigen::MatrixXf P(3, valence);
        Eigen::MatrixXf Q(3, valence);
        for(unsigned n = 0; n < valence; n++)
        {
            int vert_j = topo.neighbor_to(i, n);

            // eij = pi - pj
            // compute: P_i * D_i in the paper
            float w_cotan = per_edge_weights[i][n];
            Vec3 v = (vertices[i] - vertices[vert_j]) * w_cotan;
            //P.col(degree) = Eigen::Vector3f(v.x, v.y, v.z);
            P(0, n) = v.x;
            P(1, n) = v.y;
            P(2, n) = v.z;

            // eij' = pi' - pj'
            //compute: P_i'
            v = dfm.vert_anim_pos(weights, i) - dfm.vert_anim_pos(weights, vert_j);
            //Q.col() = Eigen::Vector3f(v.x, v.y, v.z);
            Q(0, n) = v.x;
            Q(1, n) = v.y;
            Q(2, n) = v.z;
        }
        // Compute the 3 by 3 covariance matrix:
        // actually S = (P * W * Q.t()); W is already considerred in the previous step (P=P*W)
        Eigen::Matrix3f S = (P * Q.transpose());

#if 0
        // Compute the singular value decomposition S = UDV.t
        Eigen::JacobiSVD<Eigen::MatrixXf> svd(S, Eigen::ComputeThinU | Eigen::ComputeThinV); // X = U * D * V.t()

        Eigen::MatrixXf V = svd.matrixV();
        Eigen::MatrixXf Ut = svd.matrixU().transpose();

        eye(2,2) = (V * Ut).determinant();	// remember: Eigen starts from zero index

        // V*U.t may be reflection (determinant = -1). in this case, we need to change the sign of
        // column of U corresponding to the smallest singular value (3rd column)
        Eigen::Matrix3f rot = (V * eye * Ut);


        svd_rotations[i] = Mat3((float)rot(0,0), (float)rot(0,1), (float)rot(0,2),
                                (float)rot(1,0), (float)rot(1,1), (float)rot(1,2),
                                (float)rot(2,0), (float)rot(2,1), (float)rot(2,2) ); //Ri = (V * eye * U.t());
#else
        extract_rotation( tbx::to_mat3(S), quat_rotations[i]);
        svd_rotations[i] = quat_rotations[i].to_matrix3();
#endif

    }
}

// -----------------------------------------------------------------------------

void compute_SVD_rotations(std::vector<tbx::Mat3>& svd_rotations,
                           const std::vector<std::map<int, float> >& weights,
                           const First_ring_it& topo,
                           const std::vector<Vec3>& vertices,
                           const Sub_mesh& sub_mesh,
                           const std::vector< std::vector<float> >& per_edge_weights,
                           const Skin_deformer& dfm)
{
    Eigen::Matrix3f eye = Eigen::Matrix3f::Identity();

    for(unsigned i : sub_mesh)
    {
        unsigned valence = topo.nb_neighbors(i);

        Eigen::MatrixXf P(3, valence);
        Eigen::MatrixXf Q(3, valence);
        for(unsigned n = 0; n < valence; n++)
        {
            int vert_j = topo.neighbor_to(i, n);

            // eij = pi - pj
            // compute: P_i * D_i in the paper
            float w_cotan = per_edge_weights[i][n];
            Vec3 v = (vertices[i] - vertices[vert_j]) * w_cotan;
            //P.col(degree) = Eigen::Vector3f(v.x, v.y, v.z);
            P(0, n) = v.x;
            P(1, n) = v.y;
            P(2, n) = v.z;

            // eij' = pi' - pj'
            //compute: P_i'
            v = dfm.vert_anim_pos(weights, i) - dfm.vert_anim_pos(weights, vert_j);
            //Q.col() = Eigen::Vector3f(v.x, v.y, v.z);
            Q(0, n) = v.x;
            Q(1, n) = v.y;
            Q(2, n) = v.z;
        }
        // Compute the 3 by 3 covariance matrix:
        // actually S = (P * W * Q.t()); W is already considerred in the previous step (P=P*W)
        Eigen::MatrixXf S = (P * Q.transpose());

        // Compute the singular value decomposition S = UDV.t
        Eigen::JacobiSVD<Eigen::MatrixXf> svd(S, Eigen::ComputeThinU | Eigen::ComputeThinV); // X = U * D * V.t()

        Eigen::MatrixXf V = svd.matrixV();
        Eigen::MatrixXf Ut = svd.matrixU().transpose();

        eye(2,2) = (V * Ut).determinant();	// remember: Eigen starts from zero index

        // V*U.t may be reflection (determinant = -1). in this case, we need to change the sign of
        // column of U corresponding to the smallest singular value (3rd column)
        Eigen::Matrix3f rot = (V * eye * Ut);


        svd_rotations[i] = Mat3((float)rot(0,0), (float)rot(0,1), (float)rot(0,2),
                                (float)rot(1,0), (float)rot(1,1), (float)rot(1,2),
                                (float)rot(2,0), (float)rot(2,1), (float)rot(2,2) ); //Ri = (V * eye * U.t());
    }
}


void PointCloud::computeCovarianceMatrix() {
	covarianceMatrix[3][3];
	
	double means[3] = {0, 0, 0};
	for (int i = 0; i < points.size(); i++)
		means[0] += points[i].x,
		means[1] += points[i].y,
		means[2] += points[i].z;
	means[0] /= points.size(), means[1] /= points.size(), means[2] /= points.size();
	
	for (int i = 0; i < 3; i++)
		for (int j = 0; j < 3; j++) {
			covarianceMatrix[i][j] = 0.0;
			for (int k = 0; k < points.size(); k++)
				covarianceMatrix[i][j] += (means[i] - points[k].coord(i)) *
                                                          (means[j] - points[k].coord(j));
			covarianceMatrix[i][j] /= points.size() - 1;
		}	
}


Original algorithm (but slow):

https://en.wikipedia.org/wiki/Kabsch_algorithm

https://igl.ethz.ch/projects/ARAP/svd_rot.pdf

Related:

http://nghiaho.com/?page_id=671

No comments

(optional field, I won't disclose or spam but it's necessary to notify you if I respond to your comment)
All html tags except <b> and <i> will be removed from your comment. You can make links by just typing the url or mail-address.
Anti-spam question: