import opensim import numpy as np import json import logging # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) def validateVisualizerJson(visualizeDict): """ Validate the structure of the visualizer JSON to ensure it's complete and valid. Args: visualizeDict: The dictionary containing the visualizer data Raises: ValueError: If the JSON structure is invalid """ # Check top-level structure if not isinstance(visualizeDict, dict): raise ValueError("visualizeDict must be a dictionary") required_keys = ['time', 'bodies'] for key in required_keys: if key not in visualizeDict: raise ValueError(f"Missing required key: {key}") # Validate time array time_data = visualizeDict['time'] if not isinstance(time_data, list): raise ValueError("time must be a list") if len(time_data) == 0: raise ValueError("time array cannot be empty") # Validate bodies structure bodies_data = visualizeDict['bodies'] if not isinstance(bodies_data, dict): raise ValueError("bodies must be a dictionary") if len(bodies_data) == 0: raise ValueError("bodies dictionary cannot be empty") # Validate each body for body_name, body_data in bodies_data.items(): if not isinstance(body_data, dict): raise ValueError(f"Body {body_name} data must be a dictionary") required_body_keys = ['attachedGeometries', 'scaleFactors', 'rotation', 'translation'] for key in required_body_keys: if key not in body_data: raise ValueError(f"Body {body_name} missing required key: {key}") # Validate attachedGeometries attached_geoms = body_data['attachedGeometries'] if not isinstance(attached_geoms, list): raise ValueError(f"Body {body_name} attachedGeometries must be a list") # Validate scaleFactors scale_factors = body_data['scaleFactors'] if not isinstance(scale_factors, list) or len(scale_factors) != 3: raise ValueError(f"Body {body_name} scaleFactors must be a list of 3 numbers") # Validate rotation rotation = body_data['rotation'] if not isinstance(rotation, list): raise ValueError(f"Body {body_name} rotation must be a list") if len(rotation) != len(time_data): raise ValueError(f"Body {body_name} rotation length ({len(rotation)}) must match time length ({len(time_data)})") for i, rot in enumerate(rotation): if not isinstance(rot, list) or len(rot) != 3: raise ValueError(f"Body {body_name} rotation[{i}] must be a list of 3 numbers") # Validate translation translation = body_data['translation'] if not isinstance(translation, list): raise ValueError(f"Body {body_name} translation must be a list") if len(translation) != len(time_data): raise ValueError(f"Body {body_name} translation length ({len(translation)}) must match time length ({len(time_data)})") for i, trans in enumerate(translation): if not isinstance(trans, list) or len(trans) != 3: raise ValueError(f"Body {body_name} translation[{i}] must be a list of 3 numbers") logger.info(f"JSON validation passed for {len(bodies_data)} bodies and {len(time_data)} time points") def removePatellaFromModelXML(modelPath): """ Remove patella-related components from an OpenSim model by modifying the XML file directly. This approach is more reliable than trying to modify the loaded model. Removes: - Patella bodies (patella_r, patella_l) - Patellofemoral joints - Patellofemoral constraints - Muscles that attach to patella: recfem_r/l, vasint_r/l, vaslat_r/l, vasmed_r/l - Any PathPoint references to patella bodies Args: modelPath: Path to OpenSim model file (.osim) Returns: Modified model path (same as input, file is modified in-place) """ logger.info(f"Starting XML-based patella removal for: {modelPath}") # Read the model file with open(modelPath, 'r', encoding='utf-8') as f: content = f.read() original_length = len(content) logger.info(f"Original file size: {original_length} characters") # Create backup backup_path = modelPath + '.backup' with open(backup_path, 'w', encoding='utf-8') as f: f.write(content) logger.info(f"Created backup: {backup_path}") # Remove patella bodies import re # Pattern to match entire Body elements for patella patella_body_pattern = r'.*?' content = re.sub(patella_body_pattern, '', content, flags=re.DOTALL) logger.info("Removed patella body elements from XML") # Remove patellofemoral joints patella_joint_pattern = r'.*?' content = re.sub(patella_joint_pattern, '', content, flags=re.DOTALL) logger.info("Removed patellofemoral joint elements from XML") # Remove patellofemoral constraints patella_constraint_pattern = r'.*?' content = re.sub(muscle_pattern, '', content, flags=re.DOTALL) logger.info(f"Removed muscle {muscle} from XML") # Remove any remaining PathPoint references to patella patella_pathpoint_pattern = r']*>.*?/bodyset/patella_[rl].*?' content = re.sub(patella_pathpoint_pattern, '', content, flags=re.DOTALL) logger.info("Removed PathPoint elements referencing patella from XML") # Also remove any socket_parent references to patella in PhysicalOffsetFrame patella_socket_pattern = r'/bodyset/patella_[rl]' content = re.sub(patella_socket_pattern, '', content) logger.info("Removed socket_parent references to patella from XML") # Write back the modified content with open(modelPath, 'w', encoding='utf-8') as f: f.write(content) new_length = len(content) reduction = original_length - new_length logger.info(f"Modified file size: {new_length} characters (reduced by {reduction} characters)") logger.info("XML-based patella removal completed") return modelPath # Note: The old model-based patella removal function has been replaced # with the more reliable XML-based approach (removePatellaFromModelXML) # %% This takes model and IK and generates a json of body transforms that can # be passed to the webapp visualizer def generateVisualizerJson(modelPath, ikPath, jsonOutputPath, statesInDegrees=True, vertical_offset=None, removePatella=True): """ Generate JSON visualization data from OpenSim model and motion files. Args: modelPath: Path to OpenSim model file (.osim) ikPath: Path to motion file (.mot) jsonOutputPath: Path for output JSON file statesInDegrees: Whether motion data is in degrees (default: True) vertical_offset: Vertical offset to apply to pelvis (optional) removePatella: Whether to remove patella components from model (default: True) Returns: None (writes JSON file to jsonOutputPath) """ # Patella removal functionality has been implemented above. # The model will have patella components removed if removePatella=True opensim.Logger.setLevelString('error') # Remove patella components from XML if requested if removePatella: logger.info("Using XML-based patella removal approach") modelPath = removePatellaFromModelXML(modelPath) # Now load the cleaned model model = opensim.Model(modelPath) bodyset = model.getBodySet() coords = model.getCoordinateSet() nCoords = coords.getSize() coordNames = [coords.get(i).getName() for i in range(nCoords)] # load IK stateTable = opensim.TimeSeriesTable(ikPath) stateNames = stateTable.getColumnLabels() stateTime = stateTable.getIndependentColumn() # Convert to Python list to ensure it's serializable if hasattr(stateTime, 'to_numpy'): stateTime = stateTime.to_numpy().tolist() elif hasattr(stateTime, '__iter__') and not isinstance(stateTime, (str, bytes)): stateTime = list(stateTime) try: inDegrees = stateTable.getTableMetaDataAsString('inDegrees') == 'yes' except: inDegrees = statesInDegrees print('using statesInDegrees variable, which says statesInDegrees is ' + str(statesInDegrees)) q = np.zeros((len(stateTime),nCoords)) stateNamesOut= [] columns_to_remove = [] # First identify columns to remove logger.info(f"Initial stateNames from MOT file: {stateNames}") for col in stateNames: if 'activation' in col: logger.info(f"Identifying for removal (activation criteria): {col}") columns_to_remove.append(col) elif col[0] == '/' and any(['jointset' not in col, 'value' not in col]): # full state path logger.info(f"Identifying for removal (full path criteria): {col}") columns_to_remove.append(col) else: logger.info(f"Column kept (at identification stage): {col}") # Remove identified columns logger.info(f"Columns identified for removal: {columns_to_remove}") for col in columns_to_remove: logger.info(f"Attempting to remove column: {col}") try: stateTable.removeColumn(col) logger.info(f"Successfully removed column: {col}") except Exception as e: logger.error(f"Failed to remove column {col}: {str(e)}") # Log if removal fails # Get updated column labels after removal stateNames = stateTable.getColumnLabels() logger.info(f"stateNames after removal process: {stateNames}") for motColIndex, col in enumerate(stateNames): logger.info(f"Processing column: {col} at MOT file index {motColIndex}") try: # Try to find matching coordinate matching_coords = [i for i,c in enumerate(coordNames) if c in col] if not matching_coords: logger.warning(f"No matching coordinate found for {col}") continue modelCoordIndex = matching_coords[0] # Index in the model's coordinate list coordName = col logger.info(f"Found matching coordinate: {coordName} at model index {modelCoordIndex}") if col[0] == '/': # if full state path temp = col[:col.rfind('/')] coordName = temp[temp.rfind('/')+1:] logger.info(f"Extracted coordinate name from path: {coordName}") logger.info(f"Processing data for coordinate: {coordName}") for t in range(len(stateTime)): qTemp = np.asarray(stateTable.getDependentColumn(col)[t]) if coords.get(coordName).getMotionType() == 1 and inDegrees: # rotation qTemp = np.deg2rad(qTemp) if 'pelvis_ty' in col and not (vertical_offset is None): qTemp -= (vertical_offset - 0.01) q[t,modelCoordIndex] = qTemp # Use model coordinate index for q array stateNamesOut.append(coordName) # This is always just coord - never full path logger.info(f"Successfully processed coordinate: {coordName}") except Exception as e: logger.error(f"Error processing column {col}: {str(e)}") continue # Only proceed if we have states to process if not stateNamesOut: logger.error("No valid states found in the motion file that match the model coordinates") raise ValueError("No valid states found in the motion file that match the model coordinates") logger.info(f"Successfully processed {len(stateNamesOut)} coordinates: {stateNamesOut}") # We may have deleted some columns stateNames = stateNamesOut # Create a mapping from coordinate name to model coordinate index coordNameToModelIndex = {} for coordName in stateNames: matching_coords = [i for i,c in enumerate(coordNames) if c == coordName] if matching_coords: coordNameToModelIndex[coordName] = matching_coords[0] logger.info(f"Coordinate name to model index mapping: {coordNameToModelIndex}") # check if there is a name containing 'beta' in the stateNames values. beta_present = False for stateName in stateNames: if 'beta' in stateName: beta_present = True break if beta_present: logger.info("Beta is present in the motion file") else: logger.info("Beta is NOT present in the motion file") logger.info("Initializing system state...") state = model.initSystem() # Create state Y map logger.info("Creating state variable names in system order...") yNames = opensim.createStateVariableNamesInSystemOrder(model) systemStateInds = [] logger.info("Mapping state names to system indices...") for stateName in stateNames: matching_states = [i for i, y in enumerate(yNames) if stateName + '/value' in y] if matching_states: systemStateInds.append(matching_states[0]) logger.info(f"Mapped {stateName} to system index {matching_states[0]}") else: logger.warning(f"No matching system state found for {stateName}") logger.info(f"Found {len(systemStateInds)} system state mappings") # Loop over time and bodies logger.info("Starting time loop for body transforms...") visualizeDict = {} visualizeDict['time'] = stateTime visualizeDict['bodies'] = {} logger.info(f"Processing {bodyset.getSize()} bodies...") for body in bodyset: # Note: Patella bodies should have been removed if removePatella=True visualizeDict['bodies'][body.getName()] = {} attachedGeometries = [] # Ayman said that meshes could get attached to model in different ways than # this, so this isn't most general sol'n, but should work for now thisFrame = opensim.Frame.safeDownCast(body) nGeometries = thisFrame.getPropertyByName('attached_geometry').size() # Keep track of the first valid geometry for scale factors first_valid_geometry = None for iGeom in range(nGeometries): attached_geometry = body.get_attached_geometry(iGeom) if attached_geometry.getConcreteClassName() == 'Mesh': thisMesh = opensim.Mesh.safeDownCast(attached_geometry) attachedGeometries.append(thisMesh.getGeometryFilename()) # Store the first valid geometry for scale factors if first_valid_geometry is None: first_valid_geometry = attached_geometry visualizeDict['bodies'][body.getName()]['attachedGeometries'] = attachedGeometries # Only try to get scale factors if there are geometries and we found a valid geometry if nGeometries > 0 and first_valid_geometry is not None: try: scale_factors = first_valid_geometry.get_scale_factors().to_numpy() visualizeDict['bodies'][body.getName()]['scaleFactors'] = scale_factors.tolist() except Exception as e: logger.warning(f"Could not get scale factors for body {body.getName()}: {e}") visualizeDict['bodies'][body.getName()]['scaleFactors'] = [1.0, 1.0, 1.0] else: visualizeDict['bodies'][body.getName()]['scaleFactors'] = [1.0, 1.0, 1.0] # init body translation and rotations dictionaries visualizeDict['bodies'][body.getName()]['rotation'] = [] visualizeDict['bodies'][body.getName()]['translation'] = [] for iTime, time in enumerate(stateTime): yVec = np.zeros((state.getNY())).tolist() for i, idx in enumerate(systemStateInds): coordName = stateNames[i] # Get coordinate name by position in stateNames modelCoordIdx = coordNameToModelIndex[coordName] # Get model coordinate index if modelCoordIdx < q.shape[1]: # Check bounds using model coordinate index yVec[idx] = q[iTime, modelCoordIdx] # Use model coordinate index to access q state.setY(opensim.Vector(yVec)) model.realizePosition(state) # get body translations and rotations in ground for body in bodyset: # This gives us body transform to opensim body frame, which isn't nec. # geometry origin. Ayman said getting transform to Geometry::Mesh is safest # but we don't have access to it thru API and Ayman said what we're doing # is OK for now # Note: Patella bodies should have been removed if removePatella=True try: rotation_matrix = body.getTransformInGround(state).R().convertRotationToBodyFixedXYZ().to_numpy().tolist() translation_vector = body.getTransformInGround(state).T().to_numpy().tolist() # Validate that we got valid arrays if isinstance(rotation_matrix, list) and len(rotation_matrix) == 3: visualizeDict['bodies'][body.getName()]['rotation'].append(rotation_matrix) else: logger.warning(f"Invalid rotation matrix for body {body.getName()} at time {time}") visualizeDict['bodies'][body.getName()]['rotation'].append([0.0, 0.0, 0.0]) if isinstance(translation_vector, list) and len(translation_vector) == 3: visualizeDict['bodies'][body.getName()]['translation'].append(translation_vector) else: logger.warning(f"Invalid translation vector for body {body.getName()} at time {time}") visualizeDict['bodies'][body.getName()]['translation'].append([0.0, 0.0, 0.0]) except Exception as e: logger.error(f"Error getting transform for body {body.getName()} at time {time}: {e}") visualizeDict['bodies'][body.getName()]['rotation'].append([0.0, 0.0, 0.0]) visualizeDict['bodies'][body.getName()]['translation'].append([0.0, 0.0, 0.0]) # Validate the complete structure before writing try: validateVisualizerJson(visualizeDict) with open(jsonOutputPath, 'w') as f: json.dump(visualizeDict, f) logger.info(f"Successfully wrote visualizer JSON to {jsonOutputPath}") except Exception as e: logger.error(f"Error validating or writing JSON: {e}") raise return def testValidateVisualizerJson(): """Test the JSON validation function with various inputs.""" # Test valid structure valid_data = { 'time': [0.0, 0.1, 0.2], 'bodies': { 'pelvis': { 'attachedGeometries': ['pelvis.vtp'], 'scaleFactors': [1.0, 1.0, 1.0], 'rotation': [[0.0, 0.0, 0.0], [0.1, 0.1, 0.1], [0.2, 0.2, 0.2]], 'translation': [[0.0, 0.0, 0.0], [0.1, 0.1, 0.1], [0.2, 0.2, 0.2]] } } } try: validateVisualizerJson(valid_data) print("✓ Valid JSON structure passed validation") except Exception as e: print(f"✗ Valid JSON structure failed validation: {e}") # Test invalid structures invalid_cases = [ ("Missing time key", {'bodies': {}}), ("Time not a list", {'time': 'not_a_list', 'bodies': {}}), ("Empty time array", {'time': [], 'bodies': {'body': {'attachedGeometries': [], 'scaleFactors': [1,1,1], 'rotation': [], 'translation': []}}}), ("Body missing required key", {'time': [0.0], 'bodies': {'body': {'attachedGeometries': [], 'scaleFactors': [1,1,1]}}}), ("Invalid rotation length", {'time': [0.0, 0.1], 'bodies': {'body': {'attachedGeometries': [], 'scaleFactors': [1,1,1], 'rotation': [[0,0,0]], 'translation': [[0,0,0], [0,0,0]]}}}), ] for test_name, invalid_data in invalid_cases: try: validateVisualizerJson(invalid_data) print(f"✗ {test_name} should have failed but passed") except ValueError: print(f"✓ {test_name} correctly failed validation") except Exception as e: print(f"? {test_name} failed with unexpected error: {e}") if __name__ == "__main__": # Run validation tests print("Running JSON validation tests...") testValidateVisualizerJson() print() # Original test code mocap_model_file = 'bug/model.osim' mocap_if_file = 'bug/motion.mot' output_mocap_json_path = 'bug/normal_removed_patella.json' generateVisualizerJson(modelPath=mocap_model_file, ikPath=mocap_if_file, jsonOutputPath=output_mocap_json_path, removePatella=True)