@@ -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+
423554def 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