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
// 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