Source code for stimupy.components.shapes

import numpy as np

from stimupy.components import image_base
from stimupy.components.angulars import wedge
from stimupy.components.radials import annulus, disc, ring
from stimupy.utils import resolution

__all__ = [
    "rectangle",
    "triangle",
    "cross",
    "parallelogram",
    "ellipse",
    "circle",
    "wedge",
    "annulus",
    "disc",
    "ring",
]


[docs]def rectangle( visual_size=None, ppd=None, shape=None, rectangle_size=None, rectangle_position=None, intensity_rectangle=1.0, intensity_background=0.0, rotation=0.0, ): """Draw a rectangle Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees visual angle 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 rectangle_size : Number, Sequence[Number, Number] rectangle size [height, width], in degrees visual angle rectangle_position : Number, Sequence[Number, Number], or None (default) position of the rectangle, in degrees visual angle. If None, rectangle will be placed in center of image. intensity_rectangle : float, optional intensity value for rectangle, by default 1.0 intensity_background : float, optional intensity value of background, by default 0.0 rotation : float, optional rotation (in degrees), counterclockwise, by default 0.0 (horizontal) Returns ------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for the shape (key: "rectangle_mask"), and additional keys containing stimulus parameters """ if rectangle_size is None: raise ValueError("rectangle() missing argument 'rectangle_size' which is not 'None'") # Resolve resolutions and get distances base = image_base( visual_size=visual_size, ppd=ppd, shape=shape, rotation=rotation, origin="center", ) xx = base["horizontal"] yy = base["vertical"] theta = np.deg2rad(rotation) rectangle_size = resolution.validate_visual_size(visual_size=rectangle_size) # Determine center position rect_posy = (base["visual_size"].height / 2) - (rectangle_size.height / 2) rect_posx = (base["visual_size"].width / 2) - (rectangle_size.width / 2) center_pos = (rect_posy, rect_posx) if rectangle_position is None: # If no position is given, place rectangle centrally rectangle_position = center_pos # Positions should always be positive rectangle_position = np.array(rectangle_position).clip(min=0) center_pos = np.array(center_pos).clip(min=0) # Determine shift rect_pos = resolution.shape_from_visual_size_ppd(rectangle_position, base["ppd"]) center_pos = resolution.shape_from_visual_size_ppd(center_pos, base["ppd"]) rect_shift = (np.array(rect_pos) - np.array(center_pos)).astype(int) # Rotate coordinate systems x = np.round(np.cos(theta) * xx - np.sin(theta) * yy, 8) y = np.round(np.sin(theta) * xx + np.cos(theta) * yy, 8) # Rounding for more robust behavior: x = np.round(x * (base["ppd"][0] * 2)) / (base["ppd"][0] * 2) y = np.round(y * (base["ppd"][0] * 2)) / (base["ppd"][0] * 2) # Draw rectangle img1 = np.where(x < rectangle_size.width / 2, 1, 0) img2 = np.where(x >= -rectangle_size.width / 2, 1, 0) img3 = np.where(y < rectangle_size.height / 2, 1, 0) img4 = np.where(y >= -rectangle_size.height / 2, 1, 0) img = img1 * img2 * img3 * img4 # Shift rectangle img = np.roll(img, (rect_shift[0], rect_shift[1]), axis=(0, 1)) # Does the rectangle fit? x1 = rectangle_size[1] / 2 * np.cos(theta) x2 = rectangle_size[1] / 2 * np.sin(theta) y1 = rectangle_size[0] / 2 * np.cos(theta) y2 = rectangle_size[0] / 2 * np.sin(theta) cy = x2 + y1 + np.abs(rect_shift[0] / base["ppd"][0]) cy = np.floor(cy * base["ppd"][0]) / base["ppd"][0] cx = x1 + y2 + np.abs(rect_shift[1] / base["ppd"][1]) cx = np.floor(cx * base["ppd"][1]) / base["ppd"][1] if (cy > base["visual_size"][0] / 2) or (cx > base["visual_size"][1] / 2): raise ValueError("stimulus does not fully fit into requested size") return { "img": img * (intensity_rectangle - intensity_background) + intensity_background, "rectangle_mask": img.astype(int), "visual_size": base["visual_size"], "ppd": base["ppd"], "shape": base["shape"], "rectangle_size": rectangle_size, "rectangle_position": rectangle_position, "intensity_background": intensity_background, "intensity_rectangle": intensity_rectangle, "rotation": rotation, }
[docs]def triangle( visual_size=None, ppd=None, shape=None, triangle_size=None, intensity_triangle=1.0, intensity_background=0.0, include_corners=True, rotation=0.0, ): """Draw a triangle Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees visual angle 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 triangle_size : Number, Sequence[Number, Number] triangle size [height width], in degrees visual angle intensity_triangle : float, optional intensity value for triangle, by default 1.0 intensity_background : float, optional intensity value of background, by default 0.0 rotation : float, optional rotation (in degrees), counterclockwise, by default 0.0 Returns ------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for the shape (key: "triangle_mask"), and additional keys containing stimulus parameters """ if triangle_size is None: raise ValueError("triangle() missing argument 'triangle_size' which is not 'None'") # Resolve resolutions and get distances base = image_base( visual_size=visual_size, ppd=ppd, shape=shape, rotation=rotation, origin="center", ) xx = base["horizontal"] yy = base["vertical"] triangle_size = resolution.validate_visual_size(visual_size=triangle_size) angle_diagonal = np.arctan(triangle_size[1] / triangle_size[0]) angle_diagonal = np.rad2deg(angle_diagonal) theta = np.deg2rad(rotation + angle_diagonal) x = np.round(np.cos(theta) * xx - np.sin(theta) * yy, 8) # Split image in two parts following the diagonal if include_corners: img = np.where(x <= 0, 1, 0) else: fac = base["ppd"][0] / 2 x = np.round(x * fac) / fac img = np.where(x < 0, 1, 0) # Create rectangular mask rect = rectangle( visual_size=visual_size, ppd=ppd, shape=shape, rectangle_size=triangle_size, rotation=rotation, ) img = img * rect["rectangle_mask"] return { "img": img * (intensity_triangle - intensity_background) + intensity_background, "triangle_mask": img.astype(int), "visual_size": base["visual_size"], "ppd": base["ppd"], "shape": base["shape"], "triangle_size": triangle_size, "intensity_background": intensity_background, "intensity_triangle": intensity_triangle, "rotation": rotation, "include_corners": include_corners, }
[docs]def cross( visual_size=None, ppd=None, shape=None, cross_size=None, cross_thickness=None, cross_arm_ratios=(1.0, 1.0), intensity_cross=1.0, intensity_background=0.0, rotation=0.0, ): """Draw a cross Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees visual angle 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 cross_size : Number, Sequence[Number, Number] cross size [height, width], in degrees visual angle cross_thickness : Number, Sequence[Number, Number] thickness of cross in degrees visual angle cross_arm_ratios : float or (float, float) ratio used to create arms (up-down, left-right) intensity_cross: float, optional intensity value for cross, by default 1.0 intensity_background : float, optional intensity value of background, by default 0.0 rotation : float, optional rotation (in degrees), counterclockwise, by default 0.0 Returns ------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for the shape (key: "cross_mask"), and additional keys containing stimulus parameters """ if cross_size is None: raise ValueError("cross() missing argument 'cross_size' which is not 'None'") if cross_thickness is None: raise ValueError("cross() missing argument 'cross_thickness' which is not 'None'") # Resolve resolution shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd) cross_size = resolution.validate_visual_size(cross_size) cross_thickness = resolution.validate_visual_size(cross_thickness) if isinstance(cross_arm_ratios, (float, int)): cross_arm_ratios = (cross_arm_ratios, cross_arm_ratios) # Determine coordinate center cy = visual_size.height / 2 cx = visual_size.width / 2 theta = np.deg2rad(rotation) # Calculate cross placement based on ratios of cross legs updown = cross_size.height - cross_thickness[0] down = updown / (cross_arm_ratios[0] + 1) up = updown - down leftright = cross_size.width - cross_thickness[1] right = leftright / (cross_arm_ratios[1] + 1) left = leftright - right posy1 = cy - cross_size[0] / 2 + (down - up) * np.cos(theta) / 2 posx1 = cx - cross_thickness[0] / 2 + (down - up) * np.sin(theta) / 2 posy2 = cy - cross_thickness[1] / 2 + (right - left) * np.sin(-theta) / 2 posx2 = cx - cross_size[1] / 2 + (right - left) * np.cos(-theta) / 2 # Create cross as two rectangles rect1 = rectangle( visual_size=visual_size, ppd=ppd, rectangle_size=(cross_size[0], cross_thickness[0]), rectangle_position=(posy1, posx1), rotation=rotation, ) rect2 = rectangle( visual_size=visual_size, ppd=ppd, rectangle_size=(cross_thickness[1], cross_size[1]), rectangle_position=(posy2, posx2), rotation=rotation, ) img = rect1["img"] + rect2["img"] img[img > 1] = 1 return { "img": img * (intensity_cross - intensity_background) + intensity_background, "cross_mask": img.astype(int), "shape": shape, "visual_size": visual_size, "ppd": ppd, "cross_size": cross_size, "cross_arm_ratios": cross_arm_ratios, "cross_thickness": cross_thickness, "intensity_background": intensity_background, "intensity_cross": intensity_cross, "rotation": rotation, }
[docs]def parallelogram( visual_size=None, ppd=None, shape=None, parallelogram_size=None, intensity_parallelogram=1.0, intensity_background=0.0, rotation=0.0, ): """Draw a parallelogram Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees visual angle 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 parallelogram_size : [Number, Number, Number], [Number, Number], Number or None (default) parallelogram size [height, width, depth], in degrees visual angle intensity_parallelogram : float, optional intensity value for parallelogram, by default 1.0 intensity_background : float, optional intensity value of background, by default 0.0 rotation : float, optional rotation (in degrees), counterclockwise, by default 0.0 Returns ------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for the shape (key: "parallelogram_mask"), and additional keys containing stimulus parameters """ if parallelogram_size is None: raise ValueError( "parallelogram() missing argument 'parallelogram_size' which is not 'None'" ) if isinstance(parallelogram_size, (float, int)): parallelogram_size = (parallelogram_size, parallelogram_size, 0) if len(parallelogram_size) == 2: parallelogram_size = tuple( list(parallelogram_size) + [ 0, ] ) # Resolve resolutions and get distances base = image_base( visual_size=visual_size, ppd=ppd, shape=shape, rotation=rotation, origin="center", ) xx = base["horizontal"] yy = base["vertical"] # Create rectangule rectangle_size = (parallelogram_size[0], parallelogram_size[1] + np.abs(parallelogram_size[2])) rect = rectangle( visual_size=visual_size, ppd=ppd, shape=shape, rectangle_size=rectangle_size, rotation=rotation, ) img = rect["img"] if parallelogram_size[2] != 0: if parallelogram_size[2] > 0: triangle_size = (parallelogram_size[0], np.abs(parallelogram_size[2])) rot1 = rotation else: triangle_size = (np.abs(parallelogram_size[2]), parallelogram_size[0]) rot1 = rotation - 90 angle_diagonal = np.arctan(triangle_size[1] / triangle_size[0]) angle_diagonal = np.rad2deg(angle_diagonal) theta = np.deg2rad(rot1 + angle_diagonal) x = np.round(np.cos(theta) * xx - np.sin(theta) * yy, 8) # Shift diagonals so that resulting triangles cover corners of rectangle theta = np.deg2rad(rotation) pwidth = parallelogram_size[1] / 2 * base["ppd"][0] shift1 = int(np.round(pwidth) * np.sin(theta)) shift2 = int(np.floor(pwidth) * np.cos(theta)) # Split image in two parts following the diagonal tri1 = np.where(np.roll(x, (shift1, -shift2), axis=(0, 1)) < 0, 0, 1) tri1 = np.where(x < 0, tri1, 0) tri2 = np.where(np.roll(x, (-shift1, shift2), axis=(0, 1)) > 0, 0, 1) tri2 = np.where(x >= 0, tri2, 0) # Combine everything img = tri1 * img + tri2 * img return { "img": img * (intensity_parallelogram - intensity_background) + intensity_background, "parallelogram_mask": img.astype(int), "shape": base["shape"], "visual_size": base["visual_size"], "ppd": base["ppd"], "parallelogram_size": parallelogram_size, "intensity_background": intensity_background, "intensity_parallelogram": intensity_parallelogram, "rotation": rotation, }
[docs]def ellipse( visual_size=None, ppd=None, shape=None, radius=None, intensity_ellipse=1.0, intensity_background=0.0, rotation=0.0, origin="mean", restrict_size=True, ): """Draw an ellipse Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees visual angle 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 intensity_ellipse : float, optional intensity value for ellipse, by default 1.0 intensity_background : float, optional intensity value of background, by default 0.0 rotation : float, optional rotation (in degrees), counterclockwise, by default 0.0 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) restrict_size : Bool if False, allow ellipse to reach beyond image size (default: True) Returns ------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for the shape (key: "ellipse_mask"), and additional keys containing stimulus parameters """ if radius is None: raise ValueError("ellipse() missing argument 'radius' which is not 'None'") # Resolve resolutions and get distances radius = resolution.validate_visual_size(visual_size=radius) base = image_base( visual_size=visual_size, ppd=ppd, shape=shape, rotation=rotation, origin=origin, ) xx = base["horizontal"] yy = base["vertical"] # Rotate coordinate systems theta = np.deg2rad(rotation) x = np.round(np.cos(theta) * yy - np.sin(theta) * xx, 8) y = np.round(np.sin(theta) * yy + np.cos(theta) * xx, 8) # Draw ellipse arr = np.sqrt(x**2 + (y * radius[0] / radius[1]) ** 2) img = np.where(arr <= radius[0], 1, 0) # Does ellipse fit? x1 = radius[1] * np.cos(theta) x2 = radius[1] * np.sin(theta) y1 = radius[0] * np.cos(theta) y2 = radius[0] * np.sin(theta) cy = np.floor((x2 + y1) * base["ppd"][0]) / base["ppd"][0] cx = np.floor((x1 + y2) * base["ppd"][1]) / base["ppd"][1] if restrict_size and ((cy > base["visual_size"][0] / 2) or (cx > base["visual_size"][1] / 2)): raise ValueError("stimulus does not fully fit into requested size") return { "img": img * (intensity_ellipse - intensity_background) + intensity_background, "ellipse_mask": img.astype(int), "shape": base["shape"], "visual_size": base["visual_size"], "ppd": base["ppd"], "radius": radius, "intensity_background": intensity_background, "intensity_ellipse": intensity_ellipse, "rotation": rotation, }
[docs]def circle( visual_size=None, ppd=None, shape=None, radius=None, intensity_circle=1.0, intensity_background=0.0, origin="mean", restrict_size=True, ): """Draw an ellipse Parameters ---------- visual_size : Sequence[Number, Number], Number, or None (default) visual size [height, width] of image, in degrees visual angle 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 or None (default) circle radius in degrees visual angle intensity_circle : float, optional intensity value for circle, by default 1.0 intensity_background : float, optional intensity value of background, by default 0.0 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) restrict_size : Bool if False, allow circle to reach beyond image size (default: True) Returns ---------- dict[str, Any] dict with the stimulus (key: "img"), mask with integer index for the shape (key: "circle_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, intensity_ellipse=intensity_circle, intensity_background=intensity_background, rotation=0.0, origin=origin, restrict_size=restrict_size, ) stim["circle_mask"] = stim["ellipse_mask"] stim["intensity_circle"] = intensity_circle stim.pop("ellipse_mask", "intensity_ellipse") 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": 20, } default_params.update(kwargs) # fmt: off stimuli = { "shapes_rectangle": rectangle(**default_params, rectangle_size=(4, 2.5)), "shapes_triangle": triangle(**default_params, triangle_size=(4, 2.5)), "shapes_cross": cross(**default_params, cross_size=(4, 2.5), cross_thickness=1, cross_arm_ratios=(1, 1)), "shapes_parallelogram": parallelogram(**default_params, parallelogram_size=(5.2, 3.1, 0.9)), "shapes_ellipse": ellipse(**default_params, radius=(4, 3)), "shapes_circle": circle(**default_params, radius=3), "shapes_disc": disc(**default_params, radius=3), "shapes_ring": ring(**default_params, radii=(1, 2)), "shapes_annulus": annulus(**default_params, radii=(1, 2)), "shapes_wedge": wedge(**default_params, angle=30, radius=4), } # fmt: on return stimuli if __name__ == "__main__": from stimupy.utils import plot_stimuli stims = overview() plot_stimuli(stims, mask=False, save=None)