Skip to content

Commit 52d32d3

Browse files
TitusLVRclaude
andcommitted
feat(aligner): geometric correspondence (ICP) + dual-chirality seeding
Mirror detection and alignment were unreliable on symmetric / modifier-shaded meshes because the fit trusted the topological matcher's face order, which is only an adjacency isomorphism (geometrically twisted on symmetric components). This caused false mirror labels, flipped clones, and missed genuine mirrors. Two robustness additions: - fit_orientations(): best proper (det +1) AND reflection (det -1) fit from one SVD. The assembly step now seeds BOTH chiralities as hypotheses, so a symmetric anchor can no longer hide a mirrored constellation (its other components were predicted at non-reflected positions and never assembled). - refine_fit_icp(): re-derives the face correspondence geometrically via a multi-start PCA + nearest-centroid ICP, picking the lowest-rmse proper/mirror fit. The global constellation fit uses it, so twisted correspondences fit poorly (rejected by the gate) and genuine matches fit at ~0 with the correct chirality. Verified end-to-end: chiral 3D mirror -> found, mirror=True; flat (achiral) mirror -> found, mirror=False (proper, no flip); reporting mesh -> 7/0 (spurious mirror duplicates gone). 42 unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c4246fe commit 52d32d3

2 files changed

Lines changed: 215 additions & 5 deletions

File tree

tests/test_polygon_match.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,76 @@ def test_assemble_anchor_idx_nonzero():
366366
assert not res[0]["mirror"]
367367

368368

369+
from utils.polygon_match import refine_fit_icp
370+
371+
_ICP_C = np.array([[0.0, 0, 0], [3, 0, 0], [0, 2, 0], [0, 0, 1]], dtype=float)
372+
_ICP_N = np.array([[0.0, 0, 1], [0, 1, 0], [1, 0, 0], [0, 1, 1]], dtype=float)
373+
374+
375+
def _icp_anchors(off=0.5):
376+
rows = []
377+
for c, n in zip(_ICP_C, _ICP_N):
378+
rows.append(c)
379+
rows.append(c + n * off)
380+
return np.array(rows, dtype=float)
381+
382+
383+
def _icp_apply(T, A):
384+
h = np.hstack([A, np.ones((len(A), 1))])
385+
return (h @ T.T)[:, :3]
386+
387+
388+
def _icp_swap(A, fperm):
389+
return A.reshape(-1, 2, 3)[fperm].reshape(-1, 3)
390+
391+
392+
def _rotT(th=0.5, t=(1.0, 2.0, -1.0)):
393+
R = np.array([[np.cos(th), -np.sin(th), 0],
394+
[np.sin(th), np.cos(th), 0],
395+
[0, 0, 1.0]])
396+
T = np.eye(4)
397+
T[:3, :3] = R
398+
T[:3, 3] = t
399+
return T
400+
401+
402+
def test_refine_icp_proper_correct_correspondence():
403+
ref = _icp_anchors()
404+
tgt = _icp_apply(_rotT(), ref)
405+
T, rmse, mir, perm = refine_fit_icp(ref, tgt, "KEEP")
406+
assert rmse < 1e-6
407+
assert not mir
408+
409+
410+
def test_refine_icp_proper_twisted_correspondence():
411+
# Faces 1 and 2 mislabeled — ICP must re-pair geometrically and fit at ~0,
412+
# NOT settle for a false mirror.
413+
ref = _icp_anchors()
414+
tgt = _icp_swap(_icp_apply(_rotT(), ref), [0, 2, 1, 3])
415+
T, rmse, mir, perm = refine_fit_icp(ref, tgt, "KEEP")
416+
assert rmse < 1e-6
417+
assert not mir
418+
419+
420+
def test_refine_icp_mirror_correct_correspondence():
421+
ref = _icp_anchors()
422+
tgt = ref.copy()
423+
tgt[:, 0] = -tgt[:, 0]
424+
T, rmse, mir, perm = refine_fit_icp(ref, tgt, "KEEP")
425+
assert rmse < 1e-6
426+
assert mir
427+
428+
429+
def test_refine_icp_mirror_twisted_correspondence():
430+
ref = _icp_anchors()
431+
tgt = ref.copy()
432+
tgt[:, 0] = -tgt[:, 0]
433+
tgt = _icp_swap(tgt, [1, 0, 3, 2])
434+
T, rmse, mir, perm = refine_fit_icp(ref, tgt, "KEEP")
435+
assert rmse < 1e-6
436+
assert mir
437+
438+
369439
def test_assemble_dedups_face_overlap_keeps_best():
370440
# Two single-component candidates that SHARE a face: the perfect one
371441
# (rmse 0) and an overlapping worse one. Face-disjoint dedup must keep only

