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.

The one number that separates a rotation from a reflection Three properties of the matrix the decomposition returns. Orthogonality is guaranteed and is not enough: it admits both rotations and reflections. The determinant separates them exactly, and it is a single cheap computation. Distances are preserved either way, which is why every distance-based statistic reports success. Rᵀ R = I orthogonal — true of both det(R) = +1 a proper rotation det(R) = −1 a reflection — handedness reversed ‖Rx − Ry‖ = ‖x − y‖ distances preserved either way The last line is why residuals never find it.

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))
Repairing inside the decomposition, not after it Two ways of removing a reflection. Negating an output axis afterwards produces a different transform from the correct one unless the mirror plane happens to coincide with that axis plane. Negating the last column inside the decomposition and recomputing yields the best proper rotation for the same correspondences, which is the standard result. Flip an axis afterwards — a different transform — correct only by coincidence — residuals get worse — hides the real cause Correct in the decomposition — negate the last singular direction — recompute the product — best proper rotation — the standard result The correction belongs where the ambiguity arose.

Key implementation notes:

  • The correction matrix D is 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_reflection first 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_handed exists 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.

The layouts that leave handedness unobserved Two control point layouts on the same site. Points strung along a road leave the direction perpendicular to that line poorly determined, so the decomposition is free to resolve it either way and a reflection costs nothing. Points bracketing the site observe every direction, and the ambiguity does not arise. Easting (m) Northing (m) collinear spread No algebra recovers a direction the control never observed.

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.