PupilNamespace#

class pypillometry.eyedata.pupil.PupilNamespace(data=None)[source]#

Bases: Namespace

Methods for pupil data, accessed as d.pupil (e.g., d.pupil.blinks_detect()).

The methods need pupil data and raise a ValueError if there is none. The transforming methods return the data object (see Namespace).

Methods

blinks_detect

Detect blinks in the pupillary signal using several strategies.

blinks_interpolate

Interpolating blinks in the pupillary signal.

blinks_merge_close

Merge blinks that are close together.

correct_foreshortening

Correct pupil data using a simple algorithm to correct for foreshortening effects.

estimate_baseline

Apply one of the baseline-estimation methods.

estimate_response

Estimate pupil-response based on event-onsets, see pupil_response().

fit_foreshortening

Fit foreshortening correction model to estimate camera geometry and true pupil size.

lowpass_filter

Lowpass-filter pupil signal using a Butterworth-filter.

merge_eyes

Merge pupil data from both eyes into a new eye (default "mean").

smooth_window

Apply smoothing of the signal using a moving window.

Details

Detect blinks in the pupillary signal using several strategies. First, blinks are detected as consecutive sequence of blink_val (f.eks., 0 or NaN). Second, blinks are defined as everything between two crossings of the velocity profile (from negative to positive).

Detected blinks are stored in the object, as the "blink" layer of the pupil (retrieve them with get_blinks()), and are masked under the mask policies (see mask()). They replace the blinks detected before (and their mask), and the blinks detected before are not treated as masked during detection, so re-running the detection with other parameters gives the same result as running it once (clear_layer("blink") removes them). Eyes whose pupil has interpolated samples (after blinks_interpolate()) are skipped with a warning and keep their blinks: the values there are reconstructed, so detect blinks before interpolating them.

Finally, detected blinks have to be at least min_duration ms.

Parameters:
  • eyes (list) – list of eyes to process; if empty, all available eyes are processed

  • min_duration (float) – minimum duration (in ms, or with units) for a sequence of missing numbers to be treated as blink

  • blink_val (float) – “missing value” code for the “zero” strategy. Missing samples (NaN) are always detected; samples equal to blink_val (default 0) are detected in addition. Use np.nan to detect only NaN (e.g., for baseline-corrected data where 0 is a valid value). The “zero” strategy always uses the raw data, so regions masked for other reasons are not detected as blinks.

  • winsize (float) – window-size for Savitzky-Golay velocity estimation (in ms, or with units)

  • vel_onset (float or str) –

    velocity threshold to detect blink onset (negative value).

    • If float: threshold in pupil-units per millisecond (e.g., -5.0)

    • If str: percentile of velocity distribution (e.g., “5%” for bottom 5%)

  • vel_offset (float or str) –

    velocity threshold to detect blink offset (positive value).

    • If float: threshold in pupil-units per millisecond (e.g., 5.0)

    • If str: percentile of velocity distribution (e.g., “95%” for top 5%)

  • vel_onset_min_duration (float) – minimum duration (in ms, or with units) that velocity must exceed threshold to detect as onset (to avoid noise-induced false detections)

  • vel_offset_min_duration (float) – minimum duration (in ms, or with units) that velocity must exceed threshold to detect as offset (to avoid noise-induced false detections)

  • max_duration (float) – maximum duration (in ms, or with units) of a blink detected by the “velocity” strategy; an onset without an offset within this time is dropped. None means no limit. Does not apply to the “zero” strategy.

  • strategies (list) – list of strategies to use; so far, use a list containing any combination of “zero” and “velocity” (other names raise a ValueError)

  • ignore_existing_mask (bool) – if True, use raw data ignoring existing masks if False (default), only detect blinks in the usable data under the "strict" mask policy (masked regions are skipped, see mask())

  • inplace (bool or None) – if True, make change in-place and return the object if False, make and return copy before making changes if None, use the object-level setting

Return type:

EyeData

Returns:

the object with the blinks (self or a copy, depending on inplace)

Examples

