# -*- coding:utf-8 -*-
"""
Utility Functions Module
This module provides utility functions used across the pupeyes package, including:
- Coordinate system conversions between Eyelink and PsychoPy
- Point-in-polygon testing with parallel processing
- Signal filtering and data masking
- Geometric calculations for circular stimulus arrangements
and others.
"""
import math
import numpy as np
import cv2
import warnings
import pandas as pd
import scipy.signal as signal
[docs]
def lowpass_filter(data, sampling_freq, cutoff_freq=4, order=3):
"""
Apply a Butterworth lowpass filter to the input data.
Uses scipy.signal to create and apply a Butterworth filter that removes high frequency
components above the cutoff frequency while preserving lower frequencies.
Parameters
----------
data : array-like
Input signal to be filtered
sampling_freq : float
Sampling frequency of the input signal in Hz
cutoff_freq : float, optional (default=4)
Cutoff frequency of the filter in Hz. Frequencies above this will be attenuated.
order : int, optional (default=3)
Order of the Butterworth filter. Higher orders give sharper frequency cutoffs
but may introduce more ringing artifacts.
Returns
-------
numpy.ndarray
Filtered version of the input signal with same shape as input
Notes
-----
- Uses scipy.signal.butter() to design the filter coefficients
- Applies zero-phase filtering using scipy.signal.filtfilt()
- The filter is applied forward and backward to avoid phase shifts
"""
b, a = signal.butter(N=order, Wn=cutoff_freq, btype='low', analog=False, fs=sampling_freq)
filtered_data = signal.filtfilt(b, a, data)
return filtered_data
[docs]
def make_mask(data, trials_to_mask, invert=False):
"""
Create a boolean mask for filtering data based on specified trials.
Parameters
----------
data : pandas.DataFrame
The main dataset to create a mask for
trials_to_mask : pandas.DataFrame or dict
Trials to use for creating the mask. Can be a DataFrame or a dictionary that can
be converted to a DataFrame. Should have matching column names with `data`
invert : bool, optional (default=False)
If True, inverts the mask (changes True to False and vice versa)
Returns
-------
pandas.Series
Boolean mask series with same length as input data. True values indicate rows
to keep, False values indicate rows to filter out
Notes
-----
- If trials_to_mask is a dictionary, it will attempt to convert it to a DataFrame
- Warns if resulting mask is all True or all False
- Uses pandas merge with indicator to create the mask
Examples
--------
>>> # Create sample dataset
>>> data = pd.DataFrame({
... 'trial': [1, 2, 3, 4, 5],
... 'condition': ['A', 'B', 'A', 'B', 'C'],
... 'rt': [0.5, 0.6, 0.4, 0.7, 0.5]
... })
>>>
>>> # Mask trials with condition 'A' using dictionary
>>> to_mask = {'condition': 'A'}
>>> mask = make_mask(data, to_mask)
>>> data[mask] # Shows only trials with conditions B and C
trial condition rt
1 2 B 0.6
3 4 B 0.7
4 5 C 0.5
>>>
>>> # Mask multiple trials using DataFrame
>>> to_mask_df = pd.DataFrame({
... 'trial': [1, 3],
... 'condition': ['A', 'A']
... })
>>> mask = make_mask(data, to_mask_df)
>>> data[mask] # Same result as above
trial condition rt
1 2 B 0.6
3 4 B 0.7
4 5 C 0.5
>>>
>>> # Keep only the masked trials using invert=True
>>> mask = make_mask(data, to_mask_df, invert=True)
>>> data[mask] # Shows only trials with condition A
trial condition rt
0 1 A 0.5
2 3 A 0.4
"""
# check if the joining data is a dataframe
if not isinstance(trials_to_mask, pd.DataFrame):
# try to convert to dataframe
try:
dtype = type(trials_to_mask)
trials_to_mask = pd.DataFrame([trials_to_mask.values()], columns=trials_to_mask.keys())
except:
raise ValueError("Cannot convert trials_to_mask to DataFrame.")
# mask data
mask = ~data.merge(trials_to_mask, how='left', indicator=True)['_merge'].eq('both')
# check if there are any bad trials
if mask.all() or (not mask.any()):
warnings.warn("Mask contains all True or all False values.")
# if invert is True, invert the mask
if invert:
mask = ~mask
return mask
[docs]
def convert_coordinates(coord, screen_dims=None, direction='to_el', psychopy_units='pix', round_to=2):
"""
Convert coordinates between Eyelink and PsychoPy coordinate systems.
For Eyelink, the origin is at the top-left corner of the screen.
For PsychoPy, the origin is at the center of the screen.
For more information on the psychopy coordinate system, see:
https://psychopy.org/general/units.html
Parameters
----------
coord : array-like or str
The coordinates to convert. Can be:
- array-like: [x, y]
- string: 'x,y' or '[x,y]' or '(x,y)'
screen_dims : array-like, optional
Screen dimensions [width, height] in pixels. Default is [1600, 1200].
direction : {'to_el', 'to_psychopy'}, optional
Conversion direction:
- 'to_el': convert from PsychoPy to Eyelink coordinates
- 'to_psychopy': convert from Eyelink to PsychoPy coordinates
Default is 'to_el'.
psychopy_units : {'pix', 'norm', 'height'}, optional
PsychoPy units to convert from/to:
- 'pix': pixels from center
- 'norm': normalized units [-1, 1]
- 'height': units relative to screen height
Default is 'pix'.
round_to : int or None, optional
Number of decimal places to round coordinates to. Default is 2.
If None, no rounding is performed.
Returns
-------
numpy.ndarray
Converted [x, y] coordinates
Notes
-----
Coordinate system details:
- Eyelink: origin at top-left, positive x right, positive y down
- PsychoPy: origin at center, positive x right, positive y up
Examples
--------
>>> # Convert screen center from PsychoPy to Eyelink coordinates
>>> convert_coordinates([0, 0], screen_dims=[1600, 1200])
array([800., 600.]) # half width, half height in Eyelink coordinates
>>> # Convert back from Eyelink to PsychoPy coordinates
>>> convert_coordinates([800, 600], direction='to_psychopy')
array([0., 0.]) # back to center in PsychoPy coordinates
>>> # Convert normalized coordinates (range -1 to 1)
>>> convert_coordinates([0.5, 0.5], psychopy_units='norm')
array([1200., 300.]) # scaled by screen dimensions
>>> # Convert height units (relative to screen height)
>>> convert_coordinates([0.5, 0.5], screen_dims=[1600, 1200],
... psychopy_units='height')
array([1400., 0.]) # 50% of screen height = 600 pixels
>>> # Convert from string input
>>> convert_coordinates("100,100")
array([900., 500.]) # PsychoPy (100,100) to Eyelink coordinates
Raises
------
ValueError
If direction is not 'to_el' or 'to_psychopy'
If psychopy_units is not 'pix', 'norm', or 'height'
If string coordinates cannot be parsed
"""
# Set default screen dimensions once
if screen_dims is None:
screen_dims = np.array([1600, 1200])
else:
screen_dims = np.array(screen_dims)
# Pre-compute half dimensions
half_width = screen_dims[0]/2
half_height = screen_dims[1]/2
# Convert string coordinates to numpy array
if isinstance(coord, str):
try:
coord = np.fromstring(coord.strip('[]()'), sep=',')
except:
raise ValueError("Could not convert string coordinates to array. Format should be 'x,y' or '[x,y]' or '(x,y)'")
else:
coord = np.asarray(coord)
# Convert from PsychoPy units to pixels if needed
if psychopy_units == 'norm':
coord = coord * np.array([half_width, half_height])
elif psychopy_units == 'height':
coord = coord * screen_dims[1]
elif psychopy_units != 'pix':
raise ValueError("units must be 'pix', 'norm', or 'height'")
if direction == 'to_el':
# Convert from PsychoPy (center origin) to Eyelink (top-left origin)
converted = np.array([
coord[0] + half_width,
half_height - coord[1]
])
elif direction == 'to_psychopy':
# Convert from Eyelink (top-left origin) to PsychoPy (center origin)
converted = np.array([
coord[0] - half_width,
half_height - coord[1]
])
# Convert back to original units if needed
if psychopy_units == 'norm':
converted /= np.array([half_width, half_height])
elif psychopy_units == 'height':
converted /= screen_dims[1]
else:
raise ValueError("direction must be either 'to_el' or 'to_psychopy'")
if round_to is not None:
converted = np.round(converted, round_to)
return converted
[docs]
def get_isoeccentric_positions(n_items, radius, offset_deg=0, coordinate_system='psychopy', screen_dims=None, round_to=2):
"""
Get coordinates for items arranged in a circle around screen center.
Parameters
----------
n_items : int
Number of items to position in circle
radius : float
Distance from screen center to each item
offset_deg : float, optional
Rotation offset in degrees from rightmost position (counterclockwise).
Default is 0.
coordinate_system : {'psychopy', 'eyelink'}, optional
Output coordinate system:
- 'psychopy': origin at center, positive y up
- 'eyelink': origin at top-left, positive y down
Default is 'psychopy'.
screen_dims : list, optional
Screen dimensions [width, height] in pixels. Only used if
coordinate_system is 'eyelink'. Default is [1600, 1200].
round_to : int or None, optional
Number of decimal places to round coordinates to. Default is 2.
If None, no rounding is performed.
Returns
-------
list
List of (x,y) coordinate tuples for each item position, arranged
counterclockwise starting from the rightmost position.
Notes
-----
- Items are arranged counterclockwise at equal angular intervals
- First item is placed at the rightmost position (0 degrees) plus any offset
- Angular separation between items is 360°/n_items
Examples
--------
>>> # Get 4 positions in PsychoPy coordinates (origin at center)
>>> get_isoeccentric_positions(4, 100, round_to=0)
[(100, 0), (0, 100), (-100, 0), (0, -100)]
>>> # Get 4 positions with 45° offset
>>> get_isoeccentric_positions(4, 100, offset_deg=45, round_to=0)
[(71, 71), (-71, 71), (-71, -71), (71, -71)]
>>> # Get positions in Eyelink coordinates (origin at top-left)
>>> get_isoeccentric_positions(4, 100, coordinate_system='eyelink', round_to=0)
[(900, 600), (800, 500), (700, 600), (800, 700)]
"""
if screen_dims is None:
screen_dims = [1600, 1200]
# Get raw positions centered at origin
positions = xy_circle(n_items, radius, phi0=offset_deg)
if coordinate_system == 'eyelink':
# Convert to eyelink coordinates (origin at top-left)
positions = [(x + screen_dims[0]/2, screen_dims[1]/2 - y) for x,y in positions]
if round_to is not None:
positions = [(round(x, round_to), round(y, round_to)) for x,y in positions]
return positions
[docs]
def xy_circle(n, rho, phi0=0, pole=(0, 0)):
"""
Generate points arranged in a circle.
from https://osdoc.cogsci.nl/3.3/manual/python/common/
Parameters
----------
n : int
Number of points to generate
rho : float
Radius of the circle (distance from center)
phi0 : float, optional
Starting angle in degrees (counterclockwise from right). Default is 0.
pole : tuple, optional
Center point (x, y) coordinates. Default is (0, 0).
Returns
-------
list
List of (x, y) coordinate tuples for points arranged in a circle
Notes
-----
Points are arranged counterclockwise starting from phi0. The angular
separation between points is 360°/n.
Examples
--------
>>> # Generate 4 points in a circle of radius 100
>>> xy_circle(4, 100)
[(100, 0), (0, 100), (-100, 0), (0, -100)]
>>> # Generate 4 points with 45° offset
>>> xy_circle(4, 100, phi0=45)
[(70.71, 70.71), (-70.71, 70.71), (-70.71, -70.71), (70.71, -70.71)]
"""
try:
n = int(n)
if n < 0:
raise ValueError()
except (ValueError, TypeError):
raise ValueError('n should be a non-negative integer in xy_circle()')
try:
phi0 = float(phi0)
except (ValueError, TypeError):
raise TypeError('phi0 should be numeric in xy_circle()')
l = []
for i in range(n):
l.append(xy_from_polar(rho, phi0, pole=pole))
phi0 += 360./n
return l
[docs]
def xy_from_polar(rho, phi, pole=(0, 0)):
"""
Convert polar coordinates to Cartesian coordinates.
from https://osdoc.cogsci.nl/3.3/manual/python/common/
Parameters
----------
rho : float
Radial distance from origin (or pole)
phi : float
Angle in degrees (counterclockwise from right)
pole : tuple, optional
Origin point (x, y) coordinates. Default is (0, 0).
Returns
-------
tuple
(x, y) coordinates in Cartesian system
Notes
-----
The angle phi is measured counterclockwise from the positive x-axis,
following the mathematical convention.
Examples
--------
>>> # Convert 45° angle at distance 100
>>> xy_from_polar(100, 45)
(70.71, 70.71)
>>> # Convert with offset origin
>>> xy_from_polar(100, 0, pole=(50, 50))
(150, 50)
"""
try:
rho = float(rho)
except:
raise TypeError('rho should be numeric in xy_from_polar()')
try:
phi = float(phi)
except:
raise TypeError('phi should be numeric in xy_from_polar()')
phi = math.radians(phi)
ox, oy = parse_pole(pole)
x = rho * math.cos(phi) + ox
y = rho * math.sin(phi) + oy
return x, y
[docs]
def parse_pole(pole):
"""
Parse and validate pole (origin) coordinates.
from https://osdoc.cogsci.nl/3.3/manual/python/common/
Parameters
----------
pole : tuple or array-like
(x, y) coordinates for the pole/origin point
Returns
-------
tuple
Validated (x, y) coordinates as floats
Raises
------
ValueError
If pole is not a valid 2D coordinate pair
Examples
--------
>>> parse_pole((1, 2))
(1.0, 2.0)
>>> parse_pole([1.5, 2.5])
(1.5, 2.5)
"""
try:
ox = float(pole[0])
oy = float(pole[1])
assert(len(pole) == 2)
except:
raise ValueError('pole should be a tuple (or similar) of length '
'with two numeric values')
return ox, oy
[docs]
def angular_distance(line1, line2):
"""
Calculate the angle between two lines in degrees.
Parameters
----------
line1 : tuple
Tuple of two points ((x1,y1), (x2,y2)) defining the first line
line2 : tuple
Tuple of two points ((x1,y1), (x2,y2)) defining the second line
Returns
-------
float
Angle between the lines in degrees, always in range [0, 180]
Examples
--------
>>> # Perpendicular lines
>>> line1 = ((0,0), (1,0)) # horizontal line
>>> line2 = ((0,0), (0,1)) # vertical line
>>> angular_distance(line1, line2)
90.0
>>> # 45-degree angle
>>> line1 = ((0,0), (1,0))
>>> line2 = ((0,0), (1,1))
>>> angular_distance(line1, line2)
45.0
"""
# Calculate the direction vectors of both lines
direction_vector1 = np.array(line1[1]) - np.array(line1[0])
direction_vector2 = np.array(line2[1]) - np.array(line2[0])
# Calculate the dot product of the direction vectors
dot_product = np.dot(direction_vector1, direction_vector2)
# Calculate the magnitudes (norms) of the direction vectors
norm1 = np.linalg.norm(direction_vector1)
norm2 = np.linalg.norm(direction_vector2)
# Calculate the cosine of the angle between the lines
cosine_similarity = dot_product / (norm1 * norm2)
# Calculate the angle in radians using arccosine
angular_distance_radians = np.abs(np.arccos(cosine_similarity))
# Convert the angle from radians to degrees
angular_distance_degrees = np.degrees(angular_distance_radians)
# Ensure the result is within 0-180 degrees
if angular_distance_degrees > 180:
angular_distance_degrees = 360 - angular_distance_degrees
return angular_distance_degrees
[docs]
def gaussian_2d(img, fc):
"""
Apply a 2D Gaussian filter to an image.
Python adaptation of https://github.com/cvzoya/saliency/blob/master/code_forMetrics/antonioGaussian.m
Parameters
----------
img : numpy.ndarray
2D input image array
fc : float
Cut-off frequency (-6dB)
Returns
-------
numpy.ndarray
Filtered image with same shape as input
Notes
-----
Python adaptation of the Gaussian filtering method from the saliency metrics
toolbox [1]_. The filter is applied in the frequency domain using FFT.
References
----------
.. [1] Bylinskii, Z., Judd, T., Oliva, A., Torralba, A., & Durand, F. (2016).
"What do different evaluation metrics tell us about saliency models?"
arXiv preprint arXiv:1604.03605.
Examples
--------
>>> # Create sample image with noise
>>> img = np.random.randn(100, 100)
>>> # Apply Gaussian filter
>>> filtered = gaussian_2d(img, fc=10)
"""
sn, sm = img.shape
n = max(sn, sm)
n = n + np.mod(n,2)
n = int(2**np.ceil(np.log2(n)))
# frequencies
fx,fy = np.meshgrid(range(n),range(n))
fx = fx-n/2
fy = fy-n/2
# convert cut of frequency into gaussian width
s = fc/np.sqrt(np.log(2))
# compute transfer function of gaussian filter
gf = np.exp(-(fx**2+fy**2)/(s**2))
gf = np.fft.fftshift(gf)
# convolve (in Fourier domain) each color band:
BF = np.zeros((n,n))
BF[:,:] = np.real(np.fft.ifft2(np.fft.fft2(img[:,:], s=[n,n])*gf))
# crop output to have same size than the input
BF = BF[:sn,:sm]
return BF
[docs]
def mat2gray(img):
"""
Scale image values to grayscale range [0, 1].
Parameters
----------
img : numpy.ndarray
Input image array
Returns
-------
numpy.ndarray
Normalized image with values scaled to range [0, 1]
Examples
--------
>>> # Create sample image
>>> img = np.array([[0, 127, 255], [63, 191, 255]])
>>> normalized = mat2gray(img)
>>> normalized
array([[0. , 0.5, 1. ],
[0.25, 0.75, 1. ]])
"""
img = np.double(img)
out = np.zeros(img.shape, dtype=np.double)
normalized = cv2.normalize(img, out, 1.0, 0.0, cv2.NORM_MINMAX)
return normalized