Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Store metadata from ASC in experiment metadata #884

Merged
merged 25 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
SiQube marked this conversation as resolved.
Show resolved Hide resolved
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:
dkrako marked this conversation as resolved.
Show resolved Hide resolved
"""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'])
SiQube marked this conversation as resolved.
Show resolved Hide resolved
if experiment.eyetracker is None:
saeub marked this conversation as resolved.
Show resolved Hide resolved
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']
SiQube marked this conversation as resolved.
Show resolved Hide resolved
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']
SiQube marked this conversation as resolved.
Show resolved Hide resolved
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:
saeub marked this conversation as resolved.
Show resolved Hide resolved
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(
dkrako marked this conversation as resolved.
Show resolved Hide resolved
'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
SiQube marked this conversation as resolved.
Show resolved Hide resolved
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),
SiQube marked this conversation as resolved.
Show resolved Hide resolved
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
SiQube marked this conversation as resolved.
Show resolved Hide resolved


@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
Loading