Skip to content

Commit 90e5232

Browse files
scott-hubertyWouter Krootpre-commit-ci[bot]autofix-ci[bot]
authored
ENH: Parse EyeLink BUTTON Events (e.g. from a game controller) (#13499)
Co-authored-by: Wouter Kroot <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 587c99f commit 90e5232

File tree

4 files changed

+90
-8
lines changed

4 files changed

+90
-8
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added support for parsing Eyelink ``BUTTON`` events (i.e. external controller button presses) to :func:`~mne.io.read_raw_eyelink` by :newcontrib:`Wouter Kroot`

doc/changes/names.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@
340340
.. _Victoria Peterson: https://github.com/vpeterson
341341
.. _Wei Xu: https://github.com/psyxw
342342
.. _Will Turner: https://bootstrapbill.github.io
343+
.. _Wouter Kroot: https://github.com/WouterKroot
343344
.. _Xabier de Zuazo: https://github.com/zuazo
344345
.. _Xiaokai Xia: https://github.com/dddd1007
345346
.. _Yaroslav Halchenko: http://haxbylab.dartmouth.edu/ppl/yarik.html

mne/io/eyelink/_utils.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,24 @@ def _create_dataframes_for_block(block, apply_offsets):
400400
msgs.append([ts, offset, msg])
401401
df_dict["messages"] = pd.DataFrame(msgs)
402402

403-
# TODO: Make dataframes for other eyelink events (Buttons)
403+
# make dataframes for other button events
404+
if block["events"]["BUTTON"]:
405+
button_events = block["events"]["BUTTON"]
406+
parsed = []
407+
for entry in button_events:
408+
parsed.append(
409+
{
410+
"time": float(entry[0]), # onset
411+
"button_id": int(entry[1]),
412+
"button_pressed": int(entry[2]), # 1 = press, 0 = release
413+
}
414+
)
415+
df_dict["buttons"] = pd.DataFrame(parsed)
416+
n_button = len(df_dict.get("buttons", []))
417+
logger.info(f"Found {n_button} button event(s) in this file.")
418+
else:
419+
logger.info("No button events found in this file.")
420+
404421
return df_dict
405422

406423

@@ -499,7 +516,6 @@ def _combine_block_dataframes(processed_blocks: list[dict]):
499516

500517
for df_type in all_df_types:
501518
block_dfs = []
502-
503519
for block in processed_blocks:
504520
if df_type in block["dfs"]:
505521
# We will update the dfs in-place to conserve memory
@@ -849,7 +865,7 @@ def _make_eyelink_annots(df_dict, create_annots, apply_offsets):
849865
"pupil_right",
850866
),
851867
}
852-
valid_descs = ["blinks", "saccades", "fixations", "messages"]
868+
valid_descs = ["blinks", "saccades", "fixations", "buttons", "messages"]
853869
msg = (
854870
"create_annotations must be True or a list containing one or"
855871
f" more of {valid_descs}."
@@ -875,6 +891,7 @@ def _make_eyelink_annots(df_dict, create_annots, apply_offsets):
875891
descriptions = key[:-1] # i.e "blink", "fixation", "saccade"
876892
if key == "blinks":
877893
descriptions = "BAD_" + descriptions
894+
878895
ch_names = df["eye"].map(eye_ch_map).tolist()
879896
this_annot = Annotations(
880897
onset=onsets,
@@ -890,21 +907,44 @@ def _make_eyelink_annots(df_dict, create_annots, apply_offsets):
890907
onsets = df["time"]
891908
durations = [0] * onsets
892909
descriptions = df["event_msg"]
910+
this_annot = Annotations(
911+
onset=onsets, duration=durations, description=descriptions
912+
)
913+
elif (key == "buttons") and (key in descs):
914+
required_cols = {"time", "button_id", "button_pressed"}
915+
if not required_cols.issubset(df.columns):
916+
raise ValueError(f"Missing column: {required_cols - set(df.columns)}")
917+
# Give user a hint
918+
n_presses = df["button_pressed"].sum()
919+
logger.info("Found %d button press events.", n_presses)
920+
921+
df = df.sort_values("time")
922+
onsets = df["time"]
923+
durations = np.zeros_like(onsets)
924+
descriptions = df.apply(_get_button_description, axis=1)
925+
893926
this_annot = Annotations(
894927
onset=onsets, duration=durations, description=descriptions
895928
)
896929
else:
897-
continue # TODO make df and annotations for Buttons
930+
continue
898931
if not annots:
899932
annots = this_annot
900933
elif annots:
901934
annots += this_annot
902935
if not annots:
903936
warn(f"Annotations for {descs} were requested but none could be made.")
904937
return
938+
905939
return annots
906940

907941

942+
def _get_button_description(row):
943+
button_id = int(row["button_id"])
944+
action = "press" if row["button_pressed"] == 1 else "release"
945+
return f"button_{button_id}_{action}"
946+
947+
908948
def _make_gap_annots(raw_extras, key="recording_blocks"):
909949
"""Create Annotations for gap periods between recording blocks."""
910950
df = raw_extras["dfs"][key]

mne/io/eyelink/tests/test_eyelink.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,6 @@ def test_bino_to_mono(tmp_path, fname):
200200
"""Test a file that switched from binocular to monocular mid-recording."""
201201
out_file = tmp_path / "tmp_eyelink.asc"
202202
in_file = Path(fname)
203-
204203
lines = in_file.read_text("utf-8").splitlines()
205204
# We'll also add some binocular velocity data to increase our testing coverage.
206205
start_idx = [li for li, line in enumerate(lines) if line.startswith("START")][0]
@@ -309,6 +308,18 @@ def _simulate_eye_tracking_data(in_file, out_file):
309308
"SAMPLES\tPUPIL\tLEFT\tVEL\tRES\tHTARGET\tRATE\t1000.00"
310309
"\tTRACKING\tCR\tFILTER\t2\tINPUT"
311310
)
311+
312+
# Define your known BUTTON events
313+
button_events = [
314+
(7453390, 1, 1),
315+
(7453410, 1, 0),
316+
(7453420, 1, 1),
317+
(7453430, 1, 0),
318+
(7453440, 1, 1),
319+
(7453450, 1, 0),
320+
]
321+
button_idx = 0
322+
312323
with out_file.open("w") as fp:
313324
in_recording_block = False
314325
events = []
@@ -332,6 +343,7 @@ def _simulate_eye_tracking_data(in_file, out_file):
332343
tokens.append("INPUT")
333344
elif event_type == "EBLINK":
334345
continue # simulate no blink events
346+
335347
elif event_type == "END":
336348
pass
337349
else:
@@ -354,6 +366,22 @@ def _simulate_eye_tracking_data(in_file, out_file):
354366
"...\t1497\t5189\t512.5\t.............\n"
355367
)
356368

