Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ea6b295
kabsch first commit
Mahmood-Sinan Feb 4, 2026
655af91
fixed the logic issue
Mahmood-Sinan Feb 5, 2026
60818bb
add: comments and complex support
Mahmood-Sinan Feb 6, 2026
400b85d
removed complex kinds
Mahmood-Sinan Feb 7, 2026
5208695
add complex types back, correct reflection
Mahmood-Sinan Feb 7, 2026
25df8d2
modify example, add tests, use suffix in constants and move use stdli…
Mahmood-Sinan Feb 7, 2026
33859b6
remove: debug comments
Mahmood-Sinan Feb 7, 2026
c7b3227
remove: unnecessary fypp conditions
Mahmood-Sinan Feb 7, 2026
78f35a4
added instrinsics functions to spatial
Mahmood-Sinan Feb 8, 2026
a9c489c
modify: test only for P
Mahmood-Sinan Feb 11, 2026
fb08933
modify: set arguments to pass to kabsh in test to zero
Mahmood-Sinan Feb 11, 2026
88e76fb
remove: debug statements in test
Mahmood-Sinan Feb 11, 2026
e73a93f
remove: unused imports and modify: S and rmsd init inside complex kab…
Mahmood-Sinan Feb 11, 2026
e3a1cc2
change name: kabsch to kabsch_umeyama
Mahmood-Sinan Feb 13, 2026
27b68ca
add stdlib_constants to test_kabsch_umeyama.fypp
Mahmood-Sinan Feb 13, 2026
c895ed5
move tol declaration inside blocks
Mahmood-Sinan Feb 13, 2026
d722366
revert accidental changes to example_sum.f90
Mahmood-Sinan Feb 13, 2026
b9b9832
change complex weights to real weights
Mahmood-Sinan Feb 26, 2026
37b3cba
minor changes
Mahmood-Sinan Feb 26, 2026
723661b
revert cmake change
Mahmood-Sinan Feb 26, 2026
3a7221f
add sum_w error check
Mahmood-Sinan Mar 7, 2026
51a193d
reflection handling in real cases and modify tests
Mahmood-Sinan Mar 8, 2026
7554914
add target stdlib_core to spatial/CMakeLists.txt
Mahmood-Sinan Mar 8, 2026
12f1513
modify test/spatial/CMakeLists.txt
Mahmood-Sinan Mar 8, 2026
fb4f1a2
Merge remote-tracking branch 'upstream/master' into kabsch_algorithm
Mahmood-Sinan Apr 1, 2026
fe7d96e
modify: example, add: comment in test, minor changes
Mahmood-Sinan Apr 1, 2026
81a228c
modify: dependencies
Mahmood-Sinan Apr 1, 2026
a9f0fc1
modify: docs, ford comments and example
Mahmood-Sinan Apr 2, 2026
42db95b
minor changes
Mahmood-Sinan Apr 2, 2026
fa159bd
add: debug messages for test
Mahmood-Sinan Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions doc/specs/stdlib_spatial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: spatial
---

# The `stdlib_spatial` module

This module provides implementations of algorithms for spatial data processing.

[TOC]

## `kabsch_umeyama` - Finding optimal rotation matrix

### Status

Experimental

### Description

Compute the optimal similarity transform (Kabsch–Umeyama):
\[
P \approx c \, R \, Q + t
\]
where:

- R is an orthogonal rotation matrix,
- c is an optional scaling factor,
- t is a translation vector.

