diff --git a/CHANGELOG.md b/CHANGELOG.md index 157225bd..9ed61ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ v1.1 - Added option for downloading pose pickle files. (#248) - Moved synchronization specific files to new module utilsSync.py. (PR #259) - Added main regression tests and sync unit tests. (PR #259) +- Added tests for main pipeline regressions. (PR #290) +- Added exhaustive calibration fallback. (PR #286) +- Ensure utility file handles are closed. (#293) Previous Changes ================ diff --git a/README.md b/README.md index 59a0e80f..c3a1e14f 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,17 @@ These instructions are for Windows 10. The pipeline also runs on Ubuntu. Minimum ### Reproducing results from the paper 1) Data used in the OpenCap publication are available on [SimTK](https://simtk.org/projects/opencap). This dataset includes raw data (e.g., videos, motion capture, ground reaction forces, electromyography), and processed data (e.g., scaled OpenSim models, inverse kinematics, inverse dynamics, and dynamic simulation results). 2) The scripts to process and plot the results are found in the `ReproducePaperResults` directory (see README.md in this folder for more details). + +# Tests + +The test suite validates core functionality of the OpenCap pipeline. To run the tests: + +### Prerequisites +- OpenSim must be installed and accessible via `opensim-cmd` in your PATH +- All submodules must be initialized and updated (`git submodule update --init --recursive`) +- The conda environment must be activated + +### Running Tests +Execute the following command from the root directory of the repository: +```bash +python -m pytest ./tests/ \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..329f992d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import os + + +TESTS_DIR = os.path.dirname(os.path.realpath(__file__)) +REPO_DIR = os.path.abspath(os.path.join(TESTS_DIR, '../')) +TEST_DATA_ROOT = os.path.join(TESTS_DIR, 'opencap-test-data') +TEST_DATA_DIR = os.path.join(TEST_DATA_ROOT, 'Data') +CALIBRATION_FIXTURE_DIR = os.path.join(TEST_DATA_DIR, 'calibration-fixtures') +SYNC_2CAM_DIR = os.path.join(TEST_DATA_DIR, 'sync_2-cameras') +LAB_5CAM_DIR = os.path.join( + TEST_DATA_DIR, + 'labvalidation-fixtures', + 'subject2_session0_5-cameras', +) diff --git a/tests/opencap-test-data b/tests/opencap-test-data index 304ab5b9..6cd7511d 160000 --- a/tests/opencap-test-data +++ b/tests/opencap-test-data @@ -1 +1 @@ -Subproject commit 304ab5b9d40e1af144fff8153e90c5fed4c116fb +Subproject commit 6cd7511d4d877b8cb536988e5a1cc60ccb3e19c8 diff --git a/tests/test_calibration.py b/tests/test_calibration.py new file mode 100644 index 00000000..bfd9778d --- /dev/null +++ b/tests/test_calibration.py @@ -0,0 +1,420 @@ +import os +import shutil +import sys + +import cv2 +import numpy as np +import pytest + +os.environ.setdefault('API_TOKEN', 'test-token') + +from conftest import CALIBRATION_FIXTURE_DIR, REPO_DIR + +sys.path.append(REPO_DIR) + +import utilsChecker +from utilsChecker import ( + calcExtrinsicsFromVideo, + generate3Dgrid, + loadCameraParameters, + rotateIntrinsics, +) + + +# ---- Checkerboard / fixture constants ---- + +DEFAULT_CHECKERBOARD_PARAMS = { + 'dimensions': (5, 4), + 'squareSize': 35.0, +} +LABVALIDATION_CHECKERBOARD_PARAMS = { + 'dimensions': (11, 8), + 'squareSize': 60.0, +} +MAX_MEAN_REPROJECTION_ERROR_PX = 0.5 + + +INTRINSICS_FOLDER = 'Deployed' + +ACL_EXHAUSTIVE_FALLBACK_VIDEO = os.path.join( + CALIBRATION_FIXTURE_DIR, + 'acl', + 'exhaustive_fallback_success', + 'acl_exhaustive_only_success.qt', +) +UTAH_FIXTURE_VIDEO = os.path.join( + CALIBRATION_FIXTURE_DIR, + 'utah', + 'production_success', + 'utah_production_success.mov', +) + +PRIMARY_SUCCESS_FIXTURES = [ + ( + 'acl', + os.path.join( + CALIBRATION_FIXTURE_DIR, + 'acl', + 'primary_success', + 'acl_primary_success.qt', + ), + DEFAULT_CHECKERBOARD_PARAMS, + 'iPhone13,3', + ), + ( + 'labvalidation', + os.path.join( + CALIBRATION_FIXTURE_DIR, + 'labvalidation', + 'primary_success', + 'labvalidation_subject5_session0_cam3_extrinsics.avi', + ), + LABVALIDATION_CHECKERBOARD_PARAMS, + 'iPhone13,3', + ), +] + +NEGATIVE_FIXTURES = [ + ( + 'no_checkerboard_cam0', + os.path.join( + CALIBRATION_FIXTURE_DIR, + 'comprehensive', + 'no_checkerboard', + 'no_checkerboard_cam0.mov', + ), + 'iPhone17,3', + ), + ( + 'no_checkerboard_cam1', + os.path.join( + CALIBRATION_FIXTURE_DIR, + 'comprehensive', + 'no_checkerboard', + 'no_checkerboard_cam1.mov', + ), + 'iPhone17,1', + ), + ( + 'partial_checkerboard_cam0', + os.path.join( + CALIBRATION_FIXTURE_DIR, + 'comprehensive', + 'partial_checkerboard', + 'partial_checkerboard_cam0.mov', + ), + 'iPhone17,3', + ), + ( + 'partial_checkerboard_cam1', + os.path.join( + CALIBRATION_FIXTURE_DIR, + 'comprehensive', + 'partial_checkerboard', + 'partial_checkerboard_cam1.mov', + ), + 'iPhone17,1', + ), +] + + +# ---- Unpatched cv2 handles ---- + +UNPATCHED_CV2_FIND_CHESSBOARD_CORNERS = cv2.findChessboardCorners +UNPATCHED_CV2_FIND_CHESSBOARD_CORNERS_SB_WITH_META = ( + cv2.findChessboardCornersSBWithMeta +) +UNPATCHED_CV2_CORNER_SUB_PIX = cv2.cornerSubPix +UNPATCHED_ENSURE_CORNER_ORDERING = utilsChecker.ensureCornerOrdering + + +# ---- Helpers ---- + +def load_intrinsics(video_path, iphone_model): + intrinsics_path = os.path.join( + REPO_DIR, + 'CameraIntrinsics', + iphone_model, + INTRINSICS_FOLDER, + 'cameraIntrinsics.pickle', + ) + camera_params = loadCameraParameters(intrinsics_path) + return rotateIntrinsics(camera_params, str(video_path)) + + +def input_media_dir(tmp_path): + media_dir = tmp_path.joinpath( + os.path.join( + 'Data', + 'test_session', + 'Videos', + 'Cam0', + 'InputMedia', + 'calibration', + ) + ) + media_dir.mkdir(parents=True, exist_ok=True) + return media_dir + + +def stage_video(tmp_path, video_path): + staged_video_path = input_media_dir(tmp_path) / os.path.basename(video_path) + shutil.copy2(video_path, staged_video_path) + return staged_video_path + + +def assert_extrinsics(camera_params): + assert camera_params is not None + for key in ('rotation', 'translation', 'rotation_EulerAngles'): + assert key in camera_params + assert np.all(np.isfinite(camera_params[key])) + assert camera_params['rotation'].shape == (3, 3) + assert camera_params['translation'].shape in ((3, 1), (3,)) + + +def sb_flags(exhaustive): + flags = cv2.CALIB_CB_ACCURACY | cv2.CALIB_CB_LARGER + if exhaustive: + flags |= cv2.CALIB_CB_EXHAUSTIVE + return flags + + +def mean_reproj_error(camera_params, checkerboard_params, corners, image_shape): + if camera_params is None or corners is None or image_shape is None: + return None + + image_width = image_shape[1] + camera_image_width = float(np.squeeze(camera_params['imageSize'])[1]) + scale = image_width / camera_image_width + # Need to scale as detected coreners are potentially from upsampled/downsampled image, + # but checking agaisnt original camera intrinsics + observed_corners = corners / scale + object_points = generate3Dgrid(checkerboard_params) + projected_corners, _ = cv2.projectPoints( + object_points, + camera_params['rotation_EulerAngles'], + camera_params['translation'], + camera_params['intrinsicMat'], + camera_params['distortion'], + ) + corner_errors = np.linalg.norm( + projected_corners.reshape(-1, 2) - observed_corners.reshape(-1, 2), + axis=1, + ) + return float(np.mean(corner_errors)) + + +def run_video_calibration( + video_path, + checkerboard_params, + iphone_model, + tmp_path, + monkeypatch, + fallback_enabled=True, + fallback_flag_override=None, +): + staged_video_path = stage_video(tmp_path, video_path) + camera_params = load_intrinsics(staged_video_path, iphone_model) + calls = {'primary': 0, 'fallback': 0} + fallback_flags = [] + # captured_corners stores corners detected by the different methods, + # pre and post sub pixel refining for primary path and pre and + # post re-ordering for fallback path + captured_corners = { + 'raw_primary': None, + 'refined_primary': None, + 'raw_sb': None, + 'ordered_sb': None, + 'image_shape': None, + } + + def primary_detector(*args, **kwargs): + calls['primary'] += 1 + found, corners = UNPATCHED_CV2_FIND_CHESSBOARD_CORNERS(*args, **kwargs) + if found: + captured_corners['raw_primary'] = corners.copy() + captured_corners['image_shape'] = args[0].shape + return found, corners + + # fallback_flags records flags across retries, the flags used are always those requested + # by prod except for the case where we test the old fallback without exhaustive + def fallback_detector(image, pattern_size, flags): + calls['fallback'] += 1 + if not fallback_enabled: + return False, None, None + if fallback_flag_override is not None: + flags = fallback_flag_override + fallback_flags.append(flags) + found, corners, meta = UNPATCHED_CV2_FIND_CHESSBOARD_CORNERS_SB_WITH_META( + image, pattern_size, flags + ) + if found: + captured_corners['raw_sb'] = corners.copy() + captured_corners['image_shape'] = image.shape + return found, corners, meta + + def ensure_corner_ordering(image, corners, pattern, squareResolution=1): + ordered_corners, ordering_success, ordering_error = ( + UNPATCHED_ENSURE_CORNER_ORDERING( + image, corners, pattern, squareResolution=squareResolution + ) + ) + if ordering_success: + captured_corners['ordered_sb'] = ordered_corners.copy() + captured_corners['image_shape'] = image.shape + return ordered_corners, ordering_success, ordering_error + + def corner_subpix(image, corners, win_size, zero_zone, criteria): + refined_corners = UNPATCHED_CV2_CORNER_SUB_PIX( + image, corners, win_size, zero_zone, criteria + ) + captured_corners['refined_primary'] = refined_corners.copy() + captured_corners['image_shape'] = image.shape + return refined_corners + + monkeypatch.setattr(cv2, 'findChessboardCorners', primary_detector) + monkeypatch.setattr(cv2, 'findChessboardCornersSBWithMeta', fallback_detector) + monkeypatch.setattr(cv2, 'cornerSubPix', corner_subpix) + monkeypatch.setattr(utilsChecker, 'ensureCornerOrdering', ensure_corner_ordering) + + try: + result = calcExtrinsicsFromVideo( + str(staged_video_path), + camera_params, + checkerboard_params, + visualize=False, + imageUpsampleFactor=2, + ) + except Exception as exc: + if 'checkerboard was not detected' not in str(exc): + raise + result = None + corners_for_reprojection = ( + captured_corners['refined_primary'] + if captured_corners['refined_primary'] is not None + else captured_corners['ordered_sb'] + ) + error = mean_reproj_error( + result, + checkerboard_params, + corners_for_reprojection, + captured_corners['image_shape'], + ) + return result, calls, fallback_flags, error + + +# ---- Tests ---- + +@pytest.mark.parametrize( + 'fixture_name, video_path, checkerboard_params, iphone_model', + PRIMARY_SUCCESS_FIXTURES, + ids=[fixture[0] for fixture in PRIMARY_SUCCESS_FIXTURES], +) +# Good videos that should work with no fallback +def test_primary_fixtures_calibrate( + fixture_name, video_path, checkerboard_params, iphone_model, tmp_path, monkeypatch +): + result, calls, fallback_flags, mean_error = ( + run_video_calibration( + video_path, + checkerboard_params, + iphone_model, + tmp_path, + monkeypatch, + fallback_enabled=False, + ) + ) + + assert_extrinsics(result) + assert mean_error < MAX_MEAN_REPROJECTION_ERROR_PX, ( + fixture_name, + mean_error, + ) + # These fixtures should pass within the primary detector's resize attempts. + assert 1 <= calls['primary'] <= 4 + assert calls['fallback'] == 0 + assert fallback_flags == [] + + +# Utah fixture should calibrate through the exhaustive fallback route when needed. +def test_utah_fixture_exhaustive(tmp_path, monkeypatch): + result, _, _, mean_error = run_video_calibration( + UTAH_FIXTURE_VIDEO, + DEFAULT_CHECKERBOARD_PARAMS, + 'iPhone13,3', + tmp_path, + monkeypatch, + ) + + assert_extrinsics(result) + assert mean_error < MAX_MEAN_REPROJECTION_ERROR_PX, mean_error + + +# A hard ACL vid that fails with the old fallback but succeeds with the new exhaustive fallback +def test_acl_exhaustive_recovery(tmp_path, monkeypatch): + ( + current_result, + current_calls, + current_fallback_flags, + _, + ) = run_video_calibration( + ACL_EXHAUSTIVE_FALLBACK_VIDEO, + DEFAULT_CHECKERBOARD_PARAMS, + 'iPhone13,3', + tmp_path / 'current_fallback', + monkeypatch, + fallback_flag_override=sb_flags(exhaustive=False), + ) + assert current_result is None + assert current_calls['fallback'] > 0 + assert current_fallback_flags + assert not any( + flags & cv2.CALIB_CB_EXHAUSTIVE for flags in current_fallback_flags + ) + + ( + exhaustive_result, + exhaustive_calls, + exhaustive_fallback_flags, + mean_error, + ) = run_video_calibration( + ACL_EXHAUSTIVE_FALLBACK_VIDEO, + DEFAULT_CHECKERBOARD_PARAMS, + 'iPhone13,3', + tmp_path / 'exhaustive_fallback', + monkeypatch, + ) + + assert_extrinsics(exhaustive_result) + assert mean_error < MAX_MEAN_REPROJECTION_ERROR_PX, mean_error + assert exhaustive_calls['fallback'] > 0 + assert exhaustive_fallback_flags + assert any( + flags & cv2.CALIB_CB_EXHAUSTIVE + for flags in exhaustive_fallback_flags + ) + + +@pytest.mark.parametrize( + 'fixture_name, video_path, iphone_model', + NEGATIVE_FIXTURES, + ids=[fixture[0] for fixture in NEGATIVE_FIXTURES], +) +# No board and partial board should fail even with exhaustive fallback +def test_negative_fixtures_reject( + fixture_name, video_path, iphone_model, tmp_path, monkeypatch +): + result, calls, fallback_flags, _ = ( + run_video_calibration( + video_path, + DEFAULT_CHECKERBOARD_PARAMS, + iphone_model, + tmp_path, + monkeypatch, + ) + ) + + assert result is None, fixture_name + assert calls['fallback'] > 0 + assert fallback_flags + assert all(flags & cv2.CALIB_CB_EXHAUSTIVE for flags in fallback_flags) diff --git a/tests/test_main.py b/tests/test_main.py index da7ce459..4c81b65f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,16 +1,46 @@ import logging import os +import pickle +import shutil import sys +import xml.etree.ElementTree as ET import numpy as np import pandas as pd import pytest -thisDir = os.path.dirname(os.path.realpath(__file__)) -repoDir = os.path.abspath(os.path.join(thisDir, '../')) -sys.path.append(repoDir) +# Fixture paths and camera orders used by the main pipeline regression tests +from conftest import ( + CALIBRATION_FIXTURE_DIR, + LAB_5CAM_DIR, + REPO_DIR, + SYNC_2CAM_DIR, +) + +sys.path.append(REPO_DIR) from main import main -# Helper functions to load and compare TRC and MOT files +SYNC_2CAM_CALIB_VIDEOS = { + f'Cam{cam_i}': os.path.join( + CALIBRATION_FIXTURE_DIR, + 'sync_2-cameras', + f'cam{cam_i}_calibration.qt', + ) + for cam_i in range(2) +} +LAB_5CAM_CALIB_VIDEOS = { + f'Cam{cam_i}': os.path.join( + CALIBRATION_FIXTURE_DIR, + 'labvalidation', + 'five_camera', + f'labvalidation_subject2_session0_cam{cam_i}_extrinsics.avi', + ) + for cam_i in range(5) +} +# orders specifc to the runs done to build tests +LAB_5CAM_DYNAMIC_ORDER = ['Cam2', 'Cam4', 'Cam3', 'Cam1', 'Cam0'] +SYNC_2CAM_NEUTRAL_ORDER = ['Cam1', 'Cam0'] + +# Helper functions to load and compare TRC, MOT, and OpenSim outputs def load_trc(file, num_metadata_lines=5): with open(file, 'r') as f: lines = f.readlines() @@ -18,6 +48,7 @@ def load_trc(file, num_metadata_lines=5): df = pd.read_csv(file, sep='\t', skiprows=num_metadata_lines + 1, header=None) return df, metadata + def load_mot(file, num_metadata_lines=10): with open(file, 'r') as f: lines = f.readlines() @@ -29,6 +60,47 @@ def load_mot(file, num_metadata_lines=10): def calc_rmse(series1, series2): return np.sqrt(((series1 - series2) ** 2).mean()) + +def compare_trc(output_trc, ref_trc, atol=1e-3): + output_trc_df, _ = load_trc(output_trc) + ref_trc_df, _ = load_trc(ref_trc) + pd.testing.assert_frame_equal( + output_trc_df, ref_trc_df, check_exact=False, atol=atol + ) + + +def load_osim_scales(file): + root = ET.parse(file).getroot() + scale_factors = {} + for body in root.findall('.//Body'): + body_name = body.attrib.get('name') + if not body_name: + continue + for mesh_index, mesh in enumerate(body.findall('./attached_geometry/Mesh')): + scale_element = mesh.find('scale_factors') + if scale_element is None or not scale_element.text: + continue + mesh_name = mesh.attrib.get('name', f'Mesh{mesh_index}') + scale_factors[(body_name, mesh_name)] = np.array( + [float(value) for value in scale_element.text.split()] + ) + return scale_factors + + +def compare_osim_scales(output_osim, ref_osim, atol=5e-3): + output_scale_factors = load_osim_scales(output_osim) + ref_scale_factors = load_osim_scales(ref_osim) + assert output_scale_factors.keys() == ref_scale_factors.keys() + for mesh_key in ref_scale_factors: + np.testing.assert_allclose( + output_scale_factors[mesh_key], + ref_scale_factors[mesh_key], + rtol=0, + atol=atol, + err_msg=str(mesh_key), + ) + + def compare_mot(output_mot_df, ref_mot_df, t0, tf): '''Function to compare MOT dataframes within a time range [t0, tf]. We use the specific time range to analyze the range with the motion @@ -74,6 +146,148 @@ def compare_mot(output_mot_df, ref_mot_df, t0, tf): rmse = calc_rmse(output_mot_df_slice[col], ref_mot_df_slice[col]) assert rmse <= 0.5 + +def compare_mot_files(output_mot, ref_mot, t0, tf): + output_mot_df, _ = load_mot(output_mot) + ref_mot_df, _ = load_mot(ref_mot) + pd.testing.assert_index_equal(output_mot_df.columns, ref_mot_df.columns) + compare_mot(output_mot_df, ref_mot_df, t0, tf) + + +# Build out the necessary inputs for tmp directories used across tests +def prepare_test_session( + source_session_dir, + session_dir, + trial_name, + cameras, + pose_output_folder, + scaled_model_name=None, +): + # Copy in metadata and mapping pickle + os.makedirs(session_dir, exist_ok=True) + shutil.copy2( + os.path.join(source_session_dir, 'sessionMetadata.yaml'), + os.path.join(session_dir, 'sessionMetadata.yaml'), + ) + + videos_dir = os.path.join(session_dir, 'Videos') + os.makedirs(videos_dir, exist_ok=True) + shutil.copy2( + os.path.join(source_session_dir, 'Videos', 'mappingCamDevice.pickle'), + os.path.join(videos_dir, 'mappingCamDevice.pickle'), + ) + # Copy in intrinsics/extrinsics and videos + for camName in cameras: + source_cam_dir = os.path.join(source_session_dir, 'Videos', camName) + target_cam_dir = os.path.join(videos_dir, camName) + os.makedirs(target_cam_dir, exist_ok=True) + shutil.copy2( + os.path.join(source_cam_dir, 'cameraIntrinsicsExtrinsics.pickle'), + os.path.join(target_cam_dir, 'cameraIntrinsicsExtrinsics.pickle'), + ) + + source_input_dir = os.path.join(source_cam_dir, 'InputMedia', trial_name) + target_input_dir = os.path.join(target_cam_dir, 'InputMedia', trial_name) + os.makedirs(target_input_dir, exist_ok=True) + for filename in os.listdir(source_input_dir): + if os.path.splitext(filename)[0] == trial_name: + shutil.copy2( + os.path.join(source_input_dir, filename), + os.path.join(target_input_dir, filename), + ) + # Copy in keypoints pickle and correct path to match what main expects + # This uses the format returned by web-based downloads. + local_pickle_dir = os.path.join( + target_cam_dir, + pose_output_folder, + trial_name, + ) + os.makedirs(local_pickle_dir, exist_ok=True) + source_keypoints = os.path.join( + source_cam_dir, + 'OutputPkl', + f'{trial_name}_keypoints.pkl', + ) + if os.path.exists(source_keypoints): + shutil.copy2( + source_keypoints, + os.path.join(local_pickle_dir, f'{trial_name}_rotated_pp.pkl'), + ) + # This uses the structure returned by API-based downloads. + # Ensure the correct pose_output_folder is entered here depending on pose detector. + else: + shutil.copy2( + os.path.join( + source_cam_dir, + pose_output_folder, + trial_name, + f'{trial_name}_rotated_pp.pkl', + ), + os.path.join(local_pickle_dir, f'{trial_name}_rotated_pp.pkl'), + ) + # Optionally copy in scaled model if not testing scaling + if scaled_model_name is not None: + model_dir = os.path.join(session_dir, 'OpenSimData', 'Model') + os.makedirs(model_dir, exist_ok=True) + shutil.copy2( + os.path.join(source_session_dir, 'OpenSimData', 'Model', scaled_model_name), + os.path.join(model_dir, scaled_model_name), + ) + + +# Calibration regression test +def test_main_calibration(tmp_path): + sessionName = 'sync_2-cameras_calibration' + trialName = 'calibration' + trialID = 'calibration' + dataDir = tmp_path + sessionDir = os.path.join(dataDir, 'Data', sessionName) + + os.makedirs(sessionDir, exist_ok=True) + shutil.copy2( + os.path.join(SYNC_2CAM_DIR, 'sessionMetadata.yaml'), + os.path.join(sessionDir, 'sessionMetadata.yaml'), + ) + + for camName, videoPath in SYNC_2CAM_CALIB_VIDEOS.items(): + mediaDir = os.path.join( + sessionDir, + 'Videos', + camName, + 'InputMedia', + trialName, + ) + os.makedirs(mediaDir, exist_ok=True) + _, videoExt = os.path.splitext(videoPath) + shutil.copy2( + videoPath, + os.path.join(mediaDir, f'{trialID}{videoExt}'), + ) + + main( + sessionName, + trialName, + trialID, + dataDir=dataDir, + genericFolderNames=True, + extrinsicsTrial=True, + imageUpsampleFactor=2, + ) + + for camName in SYNC_2CAM_CALIB_VIDEOS: + paramsPath = os.path.join( + sessionDir, + 'Videos', + camName, + 'cameraIntrinsicsExtrinsics.pickle', + ) + assert os.path.exists(paramsPath) + with open(paramsPath, 'rb') as f: + cameraParams = pickle.load(f) + assert np.all(np.isfinite(cameraParams['rotation'])) + assert np.all(np.isfinite(cameraParams['translation'])) + + # End to end tests with different sync methods (hand, gait, general). # Also check that syncVer updates with main changes. # Note: no pose detection, uses pre-scaled opensim model @@ -83,12 +297,23 @@ def compare_mot(output_mot_df, ref_mot_df, t0, tf): ('squats', 3.0, 8.0), ('walk', 1.0, 5.0), ]) -def test_main(trialName, t0, tf, syncVer, caplog): +def test_main(trialName, t0, tf, syncVer, caplog, tmp_path): caplog.set_level(logging.INFO) sessionName = 'sync_2-cameras' trialID = trialName - dataDir = os.path.join(thisDir, 'opencap-test-data') + dataDir = tmp_path + sessionDir = os.path.join(dataDir, 'Data', sessionName) + + prepare_test_session( + SYNC_2CAM_DIR, + sessionDir, + trialName, + ['Cam0', 'Cam1'], + 'OutputPkl_mmpose_0.8', + scaled_model_name='LaiUhlrich2022_scaled.osim', + ) + main( sessionName, trialName, @@ -101,47 +326,198 @@ def test_main(trialName, t0, tf, syncVer, caplog): assert f"Synchronizing Keypoints using version {syncVer}" in caplog.text # Compare marker data - output_trc = os.path.join(dataDir, - 'Data', - sessionName, + output_trc = os.path.join( + sessionDir, 'MarkerData', 'PostAugmentation', f'{trialName}.trc', ) ref_trc = os.path.join( - dataDir, - 'Data', - sessionName, + SYNC_2CAM_DIR, 'OutputReference', f'{trialName}.trc', ) - output_trc_df, _ = load_trc(output_trc) - ref_trc_df, _ = load_trc(ref_trc) - pd.testing.assert_frame_equal( - output_trc_df, ref_trc_df, check_exact=False, atol=1e-3 - ) + compare_trc(output_trc, ref_trc) # Compare IK data output_mot = os.path.join( - dataDir, - 'Data', - sessionName, + sessionDir, 'OpenSimData', 'Kinematics', f'{trialName}.mot', ) ref_mot = os.path.join( - dataDir, - 'Data', - sessionName, + SYNC_2CAM_DIR, 'OutputReference', f'{trialName}.mot', ) - output_mot_df, _ = load_mot(output_mot) - ref_mot_df, _ = load_mot(ref_mot) - pd.testing.assert_index_equal(output_mot_df.columns, ref_mot_df.columns) - compare_mot(output_mot_df, ref_mot_df, t0, tf) + compare_mot_files(output_mot, ref_mot, t0, tf) + +# Regression test for neutral scaling using existing pose detection outputs +def test_neutral_scaling(tmp_path): + sessionName = 'sync_2-cameras' + trialName = 'neutral' + trialID = trialName + dataDir = tmp_path + sessionDir = os.path.join(dataDir, 'Data', sessionName) + + prepare_test_session( + SYNC_2CAM_DIR, + sessionDir, + trialName, + ['Cam0', 'Cam1'], + 'OutputPkl_default', + ) + + main( + sessionName, + trialName, + trialID, + cameras_to_use=SYNC_2CAM_NEUTRAL_ORDER, + dataDir=dataDir, + genericFolderNames=True, + scaleModel=True, + syncVer='1.1', + ) + + output_post_augmentation_trc = os.path.join( + sessionDir, + 'MarkerData', + 'PostAugmentation', + f'{trialName}.trc', + ) + ref_post_augmentation_trc = os.path.join( + SYNC_2CAM_DIR, + 'MarkerData', + f'{trialName}.trc', + ) + compare_trc(output_post_augmentation_trc, ref_post_augmentation_trc, atol=1e-5) + + scaled_model = os.path.join( + sessionDir, + 'OpenSimData', + 'Model', + 'LaiUhlrich2022_scaled.osim', + ) + assert os.path.exists(scaled_model) + ref_scaled_model = os.path.join( + SYNC_2CAM_DIR, + 'OpenSimData', + 'Model', + 'LaiUhlrich2022_scaled.osim', + ) + # This used to assert at 1e-5, but OpenSim scaling can differ slightly + # across OS/builds while preserving the same neutral scaling behavior. + compare_osim_scales(scaled_model, ref_scaled_model, atol=1e-3) + + +# More than 2 camera tests. +def test_lab_5cam_calibration(tmp_path): + sessionName = 'labvalidation_calibration_5-cameras' + trialName = 'calibration' + trialID = 'calibration' + dataDir = tmp_path + sessionDir = os.path.join(dataDir, 'Data', sessionName) + + os.makedirs(sessionDir, exist_ok=True) + shutil.copy2( + os.path.join(LAB_5CAM_DIR, 'sessionMetadata.yaml'), + os.path.join(sessionDir, 'sessionMetadata.yaml'), + ) + + for camName, videoPath in LAB_5CAM_CALIB_VIDEOS.items(): + mediaDir = os.path.join( + sessionDir, + 'Videos', + camName, + 'InputMedia', + trialName, + ) + os.makedirs(mediaDir, exist_ok=True) + shutil.copy2( + videoPath, + os.path.join(mediaDir, f'{trialID}.avi'), + ) + + main( + sessionName, + trialName, + trialID, + dataDir=dataDir, + genericFolderNames=True, + extrinsicsTrial=True, + imageUpsampleFactor=2, + ) + + for camName in LAB_5CAM_CALIB_VIDEOS: + paramsPath = os.path.join( + sessionDir, + 'Videos', + camName, + 'cameraIntrinsicsExtrinsics.pickle', + ) + assert os.path.exists(paramsPath) + with open(paramsPath, 'rb') as f: + cameraParams = pickle.load(f) + assert np.all(np.isfinite(cameraParams['rotation'])) + assert np.all(np.isfinite(cameraParams['translation'])) + + +def test_lab_5cam_dynamic(tmp_path): + sessionName = 'labvalidation_subject2_session0_5-cameras' + trialName = 'squats1' + trialID = trialName + dataDir = tmp_path + sessionDir = os.path.join(dataDir, 'Data', sessionName) + + prepare_test_session( + LAB_5CAM_DIR, + sessionDir, + trialName, + LAB_5CAM_DYNAMIC_ORDER, + 'OutputPkl_1x736', + scaled_model_name='LaiUhlrich2022_scaled.osim', + ) + + main( + sessionName, + trialName, + trialID, + cameras_to_use=LAB_5CAM_DYNAMIC_ORDER, + dataDir=dataDir, + genericFolderNames=True, + poseDetector='openpose', + resolutionPoseDetection='1x736', + syncVer='1.0', + ) + + output_post_augmentation_trc = os.path.join( + sessionDir, + 'MarkerData', + 'PostAugmentation', + f'{trialName}.trc', + ) + ref_post_augmentation_trc = os.path.join( + LAB_5CAM_DIR, + 'MarkerData', + f'{trialName}.trc', + ) + compare_trc(output_post_augmentation_trc, ref_post_augmentation_trc) + + output_mot = os.path.join( + sessionDir, + 'OpenSimData', + 'Kinematics', + f'{trialName}.mot', + ) + ref_mot = os.path.join( + LAB_5CAM_DIR, + 'OpenSimData', + 'Kinematics', + f'{trialName}.mot', + ) + # excludes the setup motion + compare_mot_files(output_mot, ref_mot, 2.0, 9.0) + -# TODO: calibration and neutral -# TODO: > 2 cameras # TODO: augmenter versions diff --git a/utils.py b/utils.py index 962aa74b..48eb248f 100644 --- a/utils.py +++ b/utils.py @@ -94,16 +94,15 @@ def getMMposeDirectory(isDocker=False): return mmposeDirectory def loadCameraParameters(filename): - open_file = open(filename, "rb") - cameraParams = pickle.load(open_file) - - open_file.close() + with open(filename, "rb") as open_file: + cameraParams = pickle.load(open_file) + return cameraParams def importMetadata(filePath): - myYamlFile = open(filePath) - parsedYamlFile = yaml.load(myYamlFile, Loader=yaml.FullLoader) - + with open(filePath) as myYamlFile: + parsedYamlFile = yaml.load(myYamlFile, Loader=yaml.FullLoader) + return parsedYamlFile def download_file(url, file_name): @@ -191,7 +190,8 @@ def _loadDepthFrames(metadata_path): raise ValueError("Unsupported depth frame byte count.") depth_path = os.path.join(os.path.dirname(metadata_path), meta.get("file", "depth.bin")) - blob = open(depth_path, "rb").read() + with open(depth_path, "rb") as f: + blob = f.read() if meta.get("frame_layout") == "length_prefixed": frames = [] @@ -499,9 +499,8 @@ def postCalibrationOptions(session_path,session_id,overwrite=False): if trial['meta'] is None or overwrite == True: calibOptionsJsonPath = os.path.join(session_path,'Videos','calibOptionSelections.json') - f = open(calibOptionsJsonPath) - calibOptionsJson = json.load(f) - f.close() + with open(calibOptionsJsonPath) as f: + calibOptionsJson = json.load(f) data = { "meta":json.dumps({'calibration':calibOptionsJson}) } @@ -1478,23 +1477,21 @@ def numpy2storage(labels, data, storage_file): assert data.shape[1] == len(labels), "# labels doesn't match columns" assert labels[0] == "time" - f = open(storage_file, 'w') - f.write('name %s\n' %storage_file) - f.write('datacolumns %d\n' %data.shape[1]) - f.write('datarows %d\n' %data.shape[0]) - f.write('range %f %f\n' %(np.min(data[:, 0]), np.max(data[:, 0]))) - f.write('endheader \n') - - for i in range(len(labels)): - f.write('%s\t' %labels[i]) - f.write('\n') - - for i in range(data.shape[0]): - for j in range(data.shape[1]): - f.write('%20.8f\t' %data[i, j]) + with open(storage_file, 'w') as f: + f.write('name %s\n' %storage_file) + f.write('datacolumns %d\n' %data.shape[1]) + f.write('datarows %d\n' %data.shape[0]) + f.write('range %f %f\n' %(np.min(data[:, 0]), np.max(data[:, 0]))) + f.write('endheader \n') + + for i in range(len(labels)): + f.write('%s\t' %labels[i]) f.write('\n') - - f.close() + + for i in range(data.shape[0]): + for j in range(data.shape[1]): + f.write('%20.8f\t' %data[i, j]) + f.write('\n') def lowpassFilter(inputData, filtFreq, order=4): @@ -1612,17 +1609,15 @@ def storage2numpy(storage_file, excess_header_entries=0): >>> data['ground_force_vy'] """ # What's the line number of the line containing 'endheader'? - f = open(storage_file, 'r') - - header_line = False - for i, line in enumerate(f): - if header_line: - column_names = line.split() - break - if line.count('endheader') != 0: - line_number_of_line_containing_endheader = i + 1 - header_line = True - f.close() + with open(storage_file, 'r') as f: + header_line = False + for i, line in enumerate(f): + if header_line: + column_names = line.split() + break + if line.count('endheader') != 0: + line_number_of_line_containing_endheader = i + 1 + header_line = True # With this information, go get the data. if excess_header_entries == 0: @@ -2121,4 +2116,3 @@ def makeRequestWithRetry(method, url, files=files) response.raise_for_status() return response - diff --git a/utilsChecker.py b/utilsChecker.py index 15a3185c..ada0e724 100644 --- a/utilsChecker.py +++ b/utilsChecker.py @@ -507,7 +507,8 @@ def calcExtrinsics(imageFileName, CameraParams, CheckerBoardParams, # Fallback: if the standard detector fails, try the SB variant (more robust to # certain lighting/contrast conditions where adaptive thresholding struggles). - # Using ACCURACY|LARGER without EXHAUSTIVE to keep the fallback reasonably fast. + # EXHAUSTIVE improves recovery on difficult boards without materially slowing + # typical calibration videos. corners2_from_sb = False if not ret: ret_sb, corners_sb, _ = cv2.findChessboardCornersSBWithMeta(