Source code for stimupy.components.lines

import copy
import warnings

import numpy as np
from PIL import Image, ImageDraw

from stimupy.components.shapes import ellipse as ellipse_shape
from stimupy.utils import resolution

__all__ = [
    "line",
    "dipole",
    "ellipse",
    "circle",
]


[docs]def line( visual_size=None, ppd=None, shape=None, line_position=None, line_length=None, line_width=0, rotation=0.0, intensity_line=1.0, intensity_background=0.0, origin="corner", ): """Draw a line Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees ppd : Sequence[Number, Number], Number, or None (default) pixels per degree [vertical, horizontal] shape : Sequence[Number, Number], Number, or None (default) shape [height, width] of image, in pixels line_position : Sequence[Number, Number], Number, or None (default) line position (y, x) given the chosen origin; if None (default), the lines will go through the image center line_length : Number length of the line, in degrees visual angle line_width : Number width of the line, in degrees visual angle; if line_width=0 (default), line will be one pixel wide rotation : float, optional rotation (in degrees), counterclockwise, by default 0.0 (horizontal) intensity_line : Number intensity value of the line (default: 1) intensity_background : Number intensity value of the background (default: 0) origin : "corner", "mean" or "center" if "corner": set origin to upper left corner (default) if "mean" or "center": set origin to center (closest existing value to mean) Returns ------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for each line (key: "line_mask"), and additional keys containing stimulus parameters """ if line_length is None: raise ValueError("line() missing argument 'line_length' which is not 'None'") # Resolve resolution shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd) alpha = [np.cos(np.deg2rad(rotation)), np.sin(np.deg2rad(rotation))] if isinstance(line_position, (float, int)) and line_position is not None: line_position = (line_position, line_position) if line_position is None: origin = "center" line_position = (-line_length * alpha[0] / 2, -line_length * alpha[1] / 2) if origin == "corner": position = (line_position[0] * ppd[0], line_position[1] * ppd[1]) elif origin == "center" or "mean": position = ( int(np.round(line_position[0] * ppd[0] + shape[0] / 2)), int(np.round(line_position[1] * ppd[1] + shape[1] / 2)), ) else: raise ValueError("origin must be corner, center or mean") line_width_old = copy.deepcopy(line_width) line_width = np.round(line_width * ppd[0]) / ppd[0] if line_width != line_width: warnings.warn(f"Rounding line_width; {line_width_old} -> {line_width}") if line_width == 0: warnings.warn("line_width == 0 -> using line_width of 1px") # Create Pillow Image object img = Image.new("RGB", (shape.width, shape.height)) # Calculate line coordinates coords = ( position[::-1], ( int(np.round(position[1] + line_length * alpha[1] * ppd[1])), int(np.round(position[0] + line_length * alpha[0] * ppd[0])), ), ) # Create line image ImageDraw.Draw(img).line(coords, width=int(line_width * ppd[0])) # Convert to numpy array, create mask and adapt intensities img = np.array(img)[:, :, 0] / 255 mask = copy.deepcopy(img) img = img * (intensity_line - intensity_background) + intensity_background stim = { "img": img, "line_mask": mask.astype(int), "visual_size": visual_size, "ppd": ppd, "shape": shape, "line_position": line_position, "line_length": line_length, "line_width": line_width, "rotation": rotation, "intensity_line": intensity_line, "intensity_background": intensity_background, "origin": origin, } return stim
[docs]def dipole( visual_size=None, ppd=None, shape=None, line_length=None, line_width=0, line_gap=None, rotation=0.0, intensity_lines=(0.0, 1.0), ): """Draw a two centered parallel lines Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees ppd : Sequence[Number, Number], Number, or None (default) pixels per degree [vertical, horizontal] shape : Sequence[Number, Number], Number, or None (default) shape [height, width] of image, in pixels line_length : Number length of the line, in degrees visual angle line_width : Number width of the line, in degrees visual angle; if line_width=0 (default), line will be one pixel wide line_gap : Number distance between line centers, in degrees visual angle rotation : float, optional rotation (in degrees), counterclockwise, by default 0.0 (horizontal) intensity_lines : (Number, Number) intensity value of the line (default: (0, 1)); background intensity is the mean of these two values Returns ------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for each line (key: "line_mask"), and additional keys containing stimulus parameters """ if line_length is None: raise ValueError("dipole() missing argument 'line_length' which is not 'None'") if line_gap is None: raise ValueError("dipole() missing argument 'line_gap' which is not 'None'") if line_gap == 0: raise ValueError("line_gap should be larger than 0") intensity_background = (intensity_lines[0] + intensity_lines[1]) / 2 alpha1 = [np.cos(np.deg2rad(rotation)), np.sin(np.deg2rad(rotation))] alpha2 = [np.cos(np.deg2rad(rotation + 90)), np.sin(np.deg2rad(rotation + 90))] line_position1 = ( -line_length * alpha1[0] / 2 + line_gap / 2 * alpha2[0], -line_length * alpha1[1] / 2 + line_gap / 2 * alpha2[1], ) line_position2 = ( -line_length * alpha1[0] / 2 - line_gap / 2 * alpha2[0], -line_length * alpha1[1] / 2 - line_gap / 2 * alpha2[1], ) stim1 = line( visual_size=visual_size, ppd=ppd, shape=shape, line_position=line_position1, line_length=line_length, line_width=line_width, rotation=rotation, intensity_line=intensity_lines[0], intensity_background=intensity_background, origin="center", ) stim2 = line( visual_size=visual_size, ppd=ppd, shape=shape, line_position=line_position2, line_length=line_length, line_width=line_width, rotation=rotation, intensity_line=intensity_lines[1] - intensity_background, intensity_background=0, origin="center", ) stim1["img"] = stim1["img"] + stim2["img"] stim1["line_mask"] = (stim1["line_mask"] + stim2["line_mask"] * 2).astype(int) stim1["line_gap"] = line_gap stim1["intensity_lines"] = intensity_lines del stim1["intensity_line"] if line_width == 0: line_width = 1 / np.unique(stim1["ppd"]) if line_width >= line_gap: raise ValueError("line_width should not be larger than line_gap") return stim1
[docs]def ellipse( visual_size=None, ppd=None, shape=None, radius=None, line_width=0, intensity_line=1.0, intensity_background=0.0, ): """Draw an ellipse Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees ppd : Sequence[Number, Number], Number, or None (default) pixels per degree [vertical, horizontal] shape : Sequence[Number, Number], Number, or None (default) shape [height, width] of image, in pixels radius : Sequence[Number, Number], Number or None (default) ellipse radius [ry, rx] in degrees visual angle line_width : Number width of the line, in degrees visual angle; if line_width=0 (default), line will be one pixel wide intensity_line : Number intensity value of the line (default: 1) intensity_background : Number intensity value of the background (default: 0) Returns ------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for each line (key: "line_mask"), and additional keys containing stimulus parameters """ if radius is None: raise ValueError("ellipse() missing argument 'radius' which is not 'None'") # Resolve resolution shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd) if line_width * ppd[0] == 0: line_width_ = 1 / ppd[0] else: line_width_ = line_width stim = ellipse_shape( radius=np.array(radius), intensity_ellipse=intensity_line, visual_size=visual_size, ppd=ppd, shape=shape, intensity_background=intensity_background, origin="mean", ) stim2 = ellipse_shape( radius=np.array(radius) - line_width_, visual_size=visual_size, ppd=ppd, shape=shape, origin="mean", ) stim["img"] = np.where(stim2["ellipse_mask"] == 1, intensity_background, stim["img"]) stim["line_mask"] = np.where(stim2["ellipse_mask"] == 1, 0, stim["ellipse_mask"]) stim["intensity_line"] = intensity_line stim["line_width"] = line_width del stim["ellipse_mask"], stim["intensity_ellipse"] return stim
[docs]def circle( visual_size=None, ppd=None, shape=None, radius=None, line_width=0, intensity_line=1.0, intensity_background=0.0, ): """Draw a circle given the input parameters Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees ppd : Sequence[Number, Number], Number, or None (default) pixels per degree [vertical, horizontal] shape : Sequence[Number, Number], Number, or None (default) shape [height, width] of image, in pixels radius : Number radius of circle in degrees visual angle line_width : Number width of the line, in degrees visual angle; if line_width=0 (default), line will be one pixel wide intensity_line : Number intensity value of the line (default: 1) intensity_background : Number intensity value of the background (default: 0) Returns ---------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for each line (key: "line_mask"), and additional keys containing stimulus parameters """ if radius is None: raise ValueError("circle() missing argument 'radius' which is not 'None'") if not isinstance(radius, (int, float)): raise ValueError("radius should be a single number") stim = ellipse( visual_size=visual_size, ppd=ppd, shape=shape, radius=radius, line_width=line_width, intensity_line=intensity_line, intensity_background=intensity_background, ) return stim
def overview(**kwargs): """Generate example stimuli from this module Returns ------- stims : dict dict with all stimuli containing individual stimulus dicts. """ default_params = { "visual_size": (10, 10), "ppd": 10, } default_params.update(kwargs) p = { "line_length": 2, "line_width": 0.01, "rotation": 30, } # fmt: off stimuli = { "lines_line": line(**default_params, **p, origin="center"), "lines_dipole": dipole(**default_params, **p, line_gap=1), "lines_circle": circle(**default_params, radius=3), "lines_ellipse": ellipse(**default_params, radius=(3, 4)), } # fmt: on return stimuli if __name__ == "__main__": from stimupy.utils import plot_stimuli stims = overview() plot_stimuli(stims, mask=False, save=None)