>>> d = d.pupil.blinks_detect()
>>> d.get_blinks(variables="pupil", units="ms")

Interpolating blinks in the pupillary signal.

Implements the blink-interpolation method by Mahot (2013).

Mahot, 2013: https://figshare.com/articles/A_simple_way_to_reconstruct_pupil_size_during_eye_blinks/688001.

The blinks to interpolate are taken from the blinks stored in the object, so blinks have to be detected first (e.g., using blinks_detect()). The on- and offsets of each blink are refined using the velocity profile before interpolating. Blinks that are so close that the anchor points of one fall into the other are merged and interpolated together. The interpolated samples get the "interpolated" layer (on the variable they are stored in, see store_as): they are used under the default mask policy and masked under the "strict" one (see mask()); the interpolated blinks can be retrieved with get_interpolated().

This procedure relies heavily on eye-balling (reconstructing visually convincing signal), so it can help to inspect the result visually (e.g., using d.plot.plot_blinks()) to set good parameter values for winsize, vel_onset, vel_offset and margin (see paper linked above).

Parameters:
  • eyes (str | list) – str or list of eyes to process; if empty, all available eyes are processed

  • store_as (str) – how to store the interpolated data; either “pupil” (default) which replaces the original pupil data or a string that will be used as the new variable name in the data (e.g., “pupilinterp”)

  • method (str) – method to use; so far, only “mahot” is implemented

  • winsize (float) – size of the Savitzky-Golay window (in ms, or with units)

  • vel_onset (float or str) –

    velocity threshold to detect blink onset (negative value).

    • If float: threshold in pupil-units per millisecond (e.g., -5.0)

    • If str: percentile of velocity distribution (e.g., “5%” for bottom 5%)

  • vel_offset (float or str) –

    velocity threshold to detect blink offset (positive value).

    • If float: threshold in pupil-units per millisecond (e.g., 5.0)

    • If str: percentile of velocity distribution (e.g., “95%” for top 5%)

  • margin (Tuple[float, float]) – margin that is subtracted/added to onset and offset (in ms, or with units, e.g. (10, 30) * u.ms)

  • blinkwindow (float) – how much time before and after each blink to include (in ms, or with units)

  • interp_type (str) – type of interpolation accepted by scipy.interpolate.interp1d()

  • inplace (bool or None) – if True, make change in-place and return the object if False, make and return copy before making changes if None, use the object-level setting

Return type:

EyeData

Returns:

the object with the interpolated pupil data

Merge blinks that are close together.

The merged blinks replace the "blink" layer of the pupil (see set_blinks()); read them with get_blinks().

Parameters:
  • eyes (list) – eye(s) to process; if empty, all eyes with pupil data

  • distance (float) – Merge blinks closer than this distance, e.g. 100 * u.ms or "0.1 sec"; plain numbers are in units given by units. Default 100.

  • units (str) – Units for distance if it is a plain number. Can be “ms”, “sec”, “min”, or “h”. Default “ms”.

  • inplace (bool | None) – if True, make change in-place and return the object; if False, make and return copy before making changes; if None, use the object-level setting (specified in the constructor).

Return type:

EyeData

Returns:

the object with the merged blinks (self or a copy, depending on inplace)

correct_foreshortening(eyes=[], midpoint=None, store_as='pupil', inplace=None)[source]#

Correct pupil data using a simple algorithm to correct for foreshortening effects.

Correct the pupil data for foreshortening effects caused by saccades/eye movements. This method is based on a simple algorithm that assumes the pupil appears perfectly circular when fixating midpoint: for each sample, the distance \(\Delta\) (in mm) of the gaze position from midpoint is computed and the pupil signal is multiplied by the correction factor \(\sqrt{1+\Delta^2/d^2}\), where \(d\) is the eye-to-screen distance from the experimental setup. For a more complete model that also estimates the camera position, see fit_foreshortening().

Relevant publication (not the description of the algorithm used here): https://link.springer.com/article/10.3758/s13428-015-0588-x

  • [ ] TODO: when using interpolated pupil data, x/y data may still be missing. Make sure that the pupil data is not touched where x/y is missing