utils/polygon_match.py

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,137 @@ def fit_both(ref_pts: np.ndarray, tgt_pts: np.ndarray,
420420
return T_proper, rmse_n, False
421421

422422

423+
def fit_orientations(ref_pts: np.ndarray, tgt_pts: np.ndarray,
424+
scale_mode: str = "KEEP"):
425+
"""Return (T_proper, T_reflected): the best proper-rotation fit (det +1)
426+
and the best reflection fit (det -1), sharing one covariance SVD.
427+
428+
Used to seed BOTH chiralities when the anchor component is symmetric and
429+
therefore can't reveal which orientation the target needs — without the
430+
reflected hypothesis, a mirrored constellation never assembles (its other
431+
components are predicted at non-reflected positions). STRETCH folds
432+
reflection into the affine diagonal, so both variants coincide there."""
433+
ref = np.asarray(ref_pts, dtype=np.float64)
434+
tgt = np.asarray(tgt_pts, dtype=np.float64)
435+
if scale_mode == "STRETCH":
436+
T = solve_fit(ref, tgt, "STRETCH")
437+
return T, T
438+
cen_ref = ref.mean(axis=0)
439+
cen_tgt = tgt.mean(axis=0)
440+
P = ref - cen_ref
441+
Q = tgt - cen_tgt
442+
H = P.T @ Q
443+
U, S, Vt = np.linalg.svd(H)
444+
VtT = Vt.T
445+
d = np.sign(np.linalg.det(VtT @ U.T))
446+
d = d if d != 0.0 else 1.0
447+
R_p = VtT @ np.diag([1.0, 1.0, d]) @ U.T
448+
R_r = VtT @ np.diag([1.0, 1.0, -d]) @ U.T
449+
denom = float((P * P).sum())
450+
if scale_mode == "UNIFORM" and denom > 1e-12:
451+
s_p = float(S[0] + S[1] + d * S[2]) / denom
452+
s_r = float(S[0] + S[1] - d * S[2]) / denom
453+
else:
454+
s_p = s_r = 1.0
455+
return (_compose_srt(R_p, cen_ref, cen_tgt, s_p),
456+
_compose_srt(R_r, cen_ref, cen_tgt, s_r))
457+
458+
459+
def _greedy_assignment(dist: np.ndarray) -> np.ndarray:
460+
"""One-to-one nearest assignment on a square distance matrix. Returns a
461+
permutation `perm` where reference row i pairs with target column perm[i].
462+
Rows claim their nearest free column, processed in ascending order of their
463+
best available distance (so unambiguous pairs claim first)."""
464+
n = dist.shape[0]
465+
perm = np.full(n, -1, dtype=np.intp)
466+
used = np.zeros(n, dtype=bool)
467+
for i in np.argsort(dist.min(axis=1)):
468+
i = int(i)
469+
for j in np.argsort(dist[i]):
470+
j = int(j)
471+
if not used[j]:
472+
perm[i] = j
473+
used[j] = True
474+
break
475+
return perm
476+
477+
478+
def _pca_axes(pts: np.ndarray) -> np.ndarray:
479+
"""Principal axes (columns, descending eigenvalue) of a centered cloud."""
480+
cov = pts.T @ pts
481+
w, v = np.linalg.eigh(cov)
482+
return v[:, np.argsort(w)[::-1]]
483+
484+
485+
# Axis sign combinations: 4 proper (det +1) + 4 improper (det -1). Covers the
486+
# PCA eigenvector sign ambiguity for both chiralities of initial alignment.
487+
_PCA_SIGNS = ((1, 1, 1), (1, -1, -1), (-1, 1, -1), (-1, -1, 1),
488+
(-1, -1, -1), (-1, 1, 1), (1, -1, 1), (1, 1, -1))
489+
490+
491+
def refine_fit_icp(ref_anchors: np.ndarray, tgt_anchors: np.ndarray,
492+
scale_mode: str = "KEEP", iters: int = 4):
493+
"""Robust ICP refinement of a face-correspondence fit.
494+
495+
Anchors are laid out two rows per face: [centroid, centroid+normal*offset,
496+
centroid, ...]. The face correspondence the topological matcher returns is
497+
only an adjacency isomorphism — on symmetric components it can be
498+
geometrically twisted, forcing a false mirror or a poor fit. So this ignores
499+
the incoming order and re-derives the correspondence GEOMETRICALLY:
500+
501+
Multi-start: align the reference centroid cloud to the target cloud by their
502+
PCA frames over every axis-sign combination (both chiralities), and also try
503+
the identity (given-order) start. From each start, run a few ICP iterations
504+
(nearest-centroid one-to-one re-pairing + refit, where the fit itself picks
505+
proper or mirror by rmse). Keep the global lowest-rmse result.
506+
507+
Returns (T_4x4, rmse, is_mirror, face_perm) where face_perm[i] is the target
508+
face position (into the input face order) paired with reference face i."""
509+
nf = ref_anchors.shape[0] // 2
510+
T0, rmse0, mir0 = fit_both(ref_anchors, tgt_anchors, scale_mode)
511+
identity = np.arange(nf, dtype=np.intp)
512+
if nf < 2:
513+
return T0, rmse0, mir0, identity
514+
515+
ref_faces = ref_anchors.reshape(nf, 2, 3)
516+
tgt_faces = tgt_anchors.reshape(nf, 2, 3)
517+
ref_c = ref_faces[:, 0, :]
518+
tgt_c = tgt_faces[:, 0, :]
519+
homog_ref = np.hstack([ref_c, np.ones((nf, 1))])
520+
ref_mean = ref_c.mean(axis=0)
521+
tgt_mean = tgt_c.mean(axis=0)
522+
Ar = _pca_axes(ref_c - ref_mean)
523+
At = _pca_axes(tgt_c - tgt_mean)
524+
525+
# Initial transforms: identity-order fit + one per PCA sign combination.
526+
init_Ts = [T0]
527+
for s in _PCA_SIGNS:
528+
R0 = At @ np.diag(s).astype(float) @ Ar.T
529+
Ti = np.eye(4)
530+
Ti[:3, :3] = R0
531+
Ti[:3, 3] = tgt_mean - R0 @ ref_mean
532+
init_Ts.append(Ti)
533+
534+
best = (T0, rmse0, mir0, identity)
535+
for T in init_Ts:
536+
perm = None
537+
for _ in range(iters):
538+
moved = (homog_ref @ T.T)[:, :3]
539+
d = np.linalg.norm(moved[:, None, :] - tgt_c[None, :, :], axis=2)
540+
new_perm = _greedy_assignment(d)
541+
tgt_re = tgt_faces[new_perm].reshape(-1, 3)
542+
T, rmse, is_mir = fit_both(ref_anchors, tgt_re, scale_mode)
543+
if perm is not None and np.array_equal(new_perm, perm):
544+
perm = new_perm
545+
break
546+
perm = new_perm
547+
if rmse < best[1]:
548+
best = (T, rmse, is_mir, perm.copy())
549+
if best[1] < 1e-9:
550+
break
551+
return best
552+
553+
423554
def assemble_constellations(
424555
ref_comp_anchors: Sequence[np.ndarray],
425556
ref_comp_centroids: Sequence[np.ndarray],
@@ -480,9 +611,12 @@ def assemble_constellations(
480611
if ref_comp_facecount[anchor_idx] >= 2 or not other:
481612
if ca.anchors.shape != ref_comp_anchors[anchor_idx].shape:
482613
continue
483-
T0, _r, _m = fit_both(ref_comp_anchors[anchor_idx], ca.anchors,
484-
scale_mode)
485-
hypotheses = [T0]
614+
# Seed BOTH chiralities: a symmetric anchor can't reveal whether the
615+
# target is mirrored, and the reflected hypothesis is what lets a
616+
# mirrored constellation predict its other components correctly.
617+
Tp, Tr = fit_orientations(ref_comp_anchors[anchor_idx], ca.anchors,
618+
scale_mode)
619+
hypotheses = [Tp, Tr]
486620
else:
487621
# Degenerate anchor (single face fixes no in-plane rotation): seed
488622
# jointly with each candidate of the next-most-distinctive component.
@@ -534,10 +668,16 @@ def assemble_constellations(
534668
tgt_global = np.vstack([chosen[i].anchors for i in range(C)])
535669
if tgt_global.shape != ref_global.shape:
536670
continue
537-
_T, rmse, is_mirror = fit_both(ref_global, tgt_global, scale_mode)
671+
base_order = tuple(f for i in range(C) for f in chosen[i].faces)
672+
# Re-derive the face correspondence geometrically (the topological
673+
# matcher's order is only an adjacency isomorphism — twisted on
674+
# symmetric components). This makes the proper/mirror decision and
675+
# the fit reliable; spurious twisted assemblies fail the rmse gate.
676+
_T, rmse, is_mirror, fperm = refine_fit_icp(
677+
ref_global, tgt_global, scale_mode)
538678
if rmse / bbox > fit_rmse_rel:
539679
continue
540-
order = tuple(f for i in range(C) for f in chosen[i].faces)
680+
order = tuple(base_order[p] for p in fperm)
541681
key = frozenset(order)
542682
if key in seen:
543683
continue

0 commit comments

Comments
 (0)