Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
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 = 3
real(dp) :: P(d, N), Q(d, N), R(d, d), t(d), c, rmsd

integer :: i

P(:,1) = [3.0_dp, -2.0_dp]
P(:,2) = [7.0_dp, 4.0_dp]
P(:,3) = [5.0_dp, 0.0_dp]

Q(:,1) = [2.0_dp, 3.0_dp]
Q(:,2) = [-1.0_dp, 5.0_dp]
Q(:,3) = [1.0_dp, 4.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 *, "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}_constants ${PROJECT_NAME}_linalg_core ${PROJECT_NAME}_linalg ${PROJECT_NAME}_intrinsics)
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.

stdlib_spatial uses stdlib_error (error_stop) but the spatial target links only constants/linalg/intrinsics. To avoid undefined references when linking ${PROJECT_NAME}_spatial directly, add ${PROJECT_NAME}_core (or whichever target provides stdlib_error) to target_link_libraries here.

Suggested change
target_link_libraries(${PROJECT_NAME}_spatial PUBLIC ${PROJECT_NAME}_constants ${PROJECT_NAME}_linalg_core ${PROJECT_NAME}_linalg ${PROJECT_NAME}_intrinsics)
target_link_libraries(${PROJECT_NAME}_spatial PUBLIC ${PROJECT_NAME}_core ${PROJECT_NAME}_constants ${PROJECT_NAME}_linalg_core ${PROJECT_NAME}_linalg ${PROJECT_NAME}_intrinsics)

Copilot uses AI. Check for mistakes.
48 changes: 48 additions & 0 deletions src/spatial/stdlib_spatial.fypp
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#: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_linalg_constants
use stdlib_constants
use stdlib_error, only: error_stop
implicit none
private
public :: kabsch_umeyama

interface kabsch_umeyama
!-----------------------------------------------------------------------
!> Compute the optimal similarity transform (Kabsch–Umeyama):
!>
!> P ≈ c * R * Q + t
!>
!> where:
!> - R is an orthogonal rotation matrix
!> - c is an optional scale factor
!> - t is a translation vector
!>
!> The transformation minimizes the RMSD between corresponding columns
!> of P and Q, optionally using weights.
!-----------------------------------------------------------------------
#:for k, t, s in (KINDS_TYPES)
module subroutine kabsch_umeyama_${s}$(P, Q, R, t, c, rmsd, W, scale)
!> Reference point set (d × N)
${t}$, intent(in) :: P(:, :)
!> Target point set (d × N)
${t}$, intent(in) :: Q(:, :)
!> Optimal rotation matrix (d × d)
${t}$, intent(out) :: R(:,:)
!> Translation vector (d)
${t}$, intent(out) :: t(:)
!> Scale factor
${t}$, intent(out) :: c
!> Root-mean-square deviation
real(${k}$), intent(out) :: rmsd
!> Optional weights
${t}$, intent(in), optional :: W(:)
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 public interface also types W as ${t}$, which makes weights complex for the complex overloads. If weights are intended to be real scalars, consider changing W here to real(${k}$) (and mirroring that in the implementation) to avoid confusing/invalid API usage.

Suggested change
${t}$, intent(in), optional :: W(:)
real(${k}$), intent(in), optional :: W(:)

Copilot uses AI. Check for mistakes.
!> Enable scaling
logical, intent(in), optional :: scale
end subroutine
#:endfor
end interface
end module stdlib_spatial
173 changes: 173 additions & 0 deletions src/spatial/stdlib_spatial_kabsch_umeyama.fypp
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#: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
use stdlib_intrinsics, only: stdlib_sum_kahan, stdlib_dot_product_kahan, kahan_kernel

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

Comment on lines 23 to 28
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.
! Internal variables.
integer(ilp) :: i, j, point, d, N
${t}$, allocatable :: covariance(:,:), U(:,:), Vt(:,:), B(:,:), vec(:), tmp_N(:), tmp_d(:), c_P(:), c_Q(:)
${t}$ :: vp, vq
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.

B, vp, and vq are declared but never used. If B was intended for the determinant/reflection correction step in Kabsch/Umeyama, it should be used; otherwise remove these unused variables to keep the implementation easier to maintain.

Suggested change
${t}$, allocatable :: covariance(:,:), U(:,:), Vt(:,:), B(:,:), vec(:), tmp_N(:), tmp_d(:), c_P(:), c_Q(:)
${t}$ :: vp, vq
${t}$, allocatable :: covariance(:,:), U(:,:), Vt(:,:), vec(:), tmp_N(:), tmp_d(:), c_P(:), c_Q(:)

Copilot uses AI. Check for mistakes.
real(${k}$) :: sum_w, variance_p
real(${k}$), allocatable :: S(:)
logical :: scale_
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")
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 error_stop messages for shape mismatches are all the same and don’t identify which argument/shape is wrong, which makes debugging harder. Consider including the routine name and expected vs actual shapes (e.g., which dimensions of P/Q/R/t/W mismatch).

Suggested change
${t}$, allocatable :: covariance(:,:), U(:,:), Vt(:,:), B(:,:), vec(:), tmp_N(:), tmp_d(:), c_P(:), c_Q(:)
${t}$ :: vp, vq
real(${k}$) :: sum_w, variance_p
real(${k}$), allocatable :: S(:)
logical :: scale_
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")
integer(ilp) :: dP, dQ, dR1, dR2, dt, nP, nQ, nW
${t}$, allocatable :: covariance(:,:), U(:,:), Vt(:,:), B(:,:), vec(:), tmp_N(:), tmp_d(:), c_P(:), c_Q(:)
${t}$ :: vp, vq
real(${k}$) :: sum_w, variance_p
real(${k}$), allocatable :: S(:)
logical :: scale_
real(${k}$) :: rmsd_err
character(len=:), allocatable :: errmsg
! Dimension checks
dP = size(P, dim=1)
dQ = size(Q, dim=1)
dR1 = size(R, dim=1)
dR2 = size(R, dim=2)
dt = size(t)
if (dP /= dQ .or. dP /= dR1 .or. dP /= dR2 .or. dP /= dt) then
write(errmsg, '(A,5(I0,:,", "))') &
'kabsch_umeyama_${s}$: mismatched leading dimensions (P, Q, R(:,1), R(1,:), t) = ', &
dP, dQ, dR1, dR2, dt
call error_stop(errmsg)
end if
nP = size(P, dim=2)
nQ = size(Q, dim=2)
if (nP /= nQ) then
write(errmsg, '(A,2(I0,:,", "))') &
'kabsch_umeyama_${s}$: mismatched second dimensions (P(:,N), Q(:,N)) = ', &
nP, nQ
call error_stop(errmsg)
end if
if (present(W)) then
nW = size(W)
if (nW /= nP) then
write(errmsg, '(A,2(I0,:,", "))') &
'kabsch_umeyama_${s}$: mismatched number of points between P and W (size(P,2), size(W)) = ', &
nP, nW
call error_stop(errmsg)

Copilot uses AI. Check for mistakes.
end if
end if
d = size(P,dim=1)
N = size(P,dim=2)
scale_ = .true.
if(present(scale)) scale_ = scale

sum_w = one_${s}$ / N
if(present(W)) sum_w = one_${s}$ / stdlib_sum_kahan(W)

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

! Compute centroids of P and Q
if(present(W)) then
do i = 1, d
c_P(i) = stdlib_dot_product_kahan(w,P(i, :))
c_Q(i) = stdlib_dot_product_kahan(w,Q(i, :))
end do
else
do i = 1, d
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_N(N), source=zero_${s}$)
allocate(tmp_d(d), source=zero_${s}$)
variance_p = zero_${s}$
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.

