[Python] Shape registration using PCA and GPA not aligning

Discussão em 'Python' iniciado por Stack, Agosto 21, 2025.

  1. Stack

    Stack Membro Participativo

    I am currently working on a statistical shape model/active shape model to make it fit a vertebra. But for some reason it is not working and I have been trying to fit it for some time.

    First I tried fitting with landmarks. I took seven of the same vertebra and did annotations of 10 spots:

    [​IMG]

    I aligned all meshes with the mean to 0,0,0 and rigidly registered all landmarks and meshes

    [​IMG]

    Imports:

    import os
    import json
    import glob
    import trimesh
    import numpy as np
    from scipy.spatial import procrustes
    from trimesh.registration import icp
    from sklearn.decomposition import PCA
    import matplotlib.pyplot as plt
    from tqdm import tqdm


    Then I did GPA and PCA:

    def rigid_procrustes_with_scaling(X, Y):
    """
    Procrustes alignment with scaling (similarity transform).
    Returns Y_aligned, R, t, s
    """
    X_mean = X.mean(axis=0)
    Y_mean = Y.mean(axis=0)
    X0 = X - X_mean
    Y0 = Y - Y_mean

    norm_X = np.linalg.norm(X0)
    norm_Y = np.linalg.norm(Y0)
    s = norm_X / norm_Y

    U, _, Vt = np.linalg.svd((Y0 * s).T @ X0)
    R = U @ Vt
    if np.linalg.det(R) < 0:
    Vt[-1, :] *= -1
    R = U @ Vt

    t = X_mean - (Y_mean * s) @ R
    Y_aligned = (Y * s) @ R + t
    return Y_aligned, R, t, s

    def compute_landmark_gpa_pca(landmarks_dict, n_components=3, tol=1e-5, max_iter=100):
    """
    landmarks_dict: dict of {name: (N,3) numpy array of landmarks}
    """
    names = list(landmarks_dict.keys())
    landmarks_list = [landmarks_dict[name] for name in names]
    N = landmarks_list[0].shape[0]

    for lm in landmarks_list:
    if lm.shape != (N, 3):
    raise ValueError("All landmark sets must have shape (N, 3)")

    mean_shape = landmarks_list[0].copy()
    for iteration in range(max_iter):
    aligned = []
    for lm in landmarks_list:
    aligned_lm, _, _, _ = rigid_procrustes_with_scaling(mean_shape, lm)
    aligned.append(aligned_lm)
    new_mean = np.mean(aligned, axis=0)
    diff = np.linalg.norm(mean_shape - new_mean)
    if diff < tol:
    print(f"GPA converged in {iteration+1} steps")
    break
    mean_shape = new_mean
    else:
    print("GPA did not fully converge.")

    # PCA
    flat_shapes = np.array([a.flatten() for a in aligned])
    pca = PCA(n_components=n_components)
    pca.fit(flat_shapes)

    return mean_shape, pca


    This is how I am fitting the PCA to a new mesh

    def fit_active_shape_model_with_history(new_mesh, mean_shape, pca, n_iterations=10):
    """
    Standard ASM:
    - No scaling
    - Rigid alignment
    - PCA projection
    Returns:
    history: list of shape arrays per iteration (in model space)
    b: final PCA weights
    fitted_shape_aligned: final shape aligned to target mesh
    """
    # Initialize
    # Use similarity Procrustes for first alignment
    closest_points, _, _ = trimesh.proximity.closest_point(new_mesh, mean_shape)
    fitted_shape, R_init, t_init, s_init = rigid_procrustes_with_scaling(closest_points, mean_shape)

    class PCASimple:
    def __init__(self, mean_flat, components, explained_variance):
    self.mean_ = mean_flat
    self.components_ = components
    self.explained_variance_ = explained_variance
    def transform(self, X):
    return (X - self.mean_) @ self.components_.T
    def inverse_transform(self, b):
    return self.mean_ + b @ self.components_

    pca_model = PCASimple(mean_shape.flatten(), pca.components_, pca.explained_variance_)

    history = []

    for it in range(n_iterations):
    # Find closest points
    closest_points, _, _ = trimesh.proximity.closest_point(new_mesh, fitted_shape)
    # Rigid alignment
    fitted_shape, _, _, _ = rigid_procrustes_with_scaling(closest_points, fitted_shape)

    # PCA projection
    shape_flat = fitted_shape.flatten()
    b = pca_model.transform(shape_flat)

    stds = np.sqrt(pca_model.explained_variance_)
    b = np.clip(b, -2 * stds, 2 * stds)

    # Reconstruct
    fitted_flat = pca_model.inverse_transform(b)
    fitted_shape = fitted_flat.reshape(-1,3)

    history.append(fitted_shape.copy())

    # At the end: align fitted shape to target mesh
    closest_points, _, _ = trimesh.proximity.closest_point(new_mesh, fitted_shape)
    # Get aligned version + final transform
    fitted_shape_aligned, R_final, t_final, s_final = rigid_procrustes_with_scaling(fitted_shape, closest_points)

    return history, b, fitted_shape_aligned


    But in total, everything is just off and doesn't make any sense.

    [​IMG]

    I also tried the same but with surface point clouds to better recreate the shape of the vertebra (here I used a total of 40 vertebrae of the same type). The preprocessing is almost the same but I tried to get according points an all meshes (tried to do this by laying them over each other and taking the closest distance).

    An image of all points after the preprocessing:

    [​IMG]

    PCA and GPA is the same as above. This is my code for the fitting:

    # ---------- TEMPLATE SAMPLING ----------
    def create_template_points(template_mesh, n_points=1000, seed=42):
    np.random.seed(seed)
    return template_mesh.sample(n_points)

    def sample_mesh_to_points(mesh, template_points):
    closest_points, _, _ = trimesh.proximity.closest_point(mesh, template_points)
    return closest_points

    # ---------- PCA HELPER ----------
    class PCASimple:
    def __init__(self, mean_flat, components, explained_variance):
    self.mean_ = mean_flat
    self.components_ = components
    self.explained_variance_ = explained_variance

    def transform(self, X):
    return (X - self.mean_) @ self.components_.T

    def inverse_transform(self, b):
    return self.mean_ + b @ self.components_

    # ---------- ACTIVE SHAPE MODEL ----------
    def fit_active_shape_model_with_history(new_mesh, mean_shape, pca, target_points, n_iterations=20, step_size=0.5):
    """
    Iterative ASM fitting:
    - Align once to target mesh
    - Keep correspondences fixed
    - Adjust PCA coefficients each iteration
    """
    # Align once at start
    history = [target_points.copy()]
    history.append(mean_shape)
    fitted_shape, R_init, t_init, s_init = rigid_procrustes_with_scaling(target_points, mean_shape)

    pca_model = PCASimple(mean_shape.flatten(), pca.components_, pca.explained_variance_)
    b = np.zeros(pca_model.components_.shape[0]) # start from mean

    history.append(fitted_shape.copy())

    for _ in range(n_iterations):
    # Find target correspondences
    target_points = sample_mesh_to_points(new_mesh, fitted_shape)

    # Project target to PCA space
    target_b = pca_model.transform(target_points.flatten())

    # Step toward target in PCA space
    b = b + step_size * (target_b - b)

    # Clip b to plausible range
    stds = np.sqrt(pca_model.explained_variance_)
    b = np.clip(b, -5 * stds, 5 * stds)

    # Reconstruct new shape
    fitted_shape = pca_model.inverse_transform(b).reshape(-1, 3)

    history.append(fitted_shape.copy())

    return history, b, fitted_shape


    This is the result:

    [​IMG]

    My guess is that it's not working well because of the complex shape of the vertebra and probably also because of a displacement of parts. So sometimes there is no overlap between the mesh and the mean shape.

    I tried to use multiple or really few points. Also I tried different methods of finding the corresponding point on the new mesh and then fitting to the predicted points instead of aligning to the mesh. Here there was already a problem with the prediction of the points.

    As data I used the opensourced verse dataset-01training and dataset-03training. The datasat contains vertebra segmentations in nifti file-format, which needs to be converted to mesh file formats.

    Continue reading...

Compartilhe esta Página