import copy
import warnings
from collections import namedtuple
import numpy as np
__all__ = [
"resolve",
"resolve_1D",
"resolve_dict",
"visual_angle_from_length_ppd",
"visual_angles_from_lengths_ppd",
"visual_size_from_shape_ppd",
"length_from_visual_angle_ppd",
"lengths_from_visual_angles_ppd",
"shape_from_visual_size_ppd",
"ppd_from_shape_visual_size",
"ppd_from_length_visual_angle",
"compute_ppd",
"validate_shape",
"validate_ppd",
"validate_visual_size",
"valid_1D",
"valid_resolution",
"valid_dict",
]
Visual_size = namedtuple("Visual_size", "height width")
Shape = namedtuple("Shape", "height width")
Ppd = namedtuple("Ppd", "vertical horizontal")
class ResolutionError(ValueError):
pass
class TooManyUnknownsError(ValueError):
pass
[docs]
def resolve(shape=None, visual_size=None, ppd=None):
"""Resolves the full resolution, for 2 givens and 1 unknown
A resolution consists of a visual size in degrees, a shape in pixels,
and specification of the number of pixels per degree.
Since there is a strict geometric relation between these,
shape = visual_size * ppd,
if two are given, the third can be calculated using this function.
This function resolves the resolution in both dimensions.
Parameters
----------
shape : Sequence[Number, Number], Number, or None (default)
shape [height, width] in pixels
visual_size : Sequence[Number, Number], Number, or None (default)
visual size [height, width] in degrees
ppd : Sequence[Number, Number], Number, or None (default)
pixels per degree [vertical, horizontal]
Returns
-------
Shape NamedTuple, with two attributes:
.height: int, height in pixels
.width: int, width in pixels
See validate_shape
Visual_size NamedTuple, with two attributes:
.height: float, height in degrees visual angle
.width: float, width in degrees visual angle
See validate_visual_size
ppd NamedTuple, with two attributes:
.vertical: int, vertical pixels per degree (ppd)
.horizontal: int, horizontal pixels per degree (ppd)
see validate_ppd
"""
# Canonize inputs
ppd = validate_ppd(ppd)
visual_size = validate_visual_size(visual_size)
shape = validate_shape(shape)
# Vertical
height, visual_height, ppd_vertical = resolve_1D(
length=shape.height, visual_angle=visual_size.height, ppd=ppd.vertical
)
# Horizontal
width, visual_width, ppd_horizontal = resolve_1D(
length=shape.width, visual_angle=visual_size.width, ppd=ppd.horizontal
)
# Check & canonize outputs
shape = validate_shape((height, width))
ppd = validate_ppd((ppd_vertical, ppd_horizontal))
visual_size = validate_visual_size((visual_height, visual_width))
# Assert that resolved resolution is valid
valid_resolution(shape=shape, visual_size=visual_size, ppd=ppd)
return shape, visual_size, ppd
[docs]
def resolve_1D(length=None, visual_angle=None, ppd=None, round=True):
"""Resolves the full resolution, for 2 givens and 1 unknown
A resolution consists of a visual size in degrees, a shape in pixels,
and specification of the number of pixels per degree.
Since there is a strict geometric relation between these,
shape = visual_size * ppd,
if two are given, the third can be calculated using this function.
This function resolves the resolution in a single dimension.
Parameters
----------
length : Number, length in pixels, or None (default)
visual_angle : Number, length in degrees, or None (default)
ppd : Number, pixels per degree, or None (default)
Returns
-------
length : int, length in pixels
visual_angle : float, length in degrees
ppd : float, pixels per degree
"""
# How many unknowns passed in?
n_unknowns = (length is None) + (visual_angle is None) + (ppd is None)
# Triage based on number of unknowns
if n_unknowns > 1: # More than 1 unknown we cannot resolve
raise TooManyUnknownsError(
f"Too many unknowns to resolve resolution; {length},{visual_angle},{ppd}"
)
else: # 1 unknown, so need to resolve
# Which unknown?
if length is None:
if round:
visual_angle_old = np.round(copy.deepcopy(visual_angle), 10)
visual_angle = np.floor(np.round(visual_angle * ppd, 10)) / ppd
if visual_angle_old != visual_angle:
warnings.warn(
f"Rounding visual angle because of ppd; {visual_angle_old} ->"
f" {visual_angle}"
)
length = length_from_visual_angle_ppd(visual_angle=visual_angle, ppd=ppd, round=round)
elif visual_angle is None:
visual_angle = visual_angle_from_length_ppd(length=length, ppd=ppd)
elif ppd is None:
ppd = ppd_from_length_visual_angle(length=length, visual_angle=visual_angle)
return length, visual_angle, ppd
[docs]
def resolve_dict(dct):
"""Resolves the full resolution ("shape", "ppd", "visual_size"), for 2
givens and 1 unknown in the input dictionary
A resolution consists of a visual size in degrees, a shape in pixels,
and specification of the number of pixels per degree.
Since there is a strict geometric relation between these,
shape = visual_size * ppd,
if two are given, the third can be calculated using this function.
This function resolves the resolution in both dimensions.
Parameters
----------
dct : dict
dictionary with at least two out the three keys: "shape", "ppd", "visual_size"
Returns
-------
Resolved dict
See also
---------
stimupy.utils.resolution
"""
# Resolve
ppd = dct["ppd"] if "ppd" in dct.keys() else None
shape = dct["shape"] if "shape" in dct.keys() else None
visual_size = dct["visual_size"] if "visual_size" in dct.keys() else None
shape, visual_size, ppd = resolve(shape=shape, visual_size=visual_size, ppd=ppd)
# Update dict
dct["shape"] = shape
dct["visual_size"] = visual_size
dct["ppd"] = ppd
return
def visual_size_to_axes(visual_size, shape, origin="mean"):
"""Generate axes from visual size, shape and origin
Parameters
----------
shape : Sequence[Number, Number], Number, or None (default)
shape [height, width] in pixels
visual_size : Sequence[Number, Number], Number, or None (default)
visual size [height, width] in degrees
origin : "corner", "mean" or "center"
if "corner": set origin to upper left corner
if "mean": set origin to hypothetical image center (default)
if "center": set origin to real center (closest existing value to mean)
Returns
-------
(x, y):
x and y axes, linearly spacing visual size in intervals defined by shape,
and with the origin set in the right place.
"""
# Validate params
visual_size = validate_visual_size(visual_size=visual_size)
shape = validate_shape(shape)
# Set origin
if origin == "corner":
x = np.linspace(0, visual_size.width, shape.width)
y = np.linspace(0, visual_size.height, shape.height)
elif origin == "mean":
vrange = (visual_size.height / 2, visual_size.width / 2)
x = np.linspace(-vrange[1], vrange[1], shape.width)
y = np.linspace(-vrange[0], vrange[0], shape.height)
elif origin == "center":
vrange = (visual_size.height / 2, visual_size.width / 2)
x = np.linspace(-vrange[1], vrange[1], shape.width, endpoint=False)
y = np.linspace(-vrange[0], vrange[0], shape.height, endpoint=False)
else:
raise ValueError('origin can only be be "corner", "mean" or "center"')
return x, y
#############################
# Resolve components #
#############################
[docs]
def visual_angle_from_length_ppd(length, ppd):
"""Calculate visual angle (degrees) from length (pixels) and pixels-per-degree
Parameters
----------
length : int or None
length in pixels
ppd : int or None
pixels per degree
Returns
-------
Length (pixels) translated to visual angle (degrees)
"""
if length is not None and ppd is not None:
visual_angle = length / ppd
else:
visual_angle = None
return visual_angle
[docs]
def visual_angles_from_lengths_ppd(lengths, ppd):
"""Calculate visual sizes (degrees) from given shapes (pixels) and pixels-per-degree
Parameters
----------
lengths : Sequence[int, int, ...] or None
list of lengths
ppd : int or None
pixels per degree
Returns
-------
List with lengths (pixels) translated to visual angles (degrees)
"""
if isinstance(lengths, (int, float)):
lengths = [
lengths,
]
if lengths is not None and ppd is not None:
visual_angles = []
for length in lengths:
visual_angles.append(visual_angle_from_length_ppd(length, ppd))
else:
visual_angles = None
if len(visual_angles) == 1:
visual_angles = visual_angles[0]
return visual_angles
[docs]
def visual_size_from_shape_ppd(shape, ppd):
"""Calculate visual size (degrees) from given shape (pixels) and pixels-per-degree
Parameters
----------
shape : Sequence[int, int]; or int, or None
each element has to be of type that can be cast to int, or None.
See validate_shape
ppd : Sequence[int, int]; or int, or None
each element has to be of type that can be cast to int, or None.
See validate_ppd
Returns
-------
Visual_size NamedTuple, with two attributes:
.height: float, height in degrees visual angle
.width: float, width in degrees visual angle
See validate_visual_size
"""
# Canonize inputs
shape = validate_shape(shape)
ppd = validate_ppd(ppd)
# Calculate width and height in pixels
width = visual_angle_from_length_ppd(shape.width, ppd.horizontal)
height = visual_angle_from_length_ppd(shape.height, ppd.vertical)
# Construct Visual size NamedTuple:
visual_size = Visual_size(width=width, height=height)
return visual_size
[docs]
def length_from_visual_angle_ppd(visual_angle, ppd, round=True):
"""Calculate length (pixels) from visual angle (degrees) and pixels-per-degree
Parameters
----------
visual_angle : float or None
visual angle in degrees
ppd : int or None
pixels per degree
round : bool
if True, round output length to full pixels
Returns
-------
visual angle (degrees) translated to length (pixels)
"""
if visual_angle is not None and ppd is not None:
fpix = np.round(visual_angle * ppd, 10)
if round:
pix = int(fpix)
if fpix > 0 and fpix % pix:
warnings.warn(f"Rounding shape; {visual_angle} * {ppd} = {fpix} -> {pix}")
else:
pix = float(fpix)
else:
pix = None
return pix
[docs]
def lengths_from_visual_angles_ppd(visual_angles, ppd, round=True):
"""Calculate lengths (pixels) from visual angles (degrees) and pixels-per-degree
Parameters
----------
visual_angles : Sequence[float, float, ...] or None
list of visual angles
ppd : int or None
pixels per degree
round : bool
if True, round output length to full pixels
Returns
-------
List with visual angles (degrees) translated to lengths (pixels)
"""
if isinstance(visual_angles, (int, float, np.int64)):
visual_angles = [
visual_angles,
]
if isinstance(ppd, (list, tuple)):
raise ValueError("ppd should be a single number")
if visual_angles is not None and ppd is not None:
lengths = []
for angle in visual_angles:
lengths.append(length_from_visual_angle_ppd(angle, ppd, round=round))
else:
lengths = None
if len(lengths) == 1:
lengths = lengths[0]
return lengths
[docs]
def shape_from_visual_size_ppd(visual_size, ppd):
"""Calculate shape (pixels) from given visual size (degrees) and pixels-per-degree
Parameters
----------
visual_size : Sequence[Number, Number]; or Number; or None
each element has to be of type that can be cast to float, or None.
ppd : Sequence[int, int]; or int, or None
each element has to be of type that can be cast to int, or None.
See validate_ppd
Returns
-------
Shape NamedTuple, with two attributes:
.height: int, height in pixels
.width: int, width in pixels
See validate_shape
"""
# Canonize inputs
visual_size = validate_visual_size(visual_size)
ppd = validate_ppd(ppd)
# Calculate width and height in pixels
width = length_from_visual_angle_ppd(visual_size.width, ppd.horizontal)
height = length_from_visual_angle_ppd(visual_size.height, ppd.vertical)
# Construct Shape NamedTuple:
shape = Shape(width=width, height=height)
return shape
[docs]
def ppd_from_shape_visual_size(shape, visual_size):
"""Calculate resolution (ppd) from given shape (pixels) and visual size (degrees)
Parameters
----------
shape : Sequence[int, int]; or int, or None
each element has to be of type that can be cast to int, or None.
See validate_shape
visual_size : Sequence[Number, Number]; or Number; or None
each element has to be of type that can be cast to float, or None.
See validate_visual_size
Returns
-------
ppd NamedTuple, with two attributes:
.vertical: int, vertical pixels per degree (ppd)
.horizontal: int, horizontal pixels per degree (ppd)
see validate_ppd
"""
# Canonize inputs
shape = validate_shape(shape)
visual_size = validate_visual_size(visual_size)
# Calculate horizontal and vertical ppds
horizontal = ppd_from_length_visual_angle(shape.width, visual_size.width)
vertical = ppd_from_length_visual_angle(shape.height, visual_size.height)
# Construct Ppd NamedTuple
ppd = Ppd(horizontal=horizontal, vertical=vertical)
return ppd
[docs]
def ppd_from_length_visual_angle(length, visual_angle):
"""Calculate pixels-per-degree from length (pixels) and visual angle (degrees)
Parameters
----------
length : int or None
length in pixels
visual_angle : float or None
visual angle in degrees
Returns
-------
visual angle (degrees) translated to length (pixels)
"""
if visual_angle is not None and length is not None:
ppd = length / visual_angle
else:
ppd = None
return ppd
[docs]
def compute_ppd(screen_size, resolution, distance):
"""Compute the pixels per degree in a presentation setup
i.e., the number of pixels in the central one degree of visual angle
Parameters
----------
screen_size : (float, float)
physical size, in whatever units you prefer, of the presentation screen
resolution : (float, float)
screen resolution, in pixels,
in the same direction that screen size was measured in
distance : float
physical distance between the observer and the screen, in the same unit as screen_size
Returns
-------
float
ppd, the number of pixels in one degree of visual angle
"""
ppmm = resolution / screen_size
mmpd = 2 * np.tan(np.radians(0.5)) * distance
return ppmm * mmpd
#############################
# Validate components #
#############################
[docs]
def validate_shape(shape):
"""Put specification of shape (in pixels) in canonical form, if possible
Parameters
----------
shape : Sequence of length 1 or 2; or None
if 2 elements: interpret as (height, width)
if 1 element: use as both height and width
if None: return (None, None)
each element has to be of type that can be cast to int, or None.
Returns
-------
Shape NamedTuple, with two attributes:
.height: int, height in pixels
.width: int, width in pixels
Raises
------
ValueError
if input does not have at least 1 element
TypeError
if input is not a Sequence(int, int) and cannot be cast to one
ValueError
if input has more than 2 elements
"""
# Check if string:
if isinstance(shape, str):
shape = float(shape)
# Check if sequence
try:
if len(shape) < 1:
# Empty sequence
raise TypeError(f"shape must be of at least length 1: {shape}")
except TypeError: # not a sequence; make it one
shape = (shape, shape)
# Check if length == 2
if len(shape) == 1:
# If Sequence of len()=1 is passed in, use as both height and width
shape = (shape[0], shape[0])
elif len(shape) > 2:
# If Sequence of len()>2 is passed in: error
raise TypeError(f"shape must be of length 1 or 2, not greater: {shape}")
# Unpack
width = shape[1]
height = shape[0]
# TODO: check if whole integer?
# Convert to int
if width is not None:
width = int(width)
if height is not None:
height = int(height)
# Check non-negative
if (width is not None and width <= 0) or (height is not None and height <= 0):
raise ValueError(f"shape has to be positive; {width, height}")
# Initiate namedtuple:
return Shape(height=height, width=width)
[docs]
def validate_ppd(ppd):
"""Put specification of ppd in canonical form, if possible
Parameters
----------
ppd : Sequence of length 1 or 2; or None
if 2 elements: interpret as (vertical, horizontal)
if 1 element: use as both vertical and horizontal
if None: return (None, None)
each element has to be of type that can be cast to int, or None.
Returns
-------
ppd NamedTuple, with two attributes:
.vertical: int, vertical pixels per degree (ppd)
.horizontal: int, horizontal pixels per degree (ppd)
Raises
------
ValueError
if input does not have at least 1 element
TypeError
if input is not a Sequence(int, int) and cannot be cast to one
ValueError
if input has more than 2 elements
"""
# Check if string:
if isinstance(ppd, str):
ppd = float(ppd)
# Check if sequence
try:
if len(ppd) < 1:
# Empty sequence
raise TypeError(f"ppd must be of at least length 1: {ppd}")
except TypeError: # not a sequence; make it one
ppd = (ppd, ppd)
# Check if length == 2
if len(ppd) == 1:
# If Sequence of len()=1 is passed in, use as both height and width
ppd = (ppd[0], ppd[0])
elif len(ppd) > 2:
# If Sequence of len()>2 is passed in: error
raise TypeError(f"ppd must be of length 1 or 2, not greater: {ppd}")
# Unpack
horizontal = ppd[1]
vertical = ppd[0]
# Convert to float
if horizontal is not None:
horizontal = float(horizontal)
if vertical is not None:
vertical = float(vertical)
# Check non-negative
if (horizontal is not None and horizontal <= 0) or (vertical is not None and vertical <= 0):
raise ValueError(f"ppd has to be positive; {horizontal, vertical}")
# Initiate namedtuple:
return Ppd(horizontal=horizontal, vertical=vertical)
[docs]
def validate_visual_size(visual_size):
"""Put specification of visual size in canonical form, if possible
Parameters
----------
visual_size : Sequence of length 1 or 2; or None
if 2 elements: interpret as (height, width)
if 1 element: use as both height and width
if None: return (None, None)
each element has to be of type that can be cast to float, or None.
Returns
-------
Visual_size NamedTuple, with two attributes:
.height: float, height in degrees visual angle
.width: float, width in degrees visual angle
Raises
------
ValueError
if input does not have at least 1 element
TypeError
if input is not a Sequence(float, float) and cannot be cast to one
ValueError
if input has more than 2 elements
"""
# Check if string:
if isinstance(visual_size, str):
visual_size = float(visual_size)
# Check if sequence
try:
if len(visual_size) < 1:
# Empty sequence
raise TypeError(f"visual_size must be of at least length 1: {visual_size}")
except TypeError: # not a sequence; make it one
visual_size = (visual_size, visual_size)
# Check if length == 2
if len(visual_size) == 1:
# If Sequence of len()=1 is passed in, use as both height and width
visual_size = (visual_size[0], visual_size[0])
elif len(visual_size) > 2:
# If Sequence of len()>2 is passed in: error
raise TypeError(f"visual_size must be of length 1 or 2, not greater: {visual_size}")
# Unpack
width = visual_size[1]
height = visual_size[0]
# Convert to float
if width is not None:
width = float(width)
if height is not None:
height = float(height)
# Check non-negative
if (width is not None and width < 0) or (height is not None and height < 0):
raise ValueError(f"visual_size has to be nonnegative; {width, height}")
# Initiate namedtuple:
return Visual_size(height=height, width=width)
[docs]
def valid_1D(length, visual_angle, ppd):
"""Asserts that the combined specification of resolution is geometrically valid.
Asserts the combined specification of shape (in pixels), visual_size (deg) and ppd.
If this makes sense, i.e. (roughly), int(visual_size * ppd) == shape,
this function passes without output.
If the specification does not make sense, raises a ResolutionError.
Note that the resolution specification has to be fully resolved,
i.e., none of the parameters can be None
Parameters
----------
length : int, length in pixels
visual_angle : float, size in degrees
ppd : int, resolution in pixels-per-degree
Raises
------
ResolutionError
if resolution specification is invalid,
i.e. (roughly), if int(visual_angle * ppd) != length
"""
# Check by calculating one component
calculated = length_from_visual_angle_ppd(visual_angle=visual_angle, ppd=ppd)
if calculated != length:
raise ResolutionError(f"Invalid resolution; {visual_angle},{length},{ppd}")
[docs]
def valid_resolution(shape, visual_size, ppd):
"""Asserts that the combined specification of resolution is geometrically valid.
Asserts the combined specification of shape (in pixels), visual_size (deg) and ppd.
If this makes sense, i.e. (roughly), int(visual_size * ppd) == shape,
this function passes without output.
If the specification does not make sense, raises a ResolutionError.
Note that the resolution specification has to be fully resolved,
i.e., none of the parameters can be/contain None
Parameters
----------
shape : 2-tuple (height, width), or something that can be cast (see validate_shape)
visual_size : 2-tuple (height, width), or something that can be cast (see validate_visual_size)
ppd : 2-tuple (vertical, horizontal), or something that can be cast (see validate_ppd)
Raises
------
ResolutionError
if resolution specification is invalid,
i.e. (roughly), if int(visual_size * ppd) != shape
"""
# Canonize inputs
shape = validate_shape(shape)
ppd = validate_ppd(ppd)
visual_size = validate_visual_size(visual_size)
# Check by calculating one component
calculated = shape_from_visual_size_ppd(visual_size=visual_size, ppd=ppd)
if calculated != shape:
raise ResolutionError(f"Invalid resolution; {visual_size},{shape},{ppd}")
[docs]
def valid_dict(dct):
"""Asserts that the combined specification of resolution in dict is geometrically valid.
Asserts the combined specification of shape (in pixels), visual_size (deg) and ppd.
If this makes sense, i.e. (roughly), int(visual_size * ppd) == shape,
this function passes without output.
If the specification does not make sense, raises a ResolutionError.
Note that the resolution specification has to be fully resolved,
i.e., none of the parameters can be/contain None
Parameters
----------
dct : dict
dictionary with at least the keys "shape", "ppd", "visual_size"
Raises
------
ResolutionError
if resolution specification is invalid,
i.e. (roughly), if int(visual_size * ppd) != shape
"""
ppd = dct["ppd"] if "ppd" in dct.keys() else None
shape = dct["shape"] if "shape" in dct.keys() else None
visual_size = dct["visual_size"] if "visual_size" in dct.keys() else None
# Assert that resolution is valid
valid_resolution(shape=shape, visual_size=visual_size, ppd=ppd)