Skip to content

Commit

Permalink
feat: Store metadata from ASC in experiment metadata (#884)
Browse files Browse the repository at this point in the history
* updated io file

* updated test file

* Add tests for metadata parsing from ASC file

* Squashed commit of the following:

commit 14d047c
Author: Faizan Ansari <[email protected]>
Date:   Thu Oct 24 22:02:30 2024 +0200

    Remove files from remote directory

commit aa78078
Author: Faizan Ansari <[email protected]>
Date:   Thu Oct 24 21:53:35 2024 +0200

    updated code

commit cae54cc
Author: Faizan Ansari <[email protected]>
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 <[email protected]>
Co-authored-by: SiQube <[email protected]>
  • Loading branch information
3 people committed Nov 17, 2024
1 parent 88113c8 commit 21fd0d2
Show file tree
Hide file tree
Showing 9 changed files with 223 additions and 47 deletions.
2 changes: 2 additions & 0 deletions src/pymovements/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,6 +61,7 @@

'gaze',
'Experiment',
'EyeTracker',
'Screen',
'GazeDataFrame',

Expand Down
2 changes: 1 addition & 1 deletion src/pymovements/dataset/dataset_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/pymovements/datasets/toy_dataset_eyelink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
Expand Down
3 changes: 3 additions & 0 deletions src/pymovements/gaze/gaze_dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
85 changes: 78 additions & 7 deletions src/pymovements/gaze/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -303,16 +304,16 @@ 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
--------
Let's assume we have an EyeLink asc file stored at `tests/files/eyelink_monocular_example.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)
┌─────────┬───────┬────────────────┐
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions tests/functional/gaze_file_processing_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/dataset/dataset_files_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
132 changes: 98 additions & 34 deletions tests/unit/gaze/io/asc_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<subject_id>-?\d+)'},
r'!V TRIAL_VAR STIMULUS_COMBINATION_ID (?P<stimulus_combination_id>.+)',
],
'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<value>-?\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
Loading

0 comments on commit 21fd0d2

Please sign in to comment.