Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
85dd61a
suggestions implemented partially
Mahmood-Sinan Apr 15, 2026
af93e4f
added suggestions
Mahmood-Sinan Apr 16, 2026
a6fd123
Merge remote-tracking branch 'upstream/master' into kabsch_algorithm
Mahmood-Sinan May 30, 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 @@ -46,6 +46,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 @@ -46,6 +46,7 @@ ADD_SUBDIR(system)
ADD_SUBDIR(stats)

add_subdirectory(sparse)
add_subdirectory(spatial)

set(fppFiles
stdlib_version.fypp
Expand Down Expand Up @@ -73,5 +74,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}_lapack ${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
194 changes: 194 additions & 0 deletions src/spatial/stdlib_spatial_kabsch_umeyama.fypp
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
#: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
use stdlib_optval, only: optval
use stdlib_linalg_lapack, only: gemm, gemv

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 +25 to +31

Copilot AI Feb 13, 2026

Copy link

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(:)
${t}$ :: temp
#:if t.startswith('complex')
real(${k}$) :: rtemp
#:endif
logical :: scale_
#:if t.startswith('real')
logical :: reflect_
#:endif
real(${k}$) :: rmsd_err

scale_ = optval(scale, .true.)
! 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) &

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.

I guess you could condense the two if conditions into a single one as

if (any(shape(P) /= shape(Q)) .or. any(shape(P) /= shape(R)) then
    call error_stop("array sizes do not match")
endif

.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)

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}$)

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.

All three lines could be condensed down to

allocate(c_P(d), c_Q(d), tmp_N(N), 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

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.

Why do you need the tmp_N array here ?
Wouldn't

do concurrent( i = 1:d )
    c_P(i) = stdlib_dot_product_kahan(W, P(i, :))
    c_Q(i) = stdlib_dot_product_kahan(W, Q(i, :))
enddo

be better?

else
c_P = stdlib_sum_kahan(P, dim=2)
c_Q = stdlib_sum_kahan(Q, dim=2)
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.

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.

I can reweite the covariance accumulation as a sum of outer products over the d-dimensional point vectors. However, in that formulation there is no natural dot-product reduction left for the covariance computation. It would be something like:

do point = 1, N
    p(:) = P(:,point) - c_P(:)
    q(:) = Q(:,point) - c_Q(:)

    do j = 1, d
        do i = 1, d
            covariance(i,j) = covariance(i,j) + p(i) * q(j)
        end do
    end do
end do

Or one other way which might work will be using GEMM, but there we will have to calculate the whole centered matrices c_P and c_Q.
Let me know what you think

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)

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.

I believe you can get rid of line 103 and simply have

variance_p = stdlib_dot_product_kahan(W, tmp_N)

or would that break for complex values?

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

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.

Again, you can probably get rid of tmp_N array here and directly have

do concurrent( j=1:d, i=1:d, i <= j)
    covariance(i, j) = stdlib_dot_product_kahan( Q(j, :) - c_Q(j) , W * (P(i, :) - c_P(i)) )
    covariance(j, i) = covariance(i, j)
enddo

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

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 as here : you can get rid of the temporary tpm_N array and use directly stdlib_dot_product_kahan along with the do concurrent.

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}$
if(reflect_) Vt(d,:) = -Vt(d,:)
#:endif

! Optimal rotation matrix.
call gemm(transa='N', transb='N', m=d,n=d,k=d, alpha=one_${s}$, a=U,lda=d, b=Vt, ldb=d, beta=zero_${s}$, c=R, ldc=d)

! 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

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

t = c_P
call gemv(trans='N', m=d, n=d, alpha=-c, A=R, lda=d, x=c_Q, incx=1, beta=one_${s}$, y=t, incy=1)

! 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
vec = t
call gemv(trans='N', m=d, n=d, alpha=c, A=R, lda=d, x=Q(:, point), incx=1, beta=one_${s}$, y=vec, incy=1)
vec = vec - P(:,point)
temp = stdlib_dot_product_kahan(vec,vec)
#:if t.startswith('complex')
rtemp = real(temp, kind=${k}$)
call kahan_kernel(rtemp, rmsd, rmsd_err)
#:else
call kahan_kernel(temp, rmsd, rmsd_err)
#:endif
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 @@ -50,6 +50,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
7 changes: 7 additions & 0 deletions test/spatial/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
set(
fppFiles
"test_spatial_kabsch_umeyama.fypp"
)
fypp_f90("${fyppFlags}" "${fppFiles}" outFiles)

ADDTEST(spatial_kabsch_umeyama)
Loading
Loading