Several scalars are declared as real(${k}$) (sum_w, variance_p, rmsd), but are initialized/assigned using one_${s}$ / zero_${s}$, which become complex literals in the complex instantiations. This relies on implicit complex→real conversion (discarding the imaginary part) and can mask small numerical imaginary components. Prefer using one_${k}$ / zero_${k}$ (and explicitly real(...) where needed) for real-typed quantities.

Suggested change
variance_p = zero_${s}$
variance_p = zero_${k}$

Copilot uses AI. Check for mistakes.

if (present(W)) then
do point = 1, N
tmp_d = P(:, point) - c_P(:)
tmp_N(point) = stdlib_dot_product_kahan(tmp_d, tmp_d)
end do
variance_p = stdlib_dot_product_kahan(w, 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_dot_product_kahan(w, tmp_N)
#:else
tmp_N(:) = (P(i,:) - c_P(i)) * (Q(j,:) - c_Q(j))
covariance(i,j) = stdlib_dot_product_kahan(w, tmp_N)
#:endif
end do
end do
else
do point = 1, N
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)

! Optimal rotation matrix.
do i = 1,d
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 +129 to +141
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
Contributor Author

Choose a reason for hiding this comment

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

This is being dealt inside SVD internally. SVD returns righthanded matrices.


! Scaling factor
c = variance_p / (sum(S(1:d)))
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 scale factor uses variance_p (computed from P) and divides by sum(S). In Umeyama for P ≈ c R Q + t, the scale minimizing RMSD depends on the variance of the source set being transformed (Q) and should incorporate the same reflection-correction used for R (i.e., tr(S*D)/var(Q)). Using variance of P biases c in the presence of noise/outliers.

Suggested change
! Scaling factor
c = variance_p / (sum(S(1:d)))
! Scaling factor (Umeyama): c = tr(S) / var(Q)
c = stdlib_sum_kahan(S(1:d)) / variance_q

Copilot uses AI. Check for mistakes.
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 implementation follows the covariance convention H = (P - c_P) * (Q - c_Q)^T , under which the scale naturally depends on var(P) not var(Q)

if (.not. scale_) c = one_${s}$
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.

Even when scale is disabled, c is computed first (including a potential divide-by-zero if sum(S) is 0) and only then overwritten. Consider guarding the scale computation with if (scale_) then ... else c = 1 to avoid unnecessary work and avoid raising errors in degenerate cases when scaling is off.

Suggested change
c = variance_p / (sum(S(1:d)))
if (.not. scale_) c = one_${s}$
if (scale_) then
c = variance_p / (sum(S(1:d)))
else
c = one_${s}$
end if

Copilot uses AI. Check for mistakes.

! Translation vector t = c_P - c*R*c_Q
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_${s}$
rmsd_err = zero_${s}$
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
#: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)
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
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_f90pp("${fyppFlags}" "${fppFiles}" outFiles)
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.

This test file doesn’t appear to require C-preprocessor directives (it’s pure FYPP). Using fypp_f90pp will run an extra preprocessing stage and produce .F90 output unnecessarily. Consider using fypp_f90 here for consistency with other test directories unless there’s a specific need for f90pp.

Suggested change
fypp_f90pp("${fyppFlags}" "${fppFiles}" outFiles)
fypp_f90("${fyppFlags}" "${fppFiles}" outFiles)

Copilot uses AI. Check for mistakes.

ADDTESTPP(spatial_kabsch_umeyama)
Loading
Loading