369+
for timestamp in np.arange(7453390, 7453490): # 100 samples 7453100
370+
fp.write(
371+
f"{timestamp}\t-2434.0\t-1760.0\t840.0\t100\t20\t45\t45\t127.0\t"
372+
"...\t1497\t5189\t512.5\t.............\n"
373+
)
374+
# Check and insert button events at this timestamp
375+
if (
376+
button_idx < len(button_events)
377+
and button_events[button_idx][0] == timestamp
378+
):
379+
t, btn_id, state = button_events[button_idx]
380+
fp.write(
381+
f"BUTTON\t{t}\t{btn_id}\t{state}\t100\t20\t45\t45\t127.0\t"
382+
"1497.0\t5189.0\t512.5\t.............\n"
383+
)
384+
button_idx += 1
357385
fp.write("END\t7453390\tRIGHT\tSAMPLES\tEVENTS\n")
358386

359387

@@ -397,14 +425,24 @@ def test_multi_block_misc_channels(fname, tmp_path):
397425

398426
assert raw.ch_names == chs_in_file
399427
assert raw.annotations.description[1] == "SYNCTIME"
400-
assert raw.annotations.description[-1] == "BAD_ACQ_SKIP"
401-
assert np.isclose(raw.annotations.onset[-1], 1.001)
402-
assert np.isclose(raw.annotations.duration[-1], 0.1)
428+
429+
assert raw.annotations.description[-7] == "BAD_ACQ_SKIP"
430+
assert np.isclose(raw.annotations.onset[-7], 1.001)
431+
assert np.isclose(raw.annotations.duration[-7], 0.1)
403432

404433
data, times = raw.get_data(return_times=True)
405434
assert not np.isnan(data[0, np.where(times < 1)[0]]).any()
406435
assert np.isnan(data[0, np.logical_and(times > 1, times <= 1.1)]).all()
407436

437+
assert raw.annotations.description[-6] == "button_1_press"
438+
button_idx = [
439+
ii
440+
for ii, desc in enumerate(raw.annotations.description)
441+
if "button" in desc.lower()
442+
]
443+
assert len(button_idx) == 6
444+
assert_allclose(raw.annotations.onset[button_idx[0]], 2.102, atol=1e-3)
445+
408446
# smoke test for reading events with missing samples (should not emit a warning)
409447
find_events(raw, verbose=True)
410448

@@ -465,6 +503,7 @@ def test_href_eye_events(tmp_path):
465503
"""Test Parsing file where Eye Event Data option was set to 'HREF'."""
466504
out_file = tmp_path / "tmp_eyelink.asc"
467505
lines = fname_href.read_text("utf-8").splitlines()
506+
468507
for li, line in enumerate(lines):
469508
if not line.startswith(("ESACC", "EFIX")):
470509
continue
@@ -479,6 +518,7 @@ def test_href_eye_events(tmp_path):
479518
new_line = "\t".join(tokens) + "\n"
480519
lines[li] = new_line
481520
out_file.write_text("\n".join(lines), encoding="utf-8")
521+
482522
raw = read_raw_eyelink(out_file)
483523
# Just check that we actually parsed the Saccade and Fixation events
484524
assert "saccade" in raw.annotations.description

0 commit comments

Comments
 (0)