Parameters:
  • eyes (list) – Which eyes to correct. If empty, correct all available eyes that have pupil and gaze (x, y) data.

  • midpoint (tuple or None) – The center of the screen (x,y) in pixels where it is assumed that the pupil is completely circular. If None, the midpoint is taken to be the center of the screen as registered in the experimental_setup.

  • store_as (str) – name of the variable in which to store the corrected pupil data (as data[eye, store_as]); the default “pupil” replaces the original pupil data

  • inplace (bool or None) – Whether to modify the object in place or return a new object. true: modify in place false: return a new object None: use the object’s default setting (initialized in __init__)

Return type:

EyeData

Returns:

An EyeData object with the corrected pupil

Raises:

ValueError – if no experimental setup is set (see set_experimental_setup())

estimate_baseline(eyes=[], variable='pupil', method='envelope_iter_bspline_2', policy='default', inplace=None, **kwargs)[source]#

Apply one of the baseline-estimation methods.

The baseline is estimated from the usable samples under the mask policy (by default, interpolated samples are used, see mask()). The unusable samples (masked or NaN, e.g., blinks that were not interpolated or the excluded periods) are bridged linearly between their neighbours. Interpolate blinks first (see blinks_interpolate()) to use a better reconstruction than a straight line.

Parameters:
  • eyes (list or str) – str or list of eyes to process; if empty, all available eyes are processed

  • variable (str) – default is to use the “pupil” but it could be used to process, e.g., interpolated pupil data stored in a different variables, e.g., “pupilinterp”

  • method (str) –

  • policy (str, MaskPolicy or list of str) – mask policy that decides which samples are usable (see mask()); default "default"

  • inplace (bool or None) – if True, make change in-place and return the object if False, make and return copy before making changes if None, use the object-level setting

  • **kwargs (dict) – named arguments passed to the low-level function in pypillometry.signal.baseline.

Return type:

EyeData

Returns:

object with baseline estimated (stored in data[eye, "baseline"])

Raises:

ValueError – if method is unknown or no sample is usable

References

Mittner, M., & Groot, J. M. (2025). A novel method for modeling tonic and phasic pupil dynamics in humans. Behavior Research Methods, 57(9), 233. https://doi.org/10.3758/s13428-025-02755-7

estimate_response(eyes=[], npar='free', tmax='free', verbose=50, bounds={'npar': (1, 20), 'tmax': (100, 2000)}, min_valid=0.5, policy='default', inplace=None)[source]#

Estimate pupil-response based on event-onsets, see pupil_response().

The model has one response per event in d.events: all events are used, whatever their label. To use only some of them, set the events first, e.g. d.set_events(d.events.filter("stim")) on a copy of the data.

If a baseline has been estimated (see estimate_baseline()), it is subtracted from the pupil signal before estimating the response; otherwise, a baseline of zero is used.

Only the usable samples under the mask policy are used for the fit: by default, NaN, masked samples (e.g., blinks applied as mask) and the excluded periods (see exclude_intervals(), e.g., breaks) are not used, interpolated samples (e.g., blinks) are. For each event, the share of its response that falls on usable samples (valid) and the share that falls on real data (observed: usable under the "strict" policy, i.e., not interpolated either) are stored with the parameters (weighted by the response, see pupil_response_coverage()). Events with a valid share below min_valid are left out of the fit; their coefficient is NaN.

Parameters:
  • eyes (list or str) – str or list of eyes to process; if empty, all available eyes are processed

  • npar (str | float) – npar-parameter for the canonical response-function or “free”; in case of “free”, the function optimizes for this parameter

  • tmax (str | float) – tmax-parameter for the canonical response-function or “free”; in case of “free”, the function optimizes for this parameter

  • verbose (int) – verbosity level, passed on to pupil_response()

  • bounds (dict) – in case that one or both parameters are estimated, give the lower and upper bounds for the parameters

  • min_valid (float) – events whose share valid is below this are left out of the fit (coefficient NaN), as their amplitude cannot be estimated reliably; default 0.5; 0 uses all events

  • policy (str, MaskPolicy or list of str) – mask policy that decides which samples are usable (see mask()); default "default"

  • inplace (bool or None) – if True, make change in-place and return the object if False, make and return copy before making changes if None, use the object-level setting

