diff --git a/SofaRegressionProgram.py b/SofaRegressionProgram.py index e6f6c3e..60057a5 100644 --- a/SofaRegressionProgram.py +++ b/SofaRegressionProgram.py @@ -23,6 +23,7 @@ def __init__(self, input_folder, disable_progress_bar = False, verbose = False): self.scene_sets = [] # List self.disable_progress_bar = disable_progress_bar self.verbose = verbose + self.legacy_mode = False for root, dirs, files in os.walk(input_folder): for file in files: @@ -65,6 +66,7 @@ def write_all_sets_references(self): def compare_sets_references(self, id_set=0): scene_list = self.scene_sets[id_set] + scene_list.legacy_mode = self.legacy_mode nbr_scenes = scene_list.compare_all_references() return nbr_scenes @@ -128,6 +130,12 @@ def parse_args(): help='If set, will display more information', action='store_true' ) + parser.add_argument( + "--legacy-regression", + dest="legacy_mode", + help='If set, will read old format regression files', + action='store_true' + ) cmdline_args = parser.parse_args() @@ -146,6 +154,10 @@ def parse_args(): nbr_scenes = 0 + if args.legacy_mode: + print("Legacy regression mode activated.") + reg_prog.legacy_mode = True + replay = bool(args.replay) if replay: reg_prog.replay_references() diff --git a/tools/RegressionSceneData.py b/tools/RegressionSceneData.py index ea8fc56..0c54865 100644 --- a/tools/RegressionSceneData.py +++ b/tools/RegressionSceneData.py @@ -29,6 +29,73 @@ def default(self, obj): return obj.tolist() return JSONEncoder.default(self, obj) + +def is_mapped(node): + mapping = node.getMechanicalMapping() + + if mapping is None: + return False + else: + return True + # no mapping in this node context + + +# -------------------------------------------------- +# Helper: read the legacy state reference format +# -------------------------------------------------- +def read_legacy_reference(filename, mechanical_object): + ref_data = [] + times = [] + values = [] + + # Infer layout from MechanicalObject + n_points, dof_per_point = mechanical_object.position.value.shape + expected_size = n_points * dof_per_point + + + with gzip.open(filename, "rt") as f: + for line in f: + line = line.strip() + + if not line: + continue + + # Time marker + if line.startswith("T="): + current_time = float(line.split("=", 1)[1]) + times.append(current_time) + + # Positions + elif line.startswith("X="): + if current_time is None: + raise RuntimeError(f"X found before T in {filename}") + + raw = line.split("=", 1)[1].strip().split() + flat = np.asarray(raw, dtype=float) + + if flat.size != expected_size: + raise ValueError( + f"Legacy reference size mismatch in {filename}: " + f"expected {expected_size}, got {flat.size}\n" + ) + + values.append(flat.reshape((n_points, dof_per_point))) + + # Velocity (ignored) + elif line.startswith("V="): + continue + + if len(times) != len(values): + raise RuntimeError( + f"Legacy reference corrupted in {filename}: " + f"{len(times)} times vs {len(values)} X blocks" + ) + + return times, values + + + + class RegressionSceneData: def __init__(self, file_scene_path: str = None, file_ref_path: str = None, steps = 1000, @@ -73,6 +140,8 @@ def log_errors(self): print("### Failed: " + self.file_scene_path) print(" ### Total Error per MechanicalObject: " + str(self.total_error)) print(" ### Error by Dofs: " + str(self.error_by_dof)) + elif self.nbr_tested_frame == 0: + print("### Failed: No frames were tested for " + self.file_scene_path) else: print ("### Success: " + self.file_scene_path + " | Number of key frames compared without error: " + str(self.nbr_tested_frame)) @@ -89,10 +158,11 @@ def print_meca_objs(self): def parse_node(self, node, level = 0): for child in node.children: mstate = child.getMechanicalState() - if mstate: - if is_simulated(child): + if mstate and is_simulated(child): + + if self.meca_in_mapping is True or not is_mapped(child): self.meca_objs.append(mstate) - + self.parse_node(child, level + 1) @@ -243,6 +313,132 @@ def compare_references(self): return True + + def compare_legacy_references(self): + pbar_simu = tqdm(total=float(self.steps), disable=self.disable_progress_bar) + pbar_simu.set_description("compare_legacy_references: " + self.file_scene_path) + + nbr_meca = len(self.meca_objs) + + # Reference data + ref_times = [] # shared timeline + ref_values = [] # List[List[np.ndarray]] + + self.total_error = [] + self.error_by_dof = [] + self.nbr_tested_frame = 0 + self.regression_failed = False + + # -------------------------------------------------- + # Load legacy reference files + # -------------------------------------------------- + for meca_id in range(nbr_meca): + try: + times, values = read_legacy_reference(self.file_ref_path + ".reference_" + str(meca_id) + "_" + self.meca_objs[meca_id].name.value + "_mstate" + ".txt.gz", + self.meca_objs[meca_id]) + except Exception as e: + print( + f"Error while reading legacy references for MechanicalObject " + f"{self.meca_objs[meca_id].name.value}: {str(e)}" + ) + return False + + # Keep timeline from first MechanicalObject + if meca_id == 0: + ref_times = times + else: + if len(times) != len(ref_times): + print( + f"Reference timeline mismatch for file {self.file_scene_path}, " + f"MechanicalObject {meca_id}" + ) + return False + + ref_values.append(values) + self.total_error.append(0.0) + self.error_by_dof.append(0.0) + + if self.verbose: + print(f"ref_times len: {len(ref_times)}\n") + print(f"ref_values[0] len: {len(ref_values[0])}\n") + print(f"ref_values[0][0] shape: {ref_values[0][0].shape}\n") + + # -------------------------------------------------- + # Simulation + comparison + # -------------------------------------------------- + + frame_step = 0 + nbr_frames = len(ref_times) + dt = self.root_node.dt.value + + for step in range(0, self.steps + 1): + simu_time = dt * step + + # Use tolerance for float comparison + if frame_step < nbr_frames and np.isclose(simu_time, ref_times[frame_step]): + for meca_id in range(nbr_meca): + meca_dofs = np.copy(self.meca_objs[meca_id].position.value) + data_ref = ref_values[meca_id][frame_step] + + if meca_dofs.shape != data_ref.shape: + print( + f"Shape mismatch for file {self.file_scene_path}, " + f"MechanicalObject {meca_id}: " + f"reference {data_ref.shape} vs current {meca_dofs.shape}" + ) + return False + + data_diff = data_ref - meca_dofs + + # Compute total distance between the 2 sets + full_dist = np.linalg.norm(data_diff) + error_by_dof = full_dist / float(data_diff.size) + + if self.verbose: + print( + f"{step} | {self.meca_objs[meca_id].name.value} | " + f"full_dist: {full_dist} | " + f"error_by_dof: {error_by_dof} | " + f"nbrDofs: {data_ref.size}" + ) + + self.total_error[meca_id] += full_dist + self.error_by_dof[meca_id] += error_by_dof + + frame_step += 1 + self.nbr_tested_frame += 1 + + # security exit if simulation steps exceed nbr_frames + if frame_step == nbr_frames: + break + + Sofa.Simulation.animate(self.root_node, dt) + + pbar_simu.update(1) + pbar_simu.close() + + # Final regression returns value + if nbr_meca == 0: + self.regression_failed = True + return False + + # use the same way of computing errors as legacy mode + mean_total_error = 0.0 + mean_error_by_dof = 0.0 + for meca_id in range(nbr_meca): + mean_total_error += self.total_error[meca_id] + mean_error_by_dof += self.error_by_dof[meca_id] + + mean_total_error = mean_total_error / float(nbr_meca) + mean_error_by_dof = mean_error_by_dof / float(nbr_meca) + print ("Mean Total Error: " + str(mean_total_error) + " | Mean Error by Dof: " + str(mean_error_by_dof) + "epsilon: " + str(self.epsilon)) + if mean_error_by_dof > self.epsilon: + self.regression_failed = True + return False + + return True + + def replayReferences(self): Sofa.Gui.GUIManager.Init("myscene", "qglviewer") Sofa.Gui.GUIManager.createGUI(self.root_node, __file__) diff --git a/tools/RegressionSceneList.py b/tools/RegressionSceneList.py index a2cff1d..6704bf3 100644 --- a/tools/RegressionSceneList.py +++ b/tools/RegressionSceneList.py @@ -18,6 +18,7 @@ def __init__(self, file_path, disable_progress_bar = False, verbose = False): self.ref_dir_path = None self.disable_progress_bar = disable_progress_bar self.verbose = verbose + self.legacy_mode = False def get_nbr_scenes(self): @@ -29,6 +30,9 @@ def get_nbr_errors(self): def log_scenes_errors(self): for scene in self.scenes_data_sets: scene.log_errors() + + def set_legacy_mode(self, legacy_mode): + self.legacy_mode = legacy_mode def process_file(self): with open(self.file_path, 'r') as the_file: @@ -45,8 +49,15 @@ def process_file(self): continue if count == 0: - self.ref_dir_path = os.path.join(self.file_dir, values[0]) - self.ref_dir_path = os.path.abspath(self.ref_dir_path) + if ("$REGRESSION_DIR" in values[0]): # using environment variable + if ("REGRESSION_DIR" in os.environ): + self.ref_dir_path = values[0].replace("$REGRESSION_DIR", os.environ["REGRESSION_DIR"]) + else: + print(f"Error while processing $REGRESSION_DIR: Environment variable REGRESSION_DIR is not set.") + return + else: # direct abosulte or relative path + self.ref_dir_path = os.path.join(self.file_dir, values[0]) + self.ref_dir_path = os.path.abspath(self.ref_dir_path) if not os.path.isdir(self.ref_dir_path): print(f'Error: Reference directory mentioned by file \'{self.file_path}\' does not exist: {self.ref_dir_path}') @@ -107,10 +118,16 @@ def compare_references(self, id_scene): self.nbr_errors = self.nbr_errors + 1 print(f'Error while trying to load: {str(e)}') else: - result = self.scenes_data_sets[id_scene].compare_references() + print(f'legacy_mode={self.legacy_mode}') + if self.legacy_mode: + result = self.scenes_data_sets[id_scene].compare_legacy_references() + else: + result = self.scenes_data_sets[id_scene].compare_references() + if not result: self.nbr_errors = self.nbr_errors + 1 + def compare_all_references(self): nbr_scenes = len(self.scenes_data_sets) pbar_scenes = tqdm(total=nbr_scenes, disable=self.disable_progress_bar)