From 21fd0d21191051e64426cf1e0d1af1a994e818d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20S=C3=A4uberli?= <38892775+saeub@users.noreply.github.com> Date: Thu, 14 Nov 2024 21:41:26 +0100 Subject: [PATCH] feat: Store metadata from ASC in experiment metadata (#884) * updated io file * updated test file * Add tests for metadata parsing from ASC file * Squashed commit of the following: commit 14d047cff314b4ef1f8b8bfe428286c881a86aea Author: Faizan Ansari Date: Thu Oct 24 22:02:30 2024 +0200 Remove files from remote directory commit aa78078f46b1731604691d0332c8d3fea747ec14 Author: Faizan Ansari Date: Thu Oct 24 21:53:35 2024 +0200 updated code commit cae54cccd75800dcf8a903de5ca130c7e7c9d724 Author: Faizan Ansari Date: Thu Oct 24 15:40:16 2024 +0200 changes in io.py file * Fix formatting * Fix indentation * Fix circular imports * 2 test passed * Fix attribute name * Refactor metadata checks, add tests * Fix f-strings * Fix tests * Address comments * Improve test coverage * Add comment about screen resolution * Fix metadata conflict check * Fix test coverage * Fix type hint * Trigger codecov * rebase me * Upgrade codecov action * Revert codecov action upgrade --------- Co-authored-by: Faizan Ansari Co-authored-by: SiQube --- src/pymovements/__init__.py | 2 + src/pymovements/dataset/dataset_files.py | 2 +- .../datasets/toy_dataset_eyelink.py | 4 +- src/pymovements/gaze/gaze_dataframe.py | 3 + src/pymovements/gaze/io.py | 85 ++++++++++- tests/functional/gaze_file_processing_test.py | 4 +- tests/unit/dataset/dataset_files_test.py | 2 +- tests/unit/gaze/io/asc_test.py | 132 +++++++++++++----- tests/unit/utils/parsing_test.py | 36 +++++ 9 files changed, 223 insertions(+), 47 deletions(-) diff --git a/src/pymovements/__init__.py b/src/pymovements/__init__.py index 8cf4f9387..cb204c48d 100644 --- a/src/pymovements/__init__.py +++ b/src/pymovements/__init__.py @@ -38,6 +38,7 @@ from pymovements.events import EventGazeProcessor from pymovements.events import EventProcessor from pymovements.gaze import Experiment +from pymovements.gaze import EyeTracker from pymovements.gaze import GazeDataFrame from pymovements.gaze import Screen from pymovements.measure import register_sample_measure @@ -60,6 +61,7 @@ 'gaze', 'Experiment', + 'EyeTracker', 'Screen', 'GazeDataFrame', diff --git a/src/pymovements/dataset/dataset_files.py b/src/pymovements/dataset/dataset_files.py index 1553fddb9..dfc4c40eb 100644 --- a/src/pymovements/dataset/dataset_files.py +++ b/src/pymovements/dataset/dataset_files.py @@ -377,7 +377,7 @@ def load_gaze_file( column_schema_overrides=definition.filename_format_schema_overrides['gaze'], ) elif filepath.suffix == '.asc': - gaze_df, _ = from_asc( + gaze_df = from_asc( filepath, experiment=definition.experiment, add_columns=add_columns, diff --git a/src/pymovements/datasets/toy_dataset_eyelink.py b/src/pymovements/datasets/toy_dataset_eyelink.py index 4d70501d1..488fb5b7a 100644 --- a/src/pymovements/datasets/toy_dataset_eyelink.py +++ b/src/pymovements/datasets/toy_dataset_eyelink.py @@ -166,8 +166,8 @@ class ToyDatasetEyeLink(DatasetDefinition): origin='upper left', eyetracker=EyeTracker( sampling_rate=1000.0, - left=False, - right=True, + left=True, + right=False, model='EyeLink Portable Duo', vendor='EyeLink', ), diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py index 10f3e8bca..7d81c962d 100644 --- a/src/pymovements/gaze/gaze_dataframe.py +++ b/src/pymovements/gaze/gaze_dataframe.py @@ -283,6 +283,9 @@ def __init__( else: self.events = events.copy() + # Remove this attribute once #893 is fixed + self._metadata: dict[str, Any] | None = None + def apply( self, function: str, diff --git a/src/pymovements/gaze/io.py b/src/pymovements/gaze/io.py index 18868e3dd..e68d46fd4 100644 --- a/src/pymovements/gaze/io.py +++ b/src/pymovements/gaze/io.py @@ -25,7 +25,8 @@ import polars as pl -from pymovements.gaze import Experiment # pylint: disable=cyclic-import +from pymovements.gaze.experiment import Experiment +from pymovements.gaze.eyetracker import EyeTracker from pymovements.gaze.gaze_dataframe import GazeDataFrame # pylint: disable=cyclic-import from pymovements.utils.parsing import parse_eyelink @@ -277,7 +278,7 @@ def from_asc( experiment: Experiment | None = None, add_columns: dict[str, str] | None = None, column_schema_overrides: dict[str, Any] | None = None, -) -> tuple[GazeDataFrame, dict[str, Any]]: +) -> GazeDataFrame: """Initialize a :py:class:`pymovements.gaze.gaze_dataframe.GazeDataFrame`. Parameters @@ -303,8 +304,8 @@ def from_asc( Returns ------- - tuple[GazeDataFrame, dict[str, Any]] - The gaze data frame and a metadata dictionary read from the asc file. + GazeDataFrame + The gaze data frame read from the asc file. Examples -------- @@ -312,7 +313,7 @@ def from_asc( We can then load the data into a ``GazeDataFrame``: >>> from pymovements.gaze.io import from_asc - >>> gaze, metadata = from_asc(file='tests/files/eyelink_monocular_example.asc') + >>> gaze = from_asc(file='tests/files/eyelink_monocular_example.asc') >>> gaze.frame shape: (16, 3) ┌─────────┬───────┬────────────────┐ @@ -332,7 +333,7 @@ def from_asc( │ 2339290 ┆ 618.0 ┆ [637.6, 531.4] │ │ 2339291 ┆ 618.0 ┆ [637.3, 531.2] │ └─────────┴───────┴────────────────┘ - >>> metadata['sampling_rate'] + >>> gaze.experiment.eyetracker.sampling_rate 1000.0 """ if isinstance(patterns, str): @@ -360,6 +361,75 @@ def from_asc( for fileinfo_key, fileinfo_dtype in column_schema_overrides.items() ]) + if experiment is None: + experiment = Experiment(sampling_rate=metadata['sampling_rate']) + if experiment.eyetracker is None: + experiment.eyetracker = EyeTracker() + + # Compare metadata from experiment definition with metadata from ASC file. + # Fill in missing metadata in experiment definition and raise an error if there are conflicts + issues = [] + + # Screen resolution (assuming that width and height will always be missing or set together) + experiment_resolution = (experiment.screen.width_px, experiment.screen.height_px) + if experiment_resolution == (None, None): + experiment.screen.width_px, experiment.screen.height_px = metadata['resolution'] + elif experiment_resolution != metadata['resolution']: + issues.append(f"Screen resolution: {experiment_resolution} vs. {metadata['resolution']}") + + # Sampling rate + if experiment.eyetracker.sampling_rate is None: + experiment.eyetracker.sampling_rate = metadata['sampling_rate'] + elif experiment.eyetracker.sampling_rate != metadata['sampling_rate']: + issues.append( + f"Sampling rate: {experiment.eyetracker.sampling_rate} vs. {metadata['sampling_rate']}", + ) + + # Tracked eye + asc_left_eye = 'L' in metadata['tracked_eye'] + asc_right_eye = 'R' in metadata['tracked_eye'] + if experiment.eyetracker.left is None: + experiment.eyetracker.left = asc_left_eye + elif experiment.eyetracker.left != asc_left_eye: + issues.append(f"Left eye tracked: {experiment.eyetracker.left} vs. {asc_left_eye}") + if experiment.eyetracker.right is None: + experiment.eyetracker.right = asc_right_eye + elif experiment.eyetracker.right != asc_right_eye: + issues.append(f"Right eye tracked: {experiment.eyetracker.right} vs. {asc_right_eye}") + + # Mount configuration + if experiment.eyetracker.mount is None: + experiment.eyetracker.mount = metadata['mount_configuration']['mount_type'] + elif experiment.eyetracker.mount != metadata['mount_configuration']['mount_type']: + issues.append(f"Mount configuration: {experiment.eyetracker.mount} vs. " + f"{metadata['mount_configuration']['mount_type']}") + + # Eye tracker vendor + asc_vendor = 'EyeLink' if 'EyeLink' in metadata['model'] else None + if experiment.eyetracker.vendor is None: + experiment.eyetracker.vendor = asc_vendor + elif experiment.eyetracker.vendor != asc_vendor: + issues.append(f"Eye tracker vendor: {experiment.eyetracker.vendor} vs. {asc_vendor}") + + # Eye tracker model + if experiment.eyetracker.model is None: + experiment.eyetracker.model = metadata['model'] + elif experiment.eyetracker.model != metadata['model']: + issues.append(f"Eye tracker model: {experiment.eyetracker.model} vs. {metadata['model']}") + + # Eye tracker software version + if experiment.eyetracker.version is None: + experiment.eyetracker.version = metadata['version_number'] + elif experiment.eyetracker.version != metadata['version_number']: + issues.append(f"Eye tracker software version: {experiment.eyetracker.version} vs. " + f"{metadata['version_number']}") + + if issues: + raise ValueError( + 'Experiment metadata does not match the metadata in the ASC file:\n' + + '\n'.join(f'- {issue}' for issue in issues), + ) + # Create gaze data frame. gaze_df = GazeDataFrame( gaze_data, @@ -368,7 +438,8 @@ def from_asc( time_unit='ms', pixel_columns=['x_pix', 'y_pix'], ) - return gaze_df, metadata + gaze_df._metadata = metadata # pylint: disable=protected-access + return gaze_df def from_ipc( diff --git a/tests/functional/gaze_file_processing_test.py b/tests/functional/gaze_file_processing_test.py index 7a0e84496..88e57c545 100644 --- a/tests/functional/gaze_file_processing_test.py +++ b/tests/functional/gaze_file_processing_test.py @@ -71,7 +71,7 @@ def fixture_gaze_init_kwargs(request): }, 'eyelink_monocular': { 'file': 'tests/files/eyelink_monocular_example.asc', - 'experiment': pm.datasets.ToyDatasetEyeLink().experiment, + 'experiment': pm.Experiment(1280, 1024, 38, 30, 60, 'upper left', 1000), }, 'didec': { 'file': 'tests/files/didec_example.txt', @@ -157,7 +157,7 @@ def test_gaze_file_processing(gaze_from_kwargs): elif file_extension in {'.feather', '.ipc'}: gaze = pm.gaze.from_ipc(**gaze_from_kwargs) elif file_extension == '.asc': - gaze, _ = pm.gaze.from_asc(**gaze_from_kwargs) + gaze = pm.gaze.from_asc(**gaze_from_kwargs) assert gaze is not None diff --git a/tests/unit/dataset/dataset_files_test.py b/tests/unit/dataset/dataset_files_test.py index 80af569a2..61101ea9e 100644 --- a/tests/unit/dataset/dataset_files_test.py +++ b/tests/unit/dataset/dataset_files_test.py @@ -206,7 +206,7 @@ def test_load_eyelink_file(tmp_path, read_kwargs): filepath, fileinfo_row={}, definition=DatasetDefinition( - experiment=pm.Experiment(1024, 768, 38, 30, None, 'center', 100), + experiment=pm.Experiment(1280, 1024, 38, 30, None, 'center', 100), filename_format_schema_overrides={'gaze': {}, 'precomputed_events': {}}, ), custom_read_kwargs=read_kwargs, diff --git a/tests/unit/gaze/io/asc_test.py b/tests/unit/gaze/io/asc_test.py index 40afb2b67..7c2af7f41 100644 --- a/tests/unit/gaze/io/asc_test.py +++ b/tests/unit/gaze/io/asc_test.py @@ -131,64 +131,128 @@ ], ) def test_from_asc_has_shape_and_schema(kwargs, expected_frame): - gaze, _ = pm.gaze.from_asc(**kwargs) + gaze = pm.gaze.from_asc(**kwargs) assert_frame_equal(gaze.frame, expected_frame, check_column_order=False) @pytest.mark.parametrize( - ('kwargs', 'expected_metadata'), + ('kwargs', 'exception', 'message'), [ pytest.param( { 'file': 'tests/files/eyelink_monocular_example.asc', - 'metadata_patterns': [ - {'pattern': r'!V TRIAL_VAR SUBJECT_ID (?P-?\d+)'}, - r'!V TRIAL_VAR STIMULUS_COMBINATION_ID (?P.+)', - ], + 'patterns': 'foobar', }, + ValueError, + "unknown pattern key 'foobar'. Supported keys are: eyelink", + id='unknown_pattern', + ), + ], +) +def test_from_asc_raises_exception(kwargs, exception, message): + with pytest.raises(exception) as excinfo: + pm.gaze.from_asc(**kwargs) + + msg, = excinfo.value.args + assert msg == message + + +@pytest.mark.parametrize( + ('file', 'sampling_rate'), + [ + pytest.param('tests/files/eyelink_monocular_example.asc', 1000.0, id='1khz'), + pytest.param('tests/files/eyelink_monocular_2khz_example.asc', 2000.0, id='2khz'), + ], +) +def test_from_asc_fills_in_experiment_metadata(file, sampling_rate): + gaze = pm.gaze.from_asc(file, experiment=None) + assert gaze.experiment.screen.width_px == 1280 + assert gaze.experiment.screen.height_px == 1024 + assert gaze.experiment.eyetracker.sampling_rate == sampling_rate + assert gaze.experiment.eyetracker.left is True + assert gaze.experiment.eyetracker.right is False + assert gaze.experiment.eyetracker.model == 'EyeLink Portable Duo' + assert gaze.experiment.eyetracker.version == '6.12' + assert gaze.experiment.eyetracker.vendor == 'EyeLink' + assert gaze.experiment.eyetracker.mount == 'Desktop' + + +@pytest.mark.parametrize( + ('experiment_kwargs', 'issues'), + [ + pytest.param( { - 'subject_id': '-1', - 'stimulus_combination_id': 'start', + 'screen_width_px': 1920, + 'screen_height_px': 1080, + 'sampling_rate': 1000, }, - id='eyelink_asc_metadata_patterns', + ['Screen resolution: (1920, 1080) vs. (1280, 1024)'], + id='screen_resolution', ), pytest.param( { - 'file': 'tests/files/eyelink_monocular_example.asc', - 'metadata_patterns': [r'inexistent pattern (?P-?\d+)'], + 'eyetracker': pm.EyeTracker(sampling_rate=500), }, + ['Sampling rate: 500 vs. 1000.0'], + id='eyetracker_sampling_rate', + ), + pytest.param( { - 'value': None, + 'eyetracker': pm.EyeTracker( + left=False, + right=True, + sampling_rate=1000, + mount='Desktop', + ), }, - id='eyelink_asc_metadata_pattern_not_found', + [ + 'Left eye tracked: False vs. True', + 'Right eye tracked: True vs. False', + ], + id='eyetracker_tracked_eye', ), - ], -) -def test_from_asc_metadata_patterns(kwargs, expected_metadata): - _, metadata = pm.gaze.from_asc(**kwargs) - - for key, value in expected_metadata.items(): - assert metadata[key] == value - - -@pytest.mark.parametrize( - ('kwargs', 'exception', 'message'), - [ pytest.param( { - 'file': 'tests/files/eyelink_monocular_example.asc', - 'patterns': 'foobar', + 'eyetracker': pm.EyeTracker( + vendor='Tobii', + model='Tobii Pro Spectrum', + version='1.0', + sampling_rate=1000, + left=True, + right=False, + ), }, - ValueError, - "unknown pattern key 'foobar'. Supported keys are: eyelink", - id='unknown_pattern', + [ + 'Eye tracker vendor: Tobii vs. EyeLink', + 'Eye tracker model: Tobii Pro Spectrum vs. EyeLink Portable Duo', + 'Eye tracker software version: 1.0 vs. 6.12', + ], + id='eyetracker_vendor_model_version', + ), + pytest.param( + { + 'eyetracker': pm.EyeTracker( + mount='Remote', + sampling_rate=1000, + vendor='EyeLink', + model='EyeLink Portable Duo', + version='6.12', + ), + }, + ['Mount configuration: Remote vs. Desktop'], + id='eyetracker_mount', ), ], ) -def test_from_asc_raises_exception(kwargs, exception, message): - with pytest.raises(exception) as excinfo: - pm.gaze.from_asc(**kwargs) +def test_from_asc_detects_mismatches_in_experiment_metadata(experiment_kwargs, issues): + with pytest.raises(ValueError) as excinfo: + pm.gaze.from_asc( + 'tests/files/eyelink_monocular_example.asc', + experiment=pm.Experiment(**experiment_kwargs), + ) msg, = excinfo.value.args - assert msg == message + expected_msg = 'Experiment metadata does not match the metadata in the ASC file:\n' + expected_msg += '\n'.join(f'- {issue}' for issue in issues) + assert msg == expected_msg diff --git a/tests/unit/utils/parsing_test.py b/tests/unit/utils/parsing_test.py index f33e89fd8..13dc2dd39 100644 --- a/tests/unit/utils/parsing_test.py +++ b/tests/unit/utils/parsing_test.py @@ -206,6 +206,42 @@ def test_parse_eyelink(tmp_path): assert metadata == EXPECTED_METADATA +@pytest.mark.parametrize( + ('kwargs', 'expected_metadata'), + [ + pytest.param( + { + 'filepath': 'tests/files/eyelink_monocular_example.asc', + 'metadata_patterns': [ + {'pattern': r'!V TRIAL_VAR SUBJECT_ID (?P-?\d+)'}, + r'!V TRIAL_VAR STIMULUS_COMBINATION_ID (?P.+)', + ], + }, + { + 'subject_id': '-1', + 'stimulus_combination_id': 'start', + }, + id='eyelink_asc_metadata_patterns', + ), + pytest.param( + { + 'filepath': 'tests/files/eyelink_monocular_example.asc', + 'metadata_patterns': [r'inexistent pattern (?P-?\d+)'], + }, + { + 'value': None, + }, + id='eyelink_asc_metadata_pattern_not_found', + ), + ], +) +def test_from_asc_metadata_patterns(kwargs, expected_metadata): + _, metadata = pm.utils.parsing.parse_eyelink(**kwargs) + + for key, value in expected_metadata.items(): + assert metadata[key] == value + + @pytest.mark.parametrize( 'patterns', [