├── nss_challenge ├── __init__.py ├── metrics │ ├── __init__.py │ ├── f1.py │ ├── common.py │ ├── geometric.py │ └── rmse.py ├── utils │ ├── __init__.py │ ├── logging.py │ ├── eval.py │ └── pointcloud.py └── evaluate_registration.py ├── .gitignore ├── .coverage ├── assets ├── people │ ├── tao_sun.jpeg │ ├── yan_hao.jpeg │ ├── iro_armeni.jpeg │ ├── emily_steiner.jpg │ ├── shengyu_huang.jpeg │ ├── torben_graber.jpg │ └── michael_helmberger.jpeg ├── challenge-teaser.jpeg └── challenge-2025-teaser.png ├── tests ├── testdata │ ├── mock_prediction_2024.json │ ├── mock_prediction_2025.json │ ├── mock_target_2024.json │ └── mock_target_2025.json └── test_evaluate_registration.py ├── scripts └── pan_to_pcd.py └── README.md /nss_challenge/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nss_challenge/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nss_challenge/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | mock/ 4 | evalai/ -------------------------------------------------------------------------------- /.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/.coverage -------------------------------------------------------------------------------- /assets/people/tao_sun.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/people/tao_sun.jpeg -------------------------------------------------------------------------------- /assets/people/yan_hao.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/people/yan_hao.jpeg -------------------------------------------------------------------------------- /assets/challenge-teaser.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/challenge-teaser.jpeg -------------------------------------------------------------------------------- /assets/people/iro_armeni.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/people/iro_armeni.jpeg -------------------------------------------------------------------------------- /assets/challenge-2025-teaser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/challenge-2025-teaser.png -------------------------------------------------------------------------------- /assets/people/emily_steiner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/people/emily_steiner.jpg -------------------------------------------------------------------------------- /assets/people/shengyu_huang.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/people/shengyu_huang.jpeg -------------------------------------------------------------------------------- /assets/people/torben_graber.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/people/torben_graber.jpg -------------------------------------------------------------------------------- /assets/people/michael_helmberger.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GradientSpaces/nss-challenge/HEAD/assets/people/michael_helmberger.jpeg -------------------------------------------------------------------------------- /nss_challenge/metrics/f1.py: -------------------------------------------------------------------------------- 1 | """Compute F1 Score for the detection of outlier nodes between predicted and ground truth pose graphs.""" 2 | 3 | 4 | import numpy as np 5 | from sklearn.metrics import f1_score 6 | 7 | 8 | def get_outlier_nodes(nodes): 9 | """Return True if the transformation for a node is the zero's matrix.""" 10 | outliers = np.zeros(len(nodes)) 11 | for i, node in enumerate(nodes): 12 | pose = np.array(node.get('global_transform', node.get('tsfm'))) 13 | outliers[i] = np.all(pose == 0.0) 14 | return outliers 15 | 16 | 17 | def compute_outlier_f1(gt_graph, pred_graph): 18 | """Compute the RMSE for the entire pose graph by merging all fragments. 19 | 20 | Args 21 | ---- 22 | gt_graph (dict): Ground truth pose graph. 23 | pred_graph (dict): Predicted pose graph. 24 | 25 | Returns 26 | ------- 27 | dict: Dict that includes the F1 Score for the entire pose graph. 28 | """ 29 | outliers_true = get_outlier_nodes(gt_graph['nodes']) 30 | outliers_pred = get_outlier_nodes(pred_graph['nodes']) 31 | if np.sum(outliers_true) == 0: 32 | return {"Outlier F1": 100.0} 33 | 34 | f1 = f1_score(outliers_true, outliers_pred) 35 | return {"Outlier F1": f1 * 100} 36 | -------------------------------------------------------------------------------- /nss_challenge/utils/logging.py: -------------------------------------------------------------------------------- 1 | """Logging utilities.""" 2 | 3 | import logging 4 | 5 | 6 | def get_logger(name: str = "NSS Eval") -> logging.Logger: 7 | """Get a logger with the given name.""" 8 | logger = logging.getLogger(name) 9 | logger.setLevel(logging.INFO) 10 | formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s") 11 | console_handler = logging.StreamHandler() 12 | console_handler.setFormatter(formatter) 13 | logger.addHandler(console_handler) 14 | return logger 15 | 16 | 17 | def format_table(results, name): 18 | """Format the results dictionary into a table.""" 19 | rows = [] 20 | cols = [] 21 | for key in results.keys(): 22 | row, col = key.split('/') 23 | if row not in rows: 24 | rows.append(row) 25 | if col not in cols: 26 | cols.append(col) 27 | 28 | # Print table header 29 | log_str = "{:<40}".format(name) + "".join(["{:<15}".format(col) for col in cols]) 30 | line = "-" * len(log_str) 31 | log_str += "\n" 32 | log_str += line + "\n" 33 | 34 | # Print table rows 35 | for row in rows: 36 | log_str += "{:<40}".format(row) 37 | for col in cols: 38 | key = f"{row}/{col}" 39 | log_str += "{:<15.3f}".format(results.get(key, "NaN")) 40 | log_str += "\n" 41 | 42 | return log_str -------------------------------------------------------------------------------- /tests/testdata/mock_prediction_2024.json: -------------------------------------------------------------------------------- 1 | [{"name": "Bldg0_Scene1", "nodes": [{"id": 0, "tsfm": [[0.9961946980917455, -0.08715574274765817, 0.0, 0.1], [0.08715574274765817, 0.9961946980917455, 0.0, -0.1], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 1, "tsfm": [[0.9961946980917455, -0.08715574274765817, 0.0, 1.1], [0.08715574274765817, 0.9961946980917455, 0.0, -0.1], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 2, "tsfm": [[0.9961946980917455, -0.08715574274765817, 0.0, 2.1], [0.08715574274765817, 0.9961946980917455, 0.0, -0.1], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 3, "tsfm": [[0.9961946980917455, -0.08715574274765817, 0.0, 3.1], [0.08715574274765817, 0.9961946980917455, 0.0, -0.1], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 4, "tsfm": [[0.6427876096865394, -0.7660444431189779, 0.0, 1.1414213562373094], [0.7660444431189779, 0.6427876096865394, 0.0, 1.0], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 5, "tsfm": [[-0.08715574274765811, -0.9961946980917455, 0.0, 0.1], [0.9961946980917455, -0.08715574274765811, 0.0, 1.1], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 6, "tsfm": [[0.766044443118978, 0.6427876096865393, 0.0, 1.3877787807814457e-17], [-0.6427876096865393, 0.766044443118978, 0.0, 1.8585786437626906], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 7, "tsfm": [[0.9961946980917455, -0.08715574274765817, 0.0, 0.1], [0.08715574274765817, 0.9961946980917455, 0.0, 1.9], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 8, "tsfm": [[0.9961946980917455, -0.08715574274765817, 0.0, 0.1], [0.08715574274765817, 0.9961946980917455, 0.0, 2.9], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}, {"id": 9, "tsfm": [[0.9961946980917455, -0.08715574274765817, 0.0, 0.1], [0.08715574274765817, 0.9961946980917455, 0.0, 3.9], [0.0, 0.0, 1.0, 0.1], [0.0, 0.0, 0.0, 1.0]]}]}] -------------------------------------------------------------------------------- /tests/test_evaluate_registration.py: -------------------------------------------------------------------------------- 1 | """Test case for evaluate_registration module.""" 2 | 3 | import os 4 | import unittest 5 | 6 | import numpy as np 7 | 8 | from nss_challenge.evaluate_registration import evaluate 9 | 10 | 11 | class Args: 12 | """Helper class to convert dictionary to object.""" 13 | def __init__(self, **kwargs): 14 | self.__dict__.update(kwargs) 15 | 16 | 17 | class TestEvaluateRegistration(unittest.TestCase): 18 | 19 | def setUp(self): 20 | self.args = Args( 21 | prediction=os.path.join( 22 | os.path.dirname(__file__), "testdata", "mock_target_2025.json" 23 | ), 24 | target=os.path.join( 25 | os.path.dirname(__file__), "testdata", "mock_target_2025.json" 26 | ), 27 | translation_threshold=0.1, 28 | rotation_threshold=10, 29 | point_cloud_dir=os.path.join( 30 | os.path.dirname(__file__), "testdata", "pointclouds" 31 | ), 32 | ) 33 | 34 | def test_evaluate(self): 35 | metrics = evaluate(self.args) 36 | self.assertIsInstance(metrics, dict) 37 | 38 | metrics_scene = metrics["Bldg0_Scene1"] 39 | self.assertEqual(len(metrics_scene), 3) 40 | 41 | metric = metrics_scene["All"] 42 | self.assertTrue( 43 | np.allclose(metric["Pairwise RMSE"], 0.0, atol=1e-1) 44 | ) 45 | self.assertTrue( 46 | np.allclose(metric["Outlier F1"], 100.0, atol=1e-1) 47 | ) 48 | self.assertTrue( 49 | np.allclose(metric["Registration Recall"], 100.0, atol=1e-1) 50 | ) 51 | self.assertTrue( 52 | np.allclose(metric["Average Rotation Error"], 0.0, atol=1e-3) 53 | ) 54 | self.assertTrue( 55 | np.allclose(metric["Average Translation Error"], 0.0, atol=1e-3) 56 | ) -------------------------------------------------------------------------------- /nss_challenge/metrics/common.py: -------------------------------------------------------------------------------- 1 | """Helper functions for the metrics evaluation.""" 2 | 3 | import numpy as np 4 | from itertools import compress 5 | 6 | from ..utils.logging import get_logger 7 | 8 | 9 | logger = get_logger("Metrics") 10 | 11 | 12 | def has_transform_on_all_edges(edges): 13 | """Check if all edges have non-empty tsfm.""" 14 | return all(edge.get('relative_transform', edge.get('tsfm')) is not None for edge in edges) 15 | 16 | 17 | def get_node_transforms(nodes): 18 | """Precompute the transformation for each node in the pose graph for quick lookup.""" 19 | transforms = {} 20 | for node in nodes: 21 | node_id = node['id'] 22 | pose = np.array(node.get('global_transform', node.get('tsfm'))) 23 | transforms[node_id] = pose 24 | return transforms 25 | 26 | 27 | def filter_outlier_nodes(gt_nodes, pred_nodes=None): 28 | """Filter nodes of the graph if ground truth indicates outlier (transformation is the zero matrix)""" 29 | 30 | # non-outleir mask 31 | mask = [not np.all(np.asarray(node.get('global_transform', node.get('tsfm'))) == 0) 32 | for node in gt_nodes] 33 | 34 | filtered_gt = list(compress(gt_nodes, mask)) 35 | if pred_nodes is None: 36 | return filtered_gt 37 | 38 | filtered_pred = list(compress(pred_nodes, mask)) 39 | return filtered_gt, filtered_pred 40 | 41 | def filter_edges(graph, same_stage=True): 42 | """Filter edges of the graph based on whether they are same-stage or cross-stage.""" 43 | if "edges" not in graph: 44 | return graph 45 | filtered_edges = [edge for edge in graph["edges"] if edge["same_stage"] == same_stage] 46 | return {**graph, "edges": filtered_edges} 47 | 48 | def get_edge_transforms(edges): 49 | """Precompute the transformation for each edge in the pose graph for quick lookup.""" 50 | transforms = {} 51 | for edge in edges: 52 | src_node_id = edge.get('source_id', edge.get('source')) 53 | tgt_node_id = edge.get('target_id', edge.get('target')) 54 | edge_tsfm = np.array(edge.get('relative_transform', edge.get('tsfm'))) 55 | k = (src_node_id, tgt_node_id) 56 | if k in transforms: 57 | logger.warning("Duplicate edge found in prediction: %s -> %s", src_node_id, tgt_node_id) 58 | transforms[k] = edge_tsfm 59 | return transforms 60 | 61 | 62 | def look_up_transforms(source, target, pred_transforms, compute_pairwise=False): 63 | """Look up the transformation for the given source and target nodes.""" 64 | if compute_pairwise: 65 | # Look up and compute the pairwise transformation. 66 | src_tsfm = pred_transforms.get(source, np.eye(4)) 67 | tgt_tsfm = pred_transforms.get(target, np.eye(4)) 68 | pred_tsfm = np.matmul(np.linalg.inv(tgt_tsfm), src_tsfm) 69 | else: 70 | # Look up the predicted transformation. 71 | k = (source, target) 72 | pred_tsfm = pred_transforms.get(k, np.eye(4)) 73 | return pred_tsfm 74 | -------------------------------------------------------------------------------- /nss_challenge/metrics/geometric.py: -------------------------------------------------------------------------------- 1 | """Geometric registration error metrics.""" 2 | 3 | 4 | import numpy as np 5 | 6 | from ..utils.eval import get_rot_trans_error 7 | from ..utils.logging import get_logger 8 | from .common import ( 9 | has_transform_on_all_edges, 10 | get_edge_transforms, 11 | get_node_transforms, 12 | look_up_transforms, 13 | filter_outlier_nodes 14 | ) 15 | 16 | 17 | logger = get_logger("Metrics") 18 | 19 | 20 | def evaluate_geometric_error(gt_graph, pred_graph, translation_threshold, rotation_threshold): 21 | """Evaluates geometric registration errors. 22 | 23 | Args 24 | ---- 25 | edges_gt (list[dict]): List of ground truth edges with 'source_id', 'target_id', and 'relative_transform'. 26 | edges_pred (list[dict]): List of predicted edges with 'source_id', 'target_id', and 'relative_transform'. 27 | translation_threshold (float): Threshold for translation error to consider alignment correct. 28 | rotation_threshold (float): Threshold for rotation error (in degrees) to consider alignment correct. 29 | 30 | Returns 31 | ------- 32 | metrics (dict): Dictionary with pairwise RMSE, recall, average translation error, and average rotation error. 33 | """ 34 | _translation_error = 0 35 | _rotation_error = 0 36 | _success = 0 37 | 38 | gt_edges = gt_graph['edges'] 39 | _, pred_nodes = filter_outlier_nodes(gt_graph['nodes'], pred_graph['nodes']) 40 | pred_edges = pred_graph.get('edges', None) 41 | 42 | compute_pairwise = pred_edges is None or not has_transform_on_all_edges(pred_edges) 43 | 44 | if compute_pairwise: 45 | pred_trans = get_node_transforms(pred_nodes) 46 | logger.info("No valid edge prediction found in the graph %s, using predicted node transformations.", gt_graph['name']) 47 | else: 48 | pred_trans = get_edge_transforms(pred_edges) 49 | 50 | for gt_edge in gt_edges: 51 | gt_tsfm = np.array(gt_edge.get('relative_transform', gt_edge.get('tsfm'))) 52 | src_node_id = gt_edge.get('source_id', gt_edge.get('source')) 53 | tgt_node_id = gt_edge.get('target_id', gt_edge.get('target')) 54 | pred_tsfm = look_up_transforms(src_node_id, tgt_node_id, pred_trans, compute_pairwise=compute_pairwise) 55 | rotation_error, translation_error = get_rot_trans_error(gt_tsfm, pred_tsfm) 56 | 57 | if translation_error <= translation_threshold and rotation_error <= rotation_threshold: 58 | _success += 1 59 | _translation_error += translation_error.item() 60 | _rotation_error += rotation_error.item() 61 | 62 | num_pairs = len(gt_edges) 63 | recall = _success / num_pairs if num_pairs > 0 else 0 64 | avg_translation_error = _translation_error / _success if _success > 0 else float('inf') 65 | avg_rotation_error = _rotation_error / _success if _success > 0 else float('inf') 66 | 67 | metrics = { 68 | 'Registration Recall': recall * 100, 69 | 'Average Translation Error': avg_translation_error, 70 | 'Average Rotation Error': avg_rotation_error 71 | } 72 | return metrics -------------------------------------------------------------------------------- /tests/testdata/mock_prediction_2025.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Bldg0_Scene1", 4 | "nodes": [ 5 | { 6 | "id": 0, 7 | "global_transform": [ 8 | [0.9961946980917455, -0.08715574274765817, 0.0, 0.1], 9 | [0.08715574274765817, 0.9961946980917455, 0.0, -0.1], 10 | [0.0, 0.0, 1.0, 0.1], 11 | [0.0, 0.0, 0.0, 1.0] 12 | ] 13 | }, 14 | { 15 | "id": 1, 16 | "global_transform": [ 17 | [0.9961946980917455, -0.08715574274765817, 0.0, 1.1], 18 | [0.08715574274765817, 0.9961946980917455, 0.0, -0.1], 19 | [0.0, 0.0, 1.0, 0.1], 20 | [0.0, 0.0, 0.0, 1.0] 21 | ] 22 | }, 23 | { 24 | "id": 2, 25 | "global_transform": [ 26 | [0.9961946980917455, -0.08715574274765817, 0.0, 2.1], 27 | [0.08715574274765817, 0.9961946980917455, 0.0, -0.1], 28 | [0.0, 0.0, 1.0, 0.1], 29 | [0.0, 0.0, 0.0, 1.0] 30 | ] 31 | }, 32 | { 33 | "id": 3, 34 | "global_transform": [ 35 | [0.9961946980917455, -0.08715574274765817, 0.0, 3.1], 36 | [0.08715574274765817, 0.9961946980917455, 0.0, -0.1], 37 | [0.0, 0.0, 1.0, 0.1], 38 | [0.0, 0.0, 0.0, 1.0] 39 | ] 40 | }, 41 | { 42 | "id": 4, 43 | "global_transform": [ 44 | [0.6427876096865394, -0.7660444431189779, 0.0, 1.1414213562373094], 45 | [0.7660444431189779, 0.6427876096865394, 0.0, 1.0], 46 | [0.0, 0.0, 1.0, 0.1], 47 | [0.0, 0.0, 0.0, 1.0] 48 | ] 49 | }, 50 | { 51 | "id": 5, 52 | "global_transform": [ 53 | [-0.08715574274765811, -0.9961946980917455, 0.0, 0.1], 54 | [0.9961946980917455, -0.08715574274765811, 0.0, 1.1], 55 | [0.0, 0.0, 1.0, 0.1], 56 | [0.0, 0.0, 0.0, 1.0] 57 | ] 58 | }, 59 | { 60 | "id": 6, 61 | "global_transform": [ 62 | [0.766044443118978, 0.6427876096865393, 0.0, 1.3877787807814457e-17], 63 | [-0.6427876096865393, 0.766044443118978, 0.0, 1.8585786437626906], 64 | [0.0, 0.0, 1.0, 0.1], 65 | [0.0, 0.0, 0.0, 1.0] 66 | ] 67 | }, 68 | { 69 | "id": 7, 70 | "global_transform": [ 71 | [0.9961946980917455, -0.08715574274765817, 0.0, 0.1], 72 | [0.08715574274765817, 0.9961946980917455, 0.0, 1.9], 73 | [0.0, 0.0, 1.0, 0.1], 74 | [0.0, 0.0, 0.0, 1.0] 75 | ] 76 | }, 77 | { 78 | "id": 8, 79 | "global_transform": [ 80 | [0.9961946980917455, -0.08715574274765817, 0.0, 0.1], 81 | [0.08715574274765817, 0.9961946980917455, 0.0, 2.9], 82 | [0.0, 0.0, 1.0, 0.1], 83 | [0.0, 0.0, 0.0, 1.0] 84 | ] 85 | }, 86 | { 87 | "id": 9, 88 | "global_transform": [ 89 | [0.9961946980917455, -0.08715574274765817, 0.0, 0.1], 90 | [0.08715574274765817, 0.9961946980917455, 0.0, 3.9], 91 | [0.0, 0.0, 1.0, 0.1], 92 | [0.0, 0.0, 0.0, 1.0] 93 | ] 94 | } 95 | ] 96 | } 97 | ] 98 | -------------------------------------------------------------------------------- /scripts/pan_to_pcd.py: -------------------------------------------------------------------------------- 1 | """Tool to convert panorama images to point clouds. 2 | 3 | Usage: 4 | python pan_to_pcd.py -i -o -t -d 5 | """ 6 | 7 | import argparse 8 | import functools 9 | import multiprocessing 10 | import os 11 | 12 | import cv2 13 | import numpy as np 14 | import open3d as o3d 15 | import tqdm 16 | 17 | 18 | def pan_to_xyz(pan_img, threshold=4.5, dmax=10.0): 19 | """Convert a panorama image to 3D coordinates.""" 20 | 21 | height, width = pan_img.shape 22 | lon = np.linspace(0, 2 * np.pi, width) 23 | lat = np.linspace(np.pi / 2, -np.pi / 2, height) 24 | 25 | lon, lat = np.meshgrid(lon, lat) 26 | depth = pan_img / 65535.0 * dmax # Normalize depth values 27 | 28 | mask = depth <= threshold # Apply depth threshold 29 | 30 | # Calculate x, y, z coordinates 31 | z = depth * np.sin(lat) 32 | x = -depth * np.cos(lat) * np.sin(lon) 33 | y = depth * np.cos(lat) * np.cos(lon) 34 | x, y, z = x.flatten(), y.flatten(), z.flatten() 35 | mask = mask.flatten() 36 | 37 | pcd = np.concatenate([x[:, None], y[:, None], z[:, None]], axis=-1) 38 | return pcd[mask] 39 | 40 | 41 | def process_single(filename, threshold, dmax): 42 | """Process a single panorama file to generate a point cloud.""" 43 | outname = SAVE_DIR + filename.replace(".png", f".{int(100 * threshold)}.ply") 44 | pan_img = cv2.imread(PAN_DIR + filename, cv2.IMREAD_ANYDEPTH) 45 | xyz = pan_to_xyz(pan_img, threshold=threshold, dmax=dmax) 46 | 47 | # Create and downsample the point cloud 48 | pcd = o3d.geometry.PointCloud() 49 | pcd.points = o3d.utility.Vector3dVector(xyz) 50 | downpcd = pcd.voxel_down_sample(voxel_size=0.075) 51 | o3d.io.write_point_cloud(outname, downpcd) 52 | 53 | 54 | def process(threshold, dmax, n_jobs=16): 55 | """Main process function to handle multiple files.""" 56 | files = [f for f in os.listdir(PAN_DIR) if f.endswith(".png")] 57 | print("Number of images to convert:", len(files)) 58 | 59 | func = functools.partial(process_single, threshold=threshold, dmax=dmax) 60 | with multiprocessing.Pool(n_jobs) as pool: 61 | list(tqdm.tqdm(pool.imap(func, files), total=len(files))) 62 | 63 | 64 | if __name__ == "__main__": 65 | parser = argparse.ArgumentParser(description="Convert panorama images to point clouds.") 66 | parser.add_argument("-i", "--input", type=str, required=True, help="Input directory containing panorama images.") 67 | parser.add_argument("-o", "--output", type=str, required=True, help="Output directory for point cloud files.") 68 | parser.add_argument("-t", "--threshold", type=float, default=4.5, help="Depth threshold (m) for filtering points.") 69 | parser.add_argument("-d", "--dmax", type=float, default=10.0, help="Maximum depth (m) for point normalization.") 70 | parser.add_argument("-j", "--n_jobs", type=int, default=16, help="Number of parallel jobs to run.") 71 | 72 | args = parser.parse_args() 73 | PAN_DIR = args.input 74 | SAVE_DIR = args.output 75 | 76 | # Start processing 77 | os.makedirs(SAVE_DIR, exist_ok=True) 78 | process(args.threshold, args.dmax, args.n_jobs) -------------------------------------------------------------------------------- /nss_challenge/utils/eval.py: -------------------------------------------------------------------------------- 1 | """Evaluation utilities.""" 2 | 3 | import numpy as np 4 | 5 | 6 | def transformation_residuals(x1, x2, R, t): 7 | """Compute the pointwise residuals based on the estimated transformation. 8 | 9 | Args 10 | ---- 11 | x1 (np.ndarray): Points of the first point cloud [bs, n, 3] 12 | x2 (np.ndarray): Points of the second point cloud [bs, n, 3] 13 | R (np.ndarray): Estimated rotation matrices [bs, 3, 3] 14 | t (np.ndarray): Estimated translation vectors [bs, 3, 1] 15 | 16 | Returns 17 | ------- 18 | res (np.ndarray): Pointwise residuals (Euclidean distance) [b, n, 1] 19 | """ 20 | x2_reconstruct = np.matmul(R, x1.transpose(0, 2, 1)) + t 21 | res = np.linalg.norm(x2_reconstruct.transpose(0, 2, 1) - x2, axis=2, keepdims=True) 22 | return res 23 | 24 | 25 | def rotation_error(R1, R2): 26 | """Compute rotation error between the estimated and the ground truth rotation matrix. 27 | 28 | $$r_e = \arc\cos((trace(R_{ij}^{T} R_{ij}^{GT}) - 1) / 2)$$ 29 | 30 | Args 31 | ---- 32 | R1 (np.ndarray): Estimated rotation matrices [bs, 3, 3] or [3, 3] 33 | R2 (np.ndarray): Ground truth rotation matrices [bs, 3, 3] or [3, 3] 34 | 35 | Returns 36 | ------- 37 | ae (np.ndarray): Rotation error in angular degrees [bs] 38 | """ 39 | if R1.ndim == 2: 40 | R1 = R1[np.newaxis, :] 41 | R2 = R2[np.newaxis, :] 42 | 43 | R_ = np.matmul(R1.transpose(0, 2, 1), R2) 44 | e = np.array([(np.trace(R_[i, :, :]) - 1) / 2 for i in range(R_.shape[0])]) 45 | 46 | # Clamp the errors to the valid range (otherwise np.arccos() can result in nan) 47 | e = np.clip(e, -1, 1) 48 | ae = np.arccos(e) 49 | ae = 180. * ae / np.pi 50 | return ae 51 | 52 | 53 | def translation_error(t1, t2): 54 | """Compute translation error between the estimated and the ground truth translation vectors. 55 | 56 | Args 57 | ---- 58 | t1 (np.ndarray): Estimated translation vectors [bs, 3] or [3] 59 | t2 (np.ndarray): Ground truth translation vectors [bs, 3] or [3] 60 | 61 | Returns 62 | ------- 63 | te (np.ndarray): translation error in meters [bs] 64 | """ 65 | if t1.ndim == 3: 66 | t1 = t1.squeeze(-1) 67 | t2 = t2.squeeze(-1) 68 | 69 | if t1.ndim == 2: 70 | trans_error = np.linalg.norm(t1 - t2, axis=1) 71 | elif t1.ndim == 1: 72 | trans_error = np.linalg.norm(t1 - t2) 73 | return trans_error 74 | 75 | 76 | def get_rot_trans_error(trans_est, trans_gt): 77 | """Get rotation and translation errors.""" 78 | rot_error = rotation_error(trans_est[..., :3, :3], trans_gt[..., :3, :3]) 79 | trans_error = translation_error(trans_est[..., :3, 3], trans_gt[..., :3, 3]) 80 | return rot_error, trans_error 81 | 82 | 83 | def get_rot_mse_error(rot_est, rot_gt): 84 | """rotation MSE error used in DCPNet. 85 | 86 | Args 87 | ---- 88 | rot_est (torch tensor): [bs, 3, 3] 89 | rot_gt (torch tensor): [bs, 3, 3] 90 | 91 | Returns 92 | ------- 93 | mse_error (float): Mean squared error of the rotation. 94 | """ 95 | eye = np.tile(np.eye(3), (rot_gt.shape[0], 1, 1)) 96 | diff = eye - np.matmul(rot_est, np.linalg.inv(rot_gt)) 97 | mse_error = np.mean(np.square(diff)) 98 | return mse_error 99 | 100 | 101 | def get_trans_mse_error(t_est, t_gt): 102 | """Translation MSE error used in DCPNet 103 | 104 | Args 105 | ---- 106 | t_est (torch tensor): [bs, 3, 3] 107 | t_gt (torch tensor): [bs, 3, 3] 108 | 109 | Returns 110 | ------- 111 | mse_error (float): Mean squared error of the translation. 112 | """ 113 | t_error = translation_error(t_est, t_gt) 114 | return (t_error ** 2).mean() 115 | -------------------------------------------------------------------------------- /nss_challenge/utils/pointcloud.py: -------------------------------------------------------------------------------- 1 | """Point cloud processing utilities.""" 2 | 3 | import numpy as np 4 | import open3d as o3d 5 | import sklearn 6 | 7 | 8 | def load_ply(path): 9 | """Load point cloud from ply file.""" 10 | pcd = o3d.io.read_point_cloud(path) 11 | return pcd 12 | 13 | 14 | def to_array(tensor): 15 | """Conver tensor to array.""" 16 | if (not isinstance(tensor, np.ndarray)): 17 | return np.as_array(tensor) 18 | else: 19 | return tensor 20 | 21 | 22 | def to_o3d_vec(vec): 23 | """Convert to open3d vector objects.""" 24 | return o3d.utility.Vector3dVector(to_array(vec)) 25 | 26 | 27 | def to_o3d_pcd(xyz): 28 | """Convert array to open3d PointCloud. 29 | 30 | Args 31 | ---- 32 | xyz (np.ndarray): The input point cloud array in [N, 3]. 33 | 34 | Returns 35 | ------- 36 | pcd (open3d.geometry.PointCloud): Open3d point cloud object. 37 | """ 38 | pcd = o3d.geometry.PointCloud() 39 | pcd.points = o3d.utility.Vector3dVector( 40 | to_array(xyz).astype(np.float64) 41 | ) 42 | return pcd 43 | 44 | 45 | def to_o3d_feats(embedding): 46 | """Convert embedding array to open3d features. 47 | 48 | Args 49 | ---- 50 | embedding (np.ndarray): Embedding array of [N, D]. 51 | 52 | Returns 53 | ------- 54 | feats (open3d.registration.Feature): Open3d feature object. 55 | """ 56 | feats = o3d.registration.Feature() 57 | feats.data = to_array(embedding).T 58 | return feats 59 | 60 | 61 | def transform_points(points, trans): 62 | """Transform points using the given transformation matrix. 63 | 64 | Args 65 | ---- 66 | points (np.ndarray): The input points in [N, 3]. 67 | trans (np.ndarray): The transformation matrix in [4, 4]. 68 | 69 | Returns 70 | ------- 71 | np.ndarray: The transformed points in [N, 3]. 72 | """ 73 | return np.dot(points, trans[:3, :3].T) + trans[:3, 3] 74 | 75 | 76 | class PointCloudCache: 77 | """Cache for point clouds to avoid redundant loading. Singleton pattern.""" 78 | 79 | def __new__(cls): 80 | if not hasattr(cls, 'instance'): 81 | cls.instance = super(PointCloudCache, cls).__new__(cls) 82 | cls.instance.point_cloud_cache = {} 83 | cls.instance.kd_tree_cache = {} 84 | return cls.instance 85 | 86 | def load(self, path): 87 | """Load a point cloud from the given path.""" 88 | if path not in self.point_cloud_cache: 89 | pcd = load_ply(path) 90 | self.point_cloud_cache[path] = np.asarray(pcd.points) 91 | return self.point_cloud_cache[path] 92 | 93 | def get_tree(self, path): 94 | """Get the KD tree for the point cloud at the given path.""" 95 | if path not in self.kd_tree_cache: 96 | points = self.load(path) 97 | self.kd_tree_cache[path] = sklearn.neighbors.KDTree(points) 98 | return self.kd_tree_cache[path] 99 | 100 | def clear(self): 101 | """Clear the cache.""" 102 | self.point_cloud_cache.clear() 103 | self.kd_tree_cache.clear() 104 | 105 | 106 | def get_correspondences(src_path, tgt_path, trans, dist_thresh=0.1): 107 | """Get correspondences point pairs between two point clouds. 108 | 109 | Args 110 | ---- 111 | src_path (str): Path to the source point cloud file. 112 | tgt_path (str): Path to the target point cloud file. 113 | trans (np.ndarray): The 4x4 transformation matrix from source to target. 114 | dist_thresh (float): The distance threshold for overlapping points. Defaults to 0.1. 115 | 116 | Returns 117 | ------- 118 | np.ndarray: The indices of correspondences in the source and target point clouds. 119 | """ 120 | point_cloud_cache = PointCloudCache() 121 | src_points = point_cloud_cache.load(src_path) 122 | src_points_transformed = transform_points(src_points, trans) 123 | 124 | tree = point_cloud_cache.get_tree(tgt_path) 125 | dist, indices = tree.query(src_points_transformed, k=1, return_distance=True) 126 | correspondences = np.hstack((np.arange(len(src_points))[:, np.newaxis], indices)) 127 | correspondences = correspondences[dist[:, 0] < dist_thresh] 128 | return correspondences 129 | -------------------------------------------------------------------------------- /nss_challenge/metrics/rmse.py: -------------------------------------------------------------------------------- 1 | """Compute RMSE between predicted and ground truth pose graphs.""" 2 | 3 | 4 | import os 5 | import numpy as np 6 | 7 | from ..utils.pointcloud import PointCloudCache, get_correspondences, transform_points 8 | from .common import ( 9 | has_transform_on_all_edges, 10 | get_edge_transforms, 11 | get_node_transforms, 12 | look_up_transforms, 13 | filter_outlier_nodes 14 | ) 15 | 16 | 17 | def _get_node_name_by_id(node_id, nodes): 18 | """Find the node name given a node ID.""" 19 | for node in nodes: 20 | if node['id'] == node_id: 21 | return node['name'] 22 | return None 23 | 24 | 25 | def _compute_rmse(src_path, tgt_path, trans, gt_trans): 26 | """ 27 | Compute the RMSE between corresponding points of source and target point clouds 28 | after applying a transformation to the source. 29 | """ 30 | correspondences = get_correspondences(src_path, tgt_path, gt_trans) 31 | if correspondences.size == 0: 32 | return float('inf') # Return infinity if no correspondences found 33 | 34 | point_cloud_cache = PointCloudCache() 35 | src_points = point_cloud_cache.load(src_path) 36 | tgt_points = point_cloud_cache.load(tgt_path) 37 | 38 | src_points = transform_points(src_points, trans) 39 | src_points = src_points[correspondences[:, 0], :] 40 | tgt_points = tgt_points[correspondences[:, 1], :] 41 | distances = np.linalg.norm(src_points - tgt_points, axis=1) 42 | rmse = np.sqrt(np.mean(np.square(distances))) 43 | return rmse 44 | 45 | 46 | def compute_pairwise_rmse(gt_graph, pred_graph, base_dir): 47 | """Compute the RMSE for each pair of fragments in the pose graph. 48 | 49 | Args 50 | ---- 51 | gt_graph (dict): Ground truth pose graph. 52 | pred_graph (dict): Predicted pose graph. 53 | base_dir (str): Base directory where point clouds are stored. 54 | 55 | Returns 56 | ------- 57 | dict: Dict that includes the average RMSE for each pair of fragments. 58 | """ 59 | rmses = [] 60 | 61 | # only evaluate on non-outlier nodes 62 | _, pred_nodes = filter_outlier_nodes(gt_graph['nodes'], pred_graph['nodes']) 63 | pred_edges = pred_graph.get('edges', None) 64 | 65 | compute_pairwise = pred_edges is None or not has_transform_on_all_edges(pred_edges) 66 | 67 | if compute_pairwise: 68 | pred_transforms = get_node_transforms(pred_nodes) 69 | else: 70 | pred_transforms = get_edge_transforms(pred_edges) 71 | 72 | for gt_edge in gt_graph['edges']: 73 | src_node_name = _get_node_name_by_id(gt_edge.get('source_id', gt_edge.get('source')), gt_graph['nodes']) 74 | tgt_node_name = _get_node_name_by_id(gt_edge.get('target_id', gt_edge.get('target')), gt_graph['nodes']) 75 | src_path = os.path.join(base_dir, src_node_name) 76 | tgt_path = os.path.join(base_dir, tgt_node_name) 77 | 78 | pred_trans = look_up_transforms( 79 | source=gt_edge.get('source_id', gt_edge.get('source')), 80 | target=gt_edge.get('target_id', gt_edge.get('target')), 81 | pred_transforms=pred_transforms, 82 | compute_pairwise=compute_pairwise 83 | ) 84 | pred_trans = np.array(pred_trans) 85 | gt_trans = np.array(gt_edge.get('relative_transform', gt_edge.get('tsfm'))) 86 | 87 | rmse = _compute_rmse(src_path, tgt_path, pred_trans, gt_trans) 88 | rmses.append(rmse) 89 | 90 | overall_rmse = np.mean(rmses) 91 | return {"Pairwise RMSE": overall_rmse} 92 | 93 | 94 | def compute_global_rmse(gt_graph, pred_graph, base_dir): 95 | """Compute the RMSE for the entire pose graph by merging all fragments. 96 | 97 | Args 98 | ---- 99 | gt_graph (dict): Ground truth pose graph. 100 | pred_graph (dict): Predicted pose graph. 101 | base_dir (str): Base directory where point clouds are stored. 102 | 103 | Returns 104 | ------- 105 | dict: Dict that includes the RMSE for the entire pose graph. 106 | """ 107 | point_cloud_cache = PointCloudCache() 108 | gt_nodes, pred_nodes = filter_outlier_nodes(gt_graph['nodes'], pred_graph['nodes']) 109 | 110 | gt_transforms = get_node_transforms(gt_nodes) 111 | pred_transforms = get_node_transforms(pred_nodes) 112 | 113 | 114 | points_gt = [] 115 | points_pred = [] 116 | 117 | # Find the anchor node and its transformation 118 | anchor_id = None 119 | for node in pred_nodes: 120 | if 'anchor' in node and node['anchor'] == True: 121 | anchor_id = node['id'] 122 | break 123 | if anchor_id is None: 124 | # Use the first node in prediction as the anchor if it's not found 125 | anchor_id = pred_nodes[0]['id'] 126 | 127 | anchor_gt = np.linalg.inv(gt_transforms.get(anchor_id, np.eye(4))) 128 | anchor_pred = np.linalg.inv(pred_transforms.get(anchor_id, np.eye(4))) 129 | 130 | for gt_node in gt_nodes: 131 | node_name = gt_node['name'] 132 | path = os.path.join(base_dir, node_name) 133 | gt_trans = anchor_gt @ np.array(gt_node.get('global_transform', gt_node.get('tsfm'))) 134 | pred_trans = anchor_pred @ np.array(pred_transforms.get(gt_node['id'], np.eye(4))) 135 | 136 | points_gt.append( 137 | transform_points(point_cloud_cache.load(path), gt_trans) 138 | ) 139 | points_pred.append( 140 | transform_points(point_cloud_cache.load(path), pred_trans) 141 | ) 142 | 143 | points_gt = np.concatenate(points_gt, axis=0) 144 | points_pred = np.concatenate(points_pred, axis=0) 145 | distances = np.linalg.norm(points_gt - points_pred, axis=1) 146 | rmse = np.sqrt(np.mean(np.square(distances))) 147 | return {"Global RMSE": rmse} 148 | -------------------------------------------------------------------------------- /nss_challenge/evaluate_registration.py: -------------------------------------------------------------------------------- 1 | """NSS Challenge Evaluator for Pose Graphs. 2 | 3 | Overview 4 | -------- 5 | This script evaluates the performance of multiway registration methods for the 6 | NSS Challenge. The pose graph of each scene is defined with nodes and edges 7 | where nodes represent individual point clouds and their poses, while edges 8 | define the relationships (e.g., transformations and overlaps) between pairs of 9 | point clouds. The evaluation is performed using the following metrics: 10 | 11 | - Global RMSE Measures the Root Mean Squared Error (RMSE) across all fragments in the global coordinate system. 12 | - Outlier Detection F1 Score Measures accuracy of outlier detection. 13 | - Pairwise RMSE Measures the RMSE for each pair of fragments in the scene averaged across all pairs. 14 | - Recall The percentage of correctly aligned point pairs. 15 | - Precision 16 | - Average Translation Error The average translation error of the correctly aligned point pairs. 17 | - Average Rotation Error The average rotation error of the correctly aligned point pairs. 18 | 19 | 20 | Format 21 | ------ 22 | We use JSON files defining the pose graphs for global and pairwise point cloud 23 | registration evaluations. Each JSON file contains a list of pose graphs, each 24 | representing a specific building scene. 25 | 26 | - list[dict]: List of pose graphs. 27 | 28 | - name (str): Name for the building scene, formatted as "BldgX_SceneN". 29 | 30 | - nodes (list[dict]): List of nodes representing point clouds within the scene. 31 | - id (int): Identifier (ID) for the node within its scene. 32 | - name (str): Name of the point cloud file, formatted as "BldgX_StageY_SpotZ.ply". 33 | - global_transform (list[list[float]]): 4x4 transformation matrix of the pose of the point cloud in global coordinates. 34 | - building (str): Building name, matching "X" in the node name. 35 | - stage (str): Temporal stage, matching "Y" in the node name. 36 | - spot (str): Spot number, matching "Z" in the node name. 37 | - points (int): Number of points in the point cloud. 38 | 39 | - edges (list[dict], optional): List of edges representing pairwise relationships between nodes. Each edge is a dictionary: 40 | - source_id (int): Node ID of the source point cloud. 41 | - target_id (int): Node ID of the target point cloud. 42 | - relative_transform (list[list[float]]): 4x4 transformation matrix of the relative pose from the source to the target. 43 | - overlap_ratio (float): Overlap ratio between the source and target, ranging from 0.0 to 1.0. 44 | - temporal_change (float): Temporal change ratio indicating the amount of temporal change between the source and target, ranging from 0.0 to 1.0. 45 | - same_stage (bool): Indicates whether the source and target come from the same temporal stage. 46 | 47 | Notes 48 | ----- 49 | - In the submission, only the `id`, `global_transform` fields in the nodes and `source_id`, `target_id`, `relative_transform` fields in edges are considered. 50 | - For global pose evaluation, only the transformation in the `nodes` are considered. 51 | - For pairwise pose evaluation, metrics are computed over the defined edges. If `edges` 52 | are missing or partially missing in the prediction files, the missing parts will be 53 | computed using the nodes' global poses. 54 | - 'global_transform' and 'relative_transform' were previously named 'tsfm' 55 | 56 | For more details, please refer to the challenge website: 57 | https://nothing-stands-still.com/challenge 58 | """ 59 | 60 | from argparse import ArgumentParser 61 | import json 62 | import os 63 | 64 | import numpy as np 65 | 66 | from .metrics.rmse import compute_pairwise_rmse, compute_global_rmse 67 | from .metrics.geometric import evaluate_geometric_error 68 | from .metrics.f1 import compute_outlier_f1 69 | from .utils.logging import get_logger, format_table 70 | 71 | 72 | logger = get_logger("Evaluator") 73 | 74 | METRICS = [ 75 | {"name": "Global RMSE", "unit": "m"}, 76 | {"name": "Outlier F1", "unit": "%"}, 77 | {"name": "Pairwise RMSE", "unit": "m"}, 78 | {"name": "Registration Recall", "unit": "%"}, 79 | {"name": "Average Translation Error", "unit": "m"}, 80 | {"name": "Average Rotation Error", "unit": "deg"}, 81 | ] 82 | 83 | SUBSET = ["All", "Same-Stage", "Cross-Stage"] 84 | 85 | 86 | def load_json(path): 87 | """Load a JSON file.""" 88 | with open(path, "r", encoding="utf8") as f: 89 | return json.load(f) 90 | 91 | 92 | def evaluate_graph(gt_graph, pred_graph, translation_threshold, rotation_threshold, point_cloud_dir=None): 93 | """Evaluate the performance of the predicted pose graph.""" 94 | metrics = evaluate_geometric_error( 95 | gt_graph, pred_graph, translation_threshold, rotation_threshold 96 | ) 97 | metrics.update(compute_outlier_f1(gt_graph, pred_graph)) 98 | if point_cloud_dir is not None: 99 | if os.path.exists(point_cloud_dir): 100 | pairwise_rmse = compute_pairwise_rmse( 101 | gt_graph, pred_graph, base_dir=point_cloud_dir 102 | ) 103 | global_rmse = compute_global_rmse( 104 | gt_graph, pred_graph, base_dir=point_cloud_dir 105 | ) 106 | metrics.update(pairwise_rmse) 107 | metrics.update(global_rmse) 108 | return metrics 109 | 110 | 111 | def filter_edges(graph, same_stage=True): 112 | """Filter edges of the graph based on whether they are same-stage or cross-stage.""" 113 | if "edges" not in graph: 114 | return graph 115 | filtered_edges = [edge for edge in graph["edges"] if edge["same_stage"] == same_stage] 116 | return {**graph, "edges": filtered_edges} 117 | 118 | 119 | def evaluate_scene(gt_graph, pred_graph, translation_threshold, rotation_threshold, point_cloud_dir=None): 120 | """Evaluate the performance of the predicted pose graph for a single scene.""" 121 | logger.info("Evaluating scene: %s", gt_graph["name"]) 122 | 123 | # 1. Evaluate the full pose graph 124 | metrics_all = evaluate_graph(gt_graph, pred_graph, translation_threshold, rotation_threshold, point_cloud_dir) 125 | 126 | # 2. Evaluate the pose graph with only same-stage edges 127 | gt_same_stage = filter_edges(gt_graph, same_stage=True) 128 | pred_same_stage = filter_edges(pred_graph, same_stage=True) 129 | metrics_same_stage = evaluate_graph(gt_same_stage, pred_same_stage, translation_threshold, rotation_threshold, point_cloud_dir) 130 | 131 | # 3. Evaluate the pose graph with only cross-stage edges 132 | gt_cross_stage = filter_edges(gt_graph, same_stage=False) 133 | pred_cross_stage = filter_edges(pred_graph, same_stage=False) 134 | metrics_cross_stage = evaluate_graph(gt_cross_stage, pred_cross_stage, translation_threshold, rotation_threshold, point_cloud_dir) 135 | 136 | return { 137 | "All": metrics_all, 138 | "Same-Stage": metrics_same_stage, 139 | "Cross-Stage": metrics_cross_stage, 140 | } 141 | 142 | 143 | def evaluate(args): 144 | """Evaluate the performance of the predicted pose graph.""" 145 | # Load ground truth and prediction 146 | logger.info("Loading ground truth file: %s", args.target) 147 | ground_truth = load_json(args.target) 148 | logger.info("Loading prediction file: %s", args.prediction) 149 | prediction = load_json(args.prediction) 150 | if len(ground_truth) != len(prediction): 151 | logger.error("Number of scenes of ground truth and submission are not equal.") 152 | logger.info("Ground truth: %d scenes, Prediction: %d scenes", len(ground_truth), len(prediction)) 153 | return 154 | 155 | if args.point_cloud_dir is not None: 156 | if not os.path.exists(args.point_cloud_dir): 157 | logger.error("Point cloud directory doesn't exist: %s", args.point_cloud_dir) 158 | return 159 | else: 160 | logger.info("Point cloud directory not provided. Skipping the RMSE evaluation.") 161 | 162 | # Compute metrics for each scene 163 | metrics = {} 164 | for gt_graph, pred_graph in zip(ground_truth, prediction): 165 | metrics_per_scene = evaluate_scene( 166 | gt_graph, pred_graph, args.translation_threshold, args.rotation_threshold, args.point_cloud_dir 167 | ) 168 | metrics[gt_graph["name"]] = metrics_per_scene 169 | return metrics 170 | 171 | 172 | def log_result(metrics): 173 | """Log the evaluation results.""" 174 | 175 | # Average over all scenes 176 | metrics_overall = {} 177 | scenes = list(metrics.keys()) 178 | for m in METRICS: 179 | for subset in SUBSET: 180 | name, unit = m["name"], m["unit"] 181 | if name in metrics[scenes[0]]["All"]: 182 | metrics_overall[f"{name} [{unit}]/{subset}"] = np.nanmean( 183 | [metrics[scene_name][subset][name] for scene_name in scenes] 184 | ) 185 | 186 | table = format_table(metrics_overall, 'Overall') 187 | logger.info("Results:\n\n%s", table) 188 | 189 | # Per-scene results 190 | log_str = "" 191 | for scene_name, metrics_per_scene in metrics.items(): 192 | for m in METRICS: 193 | for subset in SUBSET: 194 | name, unit = m["name"], m["unit"] 195 | if name in metrics[scenes[0]]["All"]: 196 | metrics_overall[f"{name} [{unit}]/{subset}"] = metrics_per_scene[subset][name] 197 | table = format_table(metrics_overall, scene_name) 198 | log_str += "\n\n" + table 199 | logger.info("Results for each scene:%s", log_str) 200 | 201 | 202 | 203 | if __name__ == "__main__": 204 | parser = ArgumentParser( 205 | description="Evaluator for pose graphs in the NSS Challenge.", 206 | usage="python -m nss_challenge.evaluate_registration [--translation_threshold] [--rotation_threshold] [--point_cloud_dir]", 207 | epilog="For more details, please refer to the challenge website: https://nothing-stands-still.com/challenge", 208 | ) 209 | parser.add_argument( 210 | "target", 211 | type=str, 212 | help="Path to target json file." 213 | ) 214 | parser.add_argument( 215 | "prediction", 216 | type=str, 217 | help="Path to prediction json file." 218 | ) 219 | parser.add_argument( 220 | "--translation_threshold", 221 | "-t", 222 | type=float, 223 | default=0.1, 224 | help="Threshold (in meters) for translation error to consider successfully aligned." 225 | ) 226 | parser.add_argument( 227 | "--rotation_threshold", 228 | "-r", 229 | type=float, 230 | default=10, 231 | help="Threshold (in degrees) for rotation error to consider successfully aligned." 232 | ) 233 | parser.add_argument( 234 | "--point_cloud_dir", 235 | "-p", 236 | type=str, 237 | default=None, 238 | help="Directory containing point clouds for RMSE evaluation." 239 | ) 240 | 241 | args = parser.parse_args() 242 | metrics = evaluate(args) 243 | log_result(metrics) 244 | logger.info("Evaluation complete.") 245 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nothing Stands Still Challenge 2025 2 | 3 | We, the Gradient Spaces group at Stanford University, together with HILTI, are tackling the critical challenge of seamlessly integrating progress scans from various stages of construction toward accurate progress monitoring and prediction. To this end, we are bringing Nothing Stands Still to the 2025 International Conference on Robotics and Automation (ICRA), and specifically as a challenge at the 4th Workshop on Future of Construction. 4 | 5 | The Nothing Stands Still Challenge 2025 targets the task of multiway spatiotemporal 3D point cloud registration of data collected over time at construction sites. For more details on the data, we refer to the "Nothing Stands Still: A spatiotemporal benchmark on 3D point cloud registration" paper that contains all information about the dataset acquisition and curation, as well as on the benchmark associated with it. 6 | 7 | 8 | 9 | ## Usage 10 | 11 | ### Pre-requisites 12 | Please make sure you have the following packages installed: 13 | 14 | - Python 3.8 or later 15 | - Open3D 0.9.0 or later 16 | - NumPy 1.20.0 or later 17 | - Sklearn 0.22.0 or later 18 | 19 | ### Evaluation 20 | To evaluate your results, you can use the provided evaluation script `evaluate_registration.py`. The script takes two required arguments: the path to the ground truth JSON file and the path to the prediction JSON file. 21 | 22 | ```shell 23 | python -m nss_challenge.evaluate_registration \ 24 | "/path/to/ground_truth.json" \ 25 | "/path/to/prediction.json" \ 26 | --point_cloud_dir "/dir/to/pointclouds" 27 | ``` 28 | 29 | The optional argument `--point_clouds_dir` specifies the directory where the point clouds are stored. This is required if you want to compute the RMSE metrics. Note that this may take a longer time to compute. 30 | 31 | ### Output 32 | 33 | You can expect the following output for the overall evaluation of all scenes: 34 | ``` 35 | Overall All Same-Stage Cross-Stage 36 | ------------------------------------------------------------------------------------- 37 | Pairwise RMSE [m] 0.310 0.167 0.995 38 | F1 Outlier Detection [%] 23.304 46.812 13.534 39 | Average Translation Error [m] 0.181 0.084 0.760 40 | Average Rotation Error [deg] 5.231 2.139 9.135 41 | ``` 42 | 43 | There will also be tables showing the same metrics for each scene. 44 | 45 | 46 | ## Evaluation Protocol 47 | The goal of the challenge is to achieve a global spatiotemporal map of 3D fragments collected at any time and location at the same construction scenes, as the latter evolve. Participants will be evaluated on the cross-area split of the Nothing Stands Still dataset for the multiway registration task and particularly on the metric of Global Root Mean Squared Error (RMSE) of each scene, which we will use to select the winner. This new data split is designed to evaluate the generalization capabilities of the algorithms to unseen areas. In addition, a secondary task of outlier detection will be used to evaluate robustness of the algorithms. The winner and the first runner up will receive a cash prize (4K USD and 1K USD respectively). Everybody is welcome to participate in the challenge, however only students (undergrad, postgrad) are eligible for receiving the cash prizes. Below, we provide the details of Global RMSE and other metrics used for evaluating algorithmic behavior. 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
MetricUnitDescription
Global RMSEMetersMeasures the Root Mean Squared Error (RMSE) across all fragments in the global coordinate system, calculating the distance between ground truth points and their closest points in the estimation of aligned fragments. This is the main metric that determines the winner of the challenge.
Outlier Detection F1 ScorePercentageMeasures how accurately participants detect outlier nodes.
Average Translation ErrorMetersThe averaged translation error for the estimated transformation of each fragment pair in the scene.
Average Rotation ErrorDegreesThe averaged rotation error in degrees for the estimated transformation of each fragment pair in the scene.
75 | 76 | For more details on the metrics and evaluation, we refer to Section 5 of the "Nothing Stands Still: A spatiotemporal benchmark on 3D point cloud registration" paper. 77 | 78 | 79 | 80 | ## Dataset 81 | The data of this challenge is the set of raw 3D fragments and their corresponding temporal stamps. Since there is no odometry information due to the way the dataset was collected, in lieu of that we provide participants with a graph structure, where nodes represent the spatiotemporal fragments and edges denote spatial overlap. We will be referring to them as "odometry" graphs. The test set for the challenge is hidden and evaluation on it is hosted on our evaluation server. For the training and validation sets, we provide all ground truth information. 82 | 83 | In this Github repository we provide the evaluation code and metrics for the multiway registration task. In addition to the main and defining metric of global pose error (RMSE), we also provide participants an evaluation on the rest of the metrics describted above, so as to furhter analyze the behavior of their algorithm. 84 | 85 | 86 | ### Download the Dataset 87 | A new sampling of the NSS dataset is provided for non-commercial research purposes (no R&D) and can be downloaded from here. Note both the data and benchmark are different from the 2024 NSS Challenge, to participate in this year's challenge only this year's data is relevant: 88 |
    89 |
  • pointclouds.zip: contains the 3D fragments
  • 90 |
  • pose_graphs.zip: annotations for multiway registration
  • 91 |
  • visualizations.zip: shows the relevant area
  • 92 |
  • nss_challenge_readme.md: information on the challenge and data structure
  • 93 |
94 | Note that there is a difference between annotation.zip and annotation_multiway.zip, so make sure you download the appropriate files. 95 | 96 | ### Data Evaluation 97 | Data evaluation will be hosted on our evaluation server. Users will be able to submit their results in a specific format and get the results back once processed. Note that this server is used to collect submissions, please use the NSS challenge repo for evaluation during development. Details on the submission format and evaluation can be found on our evaluation server. 98 | 99 | 100 | ### Format 101 | Folder structure for the NSS Challenge 2025: 102 | 103 | ```yaml 104 | - challenge_2025 105 | - point_cloud # Point cloud data for all splits. 106 | - Bldg1_Stage1_Spot0.ply 107 | - Bldg1_Stage1_Spot1.ply 108 | - ... 109 | 110 | - pose_graph 111 | - train 112 | - Bldg1_Graph1.json 113 | - Bldg1_Graph2.json 114 | - ... 115 | - val 116 | - test 117 | 118 | - visualization # Visualization of each pose graph in train and val. 119 | - train 120 | - Bldg1_Graph1.png 121 | - Bldg1_Graph2.png 122 | - ... 123 | - val 124 | ``` 125 | 126 | Pose Graph Format: 127 | 128 | ```python 129 | { 130 | "name": "Bldg1_Graph3", 131 | "size": 20, # Number of nodes in the graph. 132 | "nodes": [ 133 | { 134 | "id": 0, # Node ID. 135 | "name": "Bldg1_Stage3_Spot495.ply", # Point cloud file in the point_cloud/ folder. 136 | "building": 1, # Building ID. 137 | "stage": 3, # Stage ID. 138 | "spot": 495, # Spot ID. 139 | "points": 28277, # Number of points in the point cloud. 140 | "global_transform": [ # Global transformation matrix (4 by 4) that transforms the point cloud to the world frame. 141 | [1, 0, 0, 0], # NOTE: For the outlier point clouds, the global transformation matrix is the zero matrix. 142 | [0, 1, 0, 0], 143 | [0, 0, 1, 0], 144 | [0, 0, 0, 1] 145 | ], 146 | }, 147 | ... 148 | ], 149 | 150 | "edges": [ # List of edges in the graph, only provided for the train and val splits. 151 | { 152 | "source_id": 2, # Node ID of the source point cloud. 153 | "target_id": 12, # Node ID of the target point cloud. 154 | "relative_transform": [ # Relative transformation matrix (4 by 4) that transforms the source point cloud to the target. 155 | [1, 0, 0, 0], 156 | [0, 1, 0, 0], 157 | [0, 0, 1, 0], 158 | [0, 0, 0, 1] 159 | ], 160 | "overlap_ratio": 0.618 # Overlap ratio between the source and target point clouds. 161 | "same_stage": true # Whether the source and target point clouds are in the same temporal stage. 162 | }, 163 | ... 164 | ] 165 | } 166 | ``` 167 | 168 | ### Notes 169 | 1. Each point cloud is downsampling to 0.075m voxel size. 170 | 2. 4 random graph sizes: [20, 40, 80, 160], 171 | 3. Each graph is added up to 10% outlier nodes, which are disconnected to all inlier nodes and among themselves. 172 | In the submission, the participants are required to also detect the outlier nodes by leaving the "global_transform" to the zero matrix. 173 | 4. Each inlier node has degree >= 2 (meaning that it must connect to at least two other positive nodes in the graph). 174 | 5. Average overlap ratio for each graph is guaranteed to be > 0.3, and the minimum overlap > 0.1 for all edges. 175 | 6. Quantity: 200 pose graphs per building, except for Building 4 (100 graphs). 176 | 7. Data split same as the 'Cross-Area' split in the NSS paper: 177 | - Building [1, 2, 6] for training, 178 | - Building [3] for validation, 179 | - Building [4, 5] for testing. 180 | 181 | 182 | ## Organizers 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 |
Tao SunEmily SteinerTorben GräberMichael HelmbergerIro Armeni
Stanford UniversityStanford UniversityHILTI GroupHILTI GroupStanford University
206 | -------------------------------------------------------------------------------- /tests/testdata/mock_target_2024.json: -------------------------------------------------------------------------------- 1 | [{"name": "Bldg0_Scene1", "nodes": [{"id": 0, "name": "Bldg0_Stage1_Spot0.ply", "tsfm": [[1.0, -0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "1", "spot": "0", "points": 1193}, {"id": 1, "name": "Bldg0_Stage2_Spot1.ply", "tsfm": [[1.0, -0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "2", "spot": "1", "points": 1415}, {"id": 2, "name": "Bldg0_Stage2_Spot2.ply", "tsfm": [[1.0, -0.0, 0.0, 2.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "2", "spot": "2", "points": 1116}, {"id": 3, "name": "Bldg0_Stage2_Spot3.ply", "tsfm": [[1.0, -0.0, 0.0, 3.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "2", "spot": "3", "points": 1237}, {"id": 4, "name": "Bldg0_Stage1_Spot4.ply", "tsfm": [[0.7071067811865476, -0.7071067811865475, 0.0, 1.0], [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "1", "spot": "4", "points": 1446}, {"id": 5, "name": "Bldg0_Stage1_Spot5.ply", "tsfm": [[6.123233995736766e-17, -1.0, 0.0, 0.0], [1.0, 6.123233995736766e-17, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "1", "spot": "5", "points": 1995}, {"id": 6, "name": "Bldg0_Stage1_Spot6.ply", "tsfm": [[0.7071067811865476, 0.7071067811865475, 0.0, 0.0], [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "1", "spot": "6", "points": 1627}, {"id": 7, "name": "Bldg0_Stage1_Spot7.ply", "tsfm": [[1.0, -0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "1", "spot": "7", "points": 1923}, {"id": 8, "name": "Bldg0_Stage1_Spot8.ply", "tsfm": [[1.0, -0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 3.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "1", "spot": "8", "points": 1088}, {"id": 9, "name": "Bldg0_Stage1_Spot9.ply", "tsfm": [[1.0, -0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 4.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "building": "0", "stage": "1", "spot": "9", "points": 1131}], "edges": [{"source": 0, "target": 1, "tsfm": [[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.16410131173834772, "temporal_change": 0.5809878623380261, "same_stage": true}, {"source": 0, "target": 2, "tsfm": [[1.0, 0.0, 0.0, 2.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.3647136356051619, "temporal_change": 0.11118341443015145, "same_stage": false}, {"source": 0, "target": 3, "tsfm": [[1.0, 0.0, 0.0, 3.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.5960986362747792, "temporal_change": 0.8783740259758269, "same_stage": true}, {"source": 0, "target": 4, "tsfm": [[0.7071067811865476, -0.7071067811865475, 0.0, 1.0], [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.3555533930217224, "temporal_change": 0.3326385732321273, "same_stage": false}, {"source": 0, "target": 5, "tsfm": [[6.123233995736766e-17, -1.0, 0.0, 0.0], [1.0, 6.123233995736766e-17, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.8564835611279163, "temporal_change": 0.7065206951098476, "same_stage": true}, {"source": 0, "target": 6, "tsfm": [[0.7071067811865476, 0.7071067811865475, 0.0, 0.0], [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.49860917983085273, "temporal_change": 0.6467635749119011, "same_stage": true}, {"source": 0, "target": 7, "tsfm": [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.8125471567059591, "temporal_change": 0.9783611506871192, "same_stage": true}, {"source": 0, "target": 8, "tsfm": [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 3.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.7671943737328495, "temporal_change": 0.8373820019701642, "same_stage": false}, {"source": 0, "target": 9, "tsfm": [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 4.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.9730540612671862, "temporal_change": 0.40953918707108417, "same_stage": false}, {"source": 1, "target": 2, "tsfm": [[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.712313508357342, "temporal_change": 0.8524954924736046, "same_stage": false}, {"source": 1, "target": 3, "tsfm": [[1.0, 0.0, 0.0, 2.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.4289136081104816, "temporal_change": 0.20549237430007916, "same_stage": false}, {"source": 1, "target": 4, "tsfm": [[0.7071067811865476, -0.7071067811865475, 0.0, 0.0], [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.28998525922794116, "temporal_change": 0.24479221672659313, "same_stage": false}, {"source": 1, "target": 5, "tsfm": [[6.123233995736766e-17, -1.0, 0.0, -1.0], [1.0, 6.123233995736766e-17, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.40145253307862094, "temporal_change": 0.8022234075343755, "same_stage": false}, {"source": 1, "target": 6, "tsfm": [[0.7071067811865476, 0.7071067811865475, 0.0, -1.0], [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.688760173249205, "temporal_change": 0.04153132687946759, "same_stage": true}, {"source": 1, "target": 7, "tsfm": [[1.0, 0.0, 0.0, -1.0], [0.0, 1.0, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.7834893697148205, "temporal_change": 0.47030265956518935, "same_stage": true}, {"source": 1, "target": 8, "tsfm": [[1.0, 0.0, 0.0, -1.0], [0.0, 1.0, 0.0, 3.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.662332143220334, "temporal_change": 0.16773236688611748, "same_stage": true}, {"source": 1, "target": 9, "tsfm": [[1.0, 0.0, 0.0, -1.0], [0.0, 1.0, 0.0, 4.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.8812988150478882, "temporal_change": 0.0015723685913923147, "same_stage": true}, {"source": 2, "target": 3, "tsfm": [[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.8191812250895857, "temporal_change": 0.8333335158806746, "same_stage": false}, {"source": 2, "target": 4, "tsfm": [[0.7071067811865476, -0.7071067811865475, 0.0, -1.0], [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.9712450832994173, "temporal_change": 0.9517331211693065, "same_stage": false}, {"source": 2, "target": 5, "tsfm": [[6.123233995736766e-17, -1.0, 0.0, -2.0], [1.0, 6.123233995736766e-17, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.26907982625615956, "temporal_change": 0.34316227799937105, "same_stage": false}, {"source": 2, "target": 6, "tsfm": [[0.7071067811865476, 0.7071067811865475, 0.0, -2.0], [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.8549086005199571, "temporal_change": 0.744172899233856, "same_stage": true}, {"source": 2, "target": 7, "tsfm": [[1.0, 0.0, 0.0, -2.0], [0.0, 1.0, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.8104356331524374, "temporal_change": 0.35180459962051724, "same_stage": false}, {"source": 2, "target": 8, "tsfm": [[1.0, 0.0, 0.0, -2.0], [0.0, 1.0, 0.0, 3.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.41125556803148355, "temporal_change": 0.673544302723428, "same_stage": false}, {"source": 2, "target": 9, "tsfm": [[1.0, 0.0, 0.0, -2.0], [0.0, 1.0, 0.0, 4.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.16686775224316228, "temporal_change": 0.5334882212657776, "same_stage": true}, {"source": 3, "target": 4, "tsfm": [[0.7071067811865476, -0.7071067811865475, 0.0, -2.0], [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.9258232832146298, "temporal_change": 0.22077467403598106, "same_stage": false}, {"source": 3, "target": 5, "tsfm": [[6.123233995736766e-17, -1.0, 0.0, -3.0], [1.0, 6.123233995736766e-17, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.9529312423065968, "temporal_change": 0.31721508779560037, "same_stage": false}, {"source": 3, "target": 6, "tsfm": [[0.7071067811865476, 0.7071067811865475, 0.0, -3.0], [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.18627404262115038, "temporal_change": 0.368638823156648, "same_stage": true}, {"source": 3, "target": 7, "tsfm": [[1.0, 0.0, 0.0, -3.0], [0.0, 1.0, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.7519025381661516, "temporal_change": 0.9442373674144778, "same_stage": true}, {"source": 3, "target": 8, "tsfm": [[1.0, 0.0, 0.0, -3.0], [0.0, 1.0, 0.0, 3.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.5840520131852017, "temporal_change": 0.14430804944494047, "same_stage": true}, {"source": 3, "target": 9, "tsfm": [[1.0, 0.0, 0.0, -3.0], [0.0, 1.0, 0.0, 4.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.7324162050320636, "temporal_change": 0.32191034603497914, "same_stage": true}, {"source": 4, "target": 5, "tsfm": [[0.7071067811865475, -0.7071067811865476, 0.0, -0.7071067811865475], [0.7071067811865476, 0.7071067811865475, 0.0, 0.7071067811865475], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.2535189949179224, "temporal_change": 0.06896233975990063, "same_stage": true}, {"source": 4, "target": 6, "tsfm": [[2.220446049250313e-16, 1.0, 0.0, 0.0], [-1.0, 2.220446049250313e-16, 0.0, 1.414213562373095], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.9832944084918529, "temporal_change": 0.5299013127412235, "same_stage": true}, {"source": 4, "target": 7, "tsfm": [[0.7071067811865476, 0.7071067811865475, 0.0, 0.0], [-0.7071067811865475, 0.7071067811865476, 0.0, 1.414213562373095], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.2720019436716006, "temporal_change": 0.024355927221301754, "same_stage": true}, {"source": 4, "target": 8, "tsfm": [[0.7071067811865476, 0.7071067811865475, 0.0, 0.7071067811865475], [-0.7071067811865475, 0.7071067811865476, 0.0, 2.121320343559643], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.353902004012969, "temporal_change": 0.4316864975415816, "same_stage": false}, {"source": 4, "target": 9, "tsfm": [[0.7071067811865476, 0.7071067811865475, 0.0, 1.414213562373095], [-0.7071067811865475, 0.7071067811865476, 0.0, 2.8284271247461903], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.7726006497858152, "temporal_change": 0.5902725619597127, "same_stage": true}, {"source": 5, "target": 6, "tsfm": [[-0.7071067811865475, 0.7071067811865476, 0.0, 1.0], [-0.7071067811865476, -0.7071067811865475, 0.0, 6.123233995736766e-17], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.389188906652579, "temporal_change": 0.8116982569614515, "same_stage": false}, {"source": 5, "target": 7, "tsfm": [[6.123233995736766e-17, 1.0, 0.0, 1.0], [-1.0, 6.123233995736766e-17, 0.0, 6.123233995736766e-17], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.3381193608251153, "temporal_change": 0.3121612346590581, "same_stage": true}, {"source": 5, "target": 8, "tsfm": [[6.123233995736766e-17, 1.0, 0.0, 2.0], [-1.0, 6.123233995736766e-17, 0.0, 1.224646799147353e-16], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.71831067281903, "temporal_change": 0.03810697257310913, "same_stage": true}, {"source": 5, "target": 9, "tsfm": [[6.123233995736766e-17, 1.0, 0.0, 3.0], [-1.0, 6.123233995736766e-17, 0.0, 1.8369701987210297e-16], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.2004330703820032, "temporal_change": 0.04993647825344394, "same_stage": true}, {"source": 6, "target": 7, "tsfm": [[0.7071067811865476, -0.7071067811865475, 0.0, 0.0], [0.7071067811865475, 0.7071067811865476, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.9755465007305024, "temporal_change": 0.8352062731139565, "same_stage": true}, {"source": 6, "target": 8, "tsfm": [[0.7071067811865476, -0.7071067811865475, 0.0, -0.7071067811865475], [0.7071067811865475, 0.7071067811865476, 0.0, 0.7071067811865477], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.17840778209626734, "temporal_change": 0.10627555740973671, "same_stage": false}, {"source": 6, "target": 9, "tsfm": [[0.7071067811865476, -0.7071067811865475, 0.0, -1.414213562373095], [0.7071067811865475, 0.7071067811865476, 0.0, 1.4142135623730951], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.9294789466503606, "temporal_change": 0.6199172128213667, "same_stage": true}, {"source": 7, "target": 8, "tsfm": [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.810679495884375, "temporal_change": 0.23599146817562566, "same_stage": false}, {"source": 7, "target": 9, "tsfm": [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 2.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.9989871185396186, "temporal_change": 0.8241132095726813, "same_stage": false}, {"source": 8, "target": 9, "tsfm": [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], "overlap": 0.8977360306235882, "temporal_change": 0.7238096566812862, "same_stage": false}]}] -------------------------------------------------------------------------------- /tests/testdata/mock_target_2025.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Bldg0_Scene1", 4 | "nodes": [ 5 | { 6 | "id": 0, 7 | "name": "Bldg0_Stage1_Spot0.ply", 8 | "global_transform": [ 9 | [0.0, 0.0, 0.0, 0.0], 10 | [0.0, 0.0, 0.0, 0.0], 11 | [0.0, 0.0, 0.0, 0.0], 12 | [0.0, 0.0, 0.0, 0.0] 13 | ], 14 | "building": "0", 15 | "stage": "1", 16 | "spot": "0", 17 | "points": 1193 18 | }, 19 | { 20 | "id": 1, 21 | "name": "Bldg0_Stage2_Spot1.ply", 22 | "global_transform": [ 23 | [1.0, -0.0, 0.0, 1.0], 24 | [0.0, 1.0, 0.0, 0.0], 25 | [0.0, 0.0, 1.0, 0.0], 26 | [0.0, 0.0, 0.0, 1.0] 27 | ], 28 | "building": "0", 29 | "stage": "2", 30 | "spot": "1", 31 | "points": 1415 32 | }, 33 | { 34 | "id": 2, 35 | "name": "Bldg0_Stage2_Spot2.ply", 36 | "global_transform": [ 37 | [1.0, -0.0, 0.0, 2.0], 38 | [0.0, 1.0, 0.0, 0.0], 39 | [0.0, 0.0, 1.0, 0.0], 40 | [0.0, 0.0, 0.0, 1.0] 41 | ], 42 | "building": "0", 43 | "stage": "2", 44 | "spot": "2", 45 | "points": 1116 46 | }, 47 | { 48 | "id": 3, 49 | "name": "Bldg0_Stage2_Spot3.ply", 50 | "global_transform": [ 51 | [1.0, -0.0, 0.0, 3.0], 52 | [0.0, 1.0, 0.0, 0.0], 53 | [0.0, 0.0, 1.0, 0.0], 54 | [0.0, 0.0, 0.0, 1.0] 55 | ], 56 | "building": "0", 57 | "stage": "2", 58 | "spot": "3", 59 | "points": 1237 60 | }, 61 | { 62 | "id": 4, 63 | "name": "Bldg0_Stage1_Spot4.ply", 64 | "global_transform": [ 65 | [0.7071067811865476, -0.7071067811865475, 0.0, 1.0], 66 | [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], 67 | [0.0, 0.0, 1.0, 0.0], 68 | [0.0, 0.0, 0.0, 1.0] 69 | ], 70 | "building": "0", 71 | "stage": "1", 72 | "spot": "4", 73 | "points": 1446 74 | }, 75 | { 76 | "id": 5, 77 | "name": "Bldg0_Stage1_Spot5.ply", 78 | "global_transform": [ 79 | [6.123233995736766e-17, -1.0, 0.0, 0.0], 80 | [1.0, 6.123233995736766e-17, 0.0, 1.0], 81 | [0.0, 0.0, 1.0, 0.0], 82 | [0.0, 0.0, 0.0, 1.0] 83 | ], 84 | "building": "0", 85 | "stage": "1", 86 | "spot": "5", 87 | "points": 1995 88 | }, 89 | { 90 | "id": 6, 91 | "name": "Bldg0_Stage1_Spot6.ply", 92 | "global_transform": [ 93 | [0.7071067811865476, 0.7071067811865475, 0.0, 0.0], 94 | [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], 95 | [0.0, 0.0, 1.0, 0.0], 96 | [0.0, 0.0, 0.0, 1.0] 97 | ], 98 | "building": "0", 99 | "stage": "1", 100 | "spot": "6", 101 | "points": 1627 102 | }, 103 | { 104 | "id": 7, 105 | "name": "Bldg0_Stage1_Spot7.ply", 106 | "global_transform": [ 107 | [1.0, -0.0, 0.0, 0.0], 108 | [0.0, 1.0, 0.0, 2.0], 109 | [0.0, 0.0, 1.0, 0.0], 110 | [0.0, 0.0, 0.0, 1.0] 111 | ], 112 | "building": "0", 113 | "stage": "1", 114 | "spot": "7", 115 | "points": 1923 116 | }, 117 | { 118 | "id": 8, 119 | "name": "Bldg0_Stage1_Spot8.ply", 120 | "global_transform": [ 121 | [1.0, -0.0, 0.0, 0.0], 122 | [0.0, 1.0, 0.0, 3.0], 123 | [0.0, 0.0, 1.0, 0.0], 124 | [0.0, 0.0, 0.0, 1.0] 125 | ], 126 | "building": "0", 127 | "stage": "1", 128 | "spot": "8", 129 | "points": 1088 130 | }, 131 | { 132 | "id": 9, 133 | "name": "Bldg0_Stage1_Spot9.ply", 134 | "global_transform": [ 135 | [1.0, -0.0, 0.0, 0.0], 136 | [0.0, 1.0, 0.0, 4.0], 137 | [0.0, 0.0, 1.0, 0.0], 138 | [0.0, 0.0, 0.0, 1.0] 139 | ], 140 | "building": "0", 141 | "stage": "1", 142 | "spot": "9", 143 | "points": 1131 144 | } 145 | ], 146 | "edges": [ 147 | { 148 | "source_id": 0, 149 | "target_id": 1, 150 | "relative_transform": [ 151 | [1.0, 0.0, 0.0, 1.0], 152 | [0.0, 1.0, 0.0, 0.0], 153 | [0.0, 0.0, 1.0, 0.0], 154 | [0.0, 0.0, 0.0, 1.0] 155 | ], 156 | "overlap_ratio": 0.16410131173834772, 157 | "temporal_change": 0.5809878623380261, 158 | "same_stage": true 159 | }, 160 | { 161 | "source_id": 0, 162 | "target_id": 2, 163 | "relative_transform": [ 164 | [1.0, 0.0, 0.0, 2.0], 165 | [0.0, 1.0, 0.0, 0.0], 166 | [0.0, 0.0, 1.0, 0.0], 167 | [0.0, 0.0, 0.0, 1.0] 168 | ], 169 | "overlap_ratio": 0.3647136356051619, 170 | "temporal_change": 0.11118341443015145, 171 | "same_stage": false 172 | }, 173 | { 174 | "source_id": 0, 175 | "target_id": 3, 176 | "relative_transform": [ 177 | [1.0, 0.0, 0.0, 3.0], 178 | [0.0, 1.0, 0.0, 0.0], 179 | [0.0, 0.0, 1.0, 0.0], 180 | [0.0, 0.0, 0.0, 1.0] 181 | ], 182 | "overlap_ratio": 0.5960986362747792, 183 | "temporal_change": 0.8783740259758269, 184 | "same_stage": true 185 | }, 186 | { 187 | "source_id": 0, 188 | "target_id": 4, 189 | "relative_transform": [ 190 | [0.7071067811865476, -0.7071067811865475, 0.0, 1.0], 191 | [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], 192 | [0.0, 0.0, 1.0, 0.0], 193 | [0.0, 0.0, 0.0, 1.0] 194 | ], 195 | "overlap_ratio": 0.3555533930217224, 196 | "temporal_change": 0.3326385732321273, 197 | "same_stage": false 198 | }, 199 | { 200 | "source_id": 0, 201 | "target_id": 5, 202 | "relative_transform": [ 203 | [6.123233995736766e-17, -1.0, 0.0, 0.0], 204 | [1.0, 6.123233995736766e-17, 0.0, 1.0], 205 | [0.0, 0.0, 1.0, 0.0], 206 | [0.0, 0.0, 0.0, 1.0] 207 | ], 208 | "overlap_ratio": 0.8564835611279163, 209 | "temporal_change": 0.7065206951098476, 210 | "same_stage": true 211 | }, 212 | { 213 | "source_id": 0, 214 | "target_id": 6, 215 | "relative_transform": [ 216 | [0.7071067811865476, 0.7071067811865475, 0.0, 0.0], 217 | [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], 218 | [0.0, 0.0, 1.0, 0.0], 219 | [0.0, 0.0, 0.0, 1.0] 220 | ], 221 | "overlap_ratio": 0.49860917983085273, 222 | "temporal_change": 0.6467635749119011, 223 | "same_stage": true 224 | }, 225 | { 226 | "source_id": 0, 227 | "target_id": 7, 228 | "relative_transform": [ 229 | [1.0, 0.0, 0.0, 0.0], 230 | [0.0, 1.0, 0.0, 2.0], 231 | [0.0, 0.0, 1.0, 0.0], 232 | [0.0, 0.0, 0.0, 1.0] 233 | ], 234 | "overlap_ratio": 0.8125471567059591, 235 | "temporal_change": 0.9783611506871192, 236 | "same_stage": true 237 | }, 238 | { 239 | "source_id": 0, 240 | "target_id": 8, 241 | "relative_transform": [ 242 | [1.0, 0.0, 0.0, 0.0], 243 | [0.0, 1.0, 0.0, 3.0], 244 | [0.0, 0.0, 1.0, 0.0], 245 | [0.0, 0.0, 0.0, 1.0] 246 | ], 247 | "overlap_ratio": 0.7671943737328495, 248 | "temporal_change": 0.8373820019701642, 249 | "same_stage": false 250 | }, 251 | { 252 | "source_id": 0, 253 | "target_id": 9, 254 | "relative_transform": [ 255 | [1.0, 0.0, 0.0, 0.0], 256 | [0.0, 1.0, 0.0, 4.0], 257 | [0.0, 0.0, 1.0, 0.0], 258 | [0.0, 0.0, 0.0, 1.0] 259 | ], 260 | "overlap_ratio": 0.9730540612671862, 261 | "temporal_change": 0.40953918707108417, 262 | "same_stage": false 263 | }, 264 | { 265 | "source_id": 1, 266 | "target_id": 2, 267 | "relative_transform": [ 268 | [1.0, 0.0, 0.0, 1.0], 269 | [0.0, 1.0, 0.0, 0.0], 270 | [0.0, 0.0, 1.0, 0.0], 271 | [0.0, 0.0, 0.0, 1.0] 272 | ], 273 | "overlap_ratio": 0.712313508357342, 274 | "temporal_change": 0.8524954924736046, 275 | "same_stage": false 276 | }, 277 | { 278 | "source_id": 1, 279 | "target_id": 3, 280 | "relative_transform": [ 281 | [1.0, 0.0, 0.0, 2.0], 282 | [0.0, 1.0, 0.0, 0.0], 283 | [0.0, 0.0, 1.0, 0.0], 284 | [0.0, 0.0, 0.0, 1.0] 285 | ], 286 | "overlap_ratio": 0.4289136081104816, 287 | "temporal_change": 0.20549237430007916, 288 | "same_stage": false 289 | }, 290 | { 291 | "source_id": 1, 292 | "target_id": 4, 293 | "relative_transform": [ 294 | [0.7071067811865476, -0.7071067811865475, 0.0, 0.0], 295 | [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], 296 | [0.0, 0.0, 1.0, 0.0], 297 | [0.0, 0.0, 0.0, 1.0] 298 | ], 299 | "overlap_ratio": 0.28998525922794116, 300 | "temporal_change": 0.24479221672659313, 301 | "same_stage": false 302 | }, 303 | { 304 | "source_id": 1, 305 | "target_id": 5, 306 | "relative_transform": [ 307 | [6.123233995736766e-17, -1.0, 0.0, -1.0], 308 | [1.0, 6.123233995736766e-17, 0.0, 1.0], 309 | [0.0, 0.0, 1.0, 0.0], 310 | [0.0, 0.0, 0.0, 1.0] 311 | ], 312 | "overlap_ratio": 0.40145253307862094, 313 | "temporal_change": 0.8022234075343755, 314 | "same_stage": false 315 | }, 316 | { 317 | "source_id": 1, 318 | "target_id": 6, 319 | "relative_transform": [ 320 | [0.7071067811865476, 0.7071067811865475, 0.0, -1.0], 321 | [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], 322 | [0.0, 0.0, 1.0, 0.0], 323 | [0.0, 0.0, 0.0, 1.0] 324 | ], 325 | "overlap_ratio": 0.688760173249205, 326 | "temporal_change": 0.04153132687946759, 327 | "same_stage": true 328 | }, 329 | { 330 | "source_id": 1, 331 | "target_id": 7, 332 | "relative_transform": [ 333 | [1.0, 0.0, 0.0, -1.0], 334 | [0.0, 1.0, 0.0, 2.0], 335 | [0.0, 0.0, 1.0, 0.0], 336 | [0.0, 0.0, 0.0, 1.0] 337 | ], 338 | "overlap_ratio": 0.7834893697148205, 339 | "temporal_change": 0.47030265956518935, 340 | "same_stage": true 341 | }, 342 | { 343 | "source_id": 1, 344 | "target_id": 8, 345 | "relative_transform": [ 346 | [1.0, 0.0, 0.0, -1.0], 347 | [0.0, 1.0, 0.0, 3.0], 348 | [0.0, 0.0, 1.0, 0.0], 349 | [0.0, 0.0, 0.0, 1.0] 350 | ], 351 | "overlap_ratio": 0.662332143220334, 352 | "temporal_change": 0.16773236688611748, 353 | "same_stage": true 354 | }, 355 | { 356 | "source_id": 1, 357 | "target_id": 9, 358 | "relative_transform": [ 359 | [1.0, 0.0, 0.0, -1.0], 360 | [0.0, 1.0, 0.0, 4.0], 361 | [0.0, 0.0, 1.0, 0.0], 362 | [0.0, 0.0, 0.0, 1.0] 363 | ], 364 | "overlap_ratio": 0.8812988150478882, 365 | "temporal_change": 0.0015723685913923147, 366 | "same_stage": true 367 | }, 368 | { 369 | "source_id": 2, 370 | "target_id": 3, 371 | "relative_transform": [ 372 | [1.0, 0.0, 0.0, 1.0], 373 | [0.0, 1.0, 0.0, 0.0], 374 | [0.0, 0.0, 1.0, 0.0], 375 | [0.0, 0.0, 0.0, 1.0] 376 | ], 377 | "overlap_ratio": 0.8191812250895857, 378 | "temporal_change": 0.8333335158806746, 379 | "same_stage": false 380 | }, 381 | { 382 | "source_id": 2, 383 | "target_id": 4, 384 | "relative_transform": [ 385 | [0.7071067811865476, -0.7071067811865475, 0.0, -1.0], 386 | [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], 387 | [0.0, 0.0, 1.0, 0.0], 388 | [0.0, 0.0, 0.0, 1.0] 389 | ], 390 | "overlap_ratio": 0.9712450832994173, 391 | "temporal_change": 0.9517331211693065, 392 | "same_stage": false 393 | }, 394 | { 395 | "source_id": 2, 396 | "target_id": 5, 397 | "relative_transform": [ 398 | [6.123233995736766e-17, -1.0, 0.0, -2.0], 399 | [1.0, 6.123233995736766e-17, 0.0, 1.0], 400 | [0.0, 0.0, 1.0, 0.0], 401 | [0.0, 0.0, 0.0, 1.0] 402 | ], 403 | "overlap_ratio": 0.26907982625615956, 404 | "temporal_change": 0.34316227799937105, 405 | "same_stage": false 406 | }, 407 | { 408 | "source_id": 2, 409 | "target_id": 6, 410 | "relative_transform": [ 411 | [0.7071067811865476, 0.7071067811865475, 0.0, -2.0], 412 | [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], 413 | [0.0, 0.0, 1.0, 0.0], 414 | [0.0, 0.0, 0.0, 1.0] 415 | ], 416 | "overlap_ratio": 0.8549086005199571, 417 | "temporal_change": 0.744172899233856, 418 | "same_stage": true 419 | }, 420 | { 421 | "source_id": 2, 422 | "target_id": 7, 423 | "relative_transform": [ 424 | [1.0, 0.0, 0.0, -2.0], 425 | [0.0, 1.0, 0.0, 2.0], 426 | [0.0, 0.0, 1.0, 0.0], 427 | [0.0, 0.0, 0.0, 1.0] 428 | ], 429 | "overlap_ratio": 0.8104356331524374, 430 | "temporal_change": 0.35180459962051724, 431 | "same_stage": false 432 | }, 433 | { 434 | "source_id": 2, 435 | "target_id": 8, 436 | "relative_transform": [ 437 | [1.0, 0.0, 0.0, -2.0], 438 | [0.0, 1.0, 0.0, 3.0], 439 | [0.0, 0.0, 1.0, 0.0], 440 | [0.0, 0.0, 0.0, 1.0] 441 | ], 442 | "overlap_ratio": 0.41125556803148355, 443 | "temporal_change": 0.673544302723428, 444 | "same_stage": false 445 | }, 446 | { 447 | "source_id": 2, 448 | "target_id": 9, 449 | "relative_transform": [ 450 | [1.0, 0.0, 0.0, -2.0], 451 | [0.0, 1.0, 0.0, 4.0], 452 | [0.0, 0.0, 1.0, 0.0], 453 | [0.0, 0.0, 0.0, 1.0] 454 | ], 455 | "overlap_ratio": 0.16686775224316228, 456 | "temporal_change": 0.5334882212657776, 457 | "same_stage": true 458 | }, 459 | { 460 | "source_id": 3, 461 | "target_id": 4, 462 | "relative_transform": [ 463 | [0.7071067811865476, -0.7071067811865475, 0.0, -2.0], 464 | [0.7071067811865475, 0.7071067811865476, 0.0, 1.0], 465 | [0.0, 0.0, 1.0, 0.0], 466 | [0.0, 0.0, 0.0, 1.0] 467 | ], 468 | "overlap_ratio": 0.9258232832146298, 469 | "temporal_change": 0.22077467403598106, 470 | "same_stage": false 471 | }, 472 | { 473 | "source_id": 3, 474 | "target_id": 5, 475 | "relative_transform": [ 476 | [6.123233995736766e-17, -1.0, 0.0, -3.0], 477 | [1.0, 6.123233995736766e-17, 0.0, 1.0], 478 | [0.0, 0.0, 1.0, 0.0], 479 | [0.0, 0.0, 0.0, 1.0] 480 | ], 481 | "overlap_ratio": 0.9529312423065968, 482 | "temporal_change": 0.31721508779560037, 483 | "same_stage": false 484 | }, 485 | { 486 | "source_id": 3, 487 | "target_id": 6, 488 | "relative_transform": [ 489 | [0.7071067811865476, 0.7071067811865475, 0.0, -3.0], 490 | [-0.7071067811865475, 0.7071067811865476, 0.0, 2.0], 491 | [0.0, 0.0, 1.0, 0.0], 492 | [0.0, 0.0, 0.0, 1.0] 493 | ], 494 | "overlap_ratio": 0.18627404262115038, 495 | "temporal_change": 0.368638823156648, 496 | "same_stage": true 497 | }, 498 | { 499 | "source_id": 3, 500 | "target_id": 7, 501 | "relative_transform": [ 502 | [1.0, 0.0, 0.0, -3.0], 503 | [0.0, 1.0, 0.0, 2.0], 504 | [0.0, 0.0, 1.0, 0.0], 505 | [0.0, 0.0, 0.0, 1.0] 506 | ], 507 | "overlap_ratio": 0.7519025381661516, 508 | "temporal_change": 0.9442373674144778, 509 | "same_stage": true 510 | }, 511 | { 512 | "source_id": 3, 513 | "target_id": 8, 514 | "relative_transform": [ 515 | [1.0, 0.0, 0.0, -3.0], 516 | [0.0, 1.0, 0.0, 3.0], 517 | [0.0, 0.0, 1.0, 0.0], 518 | [0.0, 0.0, 0.0, 1.0] 519 | ], 520 | "overlap_ratio": 0.5840520131852017, 521 | "temporal_change": 0.14430804944494047, 522 | "same_stage": true 523 | }, 524 | { 525 | "source_id": 3, 526 | "target_id": 9, 527 | "relative_transform": [ 528 | [1.0, 0.0, 0.0, -3.0], 529 | [0.0, 1.0, 0.0, 4.0], 530 | [0.0, 0.0, 1.0, 0.0], 531 | [0.0, 0.0, 0.0, 1.0] 532 | ], 533 | "overlap_ratio": 0.7324162050320636, 534 | "temporal_change": 0.32191034603497914, 535 | "same_stage": true 536 | }, 537 | { 538 | "source_id": 4, 539 | "target_id": 5, 540 | "relative_transform": [ 541 | [0.7071067811865475, -0.7071067811865476, 0.0, -0.7071067811865475], 542 | [0.7071067811865476, 0.7071067811865475, 0.0, 0.7071067811865475], 543 | [0.0, 0.0, 1.0, 0.0], 544 | [0.0, 0.0, 0.0, 1.0] 545 | ], 546 | "overlap_ratio": 0.2535189949179224, 547 | "temporal_change": 0.06896233975990063, 548 | "same_stage": true 549 | }, 550 | { 551 | "source_id": 4, 552 | "target_id": 6, 553 | "relative_transform": [ 554 | [2.220446049250313e-16, 1.0, 0.0, 0.0], 555 | [-1.0, 2.220446049250313e-16, 0.0, 1.414213562373095], 556 | [0.0, 0.0, 1.0, 0.0], 557 | [0.0, 0.0, 0.0, 1.0] 558 | ], 559 | "overlap_ratio": 0.9832944084918529, 560 | "temporal_change": 0.5299013127412235, 561 | "same_stage": true 562 | }, 563 | { 564 | "source_id": 4, 565 | "target_id": 7, 566 | "relative_transform": [ 567 | [0.7071067811865476, 0.7071067811865475, 0.0, 0.0], 568 | [-0.7071067811865475, 0.7071067811865476, 0.0, 1.414213562373095], 569 | [0.0, 0.0, 1.0, 0.0], 570 | [0.0, 0.0, 0.0, 1.0] 571 | ], 572 | "overlap_ratio": 0.2720019436716006, 573 | "temporal_change": 0.024355927221301754, 574 | "same_stage": true 575 | }, 576 | { 577 | "source_id": 4, 578 | "target_id": 8, 579 | "relative_transform": [ 580 | [0.7071067811865476, 0.7071067811865475, 0.0, 0.7071067811865475], 581 | [-0.7071067811865475, 0.7071067811865476, 0.0, 2.121320343559643], 582 | [0.0, 0.0, 1.0, 0.0], 583 | [0.0, 0.0, 0.0, 1.0] 584 | ], 585 | "overlap_ratio": 0.353902004012969, 586 | "temporal_change": 0.4316864975415816, 587 | "same_stage": false 588 | }, 589 | { 590 | "source_id": 4, 591 | "target_id": 9, 592 | "relative_transform": [ 593 | [0.7071067811865476, 0.7071067811865475, 0.0, 1.414213562373095], 594 | [-0.7071067811865475, 0.7071067811865476, 0.0, 2.8284271247461903], 595 | [0.0, 0.0, 1.0, 0.0], 596 | [0.0, 0.0, 0.0, 1.0] 597 | ], 598 | "overlap_ratio": 0.7726006497858152, 599 | "temporal_change": 0.5902725619597127, 600 | "same_stage": true 601 | }, 602 | { 603 | "source_id": 5, 604 | "target_id": 6, 605 | "relative_transform": [ 606 | [-0.7071067811865475, 0.7071067811865476, 0.0, 1.0], 607 | [ 608 | -0.7071067811865476, -0.7071067811865475, 0.0, 6.123233995736766e-17 609 | ], 610 | [0.0, 0.0, 1.0, 0.0], 611 | [0.0, 0.0, 0.0, 1.0] 612 | ], 613 | "overlap_ratio": 0.389188906652579, 614 | "temporal_change": 0.8116982569614515, 615 | "same_stage": false 616 | }, 617 | { 618 | "source_id": 5, 619 | "target_id": 7, 620 | "relative_transform": [ 621 | [6.123233995736766e-17, 1.0, 0.0, 1.0], 622 | [-1.0, 6.123233995736766e-17, 0.0, 6.123233995736766e-17], 623 | [0.0, 0.0, 1.0, 0.0], 624 | [0.0, 0.0, 0.0, 1.0] 625 | ], 626 | "overlap_ratio": 0.3381193608251153, 627 | "temporal_change": 0.3121612346590581, 628 | "same_stage": true 629 | }, 630 | { 631 | "source_id": 5, 632 | "target_id": 8, 633 | "relative_transform": [ 634 | [6.123233995736766e-17, 1.0, 0.0, 2.0], 635 | [-1.0, 6.123233995736766e-17, 0.0, 1.224646799147353e-16], 636 | [0.0, 0.0, 1.0, 0.0], 637 | [0.0, 0.0, 0.0, 1.0] 638 | ], 639 | "overlap_ratio": 0.71831067281903, 640 | "temporal_change": 0.03810697257310913, 641 | "same_stage": true 642 | }, 643 | { 644 | "source_id": 5, 645 | "target_id": 9, 646 | "relative_transform": [ 647 | [6.123233995736766e-17, 1.0, 0.0, 3.0], 648 | [-1.0, 6.123233995736766e-17, 0.0, 1.8369701987210297e-16], 649 | [0.0, 0.0, 1.0, 0.0], 650 | [0.0, 0.0, 0.0, 1.0] 651 | ], 652 | "overlap_ratio": 0.2004330703820032, 653 | "temporal_change": 0.04993647825344394, 654 | "same_stage": true 655 | }, 656 | { 657 | "source_id": 6, 658 | "target_id": 7, 659 | "relative_transform": [ 660 | [0.7071067811865476, -0.7071067811865475, 0.0, 0.0], 661 | [0.7071067811865475, 0.7071067811865476, 0.0, 0.0], 662 | [0.0, 0.0, 1.0, 0.0], 663 | [0.0, 0.0, 0.0, 1.0] 664 | ], 665 | "overlap_ratio": 0.9755465007305024, 666 | "temporal_change": 0.8352062731139565, 667 | "same_stage": true 668 | }, 669 | { 670 | "source_id": 6, 671 | "target_id": 8, 672 | "relative_transform": [ 673 | [0.7071067811865476, -0.7071067811865475, 0.0, -0.7071067811865475], 674 | [0.7071067811865475, 0.7071067811865476, 0.0, 0.7071067811865477], 675 | [0.0, 0.0, 1.0, 0.0], 676 | [0.0, 0.0, 0.0, 1.0] 677 | ], 678 | "overlap_ratio": 0.17840778209626734, 679 | "temporal_change": 0.10627555740973671, 680 | "same_stage": false 681 | }, 682 | { 683 | "source_id": 6, 684 | "target_id": 9, 685 | "relative_transform": [ 686 | [0.7071067811865476, -0.7071067811865475, 0.0, -1.414213562373095], 687 | [0.7071067811865475, 0.7071067811865476, 0.0, 1.4142135623730951], 688 | [0.0, 0.0, 1.0, 0.0], 689 | [0.0, 0.0, 0.0, 1.0] 690 | ], 691 | "overlap_ratio": 0.9294789466503606, 692 | "temporal_change": 0.6199172128213667, 693 | "same_stage": true 694 | }, 695 | { 696 | "source_id": 7, 697 | "target_id": 8, 698 | "relative_transform": [ 699 | [1.0, 0.0, 0.0, 0.0], 700 | [0.0, 1.0, 0.0, 1.0], 701 | [0.0, 0.0, 1.0, 0.0], 702 | [0.0, 0.0, 0.0, 1.0] 703 | ], 704 | "overlap_ratio": 0.810679495884375, 705 | "temporal_change": 0.23599146817562566, 706 | "same_stage": false 707 | }, 708 | { 709 | "source_id": 7, 710 | "target_id": 9, 711 | "relative_transform": [ 712 | [1.0, 0.0, 0.0, 0.0], 713 | [0.0, 1.0, 0.0, 2.0], 714 | [0.0, 0.0, 1.0, 0.0], 715 | [0.0, 0.0, 0.0, 1.0] 716 | ], 717 | "overlap_ratio": 0.9989871185396186, 718 | "temporal_change": 0.8241132095726813, 719 | "same_stage": false 720 | }, 721 | { 722 | "source_id": 8, 723 | "target_id": 9, 724 | "relative_transform": [ 725 | [1.0, 0.0, 0.0, 0.0], 726 | [0.0, 1.0, 0.0, 1.0], 727 | [0.0, 0.0, 1.0, 0.0], 728 | [0.0, 0.0, 0.0, 1.0] 729 | ], 730 | "overlap_ratio": 0.8977360306235882, 731 | "temporal_change": 0.7238096566812862, 732 | "same_stage": false 733 | } 734 | ] 735 | } 736 | ] 737 | --------------------------------------------------------------------------------