The transformation minimizes the root-mean-square deviation (RMSD) between corresponding columns
of P and Q, optionally using weights and with optional scaling.
The implementation is based on the algorithm described here: [Aligning point patterns with Kabsch–Umeyama algorithm](https://zpl.fi/aligning-point-patterns-with-kabsch-umeyama-algorithm)

### Syntax

`call ` [[stdlib_spatial(module):kabsch_umeyama(interface)]] `(P, Q, R, t, c, rmsd [, W, scale])`

### Arguments

`P`: Shall be a `real` or `complex` rank-2 array. It is an `intent(in)` argument.

`Q`: Shall be a rank-2 array with same kind as `P`. It is an `intent(in)` argument.

`R`: Shall be a rank-2 array with same kind as `P`. For `real` kinds, the algorithm returns a proper rotation matrix, meaning `det(R) = 1`. It is an `intent(out)` argument.

`t`: Shall be a rank-1 array with same kind as `P`. It is an `intent(out)` argument.

`c`: Scalar value of the same type as `P`. It is an `intent(out)` argument. If `scale` is disabled `c` will be returned with a value of `1`.

`rmsd`: Scalar value of `real` kind. It is an `intent(out)` argument.

`W` (optional): Shall be a rank-1 array of `real` kind. It is an `intent(in)` argument. By default, `W` is an array of `1`s.

`scale` (optional): Shall be a logical type. It is an `intent(in)` argument. By default, `scale = .true.`.

### Example

```fortran
{!example/spatial/example_kabsch_umeyama.f90!}
```
1 change: 1 addition & 0 deletions example/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ if (STDLIB_QUADRATURE)
endif()
add_subdirectory(selection)
add_subdirectory(sorting)
add_subdirectory(spatial)
add_subdirectory(specialfunctions_gamma)
if (STDLIB_SPECIALMATRICES)
add_subdirectory(specialmatrices)
Expand Down
1 change: 1 addition & 0 deletions example/spatial/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ADD_EXAMPLE(kabsch_umeyama)
36 changes: 36 additions & 0 deletions example/spatial/example_kabsch_umeyama.f90
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
program example_kabsch_umeyama
use stdlib_linalg_constants, only: dp
use stdlib_spatial, only: kabsch_umeyama
implicit none

integer, parameter :: d = 2, N = 7
real(dp) :: P(d,N), Q(d,N), R(d, d), t(d), c, rmsd

integer :: i

! 2x7 matrices.
P(1,:) = [23.0_dp, 66.0_dp, 88.0_dp, 119.0_dp, 122.0_dp, 170.0_dp, 179.0_dp]
P(2,:) = [178.0_dp, 173.0_dp, 187.0_dp, 202.0_dp, 229.0_dp, 232.0_dp, 199.0_dp]

Q(1,:) = [232.0_dp, 208.0_dp, 181.0_dp, 155.0_dp, 142.0_dp, 121.0_dp, 139.0_dp]
Q(2,:) = [38.0_dp, 32.0_dp, 31.0_dp, 45.0_dp, 33.0_dp, 59.0_dp, 69.0_dp]

call kabsch_umeyama(P, Q, R, t, c, rmsd)

print *
print *, "Recovered rotation R:"
do i = 1, d
print *, R(i,:)
end do

print *
print *, "Recovered scale c:", c

print *
print *, "Recovered translation t:"
print *, t

print *
print *, "RMSD:", rmsd

end program example_kabsch_umeyama
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ ADD_SUBDIR(system)
ADD_SUBDIR(stats)

add_subdirectory(sparse)
add_subdirectory(spatial)

set(fppFiles
stdlib_version.fypp
Expand All @@ -70,5 +71,6 @@ target_link_libraries(${PROJECT_NAME} PUBLIC
${PROJECT_NAME}_strings
${PROJECT_NAME}_blas ${PROJECT_NAME}_lapack ${PROJECT_NAME}_lapack_extended
${PROJECT_NAME}_sparse
${PROJECT_NAME}_spatial
${OPTIONAL_LIB}
)
14 changes: 14 additions & 0 deletions src/spatial/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
set(spatial_fppFiles
stdlib_spatial.fypp
stdlib_spatial_kabsch_umeyama.fypp
)

set(spatial_cppFiles
)

set(spatial_f90Files
)

configure_stdlib_target(${PROJECT_NAME}_spatial spatial_f90Files spatial_fppFiles spatial_cppFiles)

target_link_libraries(${PROJECT_NAME}_spatial PUBLIC ${PROJECT_NAME}_core ${PROJECT_NAME}_constants ${PROJECT_NAME}_linalg ${PROJECT_NAME}_intrinsics)
41 changes: 41 additions & 0 deletions src/spatial/stdlib_spatial.fypp
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#:include "common.fypp"
#:set R_KINDS_TYPES = list(zip(REAL_KINDS, REAL_TYPES, REAL_SUFFIX))
#:set C_KINDS_TYPES = list(zip(CMPLX_KINDS, CMPLX_TYPES, CMPLX_SUFFIX))
#:set KINDS_TYPES = R_KINDS_TYPES+C_KINDS_TYPES
module stdlib_spatial
use stdlib_kinds, only: sp, dp, xdp, qp
use stdlib_constants
implicit none
private
public :: kabsch_umeyama

interface kabsch_umeyama
!! ([Specifications](../page/specs/stdlib_spatial.html#kabsch_umeyama))
!! This interface computes the optimal similarity transform (Kabsch–Umeyama):
!! \[
!! P \approx c \, R \, Q + t
!! \]
!! The transformation minimizes the RMSD between corresponding columns
!! of P and Q, optionally using weights and with optional scaling.
#:for k, t, s in (KINDS_TYPES)
module subroutine kabsch_umeyama_${s}$(P, Q, R, t, c, rmsd, W, scale)
${t}$, intent(in) :: P(:, :)
!! Target point set (d × N)
${t}$, intent(in) :: Q(:, :)
!! Reference point set (d × N)
${t}$, intent(out) :: R(:, :)
!! Optimal rotation matrix (d × d)
${t}$, intent(out) :: t(:)
!! Translation vector (d)
${t}$, intent(out) :: c
!! Scale factor
real(${k}$), intent(out) :: rmsd
!! Root-mean-square deviation
real(${k}$), intent(in), optional :: W(:)
!! Optional weights
logical, intent(in), optional :: scale
!! Enable scaling
end subroutine
#:endfor
end interface
end module stdlib_spatial
201 changes: 201 additions & 0 deletions src/spatial/stdlib_spatial_kabsch_umeyama.fypp
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#:include "common.fypp"
#:set R_KINDS_TYPES = list(zip(REAL_KINDS, REAL_TYPES, REAL_SUFFIX))
#:set C_KINDS_TYPES = list(zip(CMPLX_KINDS, CMPLX_TYPES, CMPLX_SUFFIX))
#:set KINDS_TYPES = R_KINDS_TYPES+C_KINDS_TYPES
submodule(stdlib_spatial) stdlib_spatial_kabsch_umeyama
use stdlib_linalg, only: svd, det
use stdlib_intrinsics, only: stdlib_sum_kahan, stdlib_dot_product_kahan, kahan_kernel
use stdlib_error, only: error_stop

contains
#:for k, t, s in (KINDS_TYPES)
module subroutine kabsch_umeyama_${s}$(P, Q, R, t, c, rmsd, W, scale)
${t}$, intent(in) :: P(:, :)
!! Target point set (d × N)
${t}$, intent(in) :: Q(:, :)
!! Reference point set (d × N)
${t}$, intent(out) :: R(:, :)
!! Optimal rotation matrix (d × d)
${t}$, intent(out) :: t(:)
!! Translation vector (d)
${t}$, intent(out) :: c
!! Scale factor
real(${k}$), intent(out) :: rmsd
!! Root-mean-square deviation
real(${k}$), intent(in), optional :: W(:)
!! Optional weights
logical, intent(in), optional :: scale
!! Enable scaling

Comment on lines +23 to +29
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

W is declared as the same type as the point arrays (${t}$). For complex variants this implies complex weights, which then flow into sum_w/centroid/covariance computations via stdlib_sum_kahan/stdlib_dot_product_kahan and require implicit complex→real conversions. If weights are intended (as in the issue description) to be real and non-negative, consider typing W as real(${k}$) for both real and complex point sets and validate sum(W) > 0.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated.

! Internal variables.
integer :: i, j, point, d, N
${t}$, allocatable :: covariance(:,:), U(:,:), Vt(:,:), vec(:), tmp_N(:), tmp_d(:), c_P(:), c_Q(:)
real(${k}$) :: sum_w, variance_p
real(${k}$), allocatable :: S(:)
logical :: scale_
#:if t.startswith('real')
logical :: reflect_
#:endif
real(${k}$) :: rmsd_err


! Dimension checks
if(size(P,dim=1)/=size(Q,dim=1) .or. size(P,dim=1)/=size(R,dim=1) .or. size(P,dim=1)/=size(R,dim=2) &
.or. size(P,dim=1)/=size(t)) then
call error_stop("array sizes do not match")
end if
if(size(P,dim=2)/=size(Q,dim=2)) then
call error_stop("array sizes do not match")
end if
if (present(W)) then
if (size(W) /= size(P,dim=2)) then
call error_stop("array sizes do not match")
end if
end if
d = size(P,dim=1)
N = size(P,dim=2)
scale_ = .true.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

optval can be used to initialize this value. Also, optional parameters initialization are better placed at the begining of the procedure.

if(present(scale)) scale_ = scale

sum_w = one_${k}$ / N
if(present(W)) sum_w = one_${k}$ / stdlib_sum_kahan(W)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it would be better to compute sum_w in the following two steps:

if(present(W)) then
    sum_w = stdlib_sum_kahan(W)
else
    sum_w = real( N , kind = ${k}$ )
end if
!> leave opportunity for future discussion on how to add spmd reduction needed here to reduce sum_w before division

sum_w = one_${k}$ / sum_w

if(sum_w<zero_${k}$) call error_stop("Invalid weights: sum of weights must be positive")

allocate(c_P(d), source=zero_${s}$)
allocate(c_Q(d), source=zero_${s}$)
allocate(tmp_N(N), source=zero_${s}$)

! Compute centroids of P and Q
if(present(W)) then
do i = 1, d
tmp_N(:) = W(:) * P(i,:)
c_P(i) = stdlib_sum_kahan(tmp_N)
tmp_N(:) = W(:) * Q(i,:)
c_Q(i) = stdlib_sum_kahan(tmp_N)
end do
else
do i = 1, d
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

stdlib_sum_kahan also accepts a dim parameter, so in this case the expresion can be simply:

c_P(1:d) = stdlib_sum_kahan(P, dim=2)
c_Q(1:d) = stdlib_sum_kahan(Q, dim=2)

c_P(i) = stdlib_sum_kahan(P(i, :))
c_Q(i) = stdlib_sum_kahan(Q(i, :))
end do
end if
c_P = c_P * sum_w
c_Q = c_Q * sum_w

! Compute covariance matrix H = (P - c_P) * (Q - c_Q)^T and variance of P
allocate(covariance(d,d), source=zero_${s}$)
allocate(tmp_d(d), source=zero_${s}$)
variance_p = zero_${k}$

if (present(W)) then
do point = 1, N
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The loops in this section should be revisited. This algorithm is usually applied for N >> d, this means that the best approach in terms of memory access is to handle, for each point in N everything related to the topological dimension d. In that same line, stdlib_sum and stdlib_dot_product (and their kahan versions) are very interesting performance wise when handling large arrays in one go.

tmp_d = P(:, point) - c_P(:)
tmp_N(point) = stdlib_dot_product_kahan(tmp_d, tmp_d)
end do
tmp_N(:) = W(:) * tmp_N(:)
variance_p = stdlib_sum_kahan(tmp_N)
do j = 1, d
do i = 1, d
#:if t.startswith('complex')
tmp_N(:) = W(:) * (P(i,:) - c_P(i)) * conjg(Q(j,:) - c_Q(j))
covariance(i,j) = stdlib_sum_kahan(tmp_N)
#:else
tmp_N(:) = W(:) * (P(i,:) - c_P(i)) * (Q(j,:) - c_Q(j))
covariance(i,j) = stdlib_sum_kahan(tmp_N)
#:endif
end do
end do
else
! Calculate variance by the formula (1/n)*sigma(P - c_P)^2
do point = 1, N
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

tmp_d = P(:, point) - c_P(:)
tmp_N(point) = stdlib_dot_product_kahan(tmp_d, tmp_d)
end do
variance_p = stdlib_sum_kahan(tmp_N)
do j = 1, d
do i = 1, d
#:if t.startswith('complex')
tmp_N(:) = (P(i,:) - c_P(i)) * conjg(Q(j,:) - c_Q(j))
covariance(i,j) = stdlib_sum_kahan(tmp_N)
#:else
tmp_N(:) = (P(i,:) - c_P(i)) * (Q(j,:) - c_Q(j))
covariance(i,j) = stdlib_sum_kahan(tmp_N)
#:endif
end do
end do
end if

covariance = covariance * sum_w
variance_p = variance_p * sum_w

allocate(U(d,d), source=zero_${s}$)
allocate(Vt(d,d), source=zero_${s}$)
allocate(S(d), source=zero_${k}$)

! SVD of covariance matrix H -> H = U * S * Vt
call svd(covariance, S, U, Vt)

! Check for reflections in case of real entries.
#:if t.startswith('real')
reflect_ = det(matmul(U,Vt)) < zero_${s}$
#:endif

#:if t.startswith('real')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this fypp macro can be merged in the previous #:if

if(reflect_) Vt(d,:) = -Vt(d,:)
#:endif

! Optimal rotation matrix.
do i = 1,d
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same comment as before. I'm worried that using stdlib_dot_product_kahan here will be detrimental performance wise in this loop construct. I think this could be better handled with blas gemm

do j = 1,d
#:if t.startswith('complex')
R(i,j) = stdlib_dot_product_kahan(conjg(U(i,:)), Vt(:, j))
#:else
R(i,j) = stdlib_dot_product_kahan(U(i,:), Vt(:, j))
#:endif
end do
end do
Comment on lines +135 to +156
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The computed rotation matrix is R = U * Vt with no correction for improper rotations (reflections). For real inputs this can yield det(R) = -1, which contradicts the “proper rotation” requirement in the PR description and standard Kabsch/Umeyama. Consider applying the usual determinant/sign correction (e.g., a diagonal matrix with last entry = sign(det(U*Vt))) before forming R.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated


! Scaling factor
if(scale_) then
#:if t.startswith('real')
if(reflect_) then
c = variance_p / (sum(S(1:d-1)) - S(d))
else
c = variance_p / (sum(S(1:d)))
end if
#:else
c = variance_p / (sum(S(1:d)))
#:endif
else
c = one_${s}$
end if
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

a small suggestion to make this section easier to follow (includding aligning the fypp macro with the inner scope.

        ! Scaling factor
        c = one_${s}$
        if(scale_) then
            #:if t.startswith('real')
            if(reflect_) then
                c = sum(S(1:d-1)) - S(d)
            else
                c = sum(S(1:d))
            end if
            #:else
            c = sum(S(1:d))
            #:endif
            c = variance_p / c
        end if


! Translation vector t = c_P - c*R*c_Q
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This operation might be better of being writtent using gemv (https://stdlib.fortran-lang.org/interface/gemv.html) with alpha=-1 and beta=1 (assuming t is first initialized to c_P

do i = 1, d
#:if t.startswith('complex')
t(i) = c_P(i) - c * stdlib_dot_product_kahan(conjg(R(i,1:d)), c_Q(1:d))
#:else
t(i) = c_P(i) - c * stdlib_dot_product_kahan(R(i,1:d), c_Q(1:d))
#:endif
end do

! Compute RMSD
allocate(vec(d), source=zero_${s}$)
rmsd = zero_${k}$
rmsd_err = zero_${k}$
do point = 1, N
! Calculate the k^th difference vector by the formula vec_k = c*R*Q_k + t - P_k
do i = 1, d
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same comment as before, vec can be computed using gemv instead

#:if t.startswith('complex')
vec(i) = c * stdlib_dot_product_kahan(conjg(R(i,1:d)), Q(1:d,point))
#:else
vec(i) = c * stdlib_dot_product_kahan(R(i,1:d), Q(1:d,point))
#:endif
end do
vec(1:d) = vec(1:d) + t(1:d) - P(1:d,point)
call kahan_kernel(real(stdlib_dot_product_kahan(vec,vec), kind=${k}$), rmsd, rmsd_err)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It is preferable to avoid passing expressions as arguments to procedures. This induces the creation of temporal variables. Better to declare the required variables, assign the expression and then pass the argument.

end do
rmsd = sqrt(rmsd * sum_w)
end subroutine
#:endfor
end submodule stdlib_spatial_kabsch_umeyama
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ endif()
add_subdirectory(optval)
add_subdirectory(selection)
add_subdirectory(sorting)
add_subdirectory(spatial)
add_subdirectory(specialfunctions)
if (STDLIB_STATS)
add_subdirectory(stats)
Expand Down
Loading
Loading