Detecting Mirrored Transforms in Python
A reflection satisfies a least-squares control-point fit as well as a rotation does, so residuals will not find it. The determinant will: it is exactly plus one for a rotation and exactly minus one for a reflection. Check it, repair the fault inside the singular value decomposition rather than by flipping an axis afterwards, and assert handedness before the transform touches production geometry. This page is part of Scale and Rotation Synchronization.
Where the Reflection Comes From
The standard solve forms the cross-covariance matrix of the two centred point sets, decomposes it, and takes the product of the singular vector matrices as the rotation. That product is orthogonal — its columns are unit length and mutually perpendicular — which is necessary for a rotation and not sufficient. An orthogonal matrix has determinant plus or minus one, and only the positive case is a rotation. The negative case is a reflection: a transform that preserves every distance and reverses handedness.
Configurations that provoke it are common in this domain. Control points along a road, points on a single building facade, or any near-planar layout in a three-dimensional solve leave the out-of-plane direction poorly determined, and the decomposition is free to resolve it either way.
The consequence in a model is that everything is the right size and in the right place, and left is right. A staircase turns the wrong way, a road crosses to the wrong side, and a text label reads backwards — findings that are obvious in a rendering and invisible in a residual table.
Production-Ready Script
# numpy>=1.24, Python 3.9+
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
class HandednessError(ValueError):
pass
@dataclass(frozen=True)
class Similarity:
rotation: np.ndarray # (d, d), determinant +1
scale: float
translation: np.ndarray # (d,)
def apply(self, points: np.ndarray) -> np.ndarray:
p = np.asarray(points, dtype=float)
return self.scale * (p @ self.rotation.T) + self.translation
def is_reflection(rotation: np.ndarray, *, tol: float = 1e-8) -> bool:
"""A proper rotation has determinant +1; a reflection has -1."""
det = float(np.linalg.det(np.asarray(rotation, dtype=float)))
if abs(abs(det) - 1.0) > 1e-6:
raise HandednessError(f"matrix is not orthogonal (det={det:.6g})")
return det < 0
def solve_similarity(source: np.ndarray, target: np.ndarray,
*, allow_scale: bool = True) -> Similarity:
"""Umeyama solve with the reflection guard applied INSIDE the decomposition."""
a = np.asarray(source, dtype=float)
b = np.asarray(target, dtype=float)
if a.shape != b.shape or a.ndim != 2:
raise ValueError("source and target must be matching (n, d) arrays")
n, d = a.shape
if n < d + 1:
raise ValueError(f"{n} points cannot determine a {d}-dimensional similarity")
ca, cb = a.mean(axis=0), b.mean(axis=0)
A, B = a - ca, b - cb
H = A.T @ B / n
U, S, Vt = np.linalg.svd(H)
# The guard: build a correction that flips the LAST singular direction when
# the naive product would be a reflection. Flipping an output axis instead
# gives a different transform unless the mirror plane happens to align.
D = np.eye(d)
if np.linalg.det(U @ Vt) < 0:
D[-1, -1] = -1.0
R = (U @ D @ Vt).T
if is_reflection(R):
raise HandednessError("reflection survived the correction — check for collinear points")
var_a = (A ** 2).sum() / n
scale = float((S * np.diag(D)).sum() / var_a) if allow_scale else 1.0
t = cb - scale * (R @ ca)
return Similarity(rotation=R, scale=scale, translation=t)
def assert_right_handed(transform: Similarity) -> None:
"""Call before the transform touches production geometry."""
if is_reflection(transform.rotation):
raise HandednessError(
"transform is a reflection — geometry would be mirrored with no residual penalty"
)
if __name__ == "__main__":
src = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 6.0], [0.0, 6.0]])
dst = src @ np.array([[0.0, -1.0], [1.0, 0.0]]).T + np.array([100.0, 50.0])
fit = solve_similarity(src, dst)
assert_right_handed(fit)
print("det:", round(float(np.linalg.det(fit.rotation)), 9), "scale:", round(fit.scale, 6))
Key implementation notes:
- The correction matrix
Dis applied inside the product, which is the standard Umeyama result. This yields the best proper rotation for the correspondences rather than a rotation plus an unrelated axis flip. is_reflectionfirst checks orthogonality. A determinant far from ±1 means the matrix is not a rotation at all — usually a sign that the inputs were degenerate — and that is a different error worth distinguishing.- The scale uses the corrected singular values, so a mirrored configuration does not produce a scale that silently absorbs the correction.
assert_right_handedexists as a separate call so it can be used as a gate on a transform obtained from anywhere, not only from this solver.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
numpy |
>=1.24 |
linalg.svd, linalg.det |
| Dimensions | 2D and 3D | the code is dimension-agnostic |
| Minimum points | d + 1 |
more, and non-degenerate, in practice |
| Scale | optional | disable where scale is survey truth |
| Output | proper rotation | guaranteed by the guard |
Fallback Strategies
1. The reflection survives the correction. Points are collinear or coplanar to within numerical noise. Add control points off the line or plane; no algebra fixes an unobserved direction.
2. The determinant is not near ±1. The inputs are degenerate or contain a duplicate point. Deduplicate and check the conditioning before solving.
3. Residuals are excellent and the model looks mirrored. Exactly this fault. Check the determinant rather than re-examining the residuals, which will keep reporting success.
4. Only symmetric control is available. A symmetric layout cannot distinguish the two solutions from geometry alone. Verify against an asymmetric feature — a doorway, a chainage direction, a text label — and record which feature was used.
5. The transform came from elsewhere. Registration libraries do not all guard against this. Apply assert_right_handed to any transform before it reaches production geometry, whatever produced it.
FAQ
How does a reflection get into a solve at all?
From the singular value decomposition. The product of the singular vector matrices is orthogonal, which means its determinant is plus or minus one — a rotation or a reflection. Nothing in the decomposition constrains it to the former, so with certain point configurations, particularly near-planar ones in a 3D solve, the naive product comes out as a reflection.
Why do the residuals not reveal it?
Because a reflection can fit the control points exactly. If the points are symmetric about the mirror plane, or nearly so, the reflected transform maps them onto their targets just as well as the rotation would. The residual measures distance to the targets, and the reflection achieves the same distances — so it reports success.
Can I just flip a coordinate afterwards?
No. Negating an axis on the output produces a different transform from the correct one unless the mirror plane happens to be that axis plane. Repair it inside the decomposition by negating the last column of the right singular vectors and recomputing, which yields the best proper rotation for the same correspondences.
Related Pages
- Scale and Rotation Synchronization — parent reference on the similarity solve and its degrees of freedom
- Aligning BIM Models with GIS Survey Data — the SVD pipeline this guard belongs inside
- Computing RMSE for Control Point Alignment in Python — the measurement that will not catch this fault on its own