Processing monocular and binocular data#
[1]:
import pypillometry as pp
import numpy as np
Data in pypillometry can contain different variables from different eyes. The variables and eyes supported when importing raw data are
left_x,right_x(x-coordinate in screen coordinates from the eyetracker)left_y,right_y(y-coordinate in screen coordinates from the eyetracker)left_pupil,right_pupil(pupil size from left and right eye)
All of them are held in an EyeData object. Any of these variables can be left out, but at least one must be given, and the x and y of an eye go together:
pupil data only: at least one of
left_pupil,right_pupil(or both)gaze data only: at least one of
(left_x, left_y)and/or(right_x, right_y)both:
x,yandpupilfrom at least one eye
For example, let’s simulate some basic data:
[2]:
rng = np.random.default_rng(42)
n = 1000
left_x, right_x = rng.normal(size=n), rng.normal(size=n)
left_y, right_y = rng.normal(size=n), rng.normal(size=n)
left_pupil, right_pupil = rng.normal(size=n), rng.normal(size=n)
time = np.arange(n) # time in ms
# these are all ok
dpupil = pp.EyeData(left_pupil=left_pupil, right_pupil=right_pupil, time=time)
dgaze = pp.EyeData(left_x=left_x, left_y=left_y, right_x=right_x, right_y=right_y, time=time)
deye = pp.EyeData(left_x=left_x, left_y=left_y, left_pupil=left_pupil, time=time)
The x and y of an eye go together, so giving only one of them (here, left_x without left_y) fails:
[3]:
pp.EyeData(left_x=left_x, time=time)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[3], line 1
----> 1 pp.EyeData(left_x=left_x, time=time)
File ~/Nextcloud/work/projects/pupil/pypillometry/pypillometry/eyedata/eyedata.py:245, in EyeData.__init__(self, time, left_x, left_y, left_pupil, right_x, right_y, right_pupil, event_onsets, event_labels, events, sampling_rate, experimental_setup, name, calibration, fill_time_discontinuities, keep_orig, info, inplace)
243 for eye, x, y in (("left", left_x, left_y), ("right", right_x, right_y)):
244 if (x is None) != (y is None):
--> 245 raise ValueError(f"{eye}_x and {eye}_y must be provided together")
246 self.data = EyeDataDict(left_x=left_x, left_y=left_y, left_pupil=left_pupil,
247 right_x=right_x, right_y=right_y, right_pupil=right_pupil)
249 if events is not None and (event_onsets is not None or event_labels is not None):
ValueError: left_x and left_y must be provided together
Once the data is loaded, we can check which variables and eyes are available using the .eyes and .variables attributes:
[4]:
deye.eyes, deye.variables
[4]:
(['left'], ['x', 'y', 'pupil'])
Simply printing an object also shows which data are available and gives a glimpse into the data:
[5]:
deye
[5]:
EyeData — dobogaru
Signal overview
Data glimpse
EyeDataDict(vars=3,n=1000,shape=(1000,)): left_x (float64): 0.3047, -1.04, 0.7505, 0.9406, -1.951... left_y (float64): -0.452, -0.6659, 0.434, 0.2519, -1.4048... left_pupil (float64): 0.2532, 0.8952, 0.2733, 2.2388, 1.4298...
[6]:
d = pp.get_example_data("rlmw_002_short")
d.eyes, d.variables
[6]:
(['left', 'right'], ['x', 'y', 'pupil'])
Almost all of pypillometry’s functions have keyword arguments eyes= and variables= that specify which eyes/variables to operate on. By default, all variables and eyes are processed.
For example, we can run the scale() function that re-scales the data to have mean 0 and standard deviation 1. Here, we use the context manager pp.loglevel("DEBUG") to see what pypillometry does internally:
[7]:
with pp.loglevel("DEBUG"):
deye.scale(eyes="left")
pp: 19:03:54 | DEBUG | scale:2513 | channels: left_x, left_y, left_pupil
pp: 19:03:54 | DEBUG | scale:2529 | center: {'left_x': -0.028891550995946837, 'left_y': 0.03370120480230512, 'left_pupil': -0.02135748147595793}
pp: 19:03:54 | DEBUG | scale:2530 | scale: {'left_x': 0.988722355396508, 'left_y': 1.0161916099991923, 'left_pupil': 0.9978409919199771}
The output shows that all variables of the left eye have been processed.
Which functions work on which data?#
Not all of pypillometry’s functions can be applied to all variables. The functions that are specific to pupil data are in the namespace d.pupil (e.g., d.pupil.blinks_detect()), those that only work on gaze (x/y) data in d.gaze (e.g., d.gaze.mask_offscreen()). The methods of EyeData itself operate on all variables (which may or may not make sense, it is up to you to check!).
Functions that work only on pupillometric data raise an error for data without pupil size:
[8]:
dpupil.pupil.blinks_detect() # works fine
[8]:
EyeData — tumedihe
Signal overview
Data glimpse
EyeDataDict(vars=2,n=1000,shape=(1000,)): left_pupil (float64): 0.2532, 0.8952, 0.2733, 2.2388, 1.4298... right_pupil (float64): -0.258, -0.2995, -0.6967, 0.4285, 0.2346...
Recent history
- pupil.blinks_detect()
[9]:
dgaze.pupil.blinks_detect() # fails
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[9], line 1
----> 1 dgaze.pupil.blinks_detect() # fails
File ~/Nextcloud/work/projects/pupil/pypillometry/pypillometry/eyedata/pipes.py:50, in step.<locals>.wrapper(self, *args, **kwargs)
48 if isinstance(self, Namespace) and self._data is None:
49 return Step(wrapper, args, kwargs, namespace=self._name)
---> 50 return func(self, *args, **kwargs)
File ~/Nextcloud/work/projects/pupil/pypillometry/pypillometry/eyedata/history.py:70, in keephistory.<locals>.wrapper(*args, **kwargs)
68 _history_depth += 1
69 try:
---> 70 obj=func(*args,**kwargs)
71 finally:
72 _history_depth -= 1
File ~/Nextcloud/work/projects/pupil/pypillometry/pypillometry/eyedata/pupil.py:557, in PupilNamespace.blinks_detect(self, eyes, min_duration, blink_val, winsize, vel_onset, vel_offset, vel_onset_min_duration, vel_offset_min_duration, max_duration, strategies, ignore_existing_mask, inplace)
467 @step
468 @keephistory
469 def blinks_detect(self, eyes=[], min_duration:float=20, blink_val:float=0,
(...) 473 strategies: list=["zero","velocity"],
474 ignore_existing_mask=False, inplace=None):
475 """
476 Detect blinks in the pupillary signal using several strategies.
477 First, blinks are detected as consecutive sequence of `blink_val`
(...) 555 >>> d.get_blinks(variables="pupil", units="ms") # doctest: +SKIP
556 """
--> 557 self._d._require("pupil")
558 obj = self._d._get_inplace(inplace)
559 eyes=self._d._eyes_with(eyes, "pupil")
File ~/Nextcloud/work/projects/pupil/pypillometry/pypillometry/eyedata/eyedata.py:562, in EyeData._require(self, *variables)
560 missing = [var for var in variables if var not in available]
561 if missing:
--> 562 raise ValueError("no %s data; available channels: %s"
563 % (", ".join(missing), ", ".join(self.data.keys()) or "none"))
ValueError: no pupil data; available channels: left_x, left_y, right_x, right_y
Functions that are methods of EyeData itself (not of d.pupil or d.gaze) work for all data:
[10]:
dpupil.scale() # works
dgaze.scale() # works
[10]:
EyeData — vosawomi
Signal overview
Data glimpse
EyeDataDict(vars=4,n=1000,shape=(1000,)): left_x (float64): 0.3374, -1.0226, 0.7882, 0.9805, -1.9441... left_y (float64): -0.4779, -0.6884, 0.3939, 0.2147, -1.4156... right_x (float64): 0.02179, -0.6389, -0.3285, 0.7054, 0.0832... right_y (float64): 1.2814, 0.7062, 2.0163, -1.5846, -0.2704...
Recent history
- scale()
Creating new variables or eyes#
In some cases, new variables or “eyes” can be created. For example, we might want to reduce a binocular dataset to one where we average the timeseries from the two eyes. In that case, we can use the function merge_eyes():
[11]:
dpupil.merge_eyes(eyes=["left", "right"], variables=["pupil"], method="mean")
[11]:
EyeData — tumedihe
Signal overview
Data glimpse
EyeDataDict(vars=3,n=1000,shape=(1000,)): left_pupil (float64): 0.2752, 0.9186, 0.2953, 2.2651, 1.4543... right_pupil (float64): -0.3013, -0.342, -0.7317, 0.3722, 0.182... mean_pupil (float64): -0.01307, 0.2883, -0.2182, 1.3186, 0.8181...
Recent history
- pupil.blinks_detect()
- scale()
- merge_eyes(eyes=['left', 'right'],variables=['pupil'],method='mean')
We can see that a new “eye” called mean with variable pupil (i.e., mean_pupil) has been created. The original data left_pupil and right_pupil have been kept (this can be changed by using keep_eyes=False).
In other cases, the package creates new variables. For example, the function pupil.estimate_baseline() estimates the tonic fluctuations in the pupil (see Mittner & Groot, 2025) and creates a new variable <eye>_baseline. The baseline estimation does not work with missing data, so we first detect and interpolate blinks:
[12]:
d = pp.get_example_data("rlmw_002_short")
d = d | pp.pupil.blinks_detect() | pp.pupil.blinks_interpolate() | pp.pupil.estimate_baseline()
d.variables
[12]:
['x', 'y', 'pupil', 'baseline']
Debugging#
If you want to know exactly which steps pypillometry is taking, and which variables/eyes are being processed, you can use the pp.loglevel() context manager to temporarily increase the logging level (the result is a rather lengthy and detailed debug output):
[13]:
d = pp.get_example_data("rlmw_002_short")
with pp.loglevel("DEBUG"):
d.pupil.blinks_detect(eyes="left")
pp: 19:03:56 | DEBUG | blinks_detect:559 | channels: left_pupil
pp: 19:03:56 | DEBUG | blinks_detect:584 | Detecting blinks for eye left
pp: 19:03:56 | DEBUG | set_blinks:643 | channels: left_pupil
pp: 19:03:56 | DEBUG | _convert_velocity_thresholds:454 | vel_onset -5.0/ms -> -10.0000/sample
pp: 19:03:56 | DEBUG | _convert_velocity_thresholds:463 | vel_offset 5.0/ms -> 10.0000/sample
pp: 19:03:56 | DEBUG | detect_blinks_velocity:332 | Generated asymmetric velocity profiles, length 20465
pp: 19:03:56 | DEBUG | detect_blinks_velocity:336 | Found 147 potential onset points
pp: 19:03:56 | DEBUG | detect_blinks_velocity:346 | After filtering, 19 onsets remain
pp: 19:03:56 | DEBUG | detect_blinks_velocity:354 | Found 224 potential offset points
pp: 19:03:56 | DEBUG | detect_blinks_velocity:368 | After filtering, 36 offsets remain
pp: 19:03:56 | DEBUG | detect_blinks_velocity:392 | Found 13 blink pairs
pp: 19:03:56 | DEBUG | detect_blinks_velocity:403 | Returning 13 blinks after invalid-region adjustment
pp: 19:03:56 | DEBUG | blinks_detect:601 | Detected 13 blinks with velocity strategy
pp: 19:03:56 | DEBUG | detect_blinks_zero:438 | Found 11 potential blinks
pp: 19:03:56 | DEBUG | detect_blinks_zero:441 | Found 11 blinks after duration filtering
pp: 19:03:56 | DEBUG | blinks_detect:613 | Detected 11 blinks with zero strategy
pp: 19:03:56 | DEBUG | blinks_detect:618 | Merging 13 blinks with 11 blinks
pp: 19:03:56 | DEBUG | blinks_detect:620 | Merged 14 blinks
pp: 19:03:56 | DEBUG | blinks_detect:632 | Filtered 14 blinks by minimum duration
pp: 19:03:56 | DEBUG | set_blinks:643 | channels: left_pupil