Return type:

EyeData

Returns:

the object with the estimated response

Raises:

ValueError – if the object has no events

Notes

The predicted response is stored in data[eye, "response"]; the estimated parameters (npar, tmax, the coefficients coef and the bounds) are stored in params["response"]["<eye>_pupil"] (e.g., params["response"]["left_pupil"]), together with the shares valid and observed (0-1, one per event; NaN for events without response in the data, e.g., at the very end).

References

Mittner, M., & Groot, J. M. (2025). A novel method for modeling tonic and phasic pupil dynamics in humans. Behavior Research Methods, 57(9), 233. https://doi.org/10.3758/s13428-025-02755-7

fit_foreshortening(eye, r=None, d=None, intervals=None, target_fs=50.0, lowpass_freq=4.0, lambda_smooth=1.0, knots_per_second=1.0, initial_theta=None, initial_phi=None, verbose=True)[source]#

Fit foreshortening correction model to estimate camera geometry and true pupil size.

This method implements Stage 1 of the foreshortening correction algorithm, which jointly estimates the camera position (theta, phi) and the temporal dynamics of true pupil size A0(t) using B-splines. The data is automatically preprocessed (filtered and downsampled) before fitting.

Parameters:
  • eye (str) – Which eye to fit (‘left’ or ‘right’)

  • r (Union[float, str, None]) –

    Eye-to-camera distance. If not provided, uses the r from experimental_setup.

    • Plain number: assumed to be mm (with warning)

    • String: e.g., “600 mm”, “60 cm”

    • Quantity: e.g., 600 * u.mm

  • d (Union[float, str, None]) –

    Eye-to-screen distance. If not provided, uses the d from experimental_setup.

    • Plain number: assumed to be mm (with warning)

    • String: e.g., “700 mm”, “70 cm”

    • Quantity: e.g., 700 * u.mm

  • intervals (Optional[Intervals]) – Time intervals to use for fitting. If None, uses all available data. Useful for fitting only on calibration periods. Intervals in any time units or in sample indices (units="samples") are accepted; a plain list of (start, end) tuples is taken as ms. Intervals are end-exclusive.

  • target_fs (float) – Target sampling rate in Hz after downsampling for efficiency. Pupil dynamics are typically well-preserved at 50 Hz.

  • lowpass_freq (float) – Lowpass filter cutoff frequency in Hz. Removes high-frequency noise before downsampling. Should be below target_fs/2 (Nyquist).

  • lambda_smooth (float) – Smoothness regularization weight. Higher values produce smoother A0(t) estimates. Tune via cross-validation if needed.

  • knots_per_second (float) – Number of B-spline knots per second. Controls temporal resolution of A0(t) model. Typical values: 0.5-2.0.

  • initial_theta (Union[float, str, None]) –

    Initial guess for camera polar angle. If None, uses pi/2 (horizontal) as starting point.

    • Plain number: assumed to be radians (with warning)

    • String: e.g., “20 degrees”, “0.349 radians”

    • Quantity: e.g., 20 * u.deg

  • initial_phi (Union[float, str, None]) –

    Initial guess for camera azimuthal angle. If None, uses 0 (camera on x-axis) as starting point.

    • Plain number: assumed to be radians (with warning)

    • String: e.g., “-90 degrees”, “-1.57 radians”

    • Quantity: e.g., -90 * u.deg

  • verbose (bool) – Print optimization progress and fit statistics.

Return type:

ForeshorteningCalibration

Returns:

calibration (ForeshorteningCalibration) – Fitted calibration object containing camera geometry (theta, phi), spline coefficients for A0(t), and fit quality metrics.

Raises:

