This page was generated from a Jupyter notebook. View on GitHub Binder badge

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, y and pupil from 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

Samples
1,000
Sampling rate
1000.0 Hz
Duration
0.02 min (start 0.00, end 0.02)
Eyes
left
Variables
left_x, left_y, left_pupil
Events
0
Size
39.3KiB
Signal overview
left_pupil
left_pupil sparkline
left_x
left_x sparkline
left_y
left_y sparkline
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

Samples
1,000
Sampling rate
1000.0 Hz
Duration
0.02 min (start 0.00, end 0.02)
Eyes
left, right
Variables
left_pupil, right_pupil
Events
0
Blinks
left_pupil: 0, right_pupil: 0
Size
30.0KiB
Signal overview
left_pupil
left_pupil sparkline
right_pupil
right_pupil sparkline
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
  1. 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

Samples
1,000
Sampling rate
1000.0 Hz
Duration
0.02 min (start 0.00, end 0.02)
Eyes
left, right
Variables
left_x, left_y, right_x, right_y
Events
0
Size
49.8KiB
Signal overview
left_x
left_x sparkline
left_y
left_y sparkline
right_x
right_x sparkline
right_y
right_y sparkline
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
  1. 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

Samples
1,000
Sampling rate
1000.0 Hz
Duration
0.02 min (start 0.00, end 0.02)
Eyes
left, right, mean
Variables
left_pupil, right_pupil, mean_pupil
Events
0
Blinks
left_pupil: 0, right_pupil: 0, mean_pupil: 0
Size
40.9KiB
Signal overview
left_pupil
left_pupil sparkline
mean_pupil
mean_pupil sparkline
right_pupil
right_pupil sparkline
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
  1. pupil.blinks_detect()
  2. scale()
  3. 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