ValueError – If specified eye doesn’t exist or lacks gaze/pupil data, if the experimental setup is not set or lacks required information, if lowpass_freq is not below the Nyquist frequency or if fewer than 100 valid samples are available.

See also

ForeshorteningCalibration

Container for fitted parameters

correct_foreshortening

Simple foreshortening correction method

Notes

The model is:

A_measured(x,y,t) = A0(t) * cos(alpha(x,y; theta, phi))

where:

  • A0(t) = sum_k a_k * B_k(t) is modeled with B-splines

  • cos(alpha) is the foreshortening factor depending on viewing angle

  • theta, phi parameterize camera position in spherical coordinates

The optimization minimizes:

sum_i (A_i - A_pred_i)^2 + lambda_smooth * sum_k (a_{k+1} - a_k)^2

Examples

>>> import pypillometry as pp
>>> data = pp.EyeData.from_eyelink('recording.edf')
>>> 
>>> # Set experimental setup
>>> data.set_experimental_setup(
...     screen_resolution=(1920, 1080),
...     physical_screen_size=("52 cm", "29 cm"),
...     eye_to_screen_center="65 cm",
...     camera_offset=("0 cm", "-30 cm", "0 cm"),
... )
>>> 
>>> # Fit on calibration period (first 2 minutes)
>>> cal_intervals = pp.Intervals([(0, 120000)], units="ms")
>>> calib = data.pupil.fit_foreshortening(
...     eye='left',
...     intervals=cal_intervals
... )
>>> 
>>> # Or provide r and d explicitly
>>> calib = data.pupil.fit_foreshortening(
...     eye='left',
...     r="60 cm",
...     d="70 cm",
...     intervals=cal_intervals
... )
>>> 
>>> # Inspect fit quality
>>> print(calib)
>>> print(f"R² = {calib.fit_metrics['r2']:.3f}")
>>> 
>>> # Compute correction for all data
>>> correction_factor = calib.get_correction_factor(
...     data['left', 'x'], 
...     data['left', 'y']
... )
>>> corrected_pupil = data['left', 'pupil'] * correction_factor
lowpass_filter(cutoff, order=2, eyes=[], policy='default', inplace=None)[source]#

Lowpass-filter pupil signal using a Butterworth-filter.

Only the usable samples under the mask policy are filtered (by default, interpolated samples are used; see mask()). The unusable samples (masked or NaN) are bridged linearly for the filter (see lowpass_filter_iterative()), so their stored values (e.g., zeros in blinks) do not enter the filter; they keep their values. The filtered samples replace the pupil data; the mask layers are kept.

Parameters:
  • cutoff (float) – lowpass-filter cutoff frequency in Hz

  • order (int) – filter order

  • eyes (list) – list of eyes to filter; if empty, all available eyes are filtered

  • policy (str, MaskPolicy or list of str) – mask policy that decides which samples are usable (see mask()); default "default"

  • inplace (bool or None) – if True, make change in-place and return the object if False, make and return copy before making changes if None, use the object-level setting

Return type:

EyeData

Returns:

the object with the filtered pupil data

merge_eyes(method='local_offset', lowpass_cutoff=0.2, max_iter=10, keep_eyes=True, policy='default', name='mean', inplace=None)[source]#

Merge pupil data from both eyes into a new eye (default "mean").

This method combines left and right pupil signals, handling missing data (masked values) intelligently. Where both eyes are valid, the signals are averaged to reduce noise. Where only one eye is valid, that eye’s data is used (with offset correction if applicable). Valid samples are the usable samples under the mask policy (see mask()), where recorded samples come first: an interpolated sample of one eye is used only if the other eye has no recorded sample there. The merged pupil gets the layers that are set in all eyes that went into a sample (e.g., "interpolated" where both were interpolated, "blink" where both eyes are masked by a blink); masked samples without such a common layer get the "user" layer.

Parameters:
  • method (str) –

    Method for merging the two eyes. Options:

    • ”local_offset” (default): Compute a slowly-varying offset between eyes using lowpass filtering. This handles drift over the session. The right eye is shifted to match the left eye’s level before merging.

    • ”offset”: Use a single global offset (mean difference) computed over the entire signal where both eyes are valid.

    • ”both_available”: Only use data where both eyes are valid (take mean). Timepoints where only one eye is valid remain masked.

    # TODO: Implement “signal_quality” method that weights eyes by their # local signal quality (SNR), giving more weight to the cleaner eye.

  • lowpass_cutoff (float) – Cutoff frequency (Hz) for the lowpass filter used in “local_offset” method. Lower values produce a more stable offset that only tracks very slow drift. Default is 0.2 Hz (~5 second time constant).

  • max_iter (int) – Maximum iterations for iterative lowpass filtering in “local_offset” method. Default is 10.

  • keep_eyes (bool) – If True (default), keep the original left/right pupil data. If False, remove the original left/right pupil data after merging (gaze data are kept).

  • policy (str, MaskPolicy or list of str) – mask policy that decides which samples are valid (see mask()); default "default"

  • name (str) – name of the new eye (default "mean", as in merge_eyes()); the merged pupil is stored as "<name>_pupil"

  • inplace (bool | None) – If True, modify in-place. If False, return a copy. If None, use the object’s default setting.

Return type:

EyeData

Returns:

The object with merged pupil data stored as “mean_pupil”.

Raises:

ValueError – If only one eye is available (nothing to merge). If an unknown method is specified.

See also

merge_eyes

Generic merge method in EyeData (masks if all eyes invalid)

pypillometry.signal.pupil.pupil_eye_offset

Compute local offset between eyes

Notes

The merged signal is stored as “mean_pupil” (with the default name) and can be accessed via data[“mean”, “pupil”] or data[“mean_pupil”].

The mask for the merged signal reflects which timepoints have valid data: - For “local_offset” and “offset”: masked only where BOTH eyes are masked - For “both_available”: masked where EITHER eye is masked Masked samples still hold the merged stored values (e.g., interpolated blinks), computed as for valid samples (NaN only if both eyes are NaN).

The “local_offset” method is recommended for most use cases as it: 1. Uses all available data (doesn’t discard single-eye timepoints) 2. Handles session-long drift in the inter-ocular difference 3. Produces smooth transitions at boundaries between single/both-eye regions

Examples

>>> import pypillometry as pp
>>> d = pp.get_example_data("rlmw_002_short")
>>> # Default: local offset correction
>>> d1 = d.pupil.merge_eyes(inplace=False)
>>> merged = d1["mean", "pupil"]
>>> # Use global offset (simpler, assumes no drift)
>>> d2 = d.pupil.merge_eyes(method="offset", inplace=False)
>>> # Only use timepoints where both eyes are valid
>>> d3 = d.pupil.merge_eyes(method="both_available", inplace=False)
>>> # Remove the original pupil data after merging (gaze is kept)
>>> d4 = d.pupil.merge_eyes(keep_eyes=False, inplace=False)
>>> list(d4.data.keys())
['left_x', 'left_y', 'right_x', 'right_y', 'mean_pupil']
smooth_window(eyes=[], window='hanning', winsize=11, policy='default', inplace=None)[source]#

Apply smoothing of the signal using a moving window. See smooth_window().

Only the usable samples under the mask policy are smoothed; the unusable samples (masked or NaN) are bridged linearly for the smoothing and keep their stored values (as in lowpass_filter()). The mask layers are kept.

Parameters:
  • eyes (list) – list of eyes to smooth; if empty, all available eyes are smoothed

  • window (str) – (the type of window from ‘flat’, ‘hanning’, ‘hamming’, ‘bartlett’, ‘blackman’); flat window will produce a moving average smoothing.

  • winsize (float) – the length of the window in ms (or with units, e.g. "20 ms")

  • policy (str, MaskPolicy or list of str) – mask policy that decides which samples are usable (see mask()); default "default"

  • inplace (bool or None) – if True, make change in-place and return the object if False, make and return copy before making changes if None, use the object-level setting

Return type:

EyeData

Returns:

the object with the smoothed pupil data