Repository: computational-psychology/stimupy (language: python)

Issue:
fix(RHS2007): correct targets in `todorovic_benary_1_2()`, `3_4()`
Closes #90

`1_2_3_4()` was already correct.
Others now use that one as base


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 7a982c7f..67ac84ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,3 +65,7 @@ target/
 
 **/.ipynb_checkpoints/
 **/.ipynb_checkpoints/
+
+# Modelfest dataset for easy comparison / testing
+**/modelfest/
+
diff --git a/MANIFEST.in b/MANIFEST.in
index 5d55518a..392671a5 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,4 @@
 include stimupy/papers/murray2020.mat
-include stimupy/papers/carney1999_data.csv
-include stimupy/papers/carney1999_noise.tif
-include stimupy/papers/carney1999_natural_scene.tif
\ No newline at end of file
+include stimupy/papers/modelfest_data.csv
+include stimupy/papers/modelfest_noise.tif
+include stimupy/papers/modelfest_natural_scene.tif
diff --git a/stimupy/components/__init__.py b/stimupy/components/__init__.py
index 0d95f6a1..a7f86d3c 100644
--- a/stimupy/components/__init__.py
+++ b/stimupy/components/__init__.py
@@ -1,10 +1,11 @@
 import itertools
 import warnings
 from copy import deepcopy
-
 import numpy as np
 
 from stimupy.utils import int_factorize, resolution
+from stimupy.utils.contrast_conversions import adapt_intensity_range
+from stimupy.utils.utils import round_to_vals
 
 
 def image_base(visual_size=None, shape=None, ppd=None, rotation=0.0, origin="mean"):
@@ -33,6 +34,8 @@ def image_base(visual_size=None, shape=None, ppd=None, rotation=0.0, origin="mea
         "x", "y" : single axes
         "horizontal", "vertical" : numpy.ndarray of shape, with distance from origin,
         in deg. visual angle, at each pixel
+        "rotated" : numpy.ndarray of shape, with rotated distance from origin,
+        in deg. visual angle, at each pixel
         "radial" : numpyn.ndarray of shape, with radius from origin,
         in deg. visual angle, at each pixel
         "angular" : numpy.ndarray of shape, with angle relative to 3 o'clock,
@@ -420,6 +423,218 @@ def round_n_phases(n_phases, length, period="either"):
     return int(closest)
 
 
+def draw_sine_wave(
+    visual_size=None,
+    ppd=None,
+    shape=None,
+    frequency=None,
+    n_phases=None,
+    phase_width=None,
+    period="ignore",
+    rotation=None,
+    phase_shift=None,
+    intensities=None,
+    origin=None,
+    base_type=None,
+    round_phase_width=None,
+):
+    """Draw a sine-wave grating given a certain base_type
+
+    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
+    frequency : Number, or None (default)
+        spatial frequency of grating, in cycles per degree visual angle
+    n_phases : int, or None (default)
+        number of phases in the grating
+    phase_width : Number, or None (default)
+        width of a single phase, in degrees visual angle
+    period : "even", "odd", "either", "ignore" (default)
+        ensure whether the grating has "even" number of phases, "odd"
+        number of phases, either or whether not to round the number of
+        phases ("ignore")
+    rotation : float or None (default)
+        rotation of grating in degrees
+    phase_shift : float or None (default)
+        phase shift of grating in degrees
+    intensities : Sequence[float, float] or None (default)
+        min and max intensity of sine-wave
+    origin : "corner", "mean", "center" or None (default)
+        if "corner": set origin to upper left corner
+        if "mean": set origin to hypothetical image center
+        if "center": set origin to real center (closest existing value to mean)
+    base_type : str or None
+        if "horizontal", use distance from origin in x-direction,
+        if "vertical", use distance from origin in x-direction;
+        if "rotated", use combined and rotated distance from origin in x-y;
+        if "radial", use radial distance from origin,
+        if "angular", use angular distance from origin,
+        if "cityblock", use cityblock distance from origin
+    round_phase_width : Bool or None (default)
+        if True, round width of bars given resolution
+
+    Returns
+    ----------
+    dict[str, Any]
+        dict with the stimulus (key: "img"),
+        mask with integer index for each bar (key: "grating_mask"),
+        and additional keys containing stimulus parameters
+    """
+    if rotation is None:
+        raise ValueError("draw_sine_wave() missing argument 'rotation' which is not 'None'")
+    if phase_shift is None:
+        raise ValueError("draw_sine_wave() missing argument 'phase_shift' which is not 'None'")
+    if intensities is None:
+        raise ValueError("draw_sine_wave() missing argument 'intensities' which is not 'None'")
+    if origin is None:
+        raise ValueError("draw_sine_wave() missing argument 'origin' which is not 'None'")
+    if round_phase_width is None:
+        raise ValueError(
+            "draw_sine_wave() missing argument 'round_phase_width' which is not 'None'"
+        )
+    if period is None:
+        period = "ignore"
+
+    base_types = ["horizontal", "vertical", "rotated", "radial", "angular", "cityblock"]
+    if base_type not in base_types:
+        raise ValueError(f"base_type needs to be one of {base_types}")
+
+    lst = [visual_size, ppd, shape, frequency, n_phases, phase_width]
+    if len([x for x in lst if x is not None]) < 3:
+        raise ValueError(
+            "'grating()' needs 3 non-None arguments for resolving from 'visual_size', "
+            "'ppd', 'shape', 'frequency', 'n_phases', 'phase_width'"
+        )
+
+    # Try to resolve resolution
+    try:
+        shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
+    except ValueError:
+        ppd = resolution.validate_ppd(ppd)
+        shape = resolution.validate_shape(shape)
+        visual_size = resolution.validate_visual_size(visual_size)
+
+    alpha = [np.abs(np.cos(np.deg2rad(rotation))), np.abs(np.sin(np.deg2rad(rotation)))]
+
+    if shape.width is not None:
+        length = np.round(alpha[0] * shape.width + alpha[1] * shape.height)
+    else:
+        length = None
+
+    if visual_size.width is not None:
+        visual_angle = alpha[0] * visual_size.width + alpha[1] * visual_size.height
+    else:
+        visual_angle = None
+
+    if ppd.horizontal is not None:
+        ppd_1D = ppd.horizontal
+    else:
+        ppd_1D = None
+
+    if rotation % 90 != 0 and round_phase_width:
+        round_phase_width = False
+        warnings.warn("Rounding phase width is turned off for oblique gratings")
+
+    if rotation % 90 != 0 and period != "ignore":
+        period = "ignore"
+        warnings.warn("Period ignored for oblique gratings")
+
+    # Resolve params
+    params = resolve_grating_params(
+        length=length,
+        visual_angle=visual_angle,
+        n_phases=n_phases,
+        phase_width=phase_width,
+        ppd=ppd_1D,
+        frequency=frequency,
+        period=period,
+        round_phase_width=round_phase_width,
+    )
+    length = params["length"]
+    ppd_1D = params["ppd"]
+    visual_angle = params["visual_angle"]
+    frequency = params["frequency"]
+    phase_width = params["phase_width"]
+    n_phases = params["n_phases"]
+
+    # Determine size/shape of whole image
+    if None in shape:
+        shape = [length * alpha[1], length * alpha[0]]
+        if np.round(alpha[1], 5) == 0:
+            shape[0] = shape[1]
+        if np.round(alpha[0], 5) == 0:
+            shape[1] = shape[0]
+
+    if None in ppd:
+        ppd = (ppd_1D, ppd_1D)
+
+    if None in visual_size:
+        visual_size = resolution.visual_size_from_shape_ppd(shape=shape, ppd=ppd)
+
+    shape = resolution.validate_shape(shape)
+    visual_size = resolution.validate_visual_size(visual_size)
+    ppd = resolution.validate_ppd(ppd)
+
+    # Set up coordinates
+    base = image_base(
+        shape=shape, visual_size=visual_size, ppd=ppd, rotation=rotation, origin=origin
+    )
+    distances = base[base_type]
+    distances = np.round(distances, 6)
+
+    # Shift distances minimally to ensure proper behavior
+    if origin == "corner":
+        distances = adapt_intensity_range(distances, 1e-03, distances.max() - 1e-03)
+    else:
+        distances = adapt_intensity_range(
+            distances, distances.min() - 1e-05, distances.max() - 1e-05
+        )
+
+    # Draw image
+    img = np.sin(frequency * 2 * np.pi * distances + np.deg2rad(phase_shift))
+    img = adapt_intensity_range(img, intensities[0], intensities[1])
+
+    # Create mask
+    if origin == "corner" or base_type == "radial" or base_type == "cityblock":
+        vals = np.arange(
+            distances.min() + phase_width / 2, distances.max() + phase_width * 2, phase_width
+        )
+
+        if origin == "mean":
+            vals -= distances.min()
+    else:
+        dmin = distances.min()
+        dmax = distances.max() + phase_width * 2
+        vals1 = np.arange(0 + phase_width / 2, dmax, phase_width)
+        vals2 = -np.arange(-phase_width / 2, -dmin + phase_width, phase_width)
+        vals = np.unique(np.append(vals2[::-1], vals1))
+
+    phase_shift_ = (phase_shift % 360) / 180 * phase_width
+    mask = round_to_vals(
+        distances - distances.min(), np.round(vals - phase_shift_, 6) - distances.min()
+    )
+
+    for i, val in enumerate(np.unique(mask)):
+        mask = np.where(mask == val, i + 1, mask)
+
+    stim = {
+        "img": img,
+        "mask": mask.astype(int),
+        "visual_size": visual_size,
+        "ppd": ppd,
+        "shape": shape,
+        "frequency": frequency,
+        "n_phases": n_phases,
+        "phase_width": phase_width,
+    }
+    return stim
+
+
 from . import (
     angulars,
     checkerboards,
@@ -435,6 +650,15 @@ def round_n_phases(n_phases, length, period="either"):
 
 
 def create_overview():
+    """
+    Create dictionary with examples from all stimulus-components
+
+    Returns
+    -------
+    stims : dict
+        dict with all stimuli containing individual stimulus dicts.
+    """
+
     p = {
         "visual_size": 10,
         "ppd": 20,
@@ -462,16 +686,17 @@ def create_overview():
         "disc": circulars.disc(**p, radius=3),
         "ring": circulars.ring(**p, radii=(1, 3)),
         "annulus (=ring)": circulars.annulus(**p, radii=(1, 3)),
-        "circular_grating": circulars.grating(**p, frequency=1),
-        "circular_grating_v2": circulars.grating(**p, n_rings=8),
         "bessel": circulars.bessel(**p, frequency=1),
+        "circular_sine_wave": circulars.sine_wave(**p, frequency=0.5),
+        "circular_square_wave": circulars.square_wave(**p, frequency=0.5),
         # edges
         "step_edge": edges.step_edge(**p),
         "gaussian_edge": edges.gaussian_edge(**p, sigma=1.5),
         "cornsweet_edge": edges.cornsweet_edge(**p, ramp_width=3),
         # frames
-        "frames": frames.frames(**p, frame_radii=(1, 2, 3)),
-        "frames_grating": frames.grating(**p, n_frames=8),
+        "frames": frames.frames(**p, radii=(1, 2, 3)),
+        "frames_sine_wave": frames.sine_wave(**p, frequency=0.5),
+        "frames_square_wave": frames.square_wave(**p, frequency=0.5),
         # gaussians
         "gaussian": gaussians.gaussian(**p, sigma=(1, 2)),
         # gratings
@@ -505,10 +730,26 @@ def create_overview():
     return stims
 
 
-def overview(mask=False, save=None):
+def overview(mask=False, save=None, extent_key="shape"):
+    """
+    Plot overview with examples from all stimulus-components
+
+    Parameters
+    ----------
+    mask : bool or str, optional
+        If True, plot mask on top of stimulus image (default: False).
+        If string is provided, plot this key from stimulus dictionary as mask
+    save : None or str, optional
+        If None (default), do not save the plot.
+        If string is provided, save plot under this name.
+    extent_key : str, optional
+        Key to extent which will be used for plotting.
+        Default is "shape", using the image size in pixels as extent.
+
+    """
     from stimupy.utils import plot_stimuli
 
     stims = create_overview()
 
     # Plotting
-    plot_stimuli(stims, mask=mask, save=save)
+    plot_stimuli(stims, mask=mask, save=save, extent_key=extent_key)
diff --git a/stimupy/components/angulars.py b/stimupy/components/angulars.py
index 4ef4651b..2fa35bc8 100644
--- a/stimupy/components/angulars.py
+++ b/stimupy/components/angulars.py
@@ -119,7 +119,7 @@ def wedge(
         ppd=ppd,
         shape=shape,
         radii=[inner_radius, radius],
-        intensity_rings=intensity_wedge,
+        intensity_ring=intensity_wedge,
         intensity_background=intensity_background,
         origin=origin,
     )
@@ -409,7 +409,7 @@ def pinwheel(
     # Get disc
     disc = ring(
         radii=[inner_radius, radius],
-        intensity_rings=1.0,
+        intensity_ring=1.0,
         intensity_background=0.0,
         visual_size=visual_size,
         ppd=ppd,
@@ -435,6 +435,7 @@ def pinwheel(
 
     # Mask out everything but the disc
     stim["img"] = np.where(disc["ring_mask"], stim["img"], intensity_background)
-    stim["radii"] = disc["edges"]
+    stim["wedge_mask"] = np.where(disc["ring_mask"], stim["wedge_mask"], 0).astype(int)
+    stim["radius"] = radius
     stim["intensity_background"] = intensity_background
     return stim
diff --git a/stimupy/components/checkerboards.py b/stimupy/components/checkerboards.py
index a8bfbf17..b1039af4 100644
--- a/stimupy/components/checkerboards.py
+++ b/stimupy/components/checkerboards.py
@@ -1,5 +1,4 @@
 import warnings
-
 import numpy as np
 
 from stimupy.components.gratings import square_wave
@@ -8,8 +7,6 @@
     "checkerboard",
 ]
 
-# TODO: Fix bug that changing rotation, affect check size!
-
 
 def checkerboard(
     visual_size=None,
@@ -162,6 +159,5 @@ def checkerboard(
         "period": period,
         "rotation": rotation,
         "intensity_checks": intensity_checks,
-        "edges": (sw1["edges"], sw2["edges"]),
     }
     return stim
diff --git a/stimupy/components/circulars.py b/stimupy/components/circulars.py
index 352d6da5..852d97c3 100644
--- a/stimupy/components/circulars.py
+++ b/stimupy/components/circulars.py
@@ -1,13 +1,21 @@
-import copy
 import itertools
 
 import numpy as np
-import scipy.special as sp
 
-from stimupy.components import image_base, mask_elements, resolve_grating_params
+from stimupy.components import image_base, mask_elements, resolve_grating_params, draw_sine_wave
 from stimupy.utils import resolution
+from stimupy.utils.utils import apply_bessel, round_to_vals
 
-__all__ = ["disc_and_rings", "disc", "ring", "annulus", "grating", "bessel"]
+
+__all__ = [
+    "disc_and_rings",
+    "disc",
+    "ring",
+    "annulus",
+    "bessel",
+    "sine_wave",
+    "square_wave",
+]
 
 
 def resolve_circular_params(
@@ -207,6 +215,9 @@ def disc_and_rings(
         visual_size = [x for x in visual_size if x is not None]
         visual_size = resolution.validate_visual_size(visual_size)
 
+    if np.diff(radii).min() < 0:
+        raise ValueError("radii need to monotonically increase")
+
     # Get masks for rings
     params = mask_rings(radii=radii, shape=shape, visual_size=visual_size, ppd=ppd, origin=origin)
     shape = params["shape"]
@@ -230,7 +241,7 @@ def disc(
     ppd=None,
     shape=None,
     radius=None,
-    intensity_discs=1.0,
+    intensity_disc=1.0,
     intensity_background=0.5,
     origin="mean",
 ):
@@ -248,7 +259,7 @@ def disc(
         shape [height, width] of image, in pixels
     radius : Number
         outer radius of disc in degree visual angle
-    intensity_discs : Number
+    intensity_disc : Number
         intensity value of disc, by default 1.0
     intensity_background : float (optional)
         intensity value of background, by default 0.5
@@ -268,7 +279,7 @@ def disc(
         raise ValueError("disc() missing argument 'radius' which is not 'None'")
 
     radius = np.array(radius)
-    intensity = np.array(intensity_discs)
+    intensity = np.array(intensity_disc)
 
     if radius.size != 1:
         raise ValueError("Can only pass 1 radius")
@@ -277,14 +288,13 @@ def disc(
 
     stim = ring(
         radii=np.insert(radius, 0, 0),
-        intensity_rings=intensity,
+        intensity_ring=intensity,
         visual_size=visual_size,
         ppd=ppd,
         intensity_background=intensity_background,
         shape=shape,
         origin=origin,
     )
-    stim["ring_mask"] = (stim["ring_mask"] / 2).astype(int)
     return stim
 
 
@@ -293,7 +303,7 @@ def ring(
     ppd=None,
     shape=None,
     radii=None,
-    intensity_rings=1.0,
+    intensity_ring=1.0,
     intensity_background=0.5,
     origin="mean",
 ):
@@ -309,7 +319,7 @@ def ring(
         shape [height, width] of image, in pixels
     radii : Sequence[Number, Number]
         inner and outer radius of ring in degree visual angle
-    intensity_rings : Number
+    intensity_ring : Number
         intensity value of ring, by default 1.0
     intensity_background : float (optional)
         intensity value of background, by default 0.5
@@ -336,40 +346,111 @@ def ring(
         raise ValueError("ring() missing argument 'radii' which is not 'None'")
     if len(radii) != 2:
         raise ValueError("Can only pass exactly 2 radii")
-    if len([intensity_rings]) != 1:
+    if len([intensity_ring]) != 1:
         raise ValueError("Can only pass 1 intensity")
 
     if radii[1] is None:
         shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
         radii[1] = np.max(visual_size) / 2
 
+    if radii[1] < radii[0]:
+        raise ValueError("first radius needs to be smaller than second radius")
+
     stim = disc_and_rings(
         radii=radii,
-        intensity_rings=[intensity_background, intensity_rings],
+        intensity_rings=[intensity_background, intensity_ring],
         shape=shape,
         visual_size=visual_size,
         ppd=ppd,
         intensity_background=intensity_background,
         origin=origin,
     )
+    stim["ring_mask"] = np.where(stim["ring_mask"] == 2, 1, 0)
     return stim
 
 
 annulus = ring
 
 
-def grating(
+def bessel(
+    visual_size=None,
+    ppd=None,
+    shape=None,
+    frequency=None,
+    order=0,
+    intensity_rings=(1.0, 0.0),
+    origin="mean",
+):
+    """Draw a Bessel stimulus, i.e. draw circular rings following an nth order
+    Bessel function of a given frequency.
+
+    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
+    frequency : Number, or None (default)
+        spatial frequency of circular grating, in cycles per degree
+    order : int
+        n-th order Bessel function
+    intensity_rings : (float, float)
+        intensity values of rings, first value indicating center intensity
+    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
+    ----------
+    dict[str, Any]
+        dict with the stimulus (key: "img"),
+        empty mask (key: "ring_mask"),
+        and additional keys containing stimulus parameters
+    """
+
+    base = image_base(
+        visual_size=visual_size,
+        ppd=ppd,
+        shape=shape,
+        rotation=0,
+        origin=origin,
+    )
+
+    arr = base["radial"] * frequency * 2 * np.pi
+    img = apply_bessel(arr, order=order)
+    img = (img - img.min()) / (img.max() - img.min())
+    img = img * (intensity_rings[0] - intensity_rings[1]) + intensity_rings[1]
+
+    stim = {
+        "img": img,
+        "ring_mask": np.zeros(base["shape"]).astype(int),
+        "visual_size": base["visual_size"],
+        "ppd": base["ppd"],
+        "shape": base["shape"],
+        "order": order,
+        "frequency": frequency,
+        "intensity_rings": intensity_rings,
+    }
+    return stim
+
+
+def sine_wave(
     visual_size=None,
     ppd=None,
     shape=None,
     frequency=None,
     n_rings=None,
     ring_width=None,
-    intensity_rings=(1.0, 0.0),
+    phase_shift=0,
+    intensity_rings=(0.0, 1.0),
     intensity_background=0.5,
     origin="mean",
+    clip=False,
 ):
-    """Draw a circular grating, i.e., set of rings
+    """Draw a circular sine-wave grating over the whole image
 
     Parameters
     ----------
@@ -385,15 +466,18 @@ def grating(
         number of rings
     ring_width : Number, or None (default)
         width of a single ring, in degrees
-    intensities : Sequence[Number, ...]
-        intensity value for each ring, from inside to out, by default (1.0, 0.0).
-        If fewer intensities are passed than number of radii, cycles through intensities
+    phase_shift : float
+        phase shift of grating in degrees
+    intensity_rings : Sequence[float, float]
+        min and max intensity of sine-wave, by default (0.0, 1.0)
     intensity_background : float (optional)
         intensity value of background, by default 0.5
     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)
+    clip : Bool
+        if True, clip stimulus to image size (default: False)
 
     Returns
     ----------
@@ -409,55 +493,63 @@ def grating(
             "'ppd', 'shape', 'frequency', 'n_rings', 'ring_width'"
         )
 
-    # Resolve sizes
-    try:
-        shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
-    except resolution.TooManyUnknownsError:
-        pass
-
-    # Resolve grating
-    params = resolve_circular_params(
-        shape=shape,
+    sw = draw_sine_wave(
         visual_size=visual_size,
         ppd=ppd,
+        shape=shape,
         frequency=frequency,
-        n_rings=n_rings,
-        ring_width=ring_width,
-    )
-
-    # Clean-up params for passing through
-    stim_params = copy.deepcopy(params)
-    stim_params.pop("n_rings", None)
-    stim_params.pop("ring_width", None)
-    stim_params.pop("frequency", None)
-    if shape is not None:
-        stim_params["shape"] = shape
-    if visual_size is not None:
-        stim_params["visual_size"] = visual_size
-
-    # Draw stim
-    stim = disc_and_rings(
-        **stim_params,
-        intensity_background=intensity_background,
-        intensity_rings=intensity_rings,
+        n_phases=n_rings,
+        phase_width=ring_width,
+        period="ignore",
+        rotation=0,
+        phase_shift=phase_shift,
+        intensities=intensity_rings,
         origin=origin,
+        round_phase_width=False,
+        base_type="radial",
     )
 
-    # Assemble output
-    return {**stim, **params}
+    if clip:
+        csize = min(sw["visual_size"]) / 2.0
+        circle = disc(
+            visual_size=sw["visual_size"],
+            ppd=sw["ppd"],
+            radius=csize,
+            origin=origin,
+        )
+        sw["img"] = np.where(circle["ring_mask"], sw["img"], intensity_background)
+        sw["mask"] = np.where(circle["ring_mask"], sw["mask"], 0)
+
+    # Create stimulus dict
+    stim = {
+        "img": sw["img"],
+        "ring_mask": sw["mask"].astype(int),
+        "visual_size": sw["visual_size"],
+        "ppd": sw["ppd"],
+        "shape": sw["shape"],
+        "origin": origin,
+        "frequency": sw["frequency"],
+        "frame_width": sw["phase_width"],
+        "n_frames": sw["n_phases"],
+        "intensity_rings": intensity_rings,
+    }
+    return stim
 
 
-def bessel(
+def square_wave(
     visual_size=None,
     ppd=None,
     shape=None,
     frequency=None,
-    order=0,
-    intensity_rings=(1.0, 0.0),
+    n_rings=None,
+    ring_width=None,
+    phase_shift=0,
+    intensity_rings=(0.0, 1.0),
+    intensity_background=0.5,
     origin="mean",
+    clip=False,
 ):
-    """Draw a Bessel stimulus, i.e. draw circular rings following an nth order
-    Bessel function of a given frequency.
+    """Draw a circular square-wave grating over the whole image
 
     Parameters
     ----------
@@ -469,46 +561,56 @@ def bessel(
         shape [height, width] of image, in pixels
     frequency : Number, or None (default)
         spatial frequency of circular grating, in cycles per degree
-    order : int
-        n-th order Bessel function
-    intensity_rings : (float, float)
-        intensity values of rings, first value indicating center intensity
+    n_rings : int, or None (default)
+        number of rings
+    ring_width : Number, or None (default)
+        width of a single ring, in degrees
+    phase_shift : float
+        phase shift of grating in degrees
+    intensity_rings : Sequence[float, float]
+        min and max intensity of square-wave, by default (0.0, 1.0)
+    intensity_background : float (optional)
+        intensity value of background, by default 0.5
     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)
+    clip : Bool
+        if True, clip stimulus to image size (default: False)
 
     Returns
     ----------
     dict[str, Any]
         dict with the stimulus (key: "img"),
-        empty mask (key: "ring_mask"),
+        mask with integer index for each ring (key: "ring_mask"),
         and additional keys containing stimulus parameters
     """
-
-    base = image_base(
+    stim = sine_wave(
         visual_size=visual_size,
         ppd=ppd,
         shape=shape,
-        rotation=0,
+        frequency=frequency,
+        n_rings=n_rings,
+        ring_width=ring_width,
+        phase_shift=phase_shift,
+        intensity_rings=intensity_rings,
         origin=origin,
+        clip=False,
     )
 
-    img = base["radial"] * frequency * 2 * np.pi
-    img = sp.jv(order, img)
-    img = (img - img.min()) / (img.max() - img.min())
-    img = img * (intensity_rings[0] - intensity_rings[1]) + intensity_rings[1]
+    # Round sine-wave to create square wave
+    stim["img"] = round_to_vals(stim["img"], intensity_rings)
 
-    stim = {
-        "img": img,
-        "ring_mask": np.zeros(base["shape"]).astype(int),
-        "visual_size": base["visual_size"],
-        "ppd": base["ppd"],
-        "shape": base["shape"],
-        "order": order,
-        "frequency": frequency,
-        "intensity_rings": intensity_rings,
-    }
+    if clip:
+        csize = min(stim["visual_size"]) / 2.0
+        circle = disc(
+            visual_size=stim["visual_size"],
+            ppd=stim["ppd"],
+            radius=csize,
+            origin=origin,
+        )
+        stim["img"] = np.where(circle["ring_mask"], stim["img"], intensity_background)
+        stim["ring_mask"] = np.where(circle["ring_mask"], stim["ring_mask"], 0).astype(int)
     return stim
 
 
@@ -521,9 +623,12 @@ def bessel(
     }
 
     stims = {
-        "grating": grating(**p, frequency=2),
+        "disc": disc(**p, radius=3),
         "disc_and_rings": disc_and_rings(**p, radii=(1, 2, 3)),
         "ring": ring(**p, radii=(1, 2)),
+        "bessel": bessel(**p, frequency=0.5),
+        "sine_wave": sine_wave(**p, frequency=0.5),
+        "square_wave": square_wave(**p, frequency=0.5),
     }
 
-    plot_stimuli(stims, mask=False)
+    plot_stimuli(stims, mask=True)
diff --git a/stimupy/components/edges.py b/stimupy/components/edges.py
index d4d92631..ef721167 100644
--- a/stimupy/components/edges.py
+++ b/stimupy/components/edges.py
@@ -1,7 +1,7 @@
+import copy
 import numpy as np
 
 from stimupy.components import gaussians, image_base
-from stimupy.utils import resolution
 
 __all__ = [
     "step_edge",
@@ -109,7 +109,7 @@ def gaussian_edge(
         ppd=ppd,
         shape=shape,
         rotation=rotation,
-        intensity_edges=(0.0, 1.0),
+        intensity_edges=intensity_edges,
     )
 
     window = gaussians.gaussian(
@@ -124,6 +124,7 @@ def gaussian_edge(
     stim["img"] = img
     stim["sigma"] = sigma
     stim["intensity_background"] = intensity_background
+    stim["gaussian_mask"] = window["gaussian_mask"]
     return stim
 
 
@@ -132,6 +133,7 @@ def cornsweet_edge(
     ppd=None,
     shape=None,
     ramp_width=None,
+    rotation=0,
     intensity_edges=(0.0, 1.0),
     intensity_plateau=0.5,
     exponent=2.75,
@@ -157,6 +159,8 @@ def cornsweet_edge(
         shape [height, width] of grating, in pixels
     ramp_width : float
         width of luminance ramp in degrees of visual angle
+    rotation : float
+        rotation of stimulus in degrees (default: 0)
     intensity_edges : (float, float)
         intensity of edges
     intensity_plateau : float
@@ -182,40 +186,49 @@ def cornsweet_edge(
     if ramp_width is None:
         raise ValueError("cornsweet_edge() missing argument 'ramp_width' which is not 'None'")
 
-    # Resolve resolution
-    shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
-    if len(np.unique(ppd)) > 1:
-        raise ValueError("ppd should be equal in x and y direction")
-    if ramp_width > visual_size[1] / 2:
+    base = image_base(
+        visual_size=visual_size,
+        ppd=ppd,
+        shape=shape,
+        rotation=rotation,
+        origin="mean",
+    )
+
+    if ramp_width > max(base["visual_size"]) / 2:
         raise ValueError("ramp_width is too large")
 
-    ramp_width_px = int(ramp_width * ppd[1])
-    img = np.ones(shape) * intensity_plateau
-    mask = np.zeros(shape)
+    dist = np.round(base["rotated"] / ramp_width, 6)
+    d1 = copy.deepcopy(dist)
+    d2 = copy.deepcopy(dist) * (-1)
+    d1[d1 < 0] = -1
+    d1[d1 > 1] = 1
+    d2[d2 < 0] = -1
+    d2[d2 > 1] = 1
 
     # Create ramp profiles individually for left and right side
-    dist = np.arange(shape[1] / 2.0)
-    dist = dist / ramp_width_px
-    dist[dist > 1.0] = 1.0
-    profile1 = (1.0 - dist) ** exponent * (intensity_edges[0] - intensity_plateau)
-    profile2 = (1.0 - dist) ** exponent * (intensity_edges[1] - intensity_plateau)
-    img[:, : int(shape[1] / 2.0)] += profile1[::-1]
-    img[:, shape[1] // 2 :] += profile2
-
-    # Generate the edge mask
-    mask[:, int(shape[1] / 2.0 - ramp_width_px - 1) : int(shape[1] / 2.0)] = 1
-    mask[:, int(shape[1] / 2.0) : int(shape[1] / 2.0 + ramp_width_px + 1)] = 2
+    profile1 = (1.0 - d1) ** exponent * (
+        intensity_edges[0] - intensity_plateau
+    ) + intensity_plateau
+    profile2 = (1.0 - d2) ** exponent * (
+        intensity_edges[1] - intensity_plateau
+    ) + intensity_plateau
+    img = np.where(d1 == -1, 0, profile1) + np.where(d2 == -1, 0, profile2)
+    mask = np.where(d1 == -1, 0, 2) + np.where(d2 == -1, 0, 1)
+    mask[mask == 3] = 1
 
     stim = {
         "img": img,
         "edge_mask": mask.astype(int),
-        "visual_size": visual_size,
-        "ppd": ppd,
-        "shape": shape,
+        "visual_size": base["visual_size"],
+        "ppd": base["ppd"],
+        "shape": base["shape"],
         "intensity_edges": intensity_edges,
         "intensity_plateau": intensity_plateau,
         "ramp_width": ramp_width,
+        "rotation": rotation,
         "exponent": exponent,
+        "d1": d1,
+        "d2": d2,
     }
 
     return stim
diff --git a/stimupy/components/frames.py b/stimupy/components/frames.py
index 42c04690..ede3c617 100644
--- a/stimupy/components/frames.py
+++ b/stimupy/components/frames.py
@@ -1,11 +1,14 @@
 import numpy as np
 
-from stimupy.components import draw_regions, mask_elements, resolve_grating_params
-from stimupy.utils import resolution
+from stimupy.components import draw_regions, mask_elements, draw_sine_wave
+from stimupy.utils.utils import round_to_vals
+from stimupy.components.shapes import rectangle
+
 
 __all__ = [
     "frames",
-    "grating",
+    "sine_wave",
+    "square_wave",
 ]
 
 
@@ -57,7 +60,7 @@ def frames(
     visual_size=None,
     ppd=None,
     shape=None,
-    frame_radii=None,
+    radii=None,
     intensity_frames=(1.0, 0.0),
     intensity_background=0.5,
     origin="mean",
@@ -72,7 +75,7 @@ def frames(
         pixels per degree [vertical, horizontal]
     shape : Sequence[Number, Number], Number, or None (default)
         shape [height, width] of image, in pixels
-    frame_radii : Sequence[Number]
+    radii : Sequence[Number]
         radii of each frame, in degrees visual angle
     intensity_frames : Sequence[float, ...]
         intensity value for each frame, by default (1.0, 0.0).
@@ -92,12 +95,15 @@ def frames(
         mask with integer index for each frame (key: "frame_mask"),
         and additional keys containing stimulus parameters
     """
-    if frame_radii is None:
-        raise ValueError("frames() missing argument 'frame_radii' which is not 'None'")
+    if radii is None:
+        raise ValueError("frames() missing argument 'radii' which is not 'None'")
+
+    if np.diff(radii).min() < 0:
+        raise ValueError("radii need to monotonically increase")
 
     # Get frames mask
     stim = mask_frames(
-        edges=frame_radii,
+        edges=radii,
         shape=shape,
         visual_size=visual_size,
         ppd=ppd,
@@ -111,19 +117,21 @@ def frames(
     return stim
 
 
-def grating(
+def sine_wave(
     visual_size=None,
     ppd=None,
     shape=None,
     frequency=None,
     n_frames=None,
     frame_width=None,
+    phase_shift=0,
     period="ignore",
-    intensity_frames=(1.0, 0.0),
+    intensity_frames=(0.0, 1.0),
     intensity_background=0.5,
     origin="mean",
+    clip=False,
 ):
-    """Draw set of equal-width square frames, at given spatial frequency
+    """Draw a sine-wave using cityblock distances over the whole image
 
     Parameters
     ----------
@@ -139,19 +147,21 @@ def grating(
         number of frames in the grating
     frame_width : Number, or None (default)
         width of a single frame, in degrees visual angle
+    phase_shift : float
+        phase shift of grating in degrees
     period : "full", "half", "ignore" (default)
         whether to ensure the grating only has "full" periods,
         half "periods", or no guarantees ("ignore")
-    intensity_frames : Sequence[float, ...]
-        intensity value for each frame, by default (1.0, 0.0).
-        Can specify as many intensities as number of frame_widths;
-        If fewer intensities are passed than frame_widhts, cycles through intensities
+    intensity_frames : Sequence[float, float]
+        min and max intensity of sine-wave, by default (0.0, 1.0)
     intensity_background : float (optional)
         intensity value of background, by default 0.5
     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)
+    clip : Bool
+        if True, clip stimulus to image size (default: False)
 
     Returns
     ----------
@@ -167,57 +177,148 @@ def grating(
             "'ppd', 'shape', 'frequency', 'n_frames', 'frame_width'"
         )
 
-    # Try to resolve resolution
-    try:
-        shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
-    except resolution.TooManyUnknownsError:
-        ppd = resolution.validate_ppd(ppd) if ppd is not None else None
-        shape = resolution.validate_shape(shape) if shape is not None else None
-        visual_size = (
-            resolution.validate_visual_size(visual_size) if visual_size is not None else None
-        )
-
-    # Resolve params
-    length = np.array(shape).min() / 2 if shape is not None else None
-    ppd_1D = np.array(ppd).min() if ppd is not None else None
-    visual_angle = np.array(visual_size).min() / 2 if visual_size is not None else None
-    params = resolve_grating_params(
-        length=length,
-        visual_angle=visual_angle,
+    sw = draw_sine_wave(
+        visual_size=visual_size,
+        ppd=ppd,
+        shape=shape,
+        frequency=frequency,
         n_phases=n_frames,
         phase_width=frame_width,
-        ppd=ppd_1D,
-        frequency=frequency,
         period=period,
+        rotation=0,
+        phase_shift=phase_shift,
+        intensities=intensity_frames,
+        origin=origin,
+        round_phase_width=False,
+        base_type="cityblock",
     )
-    shape_, visual_size_, ppd = resolution.resolve(
-        shape=params["length"] * 2, visual_size=params["visual_angle"] * 2, ppd=params["ppd"]
-    )
 
-    if shape is not None:
-        shape_ = shape
-    if visual_size is not None:
-        visual_size_ = visual_size
+    if clip:
+        if origin == "corner":
+            rsize = min(sw["visual_size"]) / 2
+            rect = rectangle(
+                visual_size=sw["visual_size"],
+                ppd=sw["ppd"],
+                rectangle_size=rsize,
+                rectangle_position=(0, 0),
+            )
+        else:
+            rsize = min(sw["visual_size"])
+            rect = rectangle(
+                visual_size=sw["visual_size"],
+                ppd=sw["ppd"],
+                rectangle_size=rsize,
+            )
+        sw["img"] = np.where(rect["shape_mask"], sw["img"], intensity_background)
+        sw["mask"] = np.where(rect["shape_mask"], sw["mask"], 0)
+
+    # Create stimulus dict
+    stim = {
+        "img": sw["img"],
+        "frame_mask": sw["mask"].astype(int),
+        "visual_size": sw["visual_size"],
+        "ppd": sw["ppd"],
+        "shape": sw["shape"],
+        "origin": origin,
+        "frequency": sw["frequency"],
+        "frame_width": sw["phase_width"],
+        "n_frames": sw["n_phases"],
+        "period": period,
+        "intensity_frames": intensity_frames,
+    }
+    return stim
+
+
+def square_wave(
+    visual_size=None,
+    ppd=None,
+    shape=None,
+    frequency=None,
+    n_frames=None,
+    frame_width=None,
+    phase_shift=0,
+    period="ignore",
+    intensity_frames=(0.0, 1.0),
+    intensity_background=0.5,
+    origin="mean",
+    clip=False,
+):
+    """Draw a square-wave using cityblock distances over the whole image
+
+    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
+    frequency : Number, or None (default)
+        spatial frequency of grating, in cycles per degree visual angle
+    n_frames : int, or None (default)
+        number of frames in the grating
+    frame_width : Number, or None (default)
+        width of a single frame, in degrees visual angle
+    phase_shift : float
+        phase shift of grating in degrees
+    period : "full", "half", "ignore" (default)
+        whether to ensure the grating only has "full" periods,
+        half "periods", or no guarantees ("ignore")
+    intensity_frames : Sequence[float, float]
+        min and max intensity of square-wave, by default (0.0, 1.0)
+    intensity_background : float (optional)
+        intensity value of background, by default 0.5
+    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)
+    clip : Bool
+        if True, clip stimulus to image size (default: False)
+
+    Returns
+    ----------
+    dict[str, Any]
+        dict with the stimulus (key: "img"),
+        mask with integer index for each frame (key: "frame_mask"),
+        and additional keys containing stimulus parameters
+    """
 
-    # Draw
-    stim = frames(
-        frame_radii=params["edges"],
-        shape=shape_,
-        visual_size=visual_size_,
+    stim = sine_wave(
+        visual_size=visual_size,
         ppd=ppd,
+        shape=shape,
+        frequency=frequency,
+        n_frames=n_frames,
+        frame_width=frame_width,
+        phase_shift=phase_shift,
+        period=period,
         intensity_frames=intensity_frames,
-        intensity_background=intensity_background,
         origin=origin,
+        clip=False,
     )
 
-    return {
-        **stim,
-        "frequency": params["frequency"],
-        "n_frames": params["n_phases"],
-        "frame_width": params["phase_width"],
-        "period": params["period"],
-        "orientation": "cityblock",
-    }
+    # Round sine-wave to create square wave
+    stim["img"] = round_to_vals(stim["img"], intensity_frames)
+
+    if clip:
+        if origin == "corner":
+            rsize = min(stim["visual_size"]) / 2
+            rect = rectangle(
+                visual_size=stim["visual_size"],
+                ppd=stim["ppd"],
+                rectangle_size=rsize,
+                rectangle_position=(0, 0),
+            )
+        else:
+            rsize = min(stim["visual_size"])
+            rect = rectangle(
+                visual_size=stim["visual_size"],
+                ppd=stim["ppd"],
+                rectangle_size=rsize,
+            )
+        stim["img"] = np.where(rect["shape_mask"], stim["img"], intensity_background)
+        stim["frame_mask"] = np.where(rect["shape_mask"], stim["frame_mask"], 0)
+    return stim
 
 
 if __name__ == "__main__":
@@ -225,7 +326,9 @@ def grating(
 
     stims = {
         "frames": frames(visual_size=(8, 16), frame_radii=(1, 2, 3), ppd=32),
-        "grating": grating(visual_size=(8, 16), ppd=32, frequency=1.0),
+        # "grating": grating(visual_size=(8, 16), ppd=32, frequency=1.0),
+        "sine_wave": sine_wave(visual_size=(8, 16), ppd=32, frequency=0.5),
+        "square_wave": square_wave(visual_size=(8, 16), ppd=32, frequency=0.5),
     }
 
     plot_stimuli(stims, mask=False, save=None)
diff --git a/stimupy/components/gaussians.py b/stimupy/components/gaussians.py
index 6640cbd2..6e977960 100644
--- a/stimupy/components/gaussians.py
+++ b/stimupy/components/gaussians.py
@@ -62,7 +62,7 @@ def gaussian(
     yy = base["vertical"]
 
     # convert orientation parameter to radians
-    theta = np.deg2rad(rotation)
+    theta = np.deg2rad(-rotation)
 
     # determine a, b, c coefficients
     a = (np.cos(theta) ** 2 / (2 * sigma[1] ** 2)) + (np.sin(theta) ** 2 / (2 * sigma[0] ** 2))
@@ -81,6 +81,7 @@ def gaussian(
         radius=sigma,
         rotation=rotation,
         origin=origin,
+        restrict_size=False,
     )["shape_mask"]
 
     stim = {
diff --git a/stimupy/components/gratings.py b/stimupy/components/gratings.py
index 8a0f3230..894a750e 100644
--- a/stimupy/components/gratings.py
+++ b/stimupy/components/gratings.py
@@ -1,135 +1,21 @@
 import numpy as np
 
-from stimupy.components import draw_regions, mask_elements, resolve_grating_params
+# import warnings
+
+from stimupy.components import draw_sine_wave, draw_regions
 from stimupy.components.gaussians import gaussian
-from stimupy.utils import resolution
+from stimupy.utils.utils import round_to_vals
 
 __all__ = [
-    "square_wave",
     "sine_wave",
-    "gabor",
+    "square_wave",
     "staircase",
+    "gabor",
     "plaid",
 ]
 
 
-def mask_bars(
-    edges,
-    shape=None,
-    visual_size=None,
-    ppd=None,
-    orientation="horizontal",
-    rotation=0.0,
-    origin="corner",
-):
-    """Generate mask with integer indices for sequential bars
-
-    Parameters
-    ----------
-    edges : Sequence[Number, ...]
-        upper-limit, in degrees visual angle, of each bar
-    shape : Sequence[Number, Number], Number, or None (default)
-        shape [height, width] of image, in pixels
-    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]
-    orientation : "vertical" or "horizontal" (default)
-        orientation of the grating
-    rotation : float
-        rotation of grating in degrees (default: 0)
-    origin : "corner", "mean" or "center"
-        if "corner": set origin to upper left corner (default)
-        if "mean": set origin to hypothetical image center
-        if "center": set origin to real center (closest existing value to mean)
-
-    Returns
-    ----------
-    dict[str, Any]
-        mask with integer index for each bar (key: "grating_mask"),
-        and additional keys containing stimulus parameters
-    """
-
-    stim = mask_elements(
-        edges=edges,
-        orientation=orientation,
-        rotation=rotation,
-        origin=origin,
-        shape=shape,
-        visual_size=visual_size,
-        ppd=ppd,
-    )
-    stim["grating_mask"] = stim["mask"]
-    del stim["mask"]
-    return stim
-
-
-def shift_edges(
-    edges,
-    ppd=None,
-    phase_shift=None,
-    phase_width=None,
-    intensity_bars=None,
-    origin=None,
-):
-    """Function to shift edges
-
-    Parameters
-    ----------
-    edges : Sequence[Number, ...]
-        upper-limit, in degrees visual angle, of each bar
-    ppd : Sequence[Number, Number], Number, or None (default)
-        pixels per degree [vertical, horizontal]
-    phase_shift : float
-        phase shift of grating in degrees
-    phase_width : float
-        width of individual phase in visual angle
-    intensity_bars : Sequence[float, float]
-        intensity value for the two bars
-    origin : "corner", "mean" or "center"
-        if "corner": set origin to upper left corner (default)
-        if "mean": set origin to hypothetical image center
-        if "center": set origin to real center (closest existing value to mean)
-
-    Returns
-    ----------
-    Updated edges and intensity_bars
-    """
-
-    phase_shift = phase_shift % 360
-    edges = np.array(edges)
-
-    if phase_shift != 0:
-        if phase_shift > 0 and phase_shift <= 180:
-            intensity_bars = (intensity_bars[1], intensity_bars[0])
-
-        phase_shift_deg = phase_shift * phase_width / 180
-        phase_shift_deg = np.round(phase_shift_deg * ppd) / ppd
-
-        edges = edges + phase_shift_deg
-
-        if phase_shift > 0 and phase_shift <= 180:
-            edges = np.append(phase_shift_deg, edges)
-        elif phase_shift > 180:
-            edges = np.append([phase_shift_deg - phase_width, phase_shift_deg], edges)
-
-    if origin != "corner":
-        nedges = int(len(edges) / 2)
-        phase_width = np.diff(edges).mean()
-        edges_small = edges[0 : nedges + 1]
-        edges_large = edges[nedges::] - edges[nedges] + phase_width - edges[0]
-        # edges_large = -np.round(edges_large[::-1], 8)
-        edges_large = np.ceil(-edges_large[::-1] * 10e7) / 10e7
-        edges_large = edges_large[edges_large <= 1e-07]
-        edges = list(np.append(edges_large, edges_small))
-        if nedges % 2:
-            edges = [
-                edges[0] - phase_width,
-            ] + edges
-    return list(edges), intensity_bars
-
-
-def square_wave(
+def sine_wave(
     visual_size=None,
     ppd=None,
     shape=None,
@@ -139,11 +25,11 @@ def square_wave(
     period="ignore",
     rotation=0,
     phase_shift=0,
-    intensity_bars=(1.0, 0.0),
-    origin="corner",
-    round_phase_width=True,
+    intensity_bars=(0.0, 1.0),
+    origin="center",
+    round_phase_width=False,
 ):
-    """Draw square-wave grating (set of bars) of given spatial frequency
+    """Draw sine-wave grating (set of bars) of given spatial frequency
 
     Parameters
     ----------
@@ -167,16 +53,14 @@ def square_wave(
         rotation of grating in degrees (default: 0 = horizontal)
     phase_shift : float
         phase shift of grating in degrees
-    intensity_bars : Sequence[float, ...]
-        intensity value for each bar, by default (1.0, 0.0).
-        Can specify as many intensities as n_bars;
-        If fewer intensities are passed than n_bars, cycles through intensities
+    intensity_bars : Sequence[float, float]
+        min and max intensity of sine-wave, by default (0.0, 1.0)
     origin : "corner", "mean" or "center"
         if "corner": set origin to upper left corner (default)
         if "mean": set origin to hypothetical image center
         if "center": set origin to real center (closest existing value to mean)
     round_phase_width : Bool
-        if True, round width of bars
+        if True, round width of bars given resolution
 
     Returns
     ----------
@@ -185,110 +69,56 @@ def square_wave(
         mask with integer index for each bar (key: "grating_mask"),
         and additional keys containing stimulus parameters
     """
+
+    if len(intensity_bars) != 2:
+        raise ValueError("intensity_bars should be [float, float]")
+
     lst = [visual_size, ppd, shape, frequency, n_bars, bar_width]
     if len([x for x in lst if x is not None]) < 3:
         raise ValueError(
-            "'square_wave()' needs 3 non-None arguments for resolving from 'visual_size', "
+            "'sine_wave()' needs 3 non-None arguments for resolving from 'visual_size', "
             "'ppd', 'shape', 'frequency', 'n_bars', 'bar_width'"
         )
 
-    # Try to resolve resolution
-    try:
-        shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
-    except resolution.TooManyUnknownsError:
-        ppd = resolution.validate_ppd(ppd)
-        shape = resolution.validate_shape(shape)
-        visual_size = resolution.validate_visual_size(visual_size)
-
-    alpha = [np.abs(np.cos(np.deg2rad(rotation))), np.abs(np.sin(np.deg2rad(rotation)))]
-
-    if None not in shape:
-        length = np.round(alpha[0] * shape.width + alpha[1] * shape.height)
-    else:
-        length = None
-
-    if None not in visual_size:
-        visual_angle = alpha[0] * visual_size.width + alpha[1] * visual_size.height
-    else:
-        visual_angle = None
-
-    if None not in ppd:
-        ppd_1D = ppd.horizontal
-    else:
-        ppd_1D = None
-
-    # Resolve params
-    params = resolve_grating_params(
-        length=length,
-        visual_angle=visual_angle,
+    sw = draw_sine_wave(
+        visual_size=visual_size,
+        ppd=ppd,
+        shape=shape,
+        frequency=frequency,
         n_phases=n_bars,
         phase_width=bar_width,
-        ppd=ppd_1D,
-        frequency=frequency,
         period=period,
-        round_phase_width=round_phase_width,
-    )
-    length = params["length"]
-    ppd_1D = params["ppd"]
-    visual_angle = params["visual_angle"]
-
-    # Determine size/shape of whole image
-    if None in shape:
-        shape = [length * alpha[1], length * alpha[0]]
-        if np.round(alpha[1], 5) == 0:
-            shape[0] = shape[1]
-        if np.round(alpha[0], 5) == 0:
-            shape[1] = shape[0]
-
-    if None in ppd:
-        ppd = (ppd_1D, ppd_1D)
-
-    if None in visual_size:
-        visual_size = resolution.visual_size_from_shape_ppd(shape=shape, ppd=ppd)
-
-    shape = resolution.validate_shape(shape)
-    visual_size = resolution.validate_visual_size(visual_size)
-    ppd = resolution.validate_ppd(ppd)
-
-    # Phase shift:
-    edges = params["edges"]
-    edges, intensities = shift_edges(
-        edges=edges,
-        ppd=ppd[0],
-        phase_shift=phase_shift,
-        phase_width=params["phase_width"],
-        intensity_bars=intensity_bars,
-        origin=origin,
-    )
-
-    # Get bars mask
-    stim = mask_bars(
-        edges=edges,
-        shape=shape,
-        visual_size=visual_size,
-        ppd=ppd,
-        orientation="rotated",
         rotation=rotation,
+        phase_shift=phase_shift,
+        intensities=intensity_bars,
         origin=origin,
+        round_phase_width=round_phase_width,
+        base_type="rotated",
     )
 
-    # Draw image
-    stim["img"] = draw_regions(stim["grating_mask"], intensities=intensities)
-
-    return {
-        **stim,
-        "frequency": params["frequency"],
-        "bar_width": params["phase_width"],
-        "n_bars": params["n_phases"],
-        "period": params["period"],
+    # Create stimulus dict
+    stim = {
+        "img": sw["img"],
+        "grating_mask": sw["mask"].astype(int),
+        "visual_size": sw["visual_size"],
+        "ppd": sw["ppd"],
+        "shape": sw["shape"],
+        "rotation": rotation,
+        "origin": origin,
+        "frequency": sw["frequency"],
+        "bar_width": sw["phase_width"],
+        "n_bars": sw["n_phases"],
+        "period": period,
         "intensity_bars": intensity_bars,
+        "phase_shift": phase_shift,
     }
+    return stim
 
 
-def sine_wave(
-    shape=None,
+def square_wave(
     visual_size=None,
     ppd=None,
+    shape=None,
     frequency=None,
     n_bars=None,
     bar_width=None,
@@ -297,17 +127,18 @@ def sine_wave(
     phase_shift=0,
     intensity_bars=(0.0, 1.0),
     origin="corner",
+    round_phase_width=True,
 ):
-    """Draw sine-wave grating (set of bars) of given spatial frequency
+    """Draw square-wave grating (set of bars) of given spatial frequency
 
     Parameters
     ----------
-    shape : Sequence[Number, Number], Number, or None (default)
-        shape [height, width] of image, in pixels
     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
     frequency : Number, or None (default)
         spatial frequency of grating, in cycles per degree visual angle
     n_bars : int, or None (default)
@@ -322,12 +153,14 @@ def sine_wave(
         rotation of grating in degrees (default: 0 = horizontal)
     phase_shift : float
         phase shift of grating in degrees
-    intensity_bars : Sequence[float, ...]
-        maximal intensity value for each bar, by default (0.0, 1.0).
+    intensity_bars : Sequence[float, float]
+        min and max intensity of square-wave, by default (0.0, 1.0)
     origin : "corner", "mean" or "center"
         if "corner": set origin to upper left corner (default)
         if "mean": set origin to hypothetical image center
         if "center": set origin to real center (closest existing value to mean)
+    round_phase_width : Bool
+        if True, round width of bars given resolution
 
     Returns
     ----------
@@ -336,124 +169,40 @@ def sine_wave(
         mask with integer index for each bar (key: "grating_mask"),
         and additional keys containing stimulus parameters
     """
-    lst = [visual_size, ppd, shape, frequency, n_bars, bar_width]
-    if len([x for x in lst if x is not None]) < 3:
-        raise ValueError(
-            "'sine_wave()' needs 3 non-None arguments for resolving from 'visual_size', "
-            "'ppd', 'shape', 'frequency', 'n_bars', 'bar_width'"
-        )
 
-    # Try to resolve resolution
-    try:
-        shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
-    except resolution.TooManyUnknownsError:
-        ppd = resolution.validate_ppd(ppd)
-        shape = resolution.validate_shape(shape)
-        visual_size = resolution.validate_visual_size(visual_size)
-
-    alpha = [np.abs(np.cos(np.deg2rad(rotation))), np.abs(np.sin(np.deg2rad(rotation)))]
-
-    if shape.width is not None:
-        length = np.round(alpha[0] * shape.width + alpha[1] * shape.height)
-    else:
-        length = None
-
-    if visual_size.width is not None:
-        visual_angle = alpha[0] * visual_size.width + alpha[1] * visual_size.height
-    else:
-        visual_angle = None
-
-    if ppd.horizontal is not None:
-        ppd_1D = ppd.horizontal
-    else:
-        ppd_1D = None
-
-    # Resolve params
-    params = resolve_grating_params(
-        length=length,
-        visual_angle=visual_angle,
-        n_phases=n_bars,
-        phase_width=bar_width,
-        ppd=ppd_1D,
+    stim = sine_wave(
+        visual_size=visual_size,
+        ppd=ppd,
+        shape=shape,
         frequency=frequency,
+        n_bars=n_bars,
+        bar_width=bar_width,
         period=period,
-        round_phase_width=False,
-    )
-    length = params["length"]
-    ppd_1D = params["ppd"]
-    visual_angle = params["visual_angle"]
-
-    # Determine size/shape of whole image
-    if None in shape:
-        shape = [length * alpha[1], length * alpha[0]]
-        if np.round(alpha[1], 5) == 0:
-            shape[0] = shape[1]
-        if np.round(alpha[0], 5) == 0:
-            shape[1] = shape[0]
-
-    if None in ppd:
-        ppd = (ppd_1D, ppd_1D)
-
-    if None in visual_size:
-        visual_size = resolution.visual_size_from_shape_ppd(shape=shape, ppd=ppd)
-
-    shape = resolution.validate_shape(shape)
-    visual_size = resolution.validate_visual_size(visual_size)
-    ppd = resolution.validate_ppd(ppd)
-
-    # Phase shift:
-    edges = params["edges"]
-    edges, intensities = shift_edges(
-        edges=edges,
-        ppd=ppd[0],
+        rotation=rotation,
         phase_shift=phase_shift,
-        phase_width=params["phase_width"],
         intensity_bars=intensity_bars,
         origin=origin,
+        round_phase_width=round_phase_width,
     )
 
-    # Get bars mask
-    stim = mask_bars(
-        edges=edges,
-        shape=shape,
-        visual_size=visual_size,
-        ppd=ppd,
-        orientation="rotated",
-        rotation=rotation,
-        origin=origin,
-    )
-
-    # Draw image
-    stim["img"] = np.sin(
-        params["frequency"] * 2 * np.pi * stim["distances"] + np.deg2rad(phase_shift)
-    )
-    stim["img"] = stim["img"] / 2 + 0.5
-    stim["img"] = stim["img"] * (intensity_bars[1] - intensity_bars[0]) + intensity_bars[0]
-
-    return {
-        **stim,
-        "frequency": params["frequency"],
-        "bar_width": params["phase_width"],
-        "n_bars": params["n_phases"],
-        "period": params["period"],
-        "phase_shift": phase_shift,
-    }
+    # Round sine-wave to create square wave
+    stim["img"] = round_to_vals(stim["img"], intensity_bars)
+    return stim
 
 
-def gabor(
+def staircase(
     visual_size=None,
     ppd=None,
     shape=None,
     frequency=None,
+    n_bars=None,
     bar_width=None,
-    sigma=None,
-    period="ignore",
+    period="either",
     rotation=0,
-    phase_shift=0,
     intensity_bars=(0.0, 1.0),
-    origin="mean",
+    round_phase_width=True,
 ):
-    """Draw sine-wave grating (set of bars) of given spatial frequency
+    """Draw a luminance staircase
 
     Parameters
     ----------
@@ -465,24 +214,23 @@ def gabor(
         shape [height, width] of image, in pixels
     frequency : Number, or None (default)
         spatial frequency of grating, in cycles per degree visual angle
+    n_bars : int, or None (default)
+        number of bars in the grating
     bar_width : Number, or None (default)
         width of a single bar, in degrees visual angle
-    sigma : float or (float, float)
-        sigma of Gaussian in degree visual angle (y, x)
     period : "even", "odd", "either" or "ignore" (default)
         ensure whether the grating has "even" number of phases, "odd"
         number of phases, either or whether not to round the number of
         phases ("ignore")
     rotation : float
         rotation of grating in degrees (default: 0 = horizontal)
-    phase_shift : float
-        phase shift of grating in degrees
     intensity_bars : Sequence[float, ...]
-        maximal intensity value for each bar, by default (0.0, 1.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)
+        if len(intensity_bars)==2, intensity range of staircase (default 0.0, 1.0);
+        if len(intensity_bars)>2, intensity value for each bar.
+        Can specify as many intensities as n_bars.
+        If fewer intensities are passed than n_bars, cycles through intensities.
+    round_phase_width : Bool
+        if True, round width of bars given resolution
 
     Returns
     ----------
@@ -491,50 +239,47 @@ def gabor(
         mask with integer index for each bar (key: "grating_mask"),
         and additional keys containing stimulus parameters
     """
-    if sigma is None:
-        raise ValueError("gabor() missing argument 'sigma' which is not 'None'")
 
-    stim = sine_wave(
-        shape=shape,
+    stim = square_wave(
         visual_size=visual_size,
         ppd=ppd,
+        shape=shape,
         frequency=frequency,
+        n_bars=n_bars,
         bar_width=bar_width,
         period=period,
         rotation=rotation,
-        phase_shift=phase_shift,
-        intensity_bars=intensity_bars,
-        origin=origin,
+        phase_shift=0,
+        intensity_bars=(0, 1),
+        origin="corner",
+        round_phase_width=round_phase_width,
     )
 
-    gaussian_window = gaussian(
-        visual_size=visual_size,
-        ppd=ppd,
-        sigma=sigma,
-        origin=origin,
-    )
-    mean_int = (intensity_bars[0] + intensity_bars[1]) / 2
-    stim["img"] = (stim["img"] - mean_int) * gaussian_window["img"] + mean_int
+    if len(intensity_bars) == 2:
+        intensity_bars = np.linspace(
+            intensity_bars[0], intensity_bars[1], int(np.ceil(stim["n_bars"]))
+        )
 
-    return {
-        **stim,
-        "sigma": sigma,
-        "gaussian_mask": gaussian_window["gaussian_mask"],
-    }
+    # Use grating_mask to draw staircase
+    stim["img"] = draw_regions(mask=stim["grating_mask"], intensities=intensity_bars)
+    stim["intensity_bars"] = intensity_bars
+    return stim
 
 
-def staircase(
+def gabor(
     visual_size=None,
     ppd=None,
     shape=None,
     frequency=None,
-    n_bars=None,
     bar_width=None,
-    period="either",
+    sigma=None,
+    period="ignore",
     rotation=0,
+    phase_shift=0,
     intensity_bars=(0.0, 1.0),
+    origin="center",
 ):
-    """Draw a luminance staircase
+    """Draw sine-wave grating (set of bars) of given spatial frequency
 
     Parameters
     ----------
@@ -546,20 +291,24 @@ def staircase(
         shape [height, width] of image, in pixels
     frequency : Number, or None (default)
         spatial frequency of grating, in cycles per degree visual angle
-    n_bars : int, or None (default)
-        number of bars in the grating
     bar_width : Number, or None (default)
         width of a single bar, in degrees visual angle
+    sigma : float or (float, float)
+        sigma of Gaussian in degree visual angle (y, x)
     period : "even", "odd", "either" or "ignore" (default)
         ensure whether the grating has "even" number of phases, "odd"
         number of phases, either or whether not to round the number of
         phases ("ignore")
     rotation : float
         rotation of grating in degrees (default: 0 = horizontal)
+    phase_shift : float
+        phase shift of grating in degrees
     intensity_bars : Sequence[float, ...]
-        intensity value for each bar, by default (1.0, 0.0).
-        Can specify as many intensities as n_bars;
-        If fewer intensities are passed than n_bars, cycles through intensities
+        maximal intensity value for each bar, by default (0.0, 1.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)
 
     Returns
     ----------
@@ -568,103 +317,35 @@ def staircase(
         mask with integer index for each bar (key: "grating_mask"),
         and additional keys containing stimulus parameters
     """
-    lst = [visual_size, ppd, shape, frequency, n_bars, bar_width]
-    if len([x for x in lst if x is not None]) < 3:
-        raise ValueError(
-            "'staircase()' needs 3 non-None arguments for resolving from 'visual_size', "
-            "'ppd', 'shape', 'frequency', 'n_bars', 'bar_width'"
-        )
+    if sigma is None:
+        raise ValueError("gabor() missing argument 'sigma' which is not 'None'")
 
-    # Try to resolve resolution
-    try:
-        shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
-    except resolution.TooManyUnknownsError:
-        ppd = resolution.validate_ppd(ppd)
-        shape = resolution.validate_shape(shape)
-        visual_size = resolution.validate_visual_size(visual_size)
-
-    alpha = [np.abs(np.cos(np.deg2rad(rotation))), np.abs(np.sin(np.deg2rad(rotation)))]
-
-    if None not in shape:
-        length = np.round(alpha[0] * shape.width + alpha[1] * shape.height)
-    else:
-        length = None
-
-    if None not in visual_size:
-        visual_angle = alpha[0] * visual_size.width + alpha[1] * visual_size.height
-    else:
-        visual_angle = None
-
-    if None not in ppd:
-        ppd_1D = ppd.horizontal
-    else:
-        ppd_1D = None
-
-    # Resolve params
-    params = resolve_grating_params(
-        length=length,
-        visual_angle=visual_angle,
-        n_phases=n_bars,
-        phase_width=bar_width,
-        ppd=ppd_1D,
+    stim = sine_wave(
+        visual_size=visual_size,
+        ppd=ppd,
+        shape=shape,
         frequency=frequency,
+        bar_width=bar_width,
         period=period,
-    )
-    length = params["length"]
-    ppd_1D = params["ppd"]
-    visual_angle = params["visual_angle"]
-
-    # Determine size/shape of whole image
-    if None in shape:
-        shape = [length * alpha[1], length * alpha[0]]
-        if np.round(alpha[1], 5) == 0:
-            shape[0] = shape[1]
-        if np.round(alpha[0], 5) == 0:
-            shape[1] = shape[0]
-
-    if None in ppd:
-        ppd = (ppd_1D, ppd_1D)
-
-    if None in visual_size:
-        visual_size = resolution.visual_size_from_shape_ppd(shape=shape, ppd=ppd)
-
-    shape = resolution.validate_shape(shape)
-    visual_size = resolution.validate_visual_size(visual_size)
-    ppd = resolution.validate_ppd(ppd)
-
-    # Phase shift:
-    edges = params["edges"]
-    edges, intensities = shift_edges(
-        edges=edges,
-        ppd=ppd[0],
-        phase_shift=0,
-        phase_width=params["phase_width"],
+        rotation=rotation,
+        phase_shift=phase_shift,
         intensity_bars=intensity_bars,
-        origin="corner",
+        origin=origin,
     )
 
-    # Get bars mask
-    stim = mask_bars(
-        edges=params["edges"],
-        shape=shape,
+    gaussian_window = gaussian(
         visual_size=visual_size,
         ppd=ppd,
-        orientation="rotated",
-        rotation=rotation,
-        origin="corner",
+        sigma=sigma,
+        origin=origin,
     )
-
-    # Draw image
-    if len(intensity_bars) == 2:
-        intensity_bars = np.linspace(intensity_bars[0], intensity_bars[1], int(params["n_phases"]))
-    stim["img"] = draw_regions(stim["grating_mask"], intensities=intensity_bars)
+    mean_int = (intensity_bars[0] + intensity_bars[1]) / 2
+    stim["img"] = (stim["img"] - mean_int) * gaussian_window["img"] + mean_int
 
     return {
         **stim,
-        "frequency": params["frequency"],
-        "bar_width": params["phase_width"],
-        "n_bars": params["n_phases"],
-        "period": params["period"],
+        "sigma": sigma,
+        "gaussian_mask": gaussian_window["gaussian_mask"],
     }
 
 
@@ -701,8 +382,6 @@ def plaid(
         raise ValueError("plaid() missing argument 'sigma' which is not 'None'")
 
     # Create sine-wave gratings
-    grating_parameters1["origin"] = "center"
-    grating_parameters2["origin"] = "center"
     grating1 = sine_wave(**grating_parameters1)
     grating2 = sine_wave(**grating_parameters2)
 
@@ -713,6 +392,7 @@ def plaid(
     if grating1["origin"] != grating2["origin"]:
         raise ValueError("Grating origins must be the same")
 
+    # Create Gaussian window
     window = gaussian(
         visual_size=grating1["visual_size"],
         ppd=grating1["ppd"],
@@ -737,9 +417,9 @@ def plaid(
 if __name__ == "__main__":
     from stimupy.utils.plotting import plot_stimuli
 
-    rotation = 0
+    rotation = 90
     origin = "center"
-    phase_shift = 0
+    phase_shift = 30
 
     p1 = {
         "visual_size": (10, 5),
@@ -750,7 +430,7 @@ def plaid(
 
     p2 = {
         "visual_size": 5,
-        "ppd": 10,
+        "ppd": 20,
         "frequency": 2,
         "phase_shift": phase_shift,
     }
@@ -783,7 +463,7 @@ def plaid(
 
     p6 = {
         "visual_size": 4.0,
-        "ppd": 25,
+        "ppd": 20,
         "bar_width": 0.08,
     }
 
@@ -806,3 +486,4 @@ def plaid(
         "plaid": plaid(p3, p4, sigma=4.0),
     }
     plot_stimuli(stims, mask=False)
+    plot_stimuli(stims, mask=True)
diff --git a/stimupy/components/lines.py b/stimupy/components/lines.py
index 5739d940..38b6bb43 100644
--- a/stimupy/components/lines.py
+++ b/stimupy/components/lines.py
@@ -4,12 +4,13 @@
 import numpy as np
 from PIL import Image, ImageDraw
 
-from stimupy.components.shapes import ring
+from stimupy.components.shapes import ellipse as ellipse_shape
 from stimupy.utils import resolution
 
 __all__ = [
     "line",
     "dipole",
+    "ellipse",
     "circle",
 ]
 
@@ -104,9 +105,6 @@ def line(
         ),
     )
 
-    if any(num < 0 for num in coords[0]) or any(num < 0 for num in coords[1]):
-        raise ValueError("Line does not fully fit into image")
-
     # Create line image
     ImageDraw.Draw(img).line(coords, width=int(line_width * ppd[0]))
 
@@ -230,7 +228,7 @@ def dipole(
     return stim1
 
 
-def circle(
+def ellipse(
     visual_size=None,
     ppd=None,
     shape=None,
@@ -239,7 +237,7 @@ def circle(
     intensity_line=1,
     intensity_background=0,
 ):
-    """Draw a circle given the input parameters
+    """Draw an ellipse given the input parameters
 
     Parameters
     ----------
@@ -249,8 +247,8 @@ def circle(
         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
+    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
@@ -267,25 +265,87 @@ def circle(
         and additional keys containing stimulus parameters
     """
     if radius is None:
-        raise ValueError("circle() missing argument 'radius' which is not '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]
 
-    stim = ring(
-        radii=(radius, radius + line_width),
-        intensity_rings=intensity_line,
+    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",
     )
-    stim["ring_mask"] = np.where(stim["ring_mask"] == 2, 1, 0)
-    stim["line_mask"] = stim["ring_mask"]
-    del stim["ring_mask"]
+
+    stim2 = ellipse_shape(
+        radius=np.array(radius) - line_width,
+        visual_size=visual_size,
+        ppd=ppd,
+        shape=shape,
+        origin="mean",
+    )
+
+    stim["img"] = np.where(stim2["shape_mask"] == 1, intensity_background, stim["img"])
+    stim["line_mask"] = np.where(stim2["shape_mask"] == 1, 0, stim["shape_mask"])
+    del stim["shape_mask"]
+    return stim
+
+
+def circle(
+    visual_size=None,
+    ppd=None,
+    shape=None,
+    radius=None,
+    line_width=0,
+    intensity_line=1,
+    intensity_background=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
 
 
@@ -298,13 +358,12 @@ def circle(
         "line_length": 2,
         "line_width": 0.01,
         "rotation": 30,
-        # "line_position": (5, 1),
-        # "origin": "center"
     }
 
     stims = {
-        "line": line(**p1),
+        "line": line(**p1, origin="center", line_position=(-1, -1)),
         "dipole": dipole(**p1, line_gap=1),
         "circle": circle(visual_size=10, ppd=10, radius=3),
+        "ellipse": ellipse(visual_size=10, ppd=10, radius=(3, 4)),
     }
-    plot_stimuli(stims, mask=False)
+    plot_stimuli(stims, mask=True)
diff --git a/stimupy/components/mondrians.py b/stimupy/components/mondrians.py
index 1ac5a40a..4f79e74a 100644
--- a/stimupy/components/mondrians.py
+++ b/stimupy/components/mondrians.py
@@ -1,7 +1,7 @@
 import numpy as np
 
 from stimupy.components.shapes import parallelogram
-from stimupy.utils import degrees_to_pixels, resolution
+from stimupy.utils import resolution
 
 __all__ = [
     "mondrians",
@@ -88,8 +88,8 @@ def mondrians(
         except Exception:
             raise ValueError("Mondrian position tuples should be (ypos, xpos)")
 
-        ypos, xpos = degrees_to_pixels(mondrian_positions[m], ppd[0])
-        individual_shapes = degrees_to_pixels(mondrian_sizes[m], ppd[0])
+        ypos, xpos = resolution.lengths_from_visual_angles_ppd(mondrian_positions[m], ppd[0])
+        individual_shapes = resolution.lengths_from_visual_angles_ppd(mondrian_sizes[m], ppd[0])
 
         try:
             if len(individual_shapes) == 2:
diff --git a/stimupy/components/shapes.py b/stimupy/components/shapes.py
index 24722d17..bb2369ca 100644
--- a/stimupy/components/shapes.py
+++ b/stimupy/components/shapes.py
@@ -2,7 +2,7 @@
 
 from stimupy.components import image_base
 from stimupy.components.angulars import wedge
-from stimupy.components.circulars import annulus, disc, ring
+from stimupy.components.circulars import annulus, ring, disc
 from stimupy.utils import resolution
 
 __all__ = [
@@ -11,6 +11,7 @@
     "cross",
     "parallelogram",
     "ellipse",
+    "circle",
     "wedge",
     "annulus",
     "disc",
@@ -81,13 +82,15 @@ def rectangle(
     if rectangle_position is None:
         # If no position is given, place rectangle centrally
         rectangle_position = center_pos
-    if isinstance(rectangle_position, (float, int)):
-        rectangle_position = (rectangle_position, rectangle_position)
+
+    # 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 = (np.array(rectangle_position) * base["ppd"]).astype(int)
-    center_pos = np.round(np.array(center_pos) * base["ppd"])
-    rect_shift = (rect_pos - center_pos).astype(int)
+    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)
@@ -431,9 +434,9 @@ def parallelogram(
     return {
         "img": img * (intensity_parallelogram - intensity_background) + intensity_background,
         "shape_mask": img.astype(int),
-        "shape": shape,
-        "visual_size": visual_size,
-        "ppd": ppd,
+        "shape": base["shape"],
+        "visual_size": base["visual_size"],
+        "ppd": base["ppd"],
         "parallelogram_size": parallelogram_size,
         "intensity_background": intensity_background,
         "intensity_parallelogram": intensity_parallelogram,
@@ -450,6 +453,7 @@ def ellipse(
     intensity_background=0.0,
     rotation=0,
     origin="mean",
+    restrict_size=True,
 ):
     """Draw an ellipse
 
@@ -473,6 +477,8 @@ def ellipse(
         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
     ----------
@@ -514,15 +520,15 @@ def ellipse(
     cy = np.floor((x2 + y1) * base["ppd"][0]) / base["ppd"][0]
     cx = np.floor((x1 + y2) * base["ppd"][1]) / base["ppd"][1]
 
-    if (cy > base["visual_size"][0] / 2) or (cx > base["visual_size"][1] / 2):
+    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,
         "shape_mask": img.astype(int),
-        "shape": shape,
-        "visual_size": visual_size,
-        "ppd": ppd,
+        "shape": base["shape"],
+        "visual_size": base["visual_size"],
+        "ppd": base["ppd"],
         "radius": radius,
         "intensity_background": intensity_background,
         "intensity_ellipse": intensity_ellipse,
@@ -530,6 +536,65 @@ def ellipse(
     }
 
 
+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: "shape_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,
+        origin=origin,
+        restrict_size=restrict_size,
+    )
+    return stim
+
+
 if __name__ == "__main__":
     from stimupy.utils.plotting import plot_stimuli
 
@@ -546,6 +611,7 @@ def ellipse(
         "parallelogram": parallelogram(**p, parallelogram_size=(5.2, 3.1, 0.9)),
         "parallelogram2": parallelogram(shape=(100, 100), ppd=10, parallelogram_size=(10, 9, -1)),
         "ellipse": ellipse(**p, radius=(4, 3)),
+        "circle": circle(visual_size=(10, 8), ppd=50, radius=3),
     }
 
     plot_stimuli(stims, mask=False)
diff --git a/stimupy/illusions/__init__.py b/stimupy/illusions/__init__.py
index a616031e..3e2a26fa 100644
--- a/stimupy/illusions/__init__.py
+++ b/stimupy/illusions/__init__.py
@@ -21,6 +21,15 @@
 
 
 def create_overview():
+    """
+    Create dictionary with examples from all stimulus-illusions
+
+    Returns
+    -------
+    stims : dict
+        dict with all stimuli containing individual stimulus dicts.
+    """
+
     p = {
         "visual_size": (10, 10),
         "ppd": 20,
@@ -46,7 +55,7 @@ def create_overview():
     # fmt: off
     stims = {
         # Angular
-        "pinwheel": angulars.pinwheel(**p, n_segments=8),
+        "pinwheel": angulars.pinwheel(**p, n_segments=8, target_width=1, target_indices=3),
         # Benary
         "benary_general": benarys.cross_generalized(**p, target_size=1, cross_thickness=2, target_x=(3, 6, 3, 6), target_y=(4, 6, 6, 4)),
         "benary_rectangles": benarys.cross_rectangles(**p, target_size=1, cross_thickness=2),
@@ -74,10 +83,10 @@ def create_overview():
         "dungeon": dungeons.dungeon(**p, n_cells=5),
         # Frames
         "frames": frames.rings(**p, frequency=0.5, target_indices=3),
-        "frames_general": frames.rings_generalized(**p, frame_radii=(1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5), target_indices=3),
+        "frames_general": frames.rings_generalized(**p, radii=(1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5), target_indices=3),
         "2sided_frames": frames.two_sided_rings(**p, frequency=1, target_indices=3),
         "frames_bullseye": frames.bullseye(**p, frequency=0.5),
-        "frames_bullseye_general": frames.bullseye_generalized(**p, frame_radii=(1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5)),
+        "frames_bullseye_general": frames.bullseye_generalized(**p, radii=(1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5)),
         "2sided_bullseye": frames.two_sided_bullseye(**p, frequency=1),
         # Grating
         "square_wave": gratings.square_wave(**p, frequency=0.5, target_indices=(3,)),
@@ -93,7 +102,7 @@ def create_overview():
                                                           mask_size=(2, 2, 1)),
         "counterphase_induction": gratings.counterphase_induction(**p, frequency=1, target_size=4, target_phase_shift=90,),
         "grating_induction": gratings.induction(**p, frequency=0.5, target_width=0.5),
-        "grating_induction_blur": gratings.induction_blur(**p, frequency=0.5, target_width=0.5, target_blur=2),
+        "grating_induction_blur": gratings.induction_blur(**p, frequency=0.5, target_width=0.5, sigma=0.1),
         # Hermann´
         "hermann": hermanns.grid(**p, element_size=(1.5, 1.5, 0.2)),
         # Mondrians
@@ -135,10 +144,26 @@ def create_overview():
     return stims
 
 
-def overview(mask=False, save=None):
+def overview(mask=False, save=None, extent_key="shape"):
+    """
+    Plot overview with examples from all stimulus-illusions
+
+    Parameters
+    ----------
+    mask : bool or str, optional
+        If True, plot mask on top of stimulus image (default: False).
+        If string is provided, plot this key from stimulus dictionary as mask
+    save : None or str, optional
+        If None (default), do not save the plot.
+        If string is provided, save plot under this name.
+    extent_key : str, optional
+        Key to extent which will be used for plotting.
+        Default is "shape", using the image size in pixels as extent.
+
+    """
     from stimupy.utils import plot_stimuli
 
     stims = create_overview()
 
     # Plotting
-    plot_stimuli(stims, mask=mask, save=save)
+    plot_stimuli(stims, mask=mask, save=save, extent_key=extent_key)
diff --git a/stimupy/illusions/angulars.py b/stimupy/illusions/angulars.py
index 527b2af5..e01235a6 100644
--- a/stimupy/illusions/angulars.py
+++ b/stimupy/illusions/angulars.py
@@ -18,11 +18,11 @@ def pinwheel(
     n_segments=None,
     segment_width=None,
     rotation=0.0,
-    target_indices=[2, 5],
-    target_width=1.0,
-    target_center=1.0,
+    target_indices=None,
+    target_width=None,
+    target_center=None,
     intensity_segments=(1.0, 0.0),
-    intensity_background=0.3,
+    intensity_background=0.5,
     intensity_target=0.5,
     origin="mean",
 ):
@@ -99,7 +99,10 @@ def pinwheel(
         ppd=ppd,
         shape=shape,
         origin=origin,
+        inner_radius=0,
     )
+    if target_center is None:
+        target_center = stim["radius"] / 2
 
     # Place target(s)
     if isinstance(target_indices, (int)):
@@ -120,27 +123,31 @@ def pinwheel(
         intensity_target = [
             intensity_target,
         ]
-    intensity_target = itertools.cycle(intensity_target)
-
-    target_mask = np.zeros_like(stim["wedge_mask"])
-    for target_idx, (segment_idx, center, width, intensity) in enumerate(
-        zip(target_indices, target_center, target_width, intensity_target)
-    ):
-        # Draw ring
-        inner_radius = center - (width / 2)
-        outer_radius = center + (width / 2)
-        ring_stim = ring_shape(
-            radii=[inner_radius, outer_radius],
-            intensity_rings=intensity,
-            visual_size=stim["visual_size"],
-            ppd=stim["ppd"],
-            shape=stim["shape"],
-        )
-        condition1 = stim["wedge_mask"] == segment_idx
-        condition2 = ring_stim["ring_mask"] == 2
-        target_mask = np.where(condition1 & condition2, target_idx + 1, target_mask)
-        stim["img"] = np.where(target_mask == (target_idx + 1), intensity, stim["img"])
-    stim["target_mask"] = target_mask
+    if target_indices is not None:
+        if target_width is None:
+            raise ValueError("pinwheel() missing argument 'target_width' which is not 'None'")
+
+        intensity_target = itertools.cycle(intensity_target)
+
+        target_mask = np.zeros_like(stim["wedge_mask"])
+        for target_idx, (segment_idx, center, width, intensity) in enumerate(
+            zip(target_indices, target_center, target_width, intensity_target)
+        ):
+            # Draw ring
+            inner_radius = center - (width / 2)
+            outer_radius = center + (width / 2)
+            ring_stim = ring_shape(
+                radii=[inner_radius, outer_radius],
+                intensity_ring=intensity,
+                visual_size=stim["visual_size"],
+                ppd=stim["ppd"],
+                shape=stim["shape"],
+            )
+            condition1 = stim["wedge_mask"] == segment_idx
+            condition2 = ring_stim["ring_mask"] == 1
+            target_mask = np.where(condition1 & condition2, target_idx + 1, target_mask)
+            stim["img"] = np.where(target_mask == (target_idx + 1), intensity, stim["img"])
+        stim["target_mask"] = target_mask
 
     return stim
 
@@ -149,7 +156,9 @@ def pinwheel(
     from stimupy.utils import plot_stimuli
 
     stims = {
-        "pinwheel": pinwheel(visual_size=(8, 8), ppd=32, n_segments=8),
+        "pinwheel": pinwheel(
+            visual_size=8, ppd=32, n_segments=10, target_width=1, target_indices=3
+        ),
     }
 
     plot_stimuli(stims, mask=True, save=None)
diff --git a/stimupy/illusions/benarys.py b/stimupy/illusions/benarys.py
index 9110aaa2..adbe0610 100644
--- a/stimupy/illusions/benarys.py
+++ b/stimupy/illusions/benarys.py
@@ -1,10 +1,9 @@
 import warnings
 
 import numpy as np
-from scipy.ndimage import rotate
 
-from stimupy.components.shapes import cross, triangle
-from stimupy.utils import degrees_to_pixels, resolution
+from stimupy.components.shapes import cross, triangle, rectangle
+from stimupy.utils import resolution
 
 __all__ = [
     "cross_generalized",
@@ -167,14 +166,17 @@ def cross_rectangles(
         raise ValueError("Target size is larger than cross thickness")
 
     # Calculate target placement for classical Benarys cross
+    theight, twidth = resolution.shape_from_visual_size_ppd(target_size, ppd)
+    cheight, cwidth = resolution.shape_from_visual_size_ppd(cross_thickness, ppd)
+
     target_x = (
-        (visual_size[1] - cross_thickness) / 2.0 - target_size[1],
-        shape[1] / ppd[1] - np.round(target_size[1] * ppd[1]) / ppd[1],
+        (shape[1] / 2 - cwidth / 2 - twidth) / ppd[1],
+        (shape[1] - twidth) / ppd[1],
     )
 
     target_y = (
-        (visual_size[0] - cross_thickness) / 2.0 - target_size[0],
-        (visual_size[0] - cross_thickness) / 2.0,
+        (shape[0] / 2 - cheight / 2 - theight) / ppd[0],
+        (shape[0] / 2 - cheight / 2) / ppd[0],
     )
 
     stim = cross_generalized(
@@ -257,7 +259,7 @@ def cross_triangles(
 
     # Calculate target placement for classical Benarys cross
     target_x = (
-        (visual_size[1] - cross_thickness) / 2.0 - target_size[0],
+        (visual_size[1] - cross_thickness) / 2.0 - target_size[1],
         (visual_size[1] + cross_thickness) / 2.0,
     )
 
@@ -348,7 +350,7 @@ def todorovic_generalized(
         raise ValueError("ppd should be equal in x and y direction")
 
     L_size = (visual_size[0] / 2, visual_size[0] / 2, L_width, visual_size[1] - L_width)
-    top, bottom, left, right = degrees_to_pixels(L_size, np.unique(ppd))
+    top, bottom, left, right = resolution.lengths_from_visual_angles_ppd(L_size, np.unique(ppd))
     width, height = left + right, top + bottom
 
     # Create stimulus without targets
@@ -632,9 +634,9 @@ def add_targets(
                 "target_type, target_orientation, target_x and target_y need the same length."
             )
 
-        theight, twidth = degrees_to_pixels(target_size, ppd)
-        ty = degrees_to_pixels(target_y, ppd)
-        tx = degrees_to_pixels(target_x, ppd)
+        theight, twidth = resolution.lengths_from_visual_angles_ppd(target_size, ppd)
+        ty = resolution.lengths_from_visual_angles_ppd(target_y, ppd)
+        tx = resolution.lengths_from_visual_angles_ppd(target_x, ppd)
 
         if (twidth + np.array(tx)).max() > img.shape[1]:
             raise ValueError("Rightmost target does not fit in image.")
@@ -644,38 +646,43 @@ def add_targets(
         # Add targets:
         for i in range(len(target_x)):
             if target_type[i] == "r":
-                tpatch = np.ones([theight, twidth]) * intensity_target
+                target = rectangle(
+                    shape=[theight * 2, twidth * 2],
+                    ppd=ppd,
+                    rectangle_size=(theight / ppd, twidth / ppd),
+                    intensity_rectangle=intensity_target,
+                    intensity_background=0,
+                    rotation=target_orientation[i],
+                )
 
             elif target_type[i] == "t":
-                tpatch = triangle(
-                    visual_size=target_size,
+                target = triangle(
+                    shape=[theight * 2, twidth * 2],
                     ppd=ppd,
-                    triangle_size=target_size,
-                    intensity_background=0.0,
+                    triangle_size=(theight / ppd, twidth / ppd),
+                    intensity_background=0,
                     intensity_triangle=intensity_target,
-                )["img"]
+                    rotation=target_orientation[i],
+                    include_corners=True,
+                )
 
             else:
                 raise Exception("You can only use r or t as shapes")
 
-            # Rotate, resize to original shape and clean
-            tpatch = rotate(tpatch, angle=target_orientation[i])
-            thresh = 0.7
-            tpatch[tpatch < intensity_target * thresh] = 0.0
-            tpatch[tpatch > intensity_target * thresh] = intensity_target
-            tpatch = tpatch[~np.all(tpatch == 0, axis=1)]  # Remove zero-rows
-            tpatch = tpatch[:, ~np.all(tpatch == 0, axis=0)]  # Remove zero-cols
-            mpatch = np.copy(tpatch)
-            mpatch[mpatch != 0] = i + 1
+            # Remove zero-rows and -columns
+            mpatch = target["shape_mask"][~np.all(target["shape_mask"] == 0, axis=1)]
+            mpatch = mpatch[:, ~np.all(mpatch == 0, axis=0)]
+            tpatch = target["img"][~np.all(target["img"] == 0, axis=1)]
+            tpatch = tpatch[:, ~np.all(tpatch == 0, axis=0)]
             theight_, twidth_ = tpatch.shape
 
             # Only change the target parts of the image:
-            ipatch = img[ty[i] : ty[i] + theight_, tx[i] : tx[i] + twidth_]
-            ipatch[tpatch == intensity_target] = 0.0
-            tpatch = tpatch + ipatch
-
-            img[ty[i] : ty[i] + theight_, tx[i] : tx[i] + twidth_] = tpatch
-            mask[ty[i] : ty[i] + theight_, tx[i] : tx[i] + twidth_] = mpatch
+            mlarge = np.zeros(img.shape)
+            mlarge[ty[i] : ty[i] + theight_, tx[i] : tx[i] + twidth_] = mpatch
+            tlarge = np.zeros(img.shape)
+            tlarge[ty[i] : ty[i] + theight_, tx[i] : tx[i] + twidth_] = tpatch
+            img = np.where(mlarge, tlarge, img)
+            mask = np.where(mlarge, i + 1, mask)
 
         stim = {
             "target_size": target_size,
diff --git a/stimupy/illusions/circulars.py b/stimupy/illusions/circulars.py
index b17f2717..3f8a099f 100644
--- a/stimupy/illusions/circulars.py
+++ b/stimupy/illusions/circulars.py
@@ -1,9 +1,6 @@
-import itertools
-from copy import deepcopy
-
 import numpy as np
 
-from stimupy.components.circulars import grating
+from stimupy.components.circulars import square_wave as grating
 from stimupy.utils import resolution, stack_dicts
 
 __all__ = ["rings", "two_sided_rings", "bullseye", "two_sided_bullseye"]
@@ -21,6 +18,7 @@ def rings(
     intensity_rings=(1.0, 0.0),
     intensity_background=0.5,
     origin="mean",
+    clip=True,
 ):
     """Circular grating, with one or more target rings
 
@@ -64,6 +62,8 @@ def rings(
         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)
+    clip : Bool
+        if True, clip stimulus to image size (default: True)
 
     Returns
     ----------
@@ -91,41 +91,24 @@ def rings(
         intensity_rings=intensity_rings,
         shape=shape,
         origin=origin,
+        clip=clip,
     )
 
-    # Add target intensity
-    intensities = [*itertools.islice(itertools.cycle(intensity_rings), len(stim["radii"]))]
-    if target_indices is None:
-        target_indices = len(intensities) // 2
-    try:
-        for ring_idx in target_indices:
-            intensities[ring_idx] = intensity_target
-    except TypeError:
-        intensities[target_indices] = intensity_target
+    if isinstance(target_indices, (float, int)):
         target_indices = [target_indices]
 
-    # Redraw stim with target
-    stim = grating(
-        visual_size=visual_size,
-        ppd=ppd,
-        frequency=frequency,
-        n_rings=n_rings,
-        ring_width=ring_width,
-        intensity_rings=intensities,
-        intensity_background=intensity_background,
-        shape=shape,
-        origin=origin,
-    )
+    if target_indices is None:
+        target_indices = [int(stim["ring_mask"].max() / 2)]
 
-    # Update mask to only be targets
-    stim["rings"] = deepcopy(stim["ring_mask"])
-    mask = np.zeros(stim["shape"])
+    # Add targets
+    stim["target_mask"] = np.zeros(stim["shape"])
     for i, ring_idx in enumerate(target_indices):
-        mask = np.where(stim["ring_mask"] == ring_idx + 1, i + 1, 0)
-    stim["target_mask"] = mask.astype(int)
+        stim["img"] = np.where(stim["ring_mask"] == ring_idx + 1, intensity_target, stim["img"])
+        stim["target_mask"] = np.where(stim["ring_mask"] == ring_idx + 1, i + 1, 0).astype(int)
+
+    # Update stim dict
     stim["target_indices"] = target_indices
     stim["intensity_target"] = intensity_target
-
     return stim
 
 
@@ -214,6 +197,7 @@ def two_sided_rings(
         intensity_rings=intensity_rings,
         intensity_background=intensity_background,
         origin=origin,
+        clip=True,
     )
 
     stim2 = rings(
@@ -227,6 +211,7 @@ def two_sided_rings(
         intensity_rings=intensity_rings[::-1],
         intensity_background=intensity_background,
         origin=origin,
+        clip=True,
     )
 
     stim = stack_dicts(stim1, stim2)
@@ -246,6 +231,7 @@ def bullseye(
     intensity_rings=(1.0, 0.0),
     intensity_background=0.5,
     origin="mean",
+    clip=True,
 ):
     """Circular Bullseye stimulus
 
@@ -290,6 +276,8 @@ def bullseye(
         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)
+    clip : Bool
+        if True, clip stimulus to image size (default: True)
 
     Returns
     ----------
@@ -319,6 +307,7 @@ def bullseye(
         intensity_target=intensity_target,
         target_indices=0,
         origin=origin,
+        clip=clip,
     )
     return stim
 
@@ -409,6 +398,7 @@ def two_sided_bullseye(
         intensity_rings=intensity_rings,
         intensity_background=intensity_background,
         origin=origin,
+        clip=True,
     )
 
     stim2 = bullseye(
@@ -421,6 +411,7 @@ def two_sided_bullseye(
         intensity_rings=intensity_rings[::-1],
         intensity_background=intensity_background,
         origin=origin,
+        clip=True,
     )
 
     stim = stack_dicts(stim1, stim2)
diff --git a/stimupy/illusions/cornsweets.py b/stimupy/illusions/cornsweets.py
index d7433f2c..d320c722 100644
--- a/stimupy/illusions/cornsweets.py
+++ b/stimupy/illusions/cornsweets.py
@@ -12,6 +12,7 @@ def cornsweet(
     ppd=None,
     shape=None,
     ramp_width=None,
+    rotation=0,
     intensity_edges=(0.0, 1.0),
     intensity_plateau=0.5,
     exponent=2.75,
@@ -37,6 +38,8 @@ def cornsweet(
         shape [height, width] of grating, in pixels
     ramp_width : float
         width of luminance ramp in degrees of visual angle
+    rotation : float
+        rotation of stimulus in degrees (default: 0)
     intensity_edges : (float, float)
         intensity of edges
     intensity_plateau : float
@@ -65,17 +68,16 @@ def cornsweet(
         ppd=ppd,
         shape=shape,
         ramp_width=ramp_width,
+        rotation=rotation,
         intensity_edges=intensity_edges,
         intensity_plateau=intensity_plateau,
         exponent=exponent,
     )
-    shape = stim["shape"]
-    ramp_width_px = stim["ramp_width"] * stim["ppd"][0]
 
     # Generate the target mask
-    mask = np.zeros(shape)
-    mask[:, 0 : int(shape[1] / 2.0 - ramp_width_px - 1)] = 1
-    mask[:, int(shape[1] / 2.0 + ramp_width_px + 1) : :] = 2
+    mask = np.where(stim["d1"] == 1, stim["edge_mask"], 0) + np.where(
+        stim["d2"] == 1, stim["edge_mask"], 0
+    )
     stim["target_mask"] = mask.astype(int)
     return stim
 
diff --git a/stimupy/illusions/cubes.py b/stimupy/illusions/cubes.py
index fef4e80b..215729e6 100644
--- a/stimupy/illusions/cubes.py
+++ b/stimupy/illusions/cubes.py
@@ -1,6 +1,6 @@
 import numpy as np
 
-from stimupy.utils import degrees_to_pixels, resolution
+from stimupy.utils import resolution
 
 __all__ = [
     "varying_cells",
@@ -80,9 +80,9 @@ def varying_cells(
     cell_spacing = list(cell_spacing)
     cell_spacing[n_cells - 1] = 0
 
-    cheights = degrees_to_pixels(cell_heights, ppd)
-    cwidths = degrees_to_pixels(cell_widths, ppd)
-    cspaces = degrees_to_pixels(cell_spacing, ppd)
+    cheights = resolution.lengths_from_visual_angles_ppd(cell_heights, np.unique(ppd))
+    cwidths = resolution.lengths_from_visual_angles_ppd(cell_widths, np.unique(ppd))
+    cspaces = resolution.lengths_from_visual_angles_ppd(cell_spacing, np.unique(ppd))
     height = sum(cwidths) + sum(cspaces)
     width = height
 
@@ -211,8 +211,8 @@ def cube(
         targets = ()
 
     height, width = shape
-    cell_space = degrees_to_pixels(cell_spacing, np.unique(ppd)[0])
-    cell_thick = degrees_to_pixels(cell_thickness, np.unique(ppd)[0])
+    cell_space = resolution.lengths_from_visual_angles_ppd(cell_spacing, np.unique(ppd))
+    cell_thick = resolution.lengths_from_visual_angles_ppd(cell_thickness, np.unique(ppd))
 
     # Initiate image
     img = np.ones([height, width]) * intensity_background
diff --git a/stimupy/illusions/delboeufs.py b/stimupy/illusions/delboeufs.py
index 5fb22ed4..211bfefc 100644
--- a/stimupy/illusions/delboeufs.py
+++ b/stimupy/illusions/delboeufs.py
@@ -72,7 +72,7 @@ def delboeuf(
 
     inner = disc(
         radius=target_radius,
-        intensity_discs=intensity_target - intensity_background,
+        intensity_disc=intensity_target - intensity_background,
         visual_size=visual_size,
         ppd=ppd,
         shape=shape,
diff --git a/stimupy/illusions/frames.py b/stimupy/illusions/frames.py
index c1667527..bb47daa5 100644
--- a/stimupy/illusions/frames.py
+++ b/stimupy/illusions/frames.py
@@ -8,7 +8,7 @@
 __all__ = [
     "rings",
     "rings_generalized",
-    # "two_sided_rings",
+    "two_sided_rings",
     "bullseye",
     "bullseye_generalized",
     "two_sided_bullseye",
@@ -26,6 +26,8 @@ def rings(
     intensity_frames=(1.0, 0.0),
     target_indices=(),
     intensity_target=0.5,
+    origin="center",
+    clip=True,
 ):
     """Draw set of square frames, with some frame(s) as target
 
@@ -56,6 +58,12 @@ def rings(
         intensity value for each target, by default 0.5.
         Can specify as many intensities as number of target_indices;
         If fewer intensities are passed than target_indices, cycles through intensities
+    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)
+    clip : Bool
+        if True, clip stimulus to image size (default: True)
 
     Returns
     ----------
@@ -72,7 +80,7 @@ def rings(
     """
 
     # Frames component
-    stim = frames_component.grating(
+    stim = frames_component.square_wave(
         shape=shape,
         visual_size=visual_size,
         ppd=ppd,
@@ -81,6 +89,8 @@ def rings(
         frame_width=frame_width,
         period=period,
         intensity_frames=intensity_frames,
+        origin=origin,
+        clip=clip,
     )
 
     # Resolve target parameters
@@ -193,10 +203,10 @@ def two_sided_rings(
 
 
 def rings_generalized(
-    frame_radii,
     visual_size=None,
     ppd=None,
     shape=None,
+    radii=None,
     intensity_frames=(1.0, 0.0),
     intensity_background=0.5,
     target_indices=(),
@@ -207,14 +217,14 @@ def rings_generalized(
 
     Parameters
     ----------
-    frame_radii : Sequence[Number]
-        radii of each frame, in degrees visual angle
     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
+    radii : Sequence[Number] or None (default)
+        radii of each frame, in degrees visual angle
     intensity_frames : Sequence[float, ...]
         intensity value for each frame, by default (1.0, 0.0).
         Can specify as many intensities as number of frame_widths;
@@ -242,7 +252,7 @@ def rings_generalized(
 
     # Frames component
     stim = frames_component.frames(
-        frame_radii=frame_radii,
+        radii=radii,
         visual_size=visual_size,
         ppd=ppd,
         shape=shape,
@@ -284,6 +294,8 @@ def bullseye(
     intensity_frames=(1.0, 0.0),
     target_indices=(),
     intensity_target=0.5,
+    origin="center",
+    clip=True,
 ):
     """Square "bullseye", i.e., set of rings with target in center
 
@@ -314,6 +326,12 @@ def bullseye(
         intensity value for each target, by default 0.5.
         Can specify as many intensities as number of target_indices;
         If fewer intensities are passed than target_indices, cycles through intensities
+    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)
+    clip : Bool
+        if True, clip stimulus to image size (default: True)
 
     Returns
     ----------
@@ -339,15 +357,17 @@ def bullseye(
         intensity_frames=intensity_frames,
         target_indices=1,
         intensity_target=intensity_target,
+        origin=origin,
+        clip=clip,
     )
     return stim
 
 
 def bullseye_generalized(
-    frame_radii,
     visual_size=None,
     ppd=None,
     shape=None,
+    radii=None,
     intensity_frames=(1.0, 0.0),
     intensity_background=0.5,
     intensity_target=0.5,
@@ -365,6 +385,8 @@ def bullseye_generalized(
         pixels per degree [vertical, horizontal]
     shape : Sequence[Number, Number], Number, or None (default)
         shape [height, width] of image, in pixels
+    radii : Sequence[Number] or None (default)
+        radii of each frame, in degrees visual angle
     intensity_frames : Sequence[float, ...]
         intensity value for each frame, by default (1.0, 0.0).
         Can specify as many intensities as number of frame_widths;
@@ -388,7 +410,7 @@ def bullseye_generalized(
         and additional keys containing stimulus parameters
     """
     stim = rings_generalized(
-        frame_radii=frame_radii,
+        radii=radii,
         visual_size=visual_size,
         ppd=ppd,
         shape=shape,
@@ -489,7 +511,7 @@ def two_sided_bullseye(
     from stimupy.utils import plot_stimuli
 
     p1 = {
-        "frame_radii": (1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5),
+        "radii": (1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5),
         "visual_size": 10,
         "ppd": 10,
     }
diff --git a/stimupy/illusions/gratings.py b/stimupy/illusions/gratings.py
index 6b4d9917..ead52ff2 100644
--- a/stimupy/illusions/gratings.py
+++ b/stimupy/illusions/gratings.py
@@ -2,12 +2,13 @@
 import warnings
 
 import numpy as np
-from scipy.ndimage import gaussian_filter
 
 from stimupy.components.gratings import sine_wave
 from stimupy.components.gratings import square_wave as square_wave_component
+from stimupy.components.gaussians import gaussian
 from stimupy.components.shapes import parallelogram, rectangle
 from stimupy.utils import pad_dict_to_shape, pad_dict_to_visual_size, resolution
+from stimupy.utils.filters import convolve
 
 __all__ = [
     "square_wave",
@@ -283,6 +284,7 @@ def grating_masked(
     )["img"]
 
     small_grating = pad_dict_to_shape(small_grating, large_grating["shape"])
+    window = window * (small_grating["pad_mask"] - 1)
     img = np.where(window, small_grating["img"], large_grating["img"])
     mask = np.where(window, small_grating["target_mask"], 0)
 
@@ -555,7 +557,7 @@ def induction_blur(
     phase_shift=0,
     intensity_bars=(1.0, 0.0),
     target_width=None,
-    target_blur=0,
+    sigma=None,
     intensity_target=0.5,
     origin="corner",
 ):
@@ -631,7 +633,15 @@ def induction_blur(
         origin=origin,
         round_phase_width=True,
     )
-    stim["img"] = gaussian_filter(stim["img"], target_blur)
+
+    gauss = gaussian(
+        visual_size=stim["visual_size"],
+        ppd=stim["ppd"],
+        shape=stim["shape"],
+        sigma=sigma,
+        origin="center",
+    )["img"]
+    stim["img"] = convolve(stim["img"], gauss / np.sum(gauss), "same", padding=True)
 
     # Identify target region
     rectangle_size = (target_width, stim["visual_size"].width)
@@ -686,7 +696,7 @@ def induction_blur(
             **params, target_size=4, target_phase_shift=90
         ),
         "induction": induction(**params, target_width=0.5),
-        "induction_blur": induction_blur(**params, target_width=0.5, target_blur=5),
+        "induction_blur": induction_blur(**params, target_width=0.5, sigma=0.1),
     }
 
-    plot_stimuli(stims, mask=True, save=None)
+    plot_stimuli(stims, mask=False, save=None)
diff --git a/stimupy/illusions/hermanns.py b/stimupy/illusions/hermanns.py
index cf8b666a..60297a74 100644
--- a/stimupy/illusions/hermanns.py
+++ b/stimupy/illusions/hermanns.py
@@ -1,6 +1,6 @@
 import numpy as np
 
-from stimupy.utils import degrees_to_pixels, resolution
+from stimupy.utils import resolution
 
 __all__ = [
     "grid",
@@ -53,19 +53,21 @@ def grid(
     if len(np.unique(ppd)) > 1:
         raise ValueError("ppd should be equal in x and y direction")
 
-    element_height, element_width, element_thick = degrees_to_pixels(element_size, np.unique(ppd))
+    eheight, ewidth, ethick = resolution.lengths_from_visual_angles_ppd(
+        element_size, np.unique(ppd)
+    )
 
-    if element_height <= element_thick:
+    if eheight <= ethick:
         raise ValueError("Element thickness larger than height")
-    if element_width <= element_thick:
+    if ewidth <= ethick:
         raise ValueError("Element thickness larger than width")
-    if element_thick <= 0:
+    if ethick <= 0:
         raise ValueError("Increase element thickness")
 
     img = np.ones(shape) * intensity_background
-    for i in range(element_thick):
-        img[i::element_height, :] = intensity_grid
-        img[:, i::element_width] = intensity_grid
+    for i in range(ethick):
+        img[i::eheight, :] = intensity_grid
+        img[:, i::ewidth] = intensity_grid
 
     stim = {
         "img": img,
diff --git a/stimupy/illusions/mondrians.py b/stimupy/illusions/mondrians.py
index 36d9e748..1bc2779a 100644
--- a/stimupy/illusions/mondrians.py
+++ b/stimupy/illusions/mondrians.py
@@ -1,7 +1,7 @@
 import numpy as np
 
 from stimupy.components.mondrians import mondrians
-from stimupy.utils import degrees_to_pixels, resolution
+from stimupy.utils import resolution
 
 __all__ = [
     "corrugated_mondrians",
@@ -76,7 +76,7 @@ def corrugated_mondrians(
         )
 
     height, width = visual_size
-    mdepths_px = degrees_to_pixels(mondrian_depths, ppd[0])
+    mdepths_px = resolution.lengths_from_visual_angles_ppd(mondrian_depths, ppd[0])
     max_depth = np.abs(np.array(mdepths_px)).max()
     sum_depth = np.array(mdepths_px).sum()
     red_depth = np.maximum(max_depth, sum_depth)
diff --git a/stimupy/illusions/sbcs.py b/stimupy/illusions/sbcs.py
index fef67458..662e190e 100644
--- a/stimupy/illusions/sbcs.py
+++ b/stimupy/illusions/sbcs.py
@@ -283,7 +283,7 @@ def with_dots(
         radius=dot_radius,
         ppd=ppd,
         intensity_background=0.0,
-        intensity_discs=1,
+        intensity_disc=1,
     )["img"]
     patch = pad_by_visual_size(img=patch, padding=padding, ppd=ppd, pad_value=0.0)
     pixels_per_dot = patch.shape
@@ -322,8 +322,8 @@ def with_dots(
     stim = {
         "img": img,
         "target_mask": mask.astype(int),
-        "shape": img.shape,
-        "visual_size": np.array(img.shape) / ppd,
+        "shape": shape,
+        "visual_size": visual_size,
         "ppd": ppd,
         "n_dots": n_dots,
         "dot_radius": dot_radius,
@@ -419,7 +419,7 @@ def dotted(
         radius=dot_radius,
         ppd=ppd,
         intensity_background=0.0,
-        intensity_discs=1.0,
+        intensity_disc=1.0,
     )["img"]
     patch = pad_by_visual_size(img=patch, padding=padding, ppd=ppd, pad_value=0.0)
     pixels_per_dot = patch.shape
@@ -457,8 +457,8 @@ def dotted(
     stim = {
         "img": img,
         "target_mask": mask.astype(int),
-        "shape": img.shape,
-        "visual_size": np.array(img.shape) / ppd,
+        "shape": shape,
+        "visual_size": visual_size,
         "ppd": ppd,
         "n_dots": n_dots,
         "dot_radius": dot_radius,
@@ -558,8 +558,6 @@ def two_sided_with_dots(
     del stim["intensity_background"]
     stim["intensity_backgrounds"] = intensity_backgrounds
     stim["intensity_dots"] = intensity_dots
-    stim["shape"] = shape
-    stim["visual_size"] = visual_size
     return stim
 
 
@@ -649,8 +647,6 @@ def two_sided_dotted(
     del stim["intensity_background"]
     stim["intensity_backgrounds"] = intensity_backgrounds
     stim["intensity_dots"] = intensity_dots
-    stim["shape"] = shape
-    stim["visual_size"] = visual_size
     return stim
 
 
diff --git a/stimupy/illusions/todorovics.py b/stimupy/illusions/todorovics.py
index 9a5305d0..0805cbb6 100644
--- a/stimupy/illusions/todorovics.py
+++ b/stimupy/illusions/todorovics.py
@@ -2,7 +2,7 @@
 
 from stimupy.components.shapes import cross as cross_shape
 from stimupy.components.shapes import rectangle as rectangle_shape
-from stimupy.utils import degrees_to_pixels, pad_dict_to_shape, resolution, stack_dicts
+from stimupy.utils import pad_dict_to_shape, resolution, stack_dicts
 
 __all__ = [
     "rectangle_generalized",
@@ -111,9 +111,16 @@ def rectangle_generalized(
     mask = stim["shape_mask"]
 
     # Add covers
-    cheight, cwidth = degrees_to_pixels(covers_size, ppd)
-    cx = degrees_to_pixels(covers_x, np.unique(ppd))
-    cy = degrees_to_pixels(covers_y, np.unique(ppd))
+    cheight, cwidth = resolution.lengths_from_visual_angles_ppd(
+        covers_size, np.unique(ppd), round=False
+    )
+    cx = resolution.lengths_from_visual_angles_ppd(covers_x, np.unique(ppd), round=False)
+    cy = resolution.lengths_from_visual_angles_ppd(covers_y, np.unique(ppd), round=False)
+
+    cheight = int(np.round(cheight))
+    cwidth = int(np.round(cwidth))
+    cx = np.round(cx).astype(int)
+    cy = np.round(cy).astype(int)
 
     if np.max(cx) < np.min(cx) + cwidth or np.max(cy) < np.min(cy) + cheight:
         raise ValueError("Covers overlap")
@@ -147,6 +154,7 @@ def rectangle(
     ppd=None,
     shape=None,
     target_size=None,
+    target_position=None,
     covers_size=None,
     covers_offset=None,
     intensity_background=0.0,
@@ -167,6 +175,8 @@ def rectangle(
         shape [height, width] of grating, in pixels
     target_size : float or (float, float)
         size of the target in degrees of visual angle (height, width)
+    target_position : float or (float, float)
+        coordinates where to place the target
     covers_size : float or (float, float)
         size of covers in degrees of visual angle (height, width)
     covers_offset : float or (float, float)
@@ -223,7 +233,7 @@ def rectangle(
         visual_size=visual_size,
         ppd=ppd,
         target_size=target_size,
-        target_position=None,
+        target_position=target_position,
         covers_size=covers_size,
         covers_x=(x1, x2, x2, x1),
         covers_y=(y1, y2, y1, y2),
@@ -338,9 +348,16 @@ def cross_generalized(
     img = stim["img"]
     mask = stim["shape_mask"]
 
-    cheight, cwidth = degrees_to_pixels(covers_size, ppd)
-    cx = degrees_to_pixels(covers_x, ppd)
-    cy = degrees_to_pixels(covers_y, ppd)
+    cheight, cwidth = resolution.lengths_from_visual_angles_ppd(
+        covers_size, np.unique(ppd), round=False
+    )
+    cx = resolution.lengths_from_visual_angles_ppd(covers_x, np.unique(ppd), round=False)
+    cy = resolution.lengths_from_visual_angles_ppd(covers_y, np.unique(ppd), round=False)
+
+    cheight = int(np.round(cheight))
+    cwidth = int(np.round(cwidth))
+    cx = np.round(cx).astype(int)
+    cy = np.round(cy).astype(int)
 
     for i in range(len(covers_x)):
         img[cy[i] : cy[i] + cheight, cx[i] : cx[i] + cwidth] = intensity_covers
diff --git a/stimupy/illusions/wedding_cakes.py b/stimupy/illusions/wedding_cakes.py
index 62418f46..7ff8347d 100644
--- a/stimupy/illusions/wedding_cakes.py
+++ b/stimupy/illusions/wedding_cakes.py
@@ -1,7 +1,7 @@
 import numpy as np
-from scipy.signal import fftconvolve
 
-from stimupy.utils import degrees_to_pixels, resolution
+from stimupy.utils import resolution
+from stimupy.utils.filters import convolve
 
 __all__ = [
     "wedding_cake",
@@ -69,7 +69,7 @@ def wedding_cake(
         raise ValueError("ppd should be equal in x and y direction")
 
     nY, nX = shape
-    Ly, Lx, Lw = degrees_to_pixels(L_size, np.unique(ppd))
+    Ly, Lx, Lw = resolution.lengths_from_visual_angles_ppd(L_size, np.unique(ppd))
     Lyh, Lxh = int(Ly / 2) + 1, int(Lx / 2) + 1
 
     # Create L-shaped patch
@@ -93,11 +93,11 @@ def wedding_cake(
     array2 = np.rot90(array2)
     amax = int((array2.max() + 1) / 2)
 
-    array3 = fftconvolve(array1, array2, "same")
+    array3 = convolve(array1, array2, "same")
 
     if target_indices1 is not None and target_height is not None:
         # Create target patch2
-        theight = degrees_to_pixels(target_height, np.unique(ppd))
+        theight = resolution.lengths_from_visual_angles_ppd(target_height, np.unique(ppd))
         tpatch1 = np.zeros(L_patch.shape)
         tpatch1[int(Ly / 2 - theight / 2) : int(Ly / 2 + theight / 2), Lx - Lw : :] = (
             -intensity_grating[1] + intensity_target
@@ -109,10 +109,10 @@ def wedding_cake(
             arr2 = np.copy(array2)
             arr1[arr1 != ty + 2] = 0
             arr2[arr2 != tx + amax] = 0
-            array_t1 += fftconvolve(arr1, arr2, "same")
+            array_t1 += convolve(arr1, arr2, "same")
         array_t1[array_t1 < 1] = 0
         array_t1[array_t1 > 1] = 1
-        t1 = np.round(fftconvolve(array_t1, tpatch1, "same"), 5)
+        t1 = np.round(convolve(array_t1, tpatch1, "same"), 5)
         t1 = t1[Lyh : Lyh + int(nY / 2), Lxh : Lxh + int(nX / 2)]
 
     else:
@@ -120,7 +120,7 @@ def wedding_cake(
 
     if target_indices2 is not None and target_height is not None:
         # Create target patch2
-        theight = degrees_to_pixels(target_height, np.unique(ppd))
+        theight = resolution.lengths_from_visual_angles_ppd(target_height, np.unique(ppd))
         tpatch2 = np.zeros(L_patch.shape)
         tpatch2[int(Ly / 2 - theight / 2) : int(Ly / 2 + theight / 2), Lx - Lw : :] = (
             -intensity_grating[0] + intensity_target
@@ -132,17 +132,17 @@ def wedding_cake(
             arr2 = np.copy(array2)
             arr1[arr1 != ty + 2] = 0
             arr2[arr2 != tx + amax] = 0
-            array_t2 += fftconvolve(arr1, arr2, "same")
+            array_t2 += convolve(arr1, arr2, "same")
         array_t2[array_t2 < 1] = 0
         array_t2[array_t2 > 1] = 1
-        t2 = np.round(fftconvolve(array_t2, tpatch2, "same"), 5)
+        t2 = np.round(convolve(array_t2, tpatch2, "same"), 5)
         t2 = t2[Lyh - Lw : Lyh + int(nY / 2) - Lw, Lxh + Lw : Lxh + int(nX / 2) + Lw]
 
     else:
         t2 = np.zeros([int(nY / 2), int(nX / 2)])
 
     # Create wedding cake pattern
-    imgt = fftconvolve(array3, L_patch, "same")
+    imgt = convolve(array3, L_patch, "same")
     imgt = np.round(imgt)
     img = np.copy(imgt)
     img[imgt > 1] = intensity_grating[1]
diff --git a/stimupy/illusions/whites.py b/stimupy/illusions/whites.py
index bc9352be..9db388f5 100644
--- a/stimupy/illusions/whites.py
+++ b/stimupy/illusions/whites.py
@@ -8,7 +8,7 @@
 from stimupy.illusions.angulars import pinwheel as radial
 from stimupy.illusions.circulars import rings as circular
 from stimupy.illusions.wedding_cakes import wedding_cake
-from stimupy.utils import degrees_to_pixels
+from stimupy.utils import resolution
 
 __all__ = [
     "generalized",
@@ -376,7 +376,7 @@ def anderson(
     n_bars=None,
     bar_width=None,
     period="ignore",
-    intensity_bars=(1.0, 0.0),
+    intensity_bars=(0.0, 1.0),
     intensity_target=0.5,
     target_indices_top=None,
     target_indices_bottom=None,
@@ -467,17 +467,20 @@ def anderson(
 
     img = stim["img"]
     mask = stim["target_mask"]
-    stripe_center_offset_px = degrees_to_pixels(stripe_center_offset, ppd)
-    stripe_size_px = degrees_to_pixels(stripe_height, ppd)
-    cycle_width_px = degrees_to_pixels(1.0 / (frequency * 2), ppd) * 2
-    phase_width_px = cycle_width_px // 2
+    soffset = resolution.lengths_from_visual_angles_ppd(stripe_center_offset, np.unique(ppd)[0])
+    sheight = resolution.lengths_from_visual_angles_ppd(stripe_height, np.unique(ppd)[0])
+    cycle_width = (
+        resolution.lengths_from_visual_angles_ppd(1.0 / (frequency * 2), np.unique(ppd)[0]) * 2
+    )
+
+    phase_width_px = cycle_width // 2
     height, width = img.shape
     nbars = width // phase_width_px
     ttop, tbot = np.array(target_indices_top), np.array(target_indices_bottom)
     ttop[ttop < 0] = nbars + ttop[ttop < 0]
     tbot[tbot < 0] = nbars + tbot[tbot < 0]
 
-    if stripe_size_px / 2.0 > stripe_center_offset_px:
+    if sheight / 2.0 > soffset:
         raise ValueError("Stripes overlap! Increase stripe offset or decrease stripe size.")
     if (target_height / 2 - target_center_offset + stripe_height / 2 - stripe_center_offset) > 0:
         raise ValueError(
@@ -485,27 +488,23 @@ def anderson(
             "decrease stripe or target size"
         )
     if stripe_center_offset * ppd % 1 != 0:
-        offsets_new = stripe_center_offset_px / ppd
+        offsets_new = soffset / ppd
         warnings.warn(
             f"Stripe offsets rounded because of ppd; {stripe_center_offset} -> {offsets_new}"
         )
 
     # Add stripe at top
-    ystart = height // 2 - stripe_center_offset_px - stripe_size_px // 2
-    img[ystart : ystart + stripe_size_px, 0 : phase_width_px * np.min(ttop)] = intensity_stripes[0]
-    img[
-        ystart : ystart + stripe_size_px, phase_width_px * (np.max(ttop) + 1) : :
-    ] = intensity_stripes[0]
-    if (ystart < 0) or (ystart + stripe_size_px > height):
+    ystart = height // 2 - soffset - sheight // 2
+    img[ystart : ystart + sheight, 0 : phase_width_px * np.min(ttop)] = intensity_stripes[0]
+    img[ystart : ystart + sheight, phase_width_px * (np.max(ttop) + 1) : :] = intensity_stripes[0]
+    if (ystart < 0) or (ystart + sheight > height):
         raise ValueError("Anderson stripes do not fully fit into stimulus")
 
     # Add stripe at bottom
-    ystart = height // 2 + stripe_center_offset_px - stripe_size_px // 2
-    img[ystart : ystart + stripe_size_px, 0 : phase_width_px * np.min(tbot)] = intensity_stripes[1]
-    img[
-        ystart : ystart + stripe_size_px, phase_width_px * (np.max(tbot) + 1) : :
-    ] = intensity_stripes[1]
-    if (ystart < 0) or (ystart + stripe_size_px > height):
+    ystart = height // 2 + soffset - sheight // 2
+    img[ystart : ystart + sheight, 0 : phase_width_px * np.min(tbot)] = intensity_stripes[1]
+    img[ystart : ystart + sheight, phase_width_px * (np.max(tbot) + 1) : :] = intensity_stripes[1]
+    if (ystart < 0) or (ystart + sheight > height):
         raise ValueError("Anderson stripes do not fully fit into stimulus")
 
     stim["img"] = img
@@ -524,7 +523,7 @@ def howe(
     n_bars=None,
     bar_width=None,
     period="ignore",
-    intensity_bars=(1.0, 0.0),
+    intensity_bars=(0.0, 1.0),
     intensity_target=0.5,
     target_indices_top=None,
     target_indices_bottom=None,
@@ -612,7 +611,7 @@ def yazdanbakhsh(
     n_bars=None,
     bar_width=None,
     period="ignore",
-    intensity_bars=(1.0, 0.0),
+    intensity_bars=(0.0, 1.0),
     intensity_target=0.5,
     target_indices_top=None,
     target_indices_bottom=None,
@@ -698,10 +697,14 @@ def yazdanbakhsh(
 
     img = stim["img"]
     mask = stim["target_mask"]
-    gap_size_px = degrees_to_pixels(gap_size, ppd)
-    target_offset_px = degrees_to_pixels(target_center_offset, ppd)
-    tsize_px = degrees_to_pixels(target_height, ppd)
-    cycle_width_px = degrees_to_pixels(1.0 / (frequency * 2), ppd) * 2
+    gap_size_px = resolution.lengths_from_visual_angles_ppd(gap_size, np.unique(ppd)[0])
+    target_offset_px = resolution.lengths_from_visual_angles_ppd(
+        target_center_offset, np.unique(ppd)[0]
+    )
+    tsize_px = resolution.lengths_from_visual_angles_ppd(target_height, np.unique(ppd)[0])
+    cycle_width_px = (
+        resolution.lengths_from_visual_angles_ppd(1.0 / (frequency * 2), np.unique(ppd)[0]) * 2
+    )
     phase_width_px = cycle_width_px // 2
     height, width = img.shape
     nbars = width // phase_width_px
diff --git a/stimupy/noises/__init__.py b/stimupy/noises/__init__.py
index ca7a656d..0b3c220a 100644
--- a/stimupy/noises/__init__.py
+++ b/stimupy/noises/__init__.py
@@ -6,6 +6,15 @@
 
 
 def create_overview():
+    """
+    Create dictionary with examples from all stimulus-noises
+
+    Returns
+    -------
+    stims : dict
+        dict with all stimuli containing individual stimulus dicts.
+    """
+
     params = {
         "visual_size": 10,
         "ppd": 10,
@@ -31,10 +40,26 @@ def create_overview():
     return stims
 
 
-def overview(mask=False, save=None):
+def overview(mask=False, save=None, extent_key="shape"):
+    """
+    Plot overview with examples from all stimulus-noises
+
+    Parameters
+    ----------
+    mask : bool or str, optional
+        If True, plot mask on top of stimulus image (default: False).
+        If string is provided, plot this key from stimulus dictionary as mask
+    save : None or str, optional
+        If None (default), do not save the plot.
+        If string is provided, save plot under this name.
+    extent_key : str, optional
+        Key to extent which will be used for plotting.
+        Default is "shape", using the image size in pixels as extent.
+
+    """
     from stimupy.utils import plot_stimuli
 
     stims = create_overview()
 
     # Plotting
-    plot_stimuli(stims, mask=mask, save=save)
+    plot_stimuli(stims, mask=mask, save=save, extent_key=extent_key)
diff --git a/stimupy/noises/binaries.py b/stimupy/noises/binaries.py
index cb12765a..0c757afd 100644
--- a/stimupy/noises/binaries.py
+++ b/stimupy/noises/binaries.py
@@ -47,9 +47,7 @@ def binary(
     binary_noise = np.random.randint(0, 2, size=shape) - 0.5
 
     # Adjust intensity range:
-    binary_noise = adapt_intensity_range(
-        {"img": binary_noise}, intensity_range[0], intensity_range[1]
-    )["img"]
+    binary_noise = adapt_intensity_range(binary_noise, intensity_range[0], intensity_range[1])
 
     stim = {
         "img": binary_noise,
diff --git a/stimupy/noises/narrowbands.py b/stimupy/noises/narrowbands.py
index 4b76e5bf..89ca947d 100644
--- a/stimupy/noises/narrowbands.py
+++ b/stimupy/noises/narrowbands.py
@@ -5,7 +5,7 @@
 import numpy as np
 
 from stimupy.noises.utils import pseudo_white_spectrum
-from stimupy.utils import bandpass_filter, resolution
+from stimupy.utils import resolution, bandpass
 from stimupy.utils.contrast_conversions import adapt_intensity_range
 
 __all__ = [
@@ -59,18 +59,9 @@ def narrowband(
     if len(np.unique(ppd)) > 1:
         raise ValueError("ppd should be equal in x and y direction")
 
-    # We calculate sigma to eventuate given bandwidth (in octaves)
-    sigma = (
-        center_frequency
-        / ((2.0**bandwidth + 1) * np.sqrt(2.0 * np.log(2.0)))
-        * (2.0**bandwidth - 1)
-    )
-
-    # Prepare spatial frequency axes and create bandpass filter:
-    fy = np.fft.fftshift(np.fft.fftfreq(shape[0], d=1.0 / np.unique(ppd)))
-    fx = np.fft.fftshift(np.fft.fftfreq(shape[1], d=1.0 / np.unique(ppd)))
-    Fx, Fy = np.meshgrid(fx, fy)
-    bp_filter = bandpass_filter(Fy, Fx, center_frequency, sigma)
+    bp = bandpass(
+        visual_size=visual_size, ppd=ppd, center_frequency=center_frequency, bandwidth=bandwidth
+    )["img"]
 
     if pseudo_noise:
         # Create white noise with frequency amplitude of 1 everywhere
@@ -81,16 +72,14 @@ def narrowband(
         white_noise_fft = np.fft.fftshift(np.fft.fft2(white_noise))
 
     # Filter white noise with bandpass filter
-    narrow_noise_fft = white_noise_fft * bp_filter
+    narrow_noise_fft = white_noise_fft * bp
 
     # ifft
     narrow_noise = np.fft.ifft2(np.fft.ifftshift(narrow_noise_fft))
     narrow_noise = np.real(narrow_noise)
 
     # Adjust intensity range:
-    narrow_noise = adapt_intensity_range(
-        {"img": narrow_noise}, intensity_range[0], intensity_range[1]
-    )["img"]
+    narrow_noise = adapt_intensity_range(narrow_noise, intensity_range[0], intensity_range[1])
 
     stim = {
         "img": narrow_noise,
@@ -99,7 +88,7 @@ def narrowband(
         "ppd": ppd,
         "shape": shape,
         "center_frequency": center_frequency,
-        "sigma": sigma,
+        "bandwidth": bandwidth,
         "pseudo_noise": pseudo_noise,
         "intensity_range": [narrow_noise.min(), narrow_noise.max()],
     }
diff --git a/stimupy/noises/naturals.py b/stimupy/noises/naturals.py
index 6b76865e..c687415f 100644
--- a/stimupy/noises/naturals.py
+++ b/stimupy/noises/naturals.py
@@ -81,7 +81,7 @@ def one_over_f(
     noise = np.real(noise)
 
     # Adjust intensity range:
-    noise = adapt_intensity_range({"img": noise}, intensity_range[0], intensity_range[1])["img"]
+    noise = adapt_intensity_range(noise, intensity_range[0], intensity_range[1])
 
     stim = {
         "img": noise,
diff --git a/stimupy/noises/utils.py b/stimupy/noises/utils.py
index f56e311d..f46693e6 100644
--- a/stimupy/noises/utils.py
+++ b/stimupy/noises/utils.py
@@ -4,10 +4,6 @@
 
 import numpy as np
 
-from stimupy.utils import oriented_filter
-
-# TODO: what to do with orient_noise?
-
 
 def randomize_sign(array):
     """Helper function that randomizes the sign of values in an array.
@@ -137,23 +133,3 @@ def pseudo_white_spectrum(
     # Set DC = 0:
     spectrum[int(y / 2), int(x / 2)] = 0 + 0j
     return spectrum
-
-
-# Function to orient noise
-def orient_noise(noise, sigma, orientation, ppd=60.0, rms_contrast=0.2):
-    shape = noise.shape
-
-    # Prepare spatial frequency axes and create bandpass filter:
-    fy = np.fft.fftshift(np.fft.fftfreq(shape[0], d=1.0 / ppd))
-    fx = np.fft.fftshift(np.fft.fftfreq(shape[1], d=1.0 / ppd))
-    Fx, Fy = np.meshgrid(fx, fy)
-    ofilter = oriented_filter(Fx, Fy, sigma, orientation)
-
-    noise_fft = np.fft.fftshift(np.fft.fft2(noise))
-    ori_noise_fft = noise_fft * ofilter
-    ori_noise = np.fft.ifft2(np.fft.ifftshift(ori_noise_fft))
-    ori_noise = np.real(ori_noise)
-
-    # Re-adjust noise rms contrast:
-    ori_noise = rms_contrast * ori_noise / ori_noise.std()
-    return {"img": ori_noise}
diff --git a/stimupy/noises/whites.py b/stimupy/noises/whites.py
index 2a12090b..341090a1 100644
--- a/stimupy/noises/whites.py
+++ b/stimupy/noises/whites.py
@@ -58,9 +58,7 @@ def white(
         white_noise = np.random.rand(*shape) * 2.0 - 1.0
 
     # Adjust intensity range:
-    white_noise = adapt_intensity_range(
-        {"img": white_noise}, intensity_range[0], intensity_range[1]
-    )["img"]
+    white_noise = adapt_intensity_range(white_noise, intensity_range[0], intensity_range[1])
 
     stim = {
         "img": white_noise,
diff --git a/stimupy/papers/RHS2007.py b/stimupy/papers/RHS2007.py
index c68d4be3..eb25a01d 100644
--- a/stimupy/papers/RHS2007.py
+++ b/stimupy/papers/RHS2007.py
@@ -117,7 +117,7 @@ def WE_thick(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -132,7 +132,7 @@ def WE_thick(ppd=PPD, pad=True):
     params = {
         "ppd": ppd,
         "frequency": 4.0 / width,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "target_indices": (2, 5),
         "target_height": 4.0,
@@ -176,7 +176,7 @@ def WE_thin_wide(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -191,7 +191,7 @@ def WE_thin_wide(ppd=PPD, pad=True):
     params = {
         "ppd": ppd,
         "frequency": 8.0 / width,
-        "intensity_bars": (v3, v1),
+        "intensity_bars": (v1, v3),
         "intensity_target": v2,
         "target_indices": (3, 12),
         "target_height": 2.0,
@@ -235,7 +235,7 @@ def WE_dual(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
     """
 
@@ -243,7 +243,7 @@ def WE_dual(ppd=PPD, pad=True):
     params = {
         "ppd": ppd,
         "frequency": 4.0 / width,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "target_indices": (2, 5),
         "target_height": 2.0,
@@ -295,7 +295,7 @@ def WE_anderson(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -311,7 +311,7 @@ def WE_anderson(ppd=PPD, pad=True):
     params = {
         "ppd": ppd,
         "frequency": 8.0 / width,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "target_indices_top": (5,),
         "target_indices_bottom": (10,),
@@ -360,7 +360,7 @@ def WE_howe(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -377,7 +377,7 @@ def WE_howe(ppd=PPD, pad=True):
     params = {
         "ppd": ppd,
         "frequency": 8.0 / width,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "target_indices_top": (5,),
         "target_indices_bottom": (10,),
@@ -421,7 +421,7 @@ def WE_zigzag(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -464,7 +464,7 @@ def WE_radial_thick_small(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -517,7 +517,7 @@ def WE_radial_thick(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -570,7 +570,7 @@ def WE_radial_thin_small(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -623,7 +623,7 @@ def WE_radial_thin(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -676,7 +676,7 @@ def WE_circular1(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -696,13 +696,13 @@ def WE_circular1(ppd=PPD, pad=True):
 
     stim1 = illusions.whites.circular(
         visual_size=(height, width),
-        intensity_rings=(v3, v1),
+        intensity_rings=(v1, v3),
         **params,
     )
 
     stim2 = illusions.whites.circular(
         visual_size=(height, width),
-        intensity_rings=(v1, v3),
+        intensity_rings=(v3, v1),
         **params,
     )
     stim = stack_dicts(stim1, stim2)
@@ -734,7 +734,7 @@ def WE_circular05(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -754,13 +754,13 @@ def WE_circular05(ppd=PPD, pad=True):
 
     stim1 = illusions.whites.circular(
         visual_size=(height, width),
-        intensity_rings=(v3, v1),
+        intensity_rings=(v1, v3),
         **params,
     )
 
     stim2 = illusions.whites.circular(
         visual_size=(height, width),
-        intensity_rings=(v1, v3),
+        intensity_rings=(v3, v1),
         **params,
     )
     stim = stack_dicts(stim1, stim2)
@@ -792,7 +792,7 @@ def WE_circular025(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -812,13 +812,13 @@ def WE_circular025(ppd=PPD, pad=True):
 
     stim1 = illusions.whites.circular(
         visual_size=(height, width),
-        intensity_rings=(v3, v1),
+        intensity_rings=(v1, v3),
         **params,
     )
 
     stim2 = illusions.whites.circular(
         visual_size=(height, width),
-        intensity_rings=(v1, v3),
+        intensity_rings=(v3, v1),
         **params,
     )
     stim = stack_dicts(stim1, stim2)
@@ -850,7 +850,7 @@ def grating_induction(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -911,7 +911,7 @@ def sbc_large(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -970,7 +970,7 @@ def sbc_small(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1029,7 +1029,7 @@ def todorovic_equal(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1094,7 +1094,7 @@ def todorovic_in_large(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1157,7 +1157,7 @@ def todorovic_in_small(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1221,7 +1221,7 @@ def todorovic_out(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1286,7 +1286,7 @@ def checkerboard_016(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1341,7 +1341,7 @@ def checkerboard_094(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1396,7 +1396,7 @@ def checkerboard_21(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1449,7 +1449,7 @@ def corrugated_mondrian(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1508,7 +1508,7 @@ def benary_cross(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1570,7 +1570,7 @@ def todorovic_benary1_2(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1628,7 +1628,7 @@ def todorovic_benary3_4(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1686,7 +1686,7 @@ def todorovic_benary1_2_3_4(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1740,7 +1740,7 @@ def bullseye_thin(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1749,12 +1749,12 @@ def bullseye_thin(ppd=PPD, pad=True):
         Research, 44, 309–319.
     """
 
-    frame_radii = np.array([0.304, 0.426, 0.548, 0.670, 0.792])
+    radii = np.array([0.304, 0.426, 0.548, 0.670, 0.792])
 
     params = {
         "visual_size": 0.792 * 2,
         "ppd": ppd,
-        "frame_radii": frame_radii,
+        "radii": radii,
         "intensity_target": v2,
     }
 
@@ -1799,7 +1799,7 @@ def bullseye_thick(ppd=PPD, pad=True):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1807,12 +1807,12 @@ def bullseye_thick(ppd=PPD, pad=True):
     Bindman, D., & Chubb, C. (2004). Brightness assimilation in bullseye displays. Vision
         Research, 44, 309–319.
     """
-    frame_radii = np.array([0.304, 0.547, 0.790, 1.033, 1.276])
+    radii = np.array([0.304, 0.547, 0.790, 1.033, 1.276])
 
     params = {
         "visual_size": 1.276 * 2,
         "ppd": ppd,
-        "frame_radii": frame_radii,
+        "radii": radii,
         "intensity_target": v2,
     }
 
diff --git a/stimupy/papers/__init__.py b/stimupy/papers/__init__.py
index 984383b0..b88e765e 100644
--- a/stimupy/papers/__init__.py
+++ b/stimupy/papers/__init__.py
@@ -1,6 +1,7 @@
 __all__ = [
-    "carney1999",
+    "bindmann2004",
     "domijan2015",
+    "modelfest",
     "murray2020",
     "RHS2007",
     "white1981",
diff --git a/stimupy/papers/bindmann2004.py b/stimupy/papers/bindmann2004.py
new file mode 100644
index 00000000..76ee857c
--- /dev/null
+++ b/stimupy/papers/bindmann2004.py
@@ -0,0 +1,1968 @@
+"""Stimuli from Bindmann & Chubb (2004)
+https://doi.org/10.1016/S0042-6989(03)00430-9
+
+This module reproduces most of the stimuli used by Bindmann & Chubb (2004) as
+they were described in that paper.
+
+Each stimulus is provided by a separate function,
+a full list can be found as stimupy.papers.bindmann2004.__all__
+
+The output of each of these functions is a stimulus dictionary.
+
+For a visual representation of all the stimuli and their mask,
+simply run this module as a script:
+
+    $ python stimuli/papers/bindmann2004.py
+
+Attributes
+----------
+__all__ (list of str): list of all stimulus-functions
+    that are exported by this module when executing
+        >>> from stimupy.papers.bindmann2004 import *
+
+References
+-----------
+Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+"""
+
+import copy
+import warnings
+import numpy as np
+
+from stimupy import illusions
+from stimupy.utils.contrast_conversions import adapt_intensity_range_dict
+from stimupy.utils.pad import pad_dict_by_visual_size
+
+__all__ = [
+    "bullseye_thin_gw45_gb31",
+    "bullseye_thin_gw45_gb38",
+    "bullseye_thin_gw45_gb45",
+    "bullseye_thin_gw45_gb52",
+    "bullseye_thin_gw45_gb59",
+    "bullseye_thin_gw60_gb46",
+    "bullseye_thin_gw60_gb53",
+    "bullseye_thin_gw60_gb60",
+    "bullseye_thin_gw60_gb67",
+    "bullseye_thin_gw60_gb74",
+    "bullseye_thin_gw75_gb61",
+    "bullseye_thin_gw75_gb68",
+    "bullseye_thin_gw75_gb75",
+    "bullseye_thin_gw75_gb82",
+    "bullseye_thin_gw75_gb89",
+    "bullseye_thick_gw45_gb31",
+    "bullseye_thick_gw45_gb38",
+    "bullseye_thick_gw45_gb45",
+    "bullseye_thick_gw45_gb52",
+    "bullseye_thick_gw45_gb59",
+    "bullseye_thick_gw60_gb46",
+    "bullseye_thick_gw60_gb53",
+    "bullseye_thick_gw60_gb60",
+    "bullseye_thick_gw60_gb67",
+    "bullseye_thick_gw60_gb74",
+    "bullseye_thick_gw75_gb61",
+    "bullseye_thick_gw75_gb68",
+    "bullseye_thick_gw75_gb75",
+    "bullseye_thick_gw75_gb82",
+    "bullseye_thick_gw75_gb89",
+]
+
+# The authors say that they used a 15" Sony Trinitron monitor. We found the
+# following specs online
+monitor_height_px = 1024
+monitor_width_px = 768
+monitor_height_cm = 40.0
+monitor_width_cm = 30.0
+distance_cm = 132  # provided in the paper
+ppcm = monitor_height_cm / monitor_height_px
+PPD = int(np.tan(np.pi / 180.0 / 2.0) * 2.0 * distance_cm / ppcm)
+
+TARGET_SIZE = 0.608
+INTENSITY_BLACK = 1
+INTENSITY_WHITE = 118
+INTENSITY_BACKGROUND = 60
+PAD1 = ((0, 1.2), (0, 2.7))
+PAD2 = ((1.2, 0), (2.7, 0))
+
+
+def gen_all(ppd=PPD, skip=False):
+    stims = {}  # save the stimulus-dicts in a larger dict, with name as key
+    for stim_name in __all__:
+        print(f"Generating bindmann2004.{stim_name}")
+
+        # Get a reference to the actual function
+        func = globals()[stim_name]
+        try:
+            stim = func(ppd=ppd)
+
+            # Accumulate
+            stims[stim_name] = stim
+        except NotImplementedError as e:
+            if not skip:
+                raise e
+            # Skip stimuli that aren't implemented
+            print("-- not implemented")
+            pass
+
+    return stims
+
+
+def resolve_target_size(target_size=TARGET_SIZE, ppd=PPD):
+    target_size_old = copy.deepcopy(target_size)
+    target_size = np.round(target_size / 2 * ppd) / ppd * 2
+
+    if target_size_old != target_size:
+        warnings.warn(
+            f"Rounding target_size because of ppd; {target_size_old} -> {target_size}. "
+            "This might also affect stimulus size."
+        )
+    return target_size
+
+
+def resolve_band_width(band_width, ppd=PPD):
+    band_width_old = copy.deepcopy(band_width)
+    band_width = np.round(band_width * ppd) / ppd
+
+    if band_width_old != band_width:
+        warnings.warn(
+            f"Rounding band_width because of ppd; {band_width_old} -> {band_width}. "
+            "This might also affect stimulus size."
+        )
+    return band_width
+
+
+def bullseye_thin_gw45_gb31(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 31 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+        Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=31.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw45_gb38(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 38 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=38.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw45_gb45(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 45 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=45.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw45_gb52(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 52 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=52.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw45_gb59(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 59 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=59.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw60_gb46(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 46 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=46.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw60_gb53(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 53 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=53.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw60_gb60(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 60 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=60.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw60_gb67(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 67 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=67.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw60_gb74(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 74 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=74.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw75_gb61(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 61 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=61.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw75_gb68(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 68 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=68.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw75_gb75(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 75 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=75.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw75_gb82(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 82 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=82.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thin_gw75_gb89(ppd=PPD):
+    """Bullseye thin display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.122 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 89 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.122, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=89.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw45_gb31(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 31 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=31.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw45_gb38(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 38 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=38.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw45_gb45(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 45 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=45.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw45_gb52(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 52 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=52.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw45_gb59(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 45 & 59 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=45.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=59.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw60_gb46(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 46 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=46.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw60_gb53(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 53 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=53.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw60_gb60(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 60 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=60.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw60_gb67(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 67 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=67.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw60_gb74(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 60 & 74 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=60.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=74.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw75_gb61(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 61 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=61.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw75_gb68(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 68 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=68.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw75_gb75(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 75 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=75.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw75_gb82(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 82 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=82.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+def bullseye_thick_gw75_gb89(ppd=PPD):
+    """Bullseye thick display from Bindmann & Chubb (2004)
+    Target size: 0.608 x 0.608 deg
+    Band widths: 0.243 deg
+    Target distance (center to center): 1.2 x 2.7 deg
+    Target luminances: 75 & 89 cd2/m
+
+    Parameters
+    ----------
+    ppd : int
+        Resolution of stimulus in pixels per degree.
+
+    Returns
+    -------
+    dict of str
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
+        and additional keys containing stimulus parameters
+
+    References
+    -----------
+    Bindmann, D. & Chubb, C. (2004). Brightness assimilation in bullseye displays.
+    Vision Research, 44 (3), 309-319. https://doi.org/10.1016/S0042-6989(03)00430-9
+    """
+
+    target_size_half = resolve_target_size(ppd=ppd) / 2
+    band_width = resolve_band_width(0.243, ppd=ppd)
+    radii = np.linspace(target_size_half, target_size_half + band_width * 4, 5)
+
+    params = {
+        "shape": (target_size_half + band_width * 4) * 2 * ppd - 1,
+        "ppd": ppd,
+        "radii": radii,
+    }
+
+    stim1 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_BLACK, INTENSITY_WHITE),
+        intensity_target=75.0,
+    )
+    stim2 = illusions.frames.bullseye_generalized(
+        **params,
+        intensity_frames=(INTENSITY_WHITE, INTENSITY_BLACK),
+        intensity_target=89.0,
+    )
+
+    # Pad
+    stim1 = pad_dict_by_visual_size(stim1, PAD1, ppd, pad_value=INTENSITY_BACKGROUND)
+    stim2 = pad_dict_by_visual_size(stim2, PAD2, ppd, pad_value=INTENSITY_BACKGROUND)
+
+    # Update dict keys
+    stim1["img"] = np.where(stim2["pad_mask"] == 0, stim2["img"], stim1["img"])
+    stim1["target_mask"] = np.where(stim2["target_mask"], 2, stim1["target_mask"])
+    stim1["frame_mask2"] = stim2["frame_mask"]
+    stim1["pad_mask2"] = stim2["pad_mask"]
+    stim1["original_range"] = (INTENSITY_BLACK, INTENSITY_WHITE)
+
+    # Adapt range between 0 and 1
+    stim = adapt_intensity_range_dict(stim1)
+    return stim
+
+
+if __name__ == "__main__":
+    from stimupy.utils import plot_stimuli
+
+    stims = gen_all(skip=True)
+    plot_stimuli(stims, mask=False)
diff --git a/stimupy/papers/domijan2015.py b/stimupy/papers/domijan2015.py
index dd3c2b32..7d5a8e53 100644
--- a/stimupy/papers/domijan2015.py
+++ b/stimupy/papers/domijan2015.py
@@ -181,6 +181,9 @@ def resolve(shape, visual_size, ppd, original_visual_size):
             shape, np.array(original_visual_size) * visual_resize, ppd
         )
 
+    if ppd[0] % 2 != 0:
+        raise ValueError("use an even ppd for domijan2015-stimuli")
+
     return shape, visual_size, ppd, visual_resize
 
 
@@ -199,7 +202,7 @@ def dungeon(visual_size=VSIZES["dungeon"], ppd=PPD, shape=SHAPES["dungeon"]):
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -268,7 +271,7 @@ def cube(visual_size=VSIZES["cube"], ppd=PPD, shape=SHAPES["cube"]):
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -336,7 +339,7 @@ def grating(visual_size=VSIZES["grating"], ppd=PPD, shape=SHAPES["grating"]):
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -364,12 +367,12 @@ def grating(visual_size=VSIZES["grating"], ppd=PPD, shape=SHAPES["grating"]):
 
     stim1 = illusions.gratings.square_wave(
         **params,
-        intensity_bars=(v3, v1),
+        intensity_bars=(v1, v3),
         intensity_target=v2,
     )
     stim2 = illusions.gratings.square_wave(
         **params,
-        intensity_bars=(v1, v3),
+        intensity_bars=(v3, v1),
         intensity_target=v2,
     )
 
@@ -406,7 +409,7 @@ def rings(visual_size=VSIZES["rings"], ppd=PPD, shape=SHAPES["rings"]):
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -418,12 +421,12 @@ def rings(visual_size=VSIZES["rings"], ppd=PPD, shape=SHAPES["rings"]):
 
     # Resolve resolution
     shape, visual_size, ppd, visual_resize = resolve(shape, visual_size, ppd, VSIZES["rings"])
-    frame_radii = np.array((0.55, 1.05, 1.55, 2.05, 2.55, 3.05, 3.55, 4.05)) * visual_resize
+    radii = np.array((0.55, 1.05, 1.55, 2.05, 2.55, 3.05, 3.55, 4.05)) * visual_resize
 
     params = {
-        "visual_size": 4.05 * 2,
+        "visual_size": radii.max() * 2,
         "ppd": ppd,
-        "frame_radii": frame_radii,
+        "radii": radii,
     }
 
     stim1 = illusions.frames.rings_generalized(
@@ -471,7 +474,7 @@ def bullseye(visual_size=VSIZES["bullseye"], ppd=PPD, shape=SHAPES["bullseye"]):
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -483,12 +486,12 @@ def bullseye(visual_size=VSIZES["bullseye"], ppd=PPD, shape=SHAPES["bullseye"]):
 
     # Resolve resolution
     shape, visual_size, ppd, visual_resize = resolve(shape, visual_size, ppd, VSIZES["bullseye"])
-    frame_radii = np.array((0.55, 1.05, 1.55, 2.05, 2.55, 3.05, 3.55, 4.05)) * visual_resize
+    radii = np.array((0.55, 1.05, 1.55, 2.05, 2.55, 3.05, 3.55, 4.05)) * visual_resize
 
     params = {
-        "visual_size": 4.05 * 2,
+        "visual_size": radii.max() * 2,
         "ppd": ppd,
-        "frame_radii": frame_radii,
+        "radii": radii,
     }
 
     stim1 = illusions.frames.bullseye_generalized(
@@ -538,7 +541,7 @@ def simultaneous_brightness_contrast(
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -558,7 +561,7 @@ def simultaneous_brightness_contrast(
         "visual_size": visual_size[0],
         "ppd": ppd,
         "target_size": (2.1 * visual_resize, 2.1 * visual_resize),
-        "target_position": (3.9 * visual_resize, 3.9 * visual_resize),
+        "target_position": (3.8 * visual_resize, 3.8 * visual_resize),
     }
 
     stim1 = illusions.sbcs.generalized(
@@ -604,7 +607,7 @@ def white(visual_size=VSIZES["white"], ppd=PPD, pad=PAD, shape=SHAPES["white"]):
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -629,7 +632,7 @@ def white(visual_size=VSIZES["white"], ppd=PPD, pad=PAD, shape=SHAPES["white"]):
 
     stim = illusions.whites.white(
         **params,
-        intensity_bars=(v1, v3),
+        intensity_bars=(v3, v1),
         intensity_target=v2,
     )
 
@@ -670,7 +673,7 @@ def benary(visual_size=VSIZES["benary"], ppd=PPD, shape=SHAPES["benary"]):
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -729,7 +732,7 @@ def todorovic(visual_size=VSIZES["todorovic"], ppd=PPD, shape=SHAPES["todorovic"
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -748,6 +751,7 @@ def todorovic(visual_size=VSIZES["todorovic"], ppd=PPD, shape=SHAPES["todorovic"
         "visual_size": visual_size[0],
         "ppd": ppd,
         "target_size": 4.1 * visual_resize,
+        "target_position": 2.8 * visual_resize,
         "covers_size": 3.1 * visual_resize,
         "covers_offset": 2.0 * visual_resize,
     }
@@ -802,7 +806,7 @@ def checkerboard_contrast_contrast(
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -890,7 +894,7 @@ def checkerboard(
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -961,7 +965,7 @@ def checkerboard_extended(
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1029,7 +1033,7 @@ def white_yazdanbakhsh(
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1062,7 +1066,7 @@ def white_yazdanbakhsh(
 
     stim = illusions.whites.yazdanbakhsh(
         **params,
-        intensity_bars=(v1, v3),
+        intensity_bars=(v3, v1),
         intensity_target=v2,
         intensity_stripes=(v3, v1),
     )
@@ -1082,7 +1086,7 @@ def white_yazdanbakhsh(
         original_shape_no_padding=original_shape_np,
         original_visual_size_no_padding=VSIZES["white_yazdanbakhsh"],
         original_range=(1, 9),
-        intensity_range=(v1, v3),
+        intensity_range=(v3, v1),
         visual_size=resolution.visual_size_from_shape_ppd(stim["img"].shape, ppd),
         shape=stim["img"].shape,
     )
@@ -1108,7 +1112,7 @@ def white_anderson(
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1144,7 +1148,7 @@ def white_anderson(
 
     stim = illusions.whites.anderson(
         **params,
-        intensity_bars=(v3, v1),
+        intensity_bars=(v1, v3),
         intensity_target=v2,
         intensity_stripes=(v1, v3),
     )
@@ -1188,7 +1192,7 @@ def white_howe(visual_size=VSIZES["white_howe"], ppd=PPD, shape=SHAPES["white_ho
     Returns
     -------
     dict[str, Any]
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -1221,7 +1225,7 @@ def white_howe(visual_size=VSIZES["white_howe"], ppd=PPD, shape=SHAPES["white_ho
 
     stim = illusions.whites.howe(
         **params,
-        intensity_bars=(v3, v1),
+        intensity_bars=(v1, v3),
         intensity_target=v2,
         intensity_stripes=(v1, v3),
     )
diff --git a/stimupy/papers/carney1999.py b/stimupy/papers/modelfest.py
similarity index 97%
rename from stimupy/papers/carney1999.py
rename to stimupy/papers/modelfest.py
index 1aafc7be..6788b8f1 100644
--- a/stimupy/papers/carney1999.py
+++ b/stimupy/papers/modelfest.py
@@ -7,20 +7,20 @@
 https://www.visionscience.com/data/modelfest/
 
 Each stimulus is provided by a separate function,
-a full list can be found as stimupy.papers.carney1999.__all__
+a full list can be found as stimupy.papers.modelfest.__all__
 
 The output of each of these functions is a stimulus dictionary.
 
 For a visual representation of all the stimuli and their mask,
 simply run this module as a script:
 
-    $ python stimuli/papers/carney1999.py
+    $ python stimuli/papers/modelfest.py
 
 Attributes
 ----------
 __all__ (list of str): list of all stimulus-functions
     that are exported by this module when executing
-        >>> from stimupy.papers.carney1999 import *
+        >>> from stimupy.papers.modelfest import *
 
 References
 -----------
@@ -93,14 +93,14 @@
 
 mean_lum = 0.5
 
-df = pd.read_csv(Path(__file__).parents[0] / "carney1999_data.csv", header=None)
+df = pd.read_csv(Path(__file__).parents[0] / "modelfest_data.csv", header=None)
 participants = df[0]
 
 
 def gen_all(ppd=PPD, skip=False):
     stims = {}  # save the stimulus-dicts in a larger dict, with name as key
     for stim_name in __all__:
-        print(f"Generating carney1999.{stim_name}")
+        print(f"Generating modelfest.{stim_name}")
 
         # Get a reference to the actual function
         func = globals()[stim_name]
@@ -1136,7 +1136,7 @@ def Subthreshold22(ppd=PPD):
         "sigma": 0.5,
         "rotation": 90,
         "phase_shift": 90,
-        "origin": "mean",
+        "origin": "center",
     }
 
     stim1 = gratings.gabor(**params, frequency=2)
@@ -1144,7 +1144,6 @@ def Subthreshold22(ppd=PPD):
 
     stim1["img"] = stim1["img"] / 2 + stim2["img"] / 2
     stim1["grating_mask2"] = stim2["grating_mask"]
-    stim1["edges2"] = stim2["edges"]
     stim1["frequency2"] = stim2["frequency"]
     stim1["bar_width2"] = stim2["bar_width"]
     stim1["n_bars2"] = stim2["n_bars"]
@@ -1190,7 +1189,7 @@ def Subthreshold23(ppd=PPD):
         "sigma": 0.5,
         "rotation": 90,
         "phase_shift": 90,
-        "origin": "mean",
+        "origin": "center",
     }
 
     stim1 = gratings.gabor(**params, frequency=2)
@@ -1198,7 +1197,6 @@ def Subthreshold23(ppd=PPD):
 
     stim1["img"] = stim1["img"] / 2 + stim2["img"] / 2
     stim1["grating_mask2"] = stim2["grating_mask"]
-    stim1["edges2"] = stim2["edges"]
     stim1["frequency2"] = stim2["frequency"]
     stim1["bar_width2"] = stim2["bar_width"]
     stim1["n_bars2"] = stim2["n_bars"]
@@ -1244,7 +1242,7 @@ def Subthreshold24(ppd=PPD):
         "sigma": 0.5,
         "rotation": 90,
         "phase_shift": 90,
-        "origin": "mean",
+        "origin": "center",
     }
 
     stim1 = gratings.gabor(**params, frequency=4)
@@ -1252,7 +1250,6 @@ def Subthreshold24(ppd=PPD):
 
     stim1["img"] = stim1["img"] / 2 + stim2["img"] / 2
     stim1["grating_mask2"] = stim2["grating_mask"]
-    stim1["edges2"] = stim2["edges"]
     stim1["frequency2"] = stim2["frequency"]
     stim1["bar_width2"] = stim2["bar_width"]
     stim1["n_bars2"] = stim2["n_bars"]
@@ -1298,7 +1295,7 @@ def Subthreshold25(ppd=PPD):
         "sigma": 0.5,
         "rotation": 90,
         "phase_shift": 90,
-        "origin": "mean",
+        "origin": "center",
     }
 
     stim1 = gratings.gabor(**params, frequency=4)
@@ -1306,7 +1303,6 @@ def Subthreshold25(ppd=PPD):
 
     stim1["img"] = stim1["img"] / 2 + stim2["img"] / 2
     stim1["grating_mask2"] = stim2["grating_mask"]
-    stim1["edges2"] = stim2["edges"]
     stim1["frequency2"] = stim2["frequency"]
     stim1["bar_width2"] = stim2["bar_width"]
     stim1["n_bars2"] = stim2["n_bars"]
@@ -1350,6 +1346,7 @@ def Gaussians26(ppd=PPD):
         "ppd": ppd,
         "sigma": 0.5,
         "intensity_max": 1,
+        "origin": "center",
     }
 
     stim = gaussians.gaussian(**params)
@@ -1394,6 +1391,7 @@ def Gaussians27(ppd=PPD):
         "ppd": ppd,
         "sigma": 8.43 / 60,
         "intensity_max": 1,
+        "origin": "center",
     }
 
     stim = gaussians.gaussian(**params)
@@ -1438,6 +1436,7 @@ def Gaussians28(ppd=PPD):
         "ppd": ppd,
         "sigma": 2.106 / 60,
         "intensity_max": 1,
+        "origin": "center",
     }
 
     stim = gaussians.gaussian(**params)
@@ -1482,6 +1481,7 @@ def Gaussians29(ppd=PPD):
         "ppd": ppd,
         "sigma": 1.05 / 60,
         "intensity_max": 1,
+        "origin": "center",
     }
 
     stim = gaussians.gaussian(**params)
@@ -1679,6 +1679,8 @@ def GaborString33(ppd=PPD):
 
     stim["img"] = stimc["img"]
     stim["grating_mask"] = stimc["grating_mask"]
+    stim["visual_size"] = stimc["visual_size"]
+    stim["shape"] = stimc["shape"]
     stim = roll_dict(stim, (-1, -1), axes=(0, 1))
 
     v = 129
@@ -1738,8 +1740,9 @@ def GaborString34(ppd=PPD):
 
     stim1["img"] = stimc["img"]
     stim1["grating_mask"] = stimc["grating_mask"]
+    stim1["visual_size"] = stimc["visual_size"]
+    stim1["shape"] = stimc["shape"]
     stim1["phase_shift2"] = stim2["phase_shift"]
-    stim1["edges2"] = stim2["edges"]
     stim1 = roll_dict(stim1, (-1, -1), axes=(0, 1))
 
     v = 133
@@ -1809,8 +1812,8 @@ def Noise35(ppd=PPD):
         542-551. https://doi.org/10.1117/12.348473
     """
     # Read natural image from Modelfest
-    img = read_tif(Path(__file__).parents[0] / "carney1999_noise.tif")
-    img = img / img.max()
+    img = read_tif(Path(__file__).parents[0] / "modelfest_noise.tif")
+    img = img / 255
 
     stim = {
         "img": img,
@@ -1959,7 +1962,7 @@ def Plaids38(ppd=PPD):
         "frequency": 4,
         "sigma": 0.14,
         "phase_shift": 90,
-        "origin": "mean",
+        "origin": "center",
     }
 
     stim = gratings.gabor(**params, rotation=0)
@@ -1968,7 +1971,6 @@ def Plaids38(ppd=PPD):
     stim["img"] = stim["img"] / 2 + stim2["img"] / 2
     stim["rotation"] = stim2["rotation"]
     stim["grating_mask2"] = stim2["grating_mask"]
-    stim["distances2"] = stim2["distances"]
 
     v = 149
     experimental_data = {
@@ -2012,7 +2014,7 @@ def Plaids39(ppd=PPD):
         "frequency": 4,
         "sigma": 0.14,
         "phase_shift": 90,
-        "origin": "mean",
+        "origin": "center",
     }
 
     stim = gratings.gabor(**params, rotation=45)
@@ -2021,7 +2023,6 @@ def Plaids39(ppd=PPD):
     stim["img"] = stim["img"] / 2 + stim2["img"] / 2
     stim["rotation"] = stim2["rotation"]
     stim["grating_mask2"] = stim2["grating_mask"]
-    stim["distances2"] = stim2["distances"]
 
     v = 153
     experimental_data = {
@@ -2097,14 +2098,14 @@ def Bessel41(ppd=PPD):
 
     stim = bessel(visual_size=256 / PPD, ppd=ppd, frequency=4, origin="center")
     window = gaussians.gaussian(visual_size=256 / PPD, ppd=ppd, sigma=0.5)
-
-    mean_int = stim["img"].mean()
-    img = (stim["img"] - mean_int) * window["img"]
-    stim["img"] = img + mean_int
-    stim["img"] = stim["img"] + 0.41
-    stim["img"] = stim["img"] / stim["img"].max()  # TODO: adjust range
     stim["sigma"] = window["sigma"]
 
+    # Apply Gaussian windows to Bessel
+    stim["img"] = (stim["img"] - stim["img"].mean()) * window["img"]
+
+    # Set "background" intensity to 0.5 and make sure that max intensity = 1
+    stim["img"] = stim["img"] / stim["img"].max() / 2 + 0.5
+
     v = 161
     experimental_data = {
         "participants": participants,
@@ -2189,8 +2190,8 @@ def NaturalScene43(ppd=PPD):
     """
 
     # Read natural image from Modelfest
-    img = read_tif(Path(__file__).parents[0] / "carney1999_natural_scene.tif")
-    img = img / img.max()
+    img = read_tif(Path(__file__).parents[0] / "modelfest_natural_scene.tif")
+    img = img / 255
 
     stim = {
         "img": img,
@@ -2222,9 +2223,10 @@ def read_tif(filename):
 def compare(o1, s1, filename):
     import matplotlib.pyplot as plt
 
-    o1 = o1 / o1.mean()
-    s1 = s1 / s1.mean()
-    vmin, vmax = o1.min(), o1.max()
+    o1 = o1 / 255
+    s1 = s1 / 1
+    vmin, vmax = 0, 1
+
     plt.figure(figsize=(20, 6))
     plt.subplot(141), plt.imshow(o1, vmin=vmin, vmax=vmax), plt.title("Original")
     plt.subplot(142), plt.imshow(s1, vmin=vmin, vmax=vmax), plt.title("Our implementation")
@@ -2238,6 +2240,11 @@ def compare(o1, s1, filename):
 
 
 def compare_all():
+    import os
+
+    if not os.path.exists("./comparisons/"):
+        os.makedirs("./comparisons/")
+
     for stim_name in __all__:
         func = globals()[stim_name]
         o1 = read_tif(str(Path(__file__).parents[0]) + "/modelfest/" + stim_name + ".tif")
@@ -2249,5 +2256,5 @@ def compare_all():
     from stimupy.utils import plot_stimuli
 
     stims = gen_all(skip=True)
-    plot_stimuli(stims, mask=False)
+    plot_stimuli(stims, mask=False, extent_key="visual_size")
     # compare_all()
diff --git a/stimupy/papers/carney1999_data.csv b/stimupy/papers/modelfest_data.csv
similarity index 100%
rename from stimupy/papers/carney1999_data.csv
rename to stimupy/papers/modelfest_data.csv
diff --git a/stimupy/papers/carney1999_natural_scene.tif b/stimupy/papers/modelfest_natural_scene.tif
similarity index 100%
rename from stimupy/papers/carney1999_natural_scene.tif
rename to stimupy/papers/modelfest_natural_scene.tif
diff --git a/stimupy/papers/carney1999_noise.tif b/stimupy/papers/modelfest_noise.tif
similarity index 100%
rename from stimupy/papers/carney1999_noise.tif
rename to stimupy/papers/modelfest_noise.tif
diff --git a/stimupy/papers/murray2020.py b/stimupy/papers/murray2020.py
index f3ec1d57..3325a044 100644
--- a/stimupy/papers/murray2020.py
+++ b/stimupy/papers/murray2020.py
@@ -115,7 +115,7 @@ def argyle(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -165,7 +165,7 @@ def argyle_control(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -217,7 +217,7 @@ def argyle_long(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -269,7 +269,7 @@ def snake(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -321,7 +321,7 @@ def snake_control(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -373,7 +373,7 @@ def koffka_adelson(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -425,7 +425,7 @@ def koffka_broken(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -477,7 +477,7 @@ def koffka_connected(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -529,7 +529,7 @@ def checkassim(ppd=PPD, pad=PAD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -587,7 +587,7 @@ def simcon(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -639,7 +639,7 @@ def simcon_articulated(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -691,7 +691,7 @@ def white(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -709,7 +709,7 @@ def white(ppd=PPD):
         "target_indices_bottom": (2, 4, 6),
         "target_center_offset": 2,
         "target_height": 2,
-        "intensity_bars": (70, 17.5),
+        "intensity_bars": (17.5, 70),
         "intensity_target": 35.0,
         "period": "even",
     }
diff --git a/stimupy/papers/white1981.py b/stimupy/papers/white1981.py
index cd7bc0e0..d32259af 100644
--- a/stimupy/papers/white1981.py
+++ b/stimupy/papers/white1981.py
@@ -105,7 +105,7 @@ def square_white(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -140,7 +140,7 @@ def square_black(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -176,7 +176,7 @@ def grating_white_white(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -194,7 +194,7 @@ def grating_white_white(ppd=PPD):
         "bar_width": bar_width,
         "rotation": ORIENTATION,
         "intensity_background": v3,
-        "intensity_bars": (v3, v2),
+        "intensity_bars": (v2, v3),
         "intensity_target": v2,
         "target_indices": (2, 4, 6, 8),
     }
@@ -217,7 +217,7 @@ def grating_white_black(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -235,7 +235,7 @@ def grating_white_black(ppd=PPD):
         "bar_width": bar_width,
         "rotation": ORIENTATION,
         "intensity_background": v1,
-        "intensity_bars": (v3, v2),
+        "intensity_bars": (v2, v3),
         "intensity_target": v2,
         "target_indices": (2, 4, 6, 8),
     }
@@ -258,7 +258,7 @@ def grating_black_white(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -276,7 +276,7 @@ def grating_black_white(ppd=PPD):
         "bar_width": bar_width,
         "rotation": ORIENTATION,
         "intensity_background": v3,
-        "intensity_bars": (v1, v2),
+        "intensity_bars": (v2, v1),
         "intensity_target": v2,
         "target_indices": (2, 4, 6, 8),
     }
@@ -299,7 +299,7 @@ def grating_black_black(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -317,7 +317,7 @@ def grating_black_black(ppd=PPD):
         "bar_width": bar_width,
         "rotation": ORIENTATION,
         "intensity_background": v1,
-        "intensity_bars": (v1, v2),
+        "intensity_bars": (v2, v1),
         "intensity_target": v2,
         "target_indices": (2, 4, 6, 8),
     }
@@ -340,7 +340,7 @@ def grating_white_in(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -356,7 +356,7 @@ def grating_white_in(ppd=PPD):
         "n_bars": 9,
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v3, v1),
+        "intensity_bars": (v1, v3),
         "intensity_target": v2,
         "target_indices": (2, 4, 6, 8),
     }
@@ -365,7 +365,7 @@ def grating_white_in(ppd=PPD):
         "visual_size": 51 * bar_width,
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
     }
     stim = illusions.gratings.grating(
         small_grating_params=small_params,
@@ -390,7 +390,7 @@ def grating_black_in(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -406,7 +406,7 @@ def grating_black_in(ppd=PPD):
         "n_bars": 9,
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "target_indices": (2, 4, 6, 8),
     }
@@ -415,7 +415,7 @@ def grating_black_in(ppd=PPD):
         "visual_size": 51 * bar_width,
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v3, v1),
+        "intensity_bars": (v1, v3),
     }
     stim = illusions.gratings.grating(
         small_grating_params=small_params,
@@ -440,7 +440,7 @@ def grating_white_out(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -456,7 +456,7 @@ def grating_white_out(ppd=PPD):
         "visual_size": (50 * bar_width, 9 * bar_width),
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v3, v1),
+        "intensity_bars": (v1, v3),
         "intensity_target": v2,
         "target_indices": (24, 26, 28, 30),
     }
@@ -465,7 +465,7 @@ def grating_white_out(ppd=PPD):
         "visual_size": 50 * bar_width,
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
     }
     stim = illusions.gratings.grating(
         small_grating_params=small_params,
@@ -490,7 +490,7 @@ def grating_black_out(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -506,7 +506,7 @@ def grating_black_out(ppd=PPD):
         "visual_size": (50 * bar_width, 9 * bar_width),
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "target_indices": (24, 26, 28, 30),
     }
@@ -515,7 +515,7 @@ def grating_black_out(ppd=PPD):
         "visual_size": 50 * bar_width,
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v3, v1),
+        "intensity_bars": (v1, v3),
     }
     stim = illusions.gratings.grating(
         small_grating_params=small_params,
@@ -540,7 +540,7 @@ def grating_white_orthogonal(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -556,7 +556,7 @@ def grating_white_orthogonal(ppd=PPD):
         "bar_width": bar_width,
         "ppd": ppd,
         "rotation": ORIENTATION + 90,
-        "intensity_bars": (v3, v1),
+        "intensity_bars": (v1, v3),
         "intensity_target": v2,
         "target_indices": (2, 4, 6, 8),
     }
@@ -565,7 +565,7 @@ def grating_white_orthogonal(ppd=PPD):
         "visual_size": 51 * bar_width,
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
     }
     stim = illusions.gratings.grating_masked(
         small_grating_params=small_params,
@@ -592,7 +592,7 @@ def grating_black_orthogonal(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -608,7 +608,7 @@ def grating_black_orthogonal(ppd=PPD):
         "bar_width": bar_width,
         "ppd": ppd,
         "rotation": ORIENTATION + 90,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "target_indices": (2, 4, 6, 8),
     }
@@ -617,7 +617,7 @@ def grating_black_orthogonal(ppd=PPD):
         "visual_size": 51 * bar_width,
         "ppd": ppd,
         "rotation": ORIENTATION,
-        "intensity_bars": (v3, v1),
+        "intensity_bars": (v1, v3),
     }
     stim = illusions.gratings.grating_masked(
         small_grating_params=small_params,
diff --git a/stimupy/papers/white1985.py b/stimupy/papers/white1985.py
index 4119b385..23091ec1 100644
--- a/stimupy/papers/white1985.py
+++ b/stimupy/papers/white1985.py
@@ -104,7 +104,7 @@ def wide_0phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -119,9 +119,9 @@ def wide_0phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, 0.75),
+        "target_size": (bar_width * 9, 0.75),
         "target_phase_shift": START_PHASE1 + 0,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -146,7 +146,7 @@ def wide_36phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -160,9 +160,9 @@ def wide_36phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, 0.75),
+        "target_size": (bar_width * 9, 0.75),
         "target_phase_shift": START_PHASE1 + 36,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -187,7 +187,7 @@ def wide_72phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -202,9 +202,9 @@ def wide_72phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, 0.75),
+        "target_size": (bar_width * 9, 0.75),
         "target_phase_shift": START_PHASE1 + 72,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -229,7 +229,7 @@ def wide_108phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -244,9 +244,9 @@ def wide_108phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, 0.75),
+        "target_size": (bar_width * 9, 0.75),
         "target_phase_shift": START_PHASE1 + 108,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -271,7 +271,7 @@ def wide_144phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -286,9 +286,9 @@ def wide_144phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, 0.75),
+        "target_size": (bar_width * 9, 0.75),
         "target_phase_shift": START_PHASE1 + 144,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -313,7 +313,7 @@ def wide_180phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -328,9 +328,9 @@ def wide_180phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, 0.75),
+        "target_size": (bar_width * 9, 0.75),
         "target_phase_shift": START_PHASE1 + 180,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -355,7 +355,7 @@ def square_0phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -370,9 +370,9 @@ def square_0phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, bar_width),
+        "target_size": (bar_width * 9, bar_width),
         "target_phase_shift": START_PHASE2 + 0,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -397,7 +397,7 @@ def square_36phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -412,9 +412,9 @@ def square_36phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, bar_width),
+        "target_size": (bar_width * 9, bar_width),
         "target_phase_shift": START_PHASE2 + 36,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -439,7 +439,7 @@ def square_72phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -454,9 +454,9 @@ def square_72phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, bar_width),
+        "target_size": (bar_width * 9, bar_width),
         "target_phase_shift": START_PHASE2 + 72,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -481,7 +481,7 @@ def square_108phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -496,9 +496,9 @@ def square_108phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, bar_width),
+        "target_size": (bar_width * 9, bar_width),
         "target_phase_shift": START_PHASE2 + 108,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -523,7 +523,7 @@ def square_144phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -538,9 +538,9 @@ def square_144phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, bar_width),
+        "target_size": (bar_width * 9, bar_width),
         "target_phase_shift": START_PHASE2 + 144,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
@@ -565,7 +565,7 @@ def square_180phase(ppd=PPD):
     Returns
     -------
     dict of str
-        dict with the stimulus (key: "img") and target mask (key: "mask")
+        dict with the stimulus (key: "img") and target mask (key: "target_mask")
         and additional keys containing stimulus parameters
 
     References
@@ -580,9 +580,9 @@ def square_180phase(ppd=PPD):
         "visual_size": 25 * bar_width,
         "ppd": ppd,
         "bar_width": bar_width,
-        "target_size": (bar_width * 7, bar_width),
+        "target_size": (bar_width * 9, bar_width),
         "target_phase_shift": START_PHASE2 + 180,
-        "intensity_bars": (v1, v3),
+        "intensity_bars": (v3, v1),
         "intensity_target": v2,
         "orientation": "vertical",
     }
diff --git a/stimupy/utils/__init__.py b/stimupy/utils/__init__.py
index 58194300..44d9dd35 100644
--- a/stimupy/utils/__init__.py
+++ b/stimupy/utils/__init__.py
@@ -1,5 +1,5 @@
-from .filter import *
+from .filters import *
 from .pad import *
 from .plotting import *
-from .resolution import degrees_to_pixels
+from .resolution import *
 from .utils import *
diff --git a/stimupy/utils/color_conversions.py b/stimupy/utils/color_conversions.py
index b2286097..c5f943db 100644
--- a/stimupy/utils/color_conversions.py
+++ b/stimupy/utils/color_conversions.py
@@ -1,5 +1,10 @@
 import numpy as np
 
+__all__ = [
+    "luminance2munsell",
+    "munsell2luminance",
+]
+
 
 def luminance2munsell(lum_values, reference_white):
     """
diff --git a/stimupy/utils/contrast_conversions.py b/stimupy/utils/contrast_conversions.py
index cd5f8e4d..87049f06 100644
--- a/stimupy/utils/contrast_conversions.py
+++ b/stimupy/utils/contrast_conversions.py
@@ -1,5 +1,17 @@
 import numpy as np
 
+__all__ = [
+    "transparency",
+    "adapt_michelson_contrast",
+    "adapt_rms_contrast",
+    "adapt_normalized_rms_contrast",
+    "adapt_intensity_range",
+    "adapt_michelson_contrast_dict",
+    "adapt_rms_contrast_dict",
+    "adapt_normalized_rms_contrast_dict",
+    "adapt_intensity_range_dict",
+]
+
 
 def transparency(img, mask=None, alpha=0.5, tau=0.2):
     """Applies a transparency to image at specified (mask) location if provided
@@ -26,31 +38,135 @@ def transparency(img, mask=None, alpha=0.5, tau=0.2):
     return img
 
 
-def adapt_michelson_contrast(stim, michelson_contrast, mean_luminance=None):
+def adapt_michelson_contrast(img, michelson_contrast, mean_luminance=None):
     """
     Adapt Michelson contrast of image
 
     Parameters
     ----------
-    stim : dict
-        stimulus dictionary containing at least keys "img" and "mask"
+    img : np.ndarray
+        stimulus array
     michelson_contrast : float
         desired Michelson contrast
     mean_luminance : float
         desired mean luminance; if None (default), dont change mean luminance
 
     Returns
-    -------
-    Updated stimulus dict with keys "img", "michelson_contrast" and "mean_luminance"
-
+    ----------
+    img : np.ndarray
+        image with adapted michelson contrast and mean luminance if passed
     """
     if mean_luminance is None:
-        mean_luminance = stim["img"].mean()
+        mean_luminance = img.mean()
 
     # Adapt Michelson contrast
-    img = (stim["img"] - stim["img"].min()) / (stim["img"].max() - stim["img"].min())
+    img = (img - img.min()) / (img.max() - img.min())
     img = img * michelson_contrast * 2.0 * mean_luminance
     img += mean_luminance - michelson_contrast * mean_luminance
+    return img
+
+
+def adapt_rms_contrast(img, rms_contrast, mean_luminance=None):
+    """
+    Adapt rms contrast of image (std)
+
+    Parameters
+    ----------
+    img : np.ndarray
+        stimulus array
+    rms_contrast : float
+        desired rms contrast (std divided by mean intensity)
+    mean_luminance : float
+        desired mean luminance; if None (default), dont change mean luminance
+
+    Returns
+    ----------
+    img : np.ndarray
+        image with adapted rms contrast and mean luminance if passed
+    """
+    if mean_luminance is None:
+        mean_luminance = img.mean()
+
+    # Adapt rms contrast
+    img = img - img.mean()
+    img = img / img.std() * rms_contrast + mean_luminance
+    return img
+
+
+def adapt_normalized_rms_contrast(img, rms_contrast, mean_luminance=None):
+    """
+    Adapt normalized rms contrast of image (std divided by mean)
+
+    Parameters
+    ----------
+    img : np.ndarray
+        stimulus array
+    rms_contrast : float
+        desired rms contrast (std divided by mean intensity)
+    mean_luminance : float
+        desired mean luminance; if None (default), dont change mean luminance
+
+    Returns
+    ----------
+    img : np.ndarray
+        image with adapted rms contrast and mean luminance if passed
+    """
+    if mean_luminance is None:
+        mean_luminance = img.mean()
+
+    img = img - img.mean()
+    img = img / img.std() * rms_contrast * mean_luminance + mean_luminance
+    return img
+
+
+def adapt_intensity_range(img, intensity_min=0.0, intensity_max=1.0):
+    """
+    Adapt intensity range of image
+
+    Parameters
+    ----------
+    img : np.ndarray
+        stimulus array
+    intensity_min : float
+        new minimal intensity value
+    intensity_max : float
+        new maximal intensity value
+
+    Returns
+    ----------
+    img : np.ndarray
+        image with adapted intensity range
+    """
+
+    img = (img - img.min()) / (img.max() - img.min())
+    img = img * (intensity_max - intensity_min) + intensity_min
+    return img
+
+
+def adapt_michelson_contrast_dict(stim, michelson_contrast, mean_luminance=None):
+    """
+    Adapt Michelson contrast of image in dict
+
+    Parameters
+    ----------
+    stim : dict
+        stimulus dictionary containing at least key "img"
+    michelson_contrast : float
+        desired Michelson contrast
+    mean_luminance : float
+        desired mean luminance; if None (default), dont change mean luminance
+
+    Returns
+    ----------
+    dict[str, Any]
+        dict with the stimulus (key: "img"),
+        Michelson contrast (key: "michelson_contrast"),
+        mean luminance ("mean_luminance")
+        and additional keys containing stimulus parameters
+    """
+
+    # Adapt Michelson contrast of image
+    img = adapt_michelson_contrast(stim["img"], michelson_contrast, mean_luminance)
 
     stim["img"] = img
     stim["michelson_contrast"] = michelson_contrast
@@ -58,29 +174,29 @@ def adapt_michelson_contrast(stim, michelson_contrast, mean_luminance=None):
     return stim
 
 
-def adapt_rms_contrast(stim, rms_contrast, mean_luminance=None):
+def adapt_rms_contrast_dict(stim, rms_contrast, mean_luminance=None):
     """
     Adapt rms contrast of image (std)
 
     Parameters
     ----------
     stim : dict
-        stimulus dictionary containing at least keys "img" and "mask"
+        stimulus dictionary containing at least key "img"
     rms_contrast : float
         desired rms contrast (std divided by mean intensity)
     mean_luminance : float
         desired mean luminance; if None (default), dont change mean luminance
 
     Returns
-    -------
-    Updated stimulus dict with keys "img", "rms_contrast" and "mean_luminance"
-
+    ----------
+    dict[str, Any]
+        dict with the stimulus (key: "img"),
+        RMS contrast (key: "rms_contrast"),
+        mean luminance ("mean_luminance")
+        and additional keys containing stimulus parameters
     """
-    if mean_luminance is None:
-        mean_luminance = stim["img"].mean()
-
-    img = stim["img"] - stim["img"].mean()
-    img = img / img.std() * rms_contrast + mean_luminance
+    # Adapt rms_contrast of image
+    img = adapt_rms_contrast(stim["img"], rms_contrast, mean_luminance)
 
     stim["img"] = img
     stim["rms_contrast"] = rms_contrast
@@ -88,29 +204,30 @@ def adapt_rms_contrast(stim, rms_contrast, mean_luminance=None):
     return stim
 
 
-def adapt_normalized_rms_contrast(stim, rms_contrast, mean_luminance=None):
+def adapt_normalized_rms_contrast_dict(stim, rms_contrast, mean_luminance=None):
     """
     Adapt normalized rms contrast of image (std divided by mean)
 
     Parameters
     ----------
     stim : dict
-        stimulus dictionary containing at least keys "img"
+        stimulus dictionary containing at least key "img"
     rms_contrast : float
         desired rms contrast (std divided by mean intensity)
     mean_luminance : float
         desired mean luminance; if None (default), dont change mean luminance
 
     Returns
-    -------
-    Updated stimulus dict with keys "img", "rms_contrast" and "mean_luminance"
-
+    ----------
+    dict[str, Any]
+        dict with the stimulus (key: "img"),
+        RMS contrast (key: "rms_contrast"),
+        mean luminance ("mean_luminance")
+        and additional keys containing stimulus parameters
     """
-    if mean_luminance is None:
-        mean_luminance = stim["img"].mean()
 
-    img = stim["img"] - stim["img"].mean()
-    img = img / img.std() * rms_contrast * mean_luminance + mean_luminance
+    # Adapt normalized rms contrast
+    img = adapt_normalized_rms_contrast(stim["img"], rms_contrast, mean_luminance)
 
     stim["img"] = img
     stim["rms_contrast"] = rms_contrast
@@ -118,29 +235,29 @@ def adapt_normalized_rms_contrast(stim, rms_contrast, mean_luminance=None):
     return stim
 
 
-def adapt_intensity_range(stim, intensity_min=0.0, intensity_max=1.0):
+def adapt_intensity_range_dict(stim, intensity_min=0.0, intensity_max=1.0):
     """
     Adapt intensity range of image
 
     Parameters
     ----------
     stim : dict
-        stimulus dictionary containing at least keys "img"
+        stimulus dictionary containing at least key "img"
     intensity_min : float
         new minimal intensity value
     intensity_max : float
         new maximal intensity value
 
     Returns
-    -------
-    Updated stimulus dict with keys "img", "intensity_min" and "intensity_max"
-
+    ----------
+    dict[str, Any]
+        dict with the stimulus (key: "img"),
+        intensity range (key: "intensity_range"),
+        and additional keys containing stimulus parameters
     """
 
-    img = (stim["img"] - stim["img"].min()) / (stim["img"].max() - stim["img"].min())
-    img = img * (intensity_max - intensity_min) + intensity_min
+    img = adapt_intensity_range(stim["img"], intensity_min, intensity_max)
 
     stim["img"] = img
-    stim["intensity_min"] = intensity_min
-    stim["intensity_max"] = intensity_max
+    stim["intensity_range"] = (intensity_min, intensity_max)
     return stim
diff --git a/stimupy/utils/export.py b/stimupy/utils/export.py
index 524775ed..4b6069bb 100644
--- a/stimupy/utils/export.py
+++ b/stimupy/utils/export.py
@@ -4,9 +4,34 @@
 import numpy as np
 from PIL import Image
 
+__all__ = [
+    "arrs_to_checksum",
+    "to_json",
+    "write_array_to_image",
+]
+
 
 def arrs_to_checksum(stim, keys=["img", "mask"]):
-    # Hash (md5) values, and save only the hex
+    """
+    Hash (md5) values, and save only the hex
+
+    Parameters
+    ----------
+    stim : dict
+        stimulus dictionary containing keys
+    keys : str of list of str
+        keys of dict for which the hashing should be performed
+
+    Returns
+    ----------
+    dict[str, Any]
+        same as input dict but keys now only contain the hex
+    """
+    if isinstance(keys, str):
+        keys = [
+            keys,
+        ]
+
     for key in keys:
         stim[key] = md5(np.ascontiguousarray(stim[key].round(8))).hexdigest()
 
@@ -14,6 +39,17 @@ def arrs_to_checksum(stim, keys=["img", "mask"]):
 
 
 def to_json(stim, filename):
+    """
+    Stimulus-dict(s) as (pretty) JSON
+
+    Parameters
+    ----------
+    stim : dict
+        stimulus dictionary containing keys
+    filename : str
+        full path to the file to be creaated.
+
+    """
     # stimulus-dict(s) as (pretty) JSON
     with open(filename, "w", encoding="utf-8") as f:
         json.dump(stim, f, ensure_ascii=False, indent=4)
diff --git a/stimupy/utils/filter.py b/stimupy/utils/filter.py
deleted file mode 100644
index c61ec7d8..00000000
--- a/stimupy/utils/filter.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import numpy as np
-
-
-def bandpass_filter(fx, fy, fcenter, sigma):
-    """Function to create a bandpass filter
-
-    Parameters
-    ----------
-    fx
-        Array with frequencies in x-direction.
-    fy
-        Array with frequencies in y-direction.
-    fcenter
-        Center frequency of the bandpass filter
-    sigma
-        Sigma that defines the spread of the Gaussian in deg.
-
-    Returns
-    -------
-    dog
-        2D Difference-of-Gaussian filter in frequency domain.
-
-    """
-    # Calculate the distance of each 2d spatial frequency from requested center frequency
-    distance = np.abs(fcenter - np.sqrt(fx**2.0 + fy**2.0))
-
-    # Create bandpass filter:
-    gauss = (
-        1.0 / (np.sqrt(2.0 * np.pi) * sigma) * np.exp(-(distance**2.0) / (2.0 * sigma**2.0))
-    )
-    gauss = gauss / gauss.max()
-    return gauss
-
-
-# Create oriented Gaussian filter:
-def oriented_filter(fx, fy, sigma, orientation):
-    # convert orientation parameter to radians
-    theta = np.deg2rad(orientation)
-
-    # determine a, b, c coefficients
-    a = np.cos(theta) ** 2 / (2 * sigma**2)
-    b = -(np.sin(2 * theta) / (4 * sigma**2))
-    c = np.sin(theta) ** 2 / (2 * sigma**2)
-
-    # create Gaussian
-    ofilter = np.exp(-(a * fx**2 + 2 * b * fx * fy + c * fy**2))
-    return ofilter
-
-
-# Apply Gaussian envelope to a stimulus
-def apply_gaussian_env(stimulus, sigma):
-    # Inputs:
-    #    stimulus: Input stimulus, numpy array
-    #    sigma: Sigma of Gaussian in px
-    #    norm: Bool, if True normalize output array between 0 and 1
-    # Output:
-    # 2d numpy array with stimulus multiplied with Gaussian envelope
-
-    # Create a meshgrid:
-    size = stimulus.shape[0]
-    xx, yy = np.mgrid[:size, :size] - size / 2.0
-
-    # Create a Gaussian envelope:
-    gauss = (
-        1.0
-        / (np.sqrt(2.0 * np.pi) * sigma)
-        * np.exp(-(xx**2.0 + yy**2.0) / (2.0 * sigma**2.0))
-    )
-    gauss = gauss / gauss.max()
-    stimulus = stimulus * gauss
-    return stimulus
diff --git a/stimupy/utils/filters.py b/stimupy/utils/filters.py
new file mode 100644
index 00000000..a7ef54cb
--- /dev/null
+++ b/stimupy/utils/filters.py
@@ -0,0 +1,122 @@
+import numpy as np
+from scipy.signal import fftconvolve
+
+from stimupy.utils import resolution
+from stimupy.utils.pad import add_padding, remove_padding
+
+__all__ = [
+    "convolve",
+    "bandpass",
+]
+
+
+def convolve(
+    arr1,
+    arr2,
+    mode="same",
+    axes=None,
+    padding=False,
+):
+    """
+    Convolve two N-dimensional arrays using FFT
+
+    Parameters
+    ----------
+    arr1 : numpy.ndarray
+        Input array 1
+    arr2 : numpy.ndarray
+        Input array 2
+    mode : str {"full", "valid", "same"}, optional
+        String which indicates the size of the output. The default is "same".
+    axes : int or None (default), optional
+        Axes over which to convolve. The default is over all axes
+    padding : Bool
+        if True, pad array before convolving
+
+    Returns
+    -------
+    out : numpy.ndarray
+        Output array
+
+    """
+    c = int(arr1.shape[0] / 2)
+    if padding:
+        arr1 = add_padding(arr1, c, arr1.mean())
+    out = fftconvolve(arr1, arr2, mode, axes)
+    if padding:
+        out = remove_padding(out, c)
+    return out
+
+
+def bandpass(
+    visual_size=None,
+    ppd=None,
+    shape=None,
+    center_frequency=None,
+    bandwidth=None,
+):
+    """
+    Function to create a 2d bandpass filter in the frequency domain
+
+    Parameters
+    ----------
+    visual_size : Sequence[Number, Number], Number, or None (default)
+        visual size [height, width] of grating, 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 grating, in pixels
+    center_frequency : float
+        center frequency of filter in cpd
+    bandwidth : float
+        bandwidth of filter in octaves
+
+    Returns
+    -------
+    dict[str, Any]
+        dict with the filter (key: "img"),
+        and additional keys containing filter parameters
+    """
+    if center_frequency is None:
+        raise ValueError("bandpass() missing argument 'center_frequency' which is not 'None'")
+    if bandwidth is None:
+        raise ValueError("bandpass() missing argument 'bandwidth' which is not 'None'")
+
+    # Resolve resolution
+    shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd)
+
+    if center_frequency > (min(ppd) / 2):
+        raise ValueError(
+            f"Center frequency ({center_frequency}) should not exceed Nyquist limit {min(ppd)/2} (ppd/2)"
+        )
+
+    # Create frequency axes
+    fy = np.fft.fftshift(np.fft.fftfreq(shape[0], d=1.0 / ppd[0]))
+    fx = np.fft.fftshift(np.fft.fftfreq(shape[1], d=1.0 / ppd[1]))
+    Fx, Fy = np.meshgrid(fx, fy)
+
+    # Calculate the distance of each 2d spatial frequency from requested center frequency
+    distance = np.abs(center_frequency - np.sqrt(Fx**2.0 + Fy**2.0))
+
+    # Calculate sigma to eventuate given bandwidth (in octaves)
+    sigma = (
+        center_frequency
+        / ((2.0**bandwidth + 1) * np.sqrt(2.0 * np.log(2.0)))
+        * (2.0**bandwidth - 1)
+    )
+
+    # Create bandpass filter
+    fil = 1.0 / (np.sqrt(2.0 * np.pi) * sigma) * np.exp(-(distance**2.0) / (2.0 * sigma**2.0))
+    fil = fil / fil.max()
+
+    stim = {
+        "img": fil,
+        "visual_size": visual_size,
+        "ppd": ppd,
+        "shape": shape,
+        "center_frequency": center_frequency,
+        "sigma": sigma,
+        "frequency_extent": [fy[0], fy[-1], fx[0], fx[-1]],
+    }
+
+    return stim
diff --git a/stimupy/utils/masks.py b/stimupy/utils/masks.py
index 0c37aa9c..3c63a134 100644
--- a/stimupy/utils/masks.py
+++ b/stimupy/utils/masks.py
@@ -1,16 +1,25 @@
 import numpy as np
 
+__all__ = [
+    "avg_target_values",
+    "avg_img_values",
+    "all_img_values",
+    "img_values",
+]
 
-def avg_target_values(stim, f_average=np.median):
+
+def avg_target_values(stim, mask_key="target_mask", f_average=np.median):
     """Average pixel value in each target region of stimulus
 
     Parameters
     ----------
     stim : dict[str: Any]
         stimulus-dict with at least "img" and "mask"
-        containing the stimulsu image and integer-mask, respectively.
+        containing the stimulus image and integer-mask, respectively.
+    mask_key : str
+        string with mask-key name
     f_average: function, default=numpy.median
-        How to average/summarise the pixels in each  target region
+        How to average/summarise the pixels in each target region
 
     Returns
     ----------
@@ -22,7 +31,7 @@ def avg_target_values(stim, f_average=np.median):
     --------
     avg_img_values
     """
-    return avg_img_values(image=stim["img"], mask=stim["mask"], f_average=f_average)
+    return avg_img_values(image=stim["img"], mask=stim[mask_key], f_average=f_average)
 
 
 def avg_img_values(image, mask, f_average=np.median):
@@ -58,7 +67,7 @@ def avg_img_values(image, mask, f_average=np.median):
 
 
 def all_img_values(img, mask):
-    """Isolate all image values/pixels, per target region specified in integer masks
+    """Isolate all image values/pixels, per target region specified in integer mask
 
     Parameters
     ----------
diff --git a/stimupy/utils/pad.py b/stimupy/utils/pad.py
index 804dc167..d462b73e 100644
--- a/stimupy/utils/pad.py
+++ b/stimupy/utils/pad.py
@@ -1,8 +1,67 @@
 import copy
-
 import numpy as np
 
-from . import resolution
+from .utils import resolution
+
+__all__ = [
+    "add_padding",
+    "remove_padding",
+    "pad_by_visual_size",
+    "pad_to_visual_size",
+    "pad_by_shape",
+    "pad_to_shape",
+    "pad_dict_by_visual_size",
+    "pad_dict_to_visual_size",
+    "pad_dict_by_shape",
+    "pad_dict_to_shape",
+]
+
+
+def remove_padding(arr, c):
+    """
+    Remove padding by c
+
+    Parameters
+    ----------
+    arr : numpy.ndarray
+        input array
+    c : int
+        padding amount.
+
+    Returns
+    -------
+    arr : numpy.ndarray
+        reduced array
+
+    """
+    array_shape = arr.shape
+    arr = arr[c : array_shape[0] - c, c : array_shape[1] - c]
+    return arr
+
+
+def add_padding(arr, c, val):
+    """
+    Remove padding by c
+
+    Parameters
+    ----------
+    arr : numpy.ndarray
+        input array
+    c : int
+        padding amount
+    val : float
+        background value
+
+    Returns
+    -------
+    arr : numpy.ndarray
+        padded array
+
+    """
+    h, w = arr.shape
+    new_arr = np.ones([h + c * 2, w + c * 2]) * val
+    new_arr[c : h + c, c : w + c] = arr
+    return new_arr
 
 
 def pad_by_visual_size(img, padding, ppd, pad_value=0.0):
@@ -43,7 +102,7 @@ def pad_by_visual_size(img, padding, ppd, pad_value=0.0):
     padding_px = []
     for axis in padding_degs:
         shape = [
-            resolution.pix_from_visual_angle_ppd_1D(i, ppd) if i > 0 else 0
+            resolution.length_from_visual_angle_ppd(i, ppd) if i > 0 else 0
             for i, ppd in zip(axis, ppd)
         ]
         padding_px.append(shape)
@@ -149,25 +208,6 @@ def pad_to_shape(img, shape, pad_value=0):
     )
 
 
-def resize_array(arr, factor):
-    """
-    Return a copy of an array, resized by the given factor. Every value is
-    repeated factor[d] times along dimension d.
-
-    Parameters
-    ----------
-    arr : 2D array
-          the array to be resized
-    factor : tupel of 2 ints
-             the resize factor in the y and x dimensions
-
-    Returns
-    -------
-    An array of shape (arr.shape[0] * factor[0], arr.shape[1] * factor[1])
-    """
-    return np.repeat(np.repeat(arr, factor[0], axis=0), factor[1], axis=1)
-
-
 # %%
 #################################
 #         Dictionaries          #
@@ -194,11 +234,9 @@ def pad_dict_by_visual_size(dct, padding, ppd, pad_value=0.0, keys=("img", "*mas
 
     Returns
     -------
-    dict with padded images by the specified amount(s)
-
-    See also
-    ---------
-    stimupy.utils.resolution
+    dict[str, Any]
+        same as input dict but with larger key-arrays and updated keys for
+        "visual_size" and "shape"
     """
     # Create deepcopy to not override existing dict
     new_dict = copy.deepcopy(dct)
@@ -218,12 +256,21 @@ def pad_dict_by_visual_size(dct, padding, ppd, pad_value=0.0, keys=("img", "*mas
         if key in keys:
             img = dct[key]
             if isinstance(img, np.ndarray):
+                # Add mask which indicates padded region
+                new_dict["pad_mask"] = pad_by_visual_size(
+                    np.zeros(img.shape), padding, ppd, 1
+                ).astype(int)
+
                 if key.endswith("mask"):
                     img = pad_by_visual_size(img, padding, ppd, 0)
                     img = img.astype(int)
                 else:
                     img = pad_by_visual_size(img, padding, ppd, pad_value)
                 new_dict[key] = img
+
+    # Update visual_size and shape-keys
+    new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, ppd)
+    new_dict["shape"] = resolution.validate_shape(img.shape)
     return new_dict
 
 
@@ -245,11 +292,9 @@ def pad_dict_to_visual_size(dct, visual_size, ppd, pad_value=0, keys=("img", "*m
 
     Returns
     -------
-    dict with padded images by the specified amount(s)
-
-    See also
-    ---------
-    stimupy.utils.resolution
+    dict[str, Any]
+        same as input dict but with larger key-arrays and updated keys for
+        "visual_size" and "shape"
     """
 
     # visual_size to shape
@@ -261,7 +306,6 @@ def pad_dict_to_visual_size(dct, visual_size, ppd, pad_value=0, keys=("img", "*m
 
 def pad_dict_by_shape(dct, padding, pad_value=0, keys=("img", "*mask")):
     """Pad images in dictionary by specified amount(s) of pixels
-
     Can specify different amount (before, after) each axis.
 
     Parameters
@@ -279,7 +323,9 @@ def pad_dict_by_shape(dct, padding, pad_value=0, keys=("img", "*mask")):
 
     Returns
     -------
-    dict with padded images by the specified amount(s)
+    dict[str, Any]
+        same as input dict but with larger key-arrays and updated keys for
+        "visual_size" and "shape"
     """
     # Ensure padding is in integers
     padding = np.array(padding, dtype=np.int32)
@@ -302,12 +348,22 @@ def pad_dict_by_shape(dct, padding, pad_value=0, keys=("img", "*mask")):
         if key in keys:
             img = dct[key]
             if isinstance(img, np.ndarray):
+                # Add mask which indicates padded region
+                new_dict["pad_mask"] = np.pad(
+                    np.zeros(img.shape), padding, mode="constant", constant_values=1
+                ).astype(int)
+
                 if key.endswith("mask"):
                     img = np.pad(img, padding, mode="constant", constant_values=0)
                     img = img.astype(int)
                 else:
                     img = np.pad(img, padding, mode="constant", constant_values=pad_value)
                 new_dict[key] = img
+
+    # Update visual_size and shape-keys
+    new_dict["shape"] = resolution.validate_shape(img.shape)
+    if "ppd" in dct.keys():
+        new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, dct["ppd"])
     return new_dict
 
 
@@ -327,7 +383,9 @@ def pad_dict_to_shape(dct, shape, pad_value=0, keys=("img", "*mask")):
 
     Returns
     -------
-    dict with padded images by the specified amount(s)
+    dict[str, Any]
+        same as input dict but with larger key-arrays and updated keys for
+        "visual_size" and "shape"
 
     Raises
     ------
@@ -360,254 +418,20 @@ def pad_dict_to_shape(dct, shape, pad_value=0, keys=("img", "*mask")):
                 padding_after = padding_per_axis - padding_before
                 padding = np.stack([padding_before, padding_after]).T
 
+                # Add mask which indicates padded region
+                new_dict["pad_mask"] = pad_by_shape(
+                    np.zeros(img.shape), padding=padding, pad_value=1
+                ).astype(int)
+
                 if key.endswith("mask"):
                     img = pad_by_shape(img, padding=padding, pad_value=0)
                     img = img.astype(int)
                 else:
                     img = pad_by_shape(img, padding=padding, pad_value=pad_value)
                 new_dict[key] = img
-    return new_dict
 
-
-def resize_dict(dct, factor, keys=("img", "*mask")):
-    """
-    Return a copy of an array, resized by the given factor. Every value is
-    repeated factor[d] times along dimension d.
-
-    Parameters
-    ----------
-    dct : dict
-        dict containing arrays to be resized
-    factor : tupel of 2 ints
-        the resize factor in the y and x dimensions
-    keys : Sequence[String, String] or String
-        keys in dict for images to be padded
-
-    Returns
-    -------
-    dict with arrays of shape (arr.shape[0] * factor[0], arr.shape[1] * factor[1])
-    """
-    # Create deepcopy to not override existing dict
-    new_dict = copy.deepcopy(dct)
-
-    if isinstance(keys, str):
-        keys = (keys,)
-
-    # Find relevant keys
-    keys = [
-        dkey
-        for key in keys
-        for dkey in dct.keys()
-        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
-    ]
-
-    for key in dct.keys():
-        if key in keys:
-            img = dct[key]
-            if isinstance(img, np.ndarray):
-                img = np.repeat(np.repeat(img, factor[0], axis=0), factor[1], axis=1)
-                if key.endswith("mask"):
-                    img = img.astype(int)
-                new_dict[key] = img
-    return new_dict
-
-
-def stack_dicts(
-    dct1, dct2, direction="horizontal", keys=("img", "*mask"), keep_mask_indices=False
-):
-    """
-    Return a dict with resized key-arrays by the given factor. Every value is
-    repeated factor[d] times along dimension d.
-
-    Parameters
-    ----------
-    dct1: dict
-        dict containing arrays to be stacked
-    dct2: dict
-        dict containing arrays to be stacked
-    direction : str
-        stack horizontal(ly) or vertical(ly) (default: horizontal)
-    keys : Sequence[String, String] or String
-        keys in dict for images to be padded
-
-    Returns
-    -------
-    dict with keys with stacked arrays
-    """
-
-    # Create deepcopy to not override existing dict
-    new_dict = copy.deepcopy(dct1)
-
-    if isinstance(keys, str):
-        keys = (keys,)
-
-    # Find relevant keys
-    keys1 = [
-        dkey
-        for key in keys
-        for dkey in dct1.keys()
-        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
-    ]
-    keys2 = [
-        dkey
-        for key in keys
-        for dkey in dct2.keys()
-        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
-    ]
-
-    if not keys1 == keys2:
-        raise ValueError("The requested keys do not exist in both dicts")
-
-    for key in dct1.keys():
-        if key in keys1:
-            img1 = dct1[key]
-            img2 = dct2[key]
-            if isinstance(img1, np.ndarray) and isinstance(img2, np.ndarray):
-                if key.endswith("mask") and not keep_mask_indices:
-                    img2 = np.where(img2 != 0, img2 + img1.max(), 0)
-
-                if direction == "horizontal":
-                    img = np.hstack([img1, img2])
-                elif direction == "vertical":
-                    img = np.vstack([img1, img2])
-                else:
-                    raise ValueError("direction must be horizontal or vertical")
-
-                if key.endswith("mask"):
-                    img = img.astype(int)
-                new_dict[key] = img
-    return new_dict
-
-
-def rotate_dict(dct, nrots=1, keys=("img", "*mask")):
-    """
-    Return a dict with key-arrays rotated by nrots*90 degrees.
-
-    Parameters
-    ----------
-    dct: dict
-        dict containing arrays to be stacked
-    nrot : int
-        number of rotations by 90 degrees
-    keys : Sequence[String, String] or String
-        keys in dict for images to be padded
-
-    Returns
-    -------
-    dict with keys with rotated arrays
-    """
-
-    # Create deepcopy to not override existing dict
-    new_dict = copy.deepcopy(dct)
-
-    # Find relevant keys
-    keys = [
-        dkey
-        for key in keys
-        for dkey in dct.keys()
-        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
-    ]
-
-    for key in dct.keys():
-        if key in keys:
-            img = dct[key]
-            if isinstance(img, np.ndarray):
-                if isinstance(nrots, (int, float)):
-                    img = np.rot90(img, nrots)
-                else:
-                    raise ValueError("nrots must be a number")
-
-                if key.endswith("mask"):
-                    img = img.astype(int)
-                new_dict[key] = img
-    return new_dict
-
-
-def flip_dict(dct, direction="lr", keys=("img", "*mask")):
-    """
-    Return a dict with key-arrays rotated by nrots*90 degrees.
-
-    Parameters
-    ----------
-    dct: dict
-        dict containing arrays to be stacked
-    direction : str
-        "lr" for left-right, "ud" for up-down flipping
-    keys : Sequence[String, String] or String
-        keys in dict for images to be padded
-
-    Returns
-    -------
-    dict with keys with rotated arrays
-    """
-
-    # Create deepcopy to not override existing dict
-    new_dict = copy.deepcopy(dct)
-
-    # Find relevant keys
-    keys = [
-        dkey
-        for key in keys
-        for dkey in dct.keys()
-        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
-    ]
-
-    for key in dct.keys():
-        if key in keys:
-            img = dct[key]
-            if isinstance(img, np.ndarray):
-                if direction == "lr":
-                    img = np.fliplr(img)
-                elif direction == "ud":
-                    img = np.flipud(img)
-                else:
-                    raise ValueError("direction must be lr or ud")
-
-                if key.endswith("mask"):
-                    img = img.astype(int)
-                new_dict[key] = img
-    return new_dict
-
-
-def roll_dict(dct, shift, axes, keys=("img", "*mask")):
-    """
-    Return a dict with key-arrays rolled by shift in axes.
-
-    Parameters
-    ----------
-    dct: dict
-        dict containing arrays to be stacked
-    shift : int
-        number of pixels by which to shift
-    axes : Number or Sequence[Number, ...]
-        axes in which to shift
-    keys : Sequence[String, String] or String
-        keys in dict for images to be padded
-
-    Returns
-    -------
-    dict with keys with rolled arrays
-    """
-
-    # Create deepcopy to not override existing dict
-    new_dict = copy.deepcopy(dct)
-    shift = np.array(shift).astype(int)
-
-    # Find relevant keys
-    keys = [
-        dkey
-        for key in keys
-        for dkey in dct.keys()
-        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
-    ]
-
-    for key in dct.keys():
-        if key in keys:
-            img = dct[key]
-            if isinstance(img, np.ndarray):
-                img = np.roll(img, shift=shift, axis=axes)
-
-                if key.endswith("mask"):
-                    img = img.astype(int)
-                new_dict[key] = img
+    # Update visual_size and shape-keys
+    new_dict["shape"] = resolution.validate_shape(shape)
+    if "ppd" in dct.keys():
+        new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(shape, dct["ppd"])
     return new_dict
diff --git a/stimupy/utils/plotting.py b/stimupy/utils/plotting.py
index 412bec50..f77b890e 100644
--- a/stimupy/utils/plotting.py
+++ b/stimupy/utils/plotting.py
@@ -4,8 +4,23 @@
 import matplotlib.pyplot as plt
 import numpy as np
 
+__all__ = [
+    "compare_plots",
+    "plot_stim",
+    "plot_stimuli",
+]
+
 
 def compare_plots(plots):
+    """
+    Plot multiple plots in one plot for comparing.
+
+    Parameters
+    ----------
+    plots : list of plots
+        List containing plots which should be plotted
+
+    """
     M = len(plots)
     for i, (plot_name, plot) in enumerate(plots.items()):
         plt.subplot(1, M, i + 1)
@@ -22,14 +37,59 @@ def plot_stim(
     vmin=0,
     vmax=1,
     save=None,
+    extent_key="shape",
 ):
+    """
+    Utility function to plot stimulus array (key: "img") from stim dict and mask (optional)
+
+    Parameters
+    ----------
+    stim : dict
+        stimulus dict containing stimulus-array (key: "img")
+    mask : bool or str, optional
+        If True, plot mask on top of stimulus image (default: False).
+        If string is provided, plot this key from stimulus dictionary as mask
+    stim_name : str, optional
+        Stimulus name used for plotting (default: "stim")
+    ax : Axis object, optional
+        If not None (default), plot in the specified Axis object
+    vmin : float, optional
+        Minimal intensity value for plotting. The default is 0.
+    vmax : float, optional
+        Minimal intensity value for plotting. The default is 1.
+    save : None or str, optional
+        If None (default), do not save the plot.
+        If string is provided, save plot under this name.
+    extent_key : str, optional
+        Key to extent which will be used for plotting.
+        Default is "shape", using the image size in pixels as extent.
+
+    Returns
+    -------
+    ax : Axis object
+        If ax was passed and plotting is None, returns updated Axis object.
+
+    """
+    print("Plotting:", stim_name)
+
     single_plot = False
     if ax is None:
         ax = plt.gca()
         single_plot = True
 
+    if extent_key in stim.keys():
+        if len(stim[extent_key]) == 2:
+            extent = [0, stim[extent_key][1], 0, stim[extent_key][0]]
+        elif len(stim[extent_key]) == 4:
+            extent = stim[extent_key]
+        else:
+            raise ValueError("extent should either contain 2 or 4 values")
+    else:
+        warnings.warn("extent_key does not exist in dict, using pixel-extent")
+        extent = [0, stim["img"].shape[1], 0, stim["img"].shape[0]]
+
     if not mask:
-        ax.imshow(stim["img"], cmap="gray", vmin=vmin, vmax=vmax)
+        ax.imshow(stim["img"], cmap="gray", vmin=vmin, vmax=vmax, extent=extent)
     else:
         img = stim["img"]
         if isinstance(mask, str):
@@ -37,40 +97,43 @@ def plot_stim(
         else:
             mask_keys = [key for key in stim.keys() if key.endswith("mask")]
 
-        # If target_mask exists, use it.
-        if "target_mask" in mask_keys:
-            mask = stim["target_mask"]
+        if len(mask_keys) == 0:
+            ax.imshow(stim["img"], cmap="gray", vmin=vmin, vmax=vmax, extent=extent)
         else:
-            mask = stim[mask_keys[0]]
+            # If target_mask exists, use it.
+            if "target_mask" in mask_keys:
+                mask = stim["target_mask"]
+            else:
+                mask = stim[mask_keys[0]]
 
-        if (mask is None) or (len(np.unique(mask)) == 1):
-            warnings.warn("mask is None or empty- cannot plot")
-            ax.imshow(stim["img"], cmap="gray", vmin=vmin, vmax=vmax)
+            if (mask is None) or (len(np.unique(mask)) == 1):
+                warnings.warn("mask is None or empty- cannot plot mask")
+                ax.imshow(stim["img"], cmap="gray", vmin=vmin, vmax=vmax, extent=extent)
 
-        else:
-            img = np.dstack([img, img, img])
-            mask = np.dstack([mask, mask, mask])
-
-            if np.unique(mask).size >= 20:
-                colormap = plt.cm.colors.ListedColormap(np.random.rand(mask.max() + 1, 3))
-            elif np.unique(mask).size > 10 and np.unique(mask).size < 20:
-                colormap = plt.cm.tab20
             else:
-                colormap = plt.cm.tab10
-
-            for idx in np.unique(mask)[np.unique(mask) > 0]:
-                color = colormap.colors[idx]
-                color = np.reshape(color, (1, 1, 3))
-                img = np.where(mask == idx, color, img)
-            ax.imshow(img)
-
-            # Colorbar for mask indices
-            bounds = list(np.unique(mask))
-            norm = mpl.colors.BoundaryNorm(bounds, len(bounds) + 1, extend="both")
-            plt.colorbar(
-                mpl.cm.ScalarMappable(norm=norm, cmap=colormap),
-                ax=ax,
-            )
+                img = np.dstack([img, img, img])
+                mask = np.dstack([mask, mask, mask])
+
+                if np.unique(mask).size >= 20:
+                    colormap = plt.cm.colors.ListedColormap(np.random.rand(mask.max() + 1, 3))
+                elif np.unique(mask).size >= 10 and np.unique(mask).size < 20:
+                    colormap = plt.cm.tab20
+                else:
+                    colormap = plt.cm.tab10
+
+                for idx in np.unique(mask)[np.unique(mask) > 0]:
+                    color = colormap.colors[idx]
+                    color = np.reshape(color, (1, 1, 3))
+                    img = np.where(mask == idx, color, img)
+                ax.imshow(img, extent=extent)
+
+                # Colorbar for mask indices
+                bounds = list(np.unique(mask))
+                norm = mpl.colors.BoundaryNorm(bounds, len(bounds) + 1, extend="both")
+                plt.colorbar(
+                    mpl.cm.ScalarMappable(norm=norm, cmap=colormap),
+                    ax=ax,
+                )
 
     ax.set_title(label=stim_name)
 
@@ -92,7 +155,31 @@ def plot_stimuli(
     vmin=0,
     vmax=1,
     save=None,
+    extent_key="shape",
 ):
+    """
+    Utility function to plot multuple stimuli (key: "img") from stim dicts and mask (optional)
+
+    Parameters
+    ----------
+    stims : dict of dicts
+        dictionary composed of stimulus dicts containing stimulus-array (key: "img")
+    mask : bool or str, optional
+        If True, plot mask on top of stimulus image (default: False).
+        If string is provided, plot this key from stimulus dictionary as mask
+    vmin : float, optional
+        Minimal intensity value for plotting. The default is 0.
+    vmax : float, optional
+        Minimal intensity value for plotting. The default is 1.
+    save : None or str, optional
+        If None (default), do not save the plot.
+        If string is provided, save plot under this name.
+    extent_key : str, optional
+        Key to extent which will be used for plotting.
+        Default is "shape", using the image size in pixels as extent.
+
+    """
+
     # Plot each stimulus+mask
     n_stim = int(np.ceil(np.sqrt(len(stims))))
     F = plt.figure(figsize=(n_stim * 3, n_stim * 3))
@@ -106,6 +193,7 @@ def plot_stimuli(
             vmin=vmin,
             vmax=vmax,
             save=None,
+            extent_key=extent_key,
         )
 
     plt.tight_layout()
diff --git a/stimupy/utils/resolution.py b/stimupy/utils/resolution.py
index aebaa8f4..625dee80 100644
--- a/stimupy/utils/resolution.py
+++ b/stimupy/utils/resolution.py
@@ -4,6 +4,27 @@
 
 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")
@@ -124,84 +145,71 @@ def resolve_1D(length=None, visual_angle=None, ppd=None, round=True):
                         f"Rounding visual angle because of ppd; {visual_angle_old} ->"
                         f" {visual_angle}"
                     )
-            length = pix_from_visual_angle_ppd_1D(visual_angle=visual_angle, ppd=ppd, round=round)
+            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_1D(length=length, ppd=ppd)
+            visual_angle = visual_angle_from_length_ppd(length=length, ppd=ppd)
         elif ppd is None:
-            ppd = ppd_from_length_visual_angle_1D(length=length, visual_angle=visual_angle)
+            ppd = ppd_from_length_visual_angle(length=length, visual_angle=visual_angle)
 
     return length, visual_angle, ppd
 
 
-def valid_1D(length, visual_angle, ppd):
-    """Asserts that the combined specification of resolution is geometrically valid.
+def resolve_dict(dct):
+    """Resolves the full resolution ("shape", "ppd", "visual_size"), for 2
+    givens and 1 unknown in the input dictionary
 
-    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.
+    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.
 
-    Note that the resolution specification has to be fully resolved,
-    i.e., none of the parameters can be None
+    This function resolves the resolution in both dimensions.
 
     Parameters
     ----------
-    length : int, length in pixels
-    visual_angle : float, size in degrees
-    ppd : int, resolution in pixels-per-degree
+    dct : dict
+        dictionary with at least two out the three keys: "shape", "ppd", "visual_size"
 
-    Raises
-    ------
-    ResolutionError
-        if resolution specification is invalid,
-        i.e. (roughly), if int(visual_angle * ppd) != length
-    """
+    Returns
+    -------
+    Resolved dict
 
-    # Check by calculating one component
-    calculated = pix_from_visual_angle_ppd_1D(visual_angle=visual_angle, ppd=ppd)
-    if calculated != length:
-        raise ResolutionError(f"Invalid resolution; {visual_angle},{length},{ppd}")
+    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)
 
-def valid_resolution(shape, visual_size, ppd):
-    """Asserts that the combined specification of resolution is geometrically valid.
+    # Update dict
+    dct["shape"] = shape
+    dct["visual_size"] = visual_size
+    dct["ppd"] = ppd
+    return
 
-    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
+#############################
+#    Resolve components     #
+#############################
+def visual_angle_from_length_ppd(length, ppd):
+    """Calculate visual angle (degrees) from length (pixels) and pixels-per-degree
 
     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)
+    length : int or None
+        length in pixels
+    ppd : int or None
+        pixels per degree
 
-    Raises
-    ------
-    ResolutionError
-        if resolution specification is invalid,
-        i.e. (roughly), if int(visual_size * ppd) != shape
+    Returns
+    -------
+    Length (pixels) translated to visual angle (degrees)
     """
-
-    # 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}")
-
-
-#############################
-#    Resolve components     #
-#############################
-def visual_angle_from_length_ppd_1D(length, ppd):
     if length is not None and ppd is not None:
         visual_angle = length / ppd
     else:
@@ -209,6 +217,37 @@ def visual_angle_from_length_ppd_1D(length, ppd):
     return visual_angle
 
 
+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
+
+
 def visual_size_from_shape_ppd(shape, ppd):
     """Calculate visual size (degrees) from given shape (pixels) and pixels-per-degree
 
@@ -234,8 +273,8 @@ def visual_size_from_shape_ppd(shape, ppd):
     ppd = validate_ppd(ppd)
 
     # Calculate width and height in pixels
-    width = visual_angle_from_length_ppd_1D(shape.width, ppd.horizontal)
-    height = visual_angle_from_length_ppd_1D(shape.height, ppd.vertical)
+    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)
@@ -243,7 +282,22 @@ def visual_size_from_shape_ppd(shape, ppd):
     return visual_size
 
 
-def pix_from_visual_angle_ppd_1D(visual_angle, ppd, round=True):
+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)
 
@@ -252,12 +306,47 @@ def pix_from_visual_angle_ppd_1D(visual_angle, ppd, round=True):
             if fpix > 0 and fpix % pix:
                 warnings.warn(f"Rounding shape; {visual_angle} * {ppd} = {fpix} -> {pix}")
         else:
-            pix = fpix
+            pix = float(fpix)
     else:
         pix = None
     return pix
 
 
+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
+
+
 def shape_from_visual_size_ppd(visual_size, ppd):
     """Calculate shape (pixels) from given visual size (degrees) and pixels-per-degree
 
@@ -282,8 +371,8 @@ def shape_from_visual_size_ppd(visual_size, ppd):
     ppd = validate_ppd(ppd)
 
     # Calculate width and height in pixels
-    width = pix_from_visual_angle_ppd_1D(visual_size.width, ppd.horizontal)
-    height = pix_from_visual_angle_ppd_1D(visual_size.height, ppd.vertical)
+    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)
@@ -316,8 +405,8 @@ def ppd_from_shape_visual_size(shape, visual_size):
     visual_size = validate_visual_size(visual_size)
 
     # Calculate horizontal and vertical ppds
-    horizontal = ppd_from_length_visual_angle_1D(shape.width, visual_size.width)
-    vertical = ppd_from_length_visual_angle_1D(shape.height, visual_size.height)
+    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)
@@ -325,7 +414,20 @@ def ppd_from_shape_visual_size(shape, visual_size):
     return ppd
 
 
-def ppd_from_length_visual_angle_1D(length, visual_angle):
+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:
@@ -333,6 +435,33 @@ def ppd_from_length_visual_angle_1D(length, visual_angle):
     return ppd
 
 
+def compute_ppd(screen_size, resolution, distance):
+    """
+    Compute the pixels per degree, i.e. the number of pixels in the central
+    one degree of visual angle, in a presentation setup.
+
+    Parameters
+    ----------
+    screen_size : scalar
+                  the size of the presentation screen, in whatever unti you
+                  prefer.
+    resolution : scalar
+                 the sceen resolution in the same direction that screen size
+                 was measured in.
+    distance : scalar
+               the distance between the observer and the screen, in the same
+               unit as screen_size.
+    Returns
+    -------
+    ppd : number
+          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    #
 #############################
@@ -533,51 +662,96 @@ def validate_visual_size(visual_size):
     return Visual_size(height=height, width=width)
 
 
-def degrees_to_pixels(degrees, ppd):
-    """
-    convert degrees of visual angle to pixels, given the number of pixels in
-    1deg of visual angle.
+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
     ----------
-    degrees : number, tuple, list or a ndarray
-              the degree values to be converted.
-    ppd : number
-          the number of pixels in the central 1 degree of visual angle.
+    length : int, length in pixels
+    visual_angle : float, size in degrees
+    ppd : int, resolution in pixels-per-degree
 
-    Returns
-    -------
-    pixels : number or ndarray
+    Raises
+    ------
+    ResolutionError
+        if resolution specification is invalid,
+        i.e. (roughly), if int(visual_angle * ppd) != length
     """
-    degrees = np.array(degrees)
-    return (np.round(degrees * ppd)).astype(int)
 
-    # This is the 'super correct' conversion, but it makes very little difference in practice
-    # return (np.tan(np.radians(degrees / 2.)) / np.tan(np.radians(.5)) * ppd).astype(int)
+    # 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}")
+
 
+def valid_resolution(shape, visual_size, ppd):
+    """Asserts that the combined specification of resolution is geometrically valid.
 
-def compute_ppd(screen_size, resolution, distance):
+    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
     """
-    Compute the pixels per degree, i.e. the number of pixels in the central
-    one degree of visual angle, in a presentation setup.
+
+    # 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}")
+
+
+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
     ----------
-    screen_size : scalar
-                  the size of the presentation screen, in whatever unti you
-                  prefer.
-    resolution : scalar
-                 the sceen resolution in the same direction that screen size
-                 was measured in.
-    distance : scalar
-               the distance between the observer and the screen, in the same
-               unit as screen_size.
-    Returns
-    -------
-    ppd : number
-          the number of pixels in one degree of visual angle.
+    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
 
-    ppmm = resolution / screen_size
-    mmpd = 2 * np.tan(np.radians(0.5)) * distance
-    return ppmm * mmpd
+    # Assert that resolution is valid
+    valid_resolution(shape=shape, visual_size=visual_size, ppd=ppd)
diff --git a/stimupy/utils/utils.py b/stimupy/utils/utils.py
index 1e889ce9..b8a6211d 100644
--- a/stimupy/utils/utils.py
+++ b/stimupy/utils/utils.py
@@ -1,35 +1,46 @@
-"""
-Provides some functionality for creating and manipulating visual stimuli
-represented as numpy arrays.
-"""
-
-import warnings
+import copy
 import numpy as np
+import scipy.special as sp
+
+from stimupy.utils import resolution
+
+__all__ = [
+    "round_to_vals",
+    "int_factorize",
+    "get_function_argument_names",
+    "apply_bessel",
+    "resize_array",
+    "resize_dict",
+    "stack_dicts",
+    "rotate_dict",
+    "flip_dict",
+    "roll_dict",
+    "strip_dict",
+]
 
 
-def shift_pixels(img, shift):
+def round_to_vals(arr, vals):
     """
-    Shift image by specified number of pixels. The pixels pushed on the edge will reappear on the other side (wrap around)
+    Round array to provided values (vals)
 
     Parameters
     ----------
-    img : 2D array representing the image to be shifted
-    shift: (x,y) tuple specifying the number of pixels to shift. Positive x specifies shift in the right direction
-        and positive y shift downwards
+    arr : np.ndarray
+        Numpy array which values will be rounded
+    vals : Sequence(float, ...)
+        Values to which array will be rounded
 
     Returns
     -------
-    img : shifted image
-    """
-    return np.roll(img, shift, (1, 0))
-
+    out_arr : np.ndarray
+        Rounded output array
 
-def round_to_vals(input_arr, vals):
+    """
     n_val = len(vals)
-    input_arr = np.repeat(np.expand_dims(input_arr, -1), n_val, axis=2)
-    vals_arr = np.ones(input_arr.shape) * np.array(np.expand_dims(vals, [0, 1]))
+    arr = np.repeat(np.expand_dims(arr, -1), n_val, axis=2)
+    vals_arr = np.ones(arr.shape) * np.array(np.expand_dims(vals, [0, 1]))
 
-    indices = np.argmin(np.abs(input_arr - vals_arr), axis=2)
+    indices = np.argmin(np.abs(arr - vals_arr), axis=2)
     out_arr = np.copy(indices).astype(float)
 
     for i in range(n_val):
@@ -37,24 +48,6 @@ def round_to_vals(input_arr, vals):
     return out_arr
 
 
-def to_img(array, save):
-    from PIL import Image
-
-    if array.min() < 0:
-        array = array - array.min()
-    if array.max() > 1:
-        array = array / array.max()
-    array = (array * 255).astype(np.uint8)
-
-    im = Image.fromarray(array)
-
-    try:
-        im.save(save)
-    except ValueError:
-        warnings.warn("No file extension provided, saving as png")
-        im.save(save + ".png", "PNG")
-
-
 def int_factorize(n):
     """All integer factors of integer n
 
@@ -82,3 +75,362 @@ def int_factorize(n):
             factors.add(n // i)
 
     return factors
+
+
+def get_function_argument_names(func):
+    """
+    Get all argument names for a given function
+
+    Parameters
+    ----------
+    func : function
+        Get argument names from this function
+
+    Returns
+    -------
+    names : tuple
+        Tuple containing all argument names of given function
+    """
+    names = func.__code__.co_varnames[: func.__code__.co_argcount]
+    return names
+
+
+def apply_bessel(arr, order=0):
+    """
+    Bessel function of the first kind of real order and complex argument.
+
+    Parameters
+    ----------
+    arr : np.ndarray
+        Input array
+    order : float
+        Order of the bessel function. Default is 0.
+
+    Returns
+    -------
+    out : np.ndarray
+        Output array
+
+    """
+    out = sp.jv(order, arr)
+    return out
+
+
+def resize_array(arr, factor):
+    """
+    Return a copy of an array, resized by the given factor. Every value is
+    repeated factor[d] times along dimension d.
+
+    Parameters
+    ----------
+    arr : 2D array
+          the array to be resized
+    factor : tupel of 2 ints
+             the resize factor in the y and x dimensions
+
+    Returns
+    -------
+    An array of shape (arr.shape[0] * factor[0], arr.shape[1] * factor[1])
+    """
+    return np.repeat(np.repeat(arr, factor[0], axis=0), factor[1], axis=1)
+
+
+def resize_dict(dct, factor, keys=("img", "*mask")):
+    """
+    Return a copy of an array, resized by the given factor. Every value is
+    repeated factor[d] times along dimension d.
+
+    Parameters
+    ----------
+    dct : dict
+        dict containing arrays to be resized
+    factor : tupel of 2 ints
+        the resize factor in the y and x dimensions
+    keys : Sequence[String, String] or String
+        keys in dict for images to be padded
+
+    Returns
+    -------
+    dict[str, Any]
+        same as input dict but with larger key-arrays according to
+        "(arr.shape[0] * factor[0], arr.shape[1] * factor[1])"
+        and updated keys for "visual_size" and "shape"
+    """
+    # Create deepcopy to not override existing dict
+    new_dict = copy.deepcopy(dct)
+
+    if isinstance(keys, str):
+        keys = (keys,)
+
+    # Find relevant keys
+    keys = [
+        dkey
+        for key in keys
+        for dkey in dct.keys()
+        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
+    ]
+
+    for key in dct.keys():
+        if key in keys:
+            img = dct[key]
+            if isinstance(img, np.ndarray):
+                img = np.repeat(np.repeat(img, factor[0], axis=0), factor[1], axis=1)
+                if key.endswith("mask"):
+                    img = img.astype(int)
+                new_dict[key] = img
+
+    # Update visual_size and shape-keys
+    dct["shape"] = resolution.validate_shape(img.shape)
+    if "ppd" in dct.keys():
+        dct["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, dct["ppd"])
+    return new_dict
+
+
+def stack_dicts(
+    dct1, dct2, direction="horizontal", keys=("img", "*mask"), keep_mask_indices=False
+):
+    """
+    Return a dict with resized key-arrays by the given factor. Every value is
+    repeated factor[d] times along dimension d.
+
+    Parameters
+    ----------
+    dct1: dict
+        dict containing arrays to be stacked
+    dct2: dict
+        dict containing arrays to be stacked
+    direction : str
+        stack horizontal(ly) or vertical(ly) (default: horizontal)
+    keys : Sequence[String, String] or String
+        keys in dict for images to be padded
+
+    Returns
+    -------
+    dict[str, Any]
+        same as input dict1 but with stacked key-arrays and updated keys for
+        "visual_size" and "shape"
+    """
+
+    # Create deepcopy to not override existing dict
+    new_dict = copy.deepcopy(dct1)
+
+    if isinstance(keys, str):
+        keys = (keys,)
+
+    # Find relevant keys
+    keys1 = [
+        dkey
+        for key in keys
+        for dkey in dct1.keys()
+        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
+    ]
+    keys2 = [
+        dkey
+        for key in keys
+        for dkey in dct2.keys()
+        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
+    ]
+
+    if not keys1 == keys2:
+        raise ValueError("The requested keys do not exist in both dicts")
+
+    for key in dct1.keys():
+        if key in keys1:
+            img1 = dct1[key]
+            img2 = dct2[key]
+            if isinstance(img1, np.ndarray) and isinstance(img2, np.ndarray):
+                if key.endswith("mask") and not keep_mask_indices:
+                    img2 = np.where(img2 != 0, img2 + img1.max(), 0)
+
+                if direction == "horizontal":
+                    img = np.hstack([img1, img2])
+                elif direction == "vertical":
+                    img = np.vstack([img1, img2])
+                else:
+                    raise ValueError("direction must be horizontal or vertical")
+
+                if key.endswith("mask"):
+                    img = img.astype(int)
+                new_dict[key] = img
+
+    # Update visual_size and shape-keys
+    new_dict["shape"] = resolution.validate_shape(img.shape)
+    if "ppd" in new_dict.keys():
+        new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, new_dict["ppd"])
+    return new_dict
+
+
+def rotate_dict(dct, nrots=1, keys=("img", "*mask")):
+    """
+    Return a dict with key-arrays rotated by nrots*90 degrees.
+
+    Parameters
+    ----------
+    dct: dict
+        dict containing arrays to be stacked
+    nrot : int
+        number of rotations by 90 degrees
+    keys : Sequence[String, String] or String
+        keys in dict for images to be padded
+
+    Returns
+    -------
+    dict[str, Any]
+        same as input dict but with rotated key-arrays and updated keys for
+        "visual_size" and "shape"
+    """
+
+    # Create deepcopy to not override existing dict
+    new_dict = copy.deepcopy(dct)
+
+    # Find relevant keys
+    keys = [
+        dkey
+        for key in keys
+        for dkey in dct.keys()
+        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
+    ]
+
+    for key in dct.keys():
+        if key in keys:
+            img = dct[key]
+            if isinstance(img, np.ndarray):
+                if isinstance(nrots, (int, float)):
+                    img = np.rot90(img, nrots)
+                else:
+                    raise ValueError("nrots must be a number")
+
+                if key.endswith("mask"):
+                    img = img.astype(int)
+                new_dict[key] = img
+
+    # Update visual_size and shape-keys
+    new_dict["shape"] = resolution.validate_shape(img.shape)
+    if "ppd" in new_dict.keys():
+        new_dict["visual_size"] = resolution.visual_size_from_shape_ppd(img.shape, new_dict["ppd"])
+    return new_dict
+
+
+def flip_dict(dct, direction="lr", keys=("img", "*mask")):
+    """
+    Return a dict with key-arrays rotated by nrots*90 degrees.
+
+    Parameters
+    ----------
+    dct: dict
+        dict containing arrays to be stacked
+    direction : str
+        "lr" for left-right, "ud" for up-down flipping
+    keys : Sequence[String, String] or String
+        keys in dict for images to be padded
+
+    Returns
+    -------
+    dict[str, Any]
+        same as input dict but with flipped key-arrays
+    """
+
+    # Create deepcopy to not override existing dict
+    new_dict = copy.deepcopy(dct)
+
+    # Find relevant keys
+    keys = [
+        dkey
+        for key in keys
+        for dkey in dct.keys()
+        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
+    ]
+
+    for key in dct.keys():
+        if key in keys:
+            img = dct[key]
+            if isinstance(img, np.ndarray):
+                if direction == "lr":
+                    img = np.fliplr(img)
+                elif direction == "ud":
+                    img = np.flipud(img)
+                else:
+                    raise ValueError("direction must be lr or ud")
+
+                if key.endswith("mask"):
+                    img = img.astype(int)
+                new_dict[key] = img
+    return new_dict
+
+
+def roll_dict(dct, shift, axes, keys=("img", "*mask")):
+    """
+    Return a dict with key-arrays rolled by shift in axes.
+
+    Parameters
+    ----------
+    dct: dict
+        dict containing arrays to be stacked
+    shift : int
+        number of pixels by which to shift
+    axes : Number or Sequence[Number, ...]
+        axes in which to shift
+    keys : Sequence[String, String] or String
+        keys in dict for images to be padded
+
+    Returns
+    -------
+    dict[str, Any]
+        same as input dict but with rolled key-arrays
+    """
+
+    # Create deepcopy to not override existing dict
+    new_dict = copy.deepcopy(dct)
+    shift = np.array(shift).astype(int)
+
+    # Find relevant keys
+    keys = [
+        dkey
+        for key in keys
+        for dkey in dct.keys()
+        if ((dkey == key) or ((dkey.endswith(key[1::])) and (key.startswith("*"))))
+    ]
+
+    for key in dct.keys():
+        if key in keys:
+            img = dct[key]
+            if isinstance(img, np.ndarray):
+                img = np.roll(img, shift=shift, axis=axes)
+
+                if key.endswith("mask"):
+                    img = img.astype(int)
+                new_dict[key] = img
+    return new_dict
+
+
+def strip_dict(
+    dct,
+    func,
+):
+    """
+    Create a dictionary by stripping it from all keys that are not also
+    an argument to the provided function
+
+    Parameters
+    ----------
+    dct: dict
+        dict which will be stripped
+    func : function
+        Get argument names from this function
+
+    Returns
+    -------
+    dict[str, Any]
+        same as input dict but stripped from all keys which are not also
+        an argument to the provided function
+    """
+
+    # Get all argument names for given function
+    names = get_function_argument_names(func)
+
+    # Add keys from dct into new_dict if the key is also an argument name
+    new_dict = dict()
+    for name in names:
+        if name in dct.keys():
+            new_dict[name] = dct[name]
+    return new_dict


Test patch:
diff --git a/tests/papers/RHS2007.json b/tests/papers/RHS2007.json
index 1aa12ddd..a653dc3c 100644
--- a/tests/papers/RHS2007.json
+++ b/tests/papers/RHS2007.json
@@ -52,7 +52,7 @@
         "mask": "99d7f9eef441d747393a87d23f32c17e"
     },
     "grating_induction": {
-        "img": "dcc7759e70cacaf4d3a90d9f54f61e16",
+        "img": "816b6d5b224375480e5b23261aa6b919",
         "mask": "7f2d12554fdf1412cad55402f9e2a1b5"
     },
     "sbc_large": {
@@ -96,20 +96,20 @@
         "mask": "71088893c90765cf981304950dd4e28c"
     },
     "benary_cross": {
-        "img": "2865af1cc6d35f7e36400703addd7f2a",
-        "mask": "21f78bb399a54811d173a02ad20ffc4b"
+        "img": "35c018affdc41947da3de38b86175a96",
+        "mask": "6920625ccfcc07d7e904b4b49188d39f"
     },
     "todorovic_benary1_2": {
         "img": "973b958be7468c55e2f86094d21690e8",
         "mask": "c2b7f645d607b64f3d74b5d64156af4b"
     },
     "todorovic_benary3_4": {
-        "img": "29a0fc49e9a8071c5499ee5cea2499f9",
-        "mask": "d25a9fadff76d272a3227c184b88743d"
+        "img": "02fb79c76a8ba63ef6d85a88037ecf8f",
+        "mask": "e93a68afad23c70d25d553c0b8884d63"
     },
     "todorovic_benary1_2_3_4": {
-        "img": "5796edd6dc6153a2a47f6b9a06ed698d",
-        "mask": "763e018b2d95aa97394c4d19214ac9f6"
+        "img": "89b7ff63872f65e426bf9632befcb26c",
+        "mask": "31f959a3d7295518406c02a01d5c4c95"
     },
     "bullseye_thin": {
         "img": "9ea6183253eecc16a7a82864844d852a",
diff --git a/tests/papers/bindmann2004.json b/tests/papers/bindmann2004.json
new file mode 100644
index 00000000..f1e5ba62
--- /dev/null
+++ b/tests/papers/bindmann2004.json
@@ -0,0 +1,122 @@
+{
+    "bullseye_thin_gw45_gb31": {
+        "img": "f06621c50e0205c4bccedc7a0311d7c9",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw45_gb38": {
+        "img": "5cc2ef6c282026e04b3c02794631ef19",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw45_gb45": {
+        "img": "adaf13a8e370e14d2e185c28ea5a0421",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw45_gb52": {
+        "img": "c460a39bae1cbf37e70293f8daeaee94",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw45_gb59": {
+        "img": "15107c5d3681959f4e8b6831a26aaa42",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw60_gb46": {
+        "img": "daf03e77ddaac425b4e2a3a9a48997c0",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw60_gb53": {
+        "img": "b86ec70b5ada5d692054dfd5a0cf1b9d",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw60_gb60": {
+        "img": "89224c45340b8a68cb16e8379e0d5fcf",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw60_gb67": {
+        "img": "0ef1de33158ebe54920e47cef1d73f49",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw60_gb74": {
+        "img": "dd5d96b6010888e61aa76579a1cb43e8",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw75_gb61": {
+        "img": "dd859568886cb74c57915d927d1295cf",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw75_gb68": {
+        "img": "5b9a3bc585f795f2cf3f514e2e5135a6",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw75_gb75": {
+        "img": "501c63a2ac14d558531992d33acfb78c",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw75_gb82": {
+        "img": "193323d2ecf367f81484f491de470eb0",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thin_gw75_gb89": {
+        "img": "e446fde9f34f68a3525419a2914e5875",
+        "mask": "c2fb80b9db4a0b95f7019838fb9e5947"
+    },
+    "bullseye_thick_gw45_gb31": {
+        "img": "6bfaa2190d4aabf824f41de81d9aee6d",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw45_gb38": {
+        "img": "33586107591a4abbcbcf70b646e130d2",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw45_gb45": {
+        "img": "9026edc9ba1255af1070e055c6562877",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw45_gb52": {
+        "img": "736b825806407a863f89f27f59aeb46d",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw45_gb59": {
+        "img": "6903f39a1956abbee6ad84342d09baa5",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw60_gb46": {
+        "img": "bf31e11fcd539238209f004939011883",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw60_gb53": {
+        "img": "c03bbaaaddcd582487480f084f1bada9",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw60_gb60": {
+        "img": "facddd57d55b27da6fafc67161542ba4",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw60_gb67": {
+        "img": "02e9eff8b8f92c8fc26147038ad429cc",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw60_gb74": {
+        "img": "1dab1b68f70320d02fd820a159874d71",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw75_gb61": {
+        "img": "3aa28321ebc00065bcc76606e8822e2c",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw75_gb68": {
+        "img": "aa0810cf21e10e38dae04a8c15f22b4e",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw75_gb75": {
+        "img": "86e1ae4b774d401b6672b7f31795a17c",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw75_gb82": {
+        "img": "5562be3e52023ecc9e0e1cdc38466b36",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    },
+    "bullseye_thick_gw75_gb89": {
+        "img": "19efa6cd57f551b490cb71409b923ddb",
+        "mask": "2267230f568fdb1d953052c5d108957e"
+    }
+}
\ No newline at end of file
diff --git a/tests/papers/modelfest.json b/tests/papers/modelfest.json
new file mode 100644
index 00000000..d064029c
--- /dev/null
+++ b/tests/papers/modelfest.json
@@ -0,0 +1,174 @@
+{
+    "GaborPatch1": {
+        "img": "b0c6e94a8d507a5bbbeda37653424e28",
+        "mask": "a9c39314b34f38c7bab02ab95bc55678"
+    },
+    "GaborPatch2": {
+        "img": "87515da6c5975fc8c47b624889355436",
+        "mask": "bd40b27f835ede1c1a6a7a2e06def32d"
+    },
+    "GaborPatch3": {
+        "img": "4588e45525246539dd2645cf6c4c55f6",
+        "mask": "a146c2bd75529bb36dd1a8e5a9d1ca83"
+    },
+    "GaborPatch4": {
+        "img": "10ad35a99081c54e516852616dfefebb",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "GaborPatch5": {
+        "img": "c9c46936392763065cbbf9e961409598",
+        "mask": "b0c483adf269e731716aef444be9c011"
+    },
+    "GaborPatch6": {
+        "img": "a21c6f42bcfcf566cfe5195d188606a0",
+        "mask": "9be78d09a7c29595d20830ac72b49967"
+    },
+    "GaborPatch7": {
+        "img": "3d089e5545054df7702e5c9e37f43443",
+        "mask": "b834ed188f4ef1f0de5523f9bd0fbe40"
+    },
+    "GaborPatch8": {
+        "img": "813bd907974bd78de12c589bc0023905",
+        "mask": "c8d7e116a6930dd7c1ba7eb50bf1fbcc"
+    },
+    "GaborPatch9": {
+        "img": "f8e7e2a996416eb7c43044b290ae0c88",
+        "mask": "62687a128b5b770fb8032be55239bcc3"
+    },
+    "GaborPatch10": {
+        "img": "ac028986c70ea5343701a7f94def7b96",
+        "mask": "fcb83e2f3bfb865cfcf05d2a841c14fd"
+    },
+    "GaborPatch11": {
+        "img": "49289809350f1d3cde265510c617d6f1",
+        "mask": "bd40b27f835ede1c1a6a7a2e06def32d"
+    },
+    "GaborPatch12": {
+        "img": "cf4f918c50f2c26d64c3bcec89248c83",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "GaborPatch13": {
+        "img": "055e9d74bb5693c8f2b1116e432c3160",
+        "mask": "9be78d09a7c29595d20830ac72b49967"
+    },
+    "GaborPatch14": {
+        "img": "3e5ec33db0d82784aca2ca93d6079360",
+        "mask": "c8d7e116a6930dd7c1ba7eb50bf1fbcc"
+    },
+    "ElongatedGabor15": {
+        "img": "8fd80a0d7449caf29dda59d08a5c6632",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "ElongatedGabor16": {
+        "img": "fa047d68663707893c03914dc081fa46",
+        "mask": "9be78d09a7c29595d20830ac72b49967"
+    },
+    "ElongatedGabor17": {
+        "img": "377491e7d9c0803598cca78d51a7dd09",
+        "mask": "c8d7e116a6930dd7c1ba7eb50bf1fbcc"
+    },
+    "Baguette18": {
+        "img": "50de070e3690b63d5732954c80fc48d4",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "Baguette19": {
+        "img": "099e4c4e29a47c9624a3f221d566c3d7",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "Baguette20": {
+        "img": "a138eed458804c462fef9698a4eea4ce",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "Baguette21": {
+        "img": "c9387ad4aea51ffd2c96d20a8b495e63",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "Subthreshold22": {
+        "img": "d14d0bdda653f62aa58e18dd6e2ec0b9",
+        "mask": "bd40b27f835ede1c1a6a7a2e06def32d"
+    },
+    "Subthreshold23": {
+        "img": "4663991d2e2fe4928002d3fe826e62b7",
+        "mask": "bd40b27f835ede1c1a6a7a2e06def32d"
+    },
+    "Subthreshold24": {
+        "img": "de5940a24a0249713c61ab4acf825460",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "Subthreshold25": {
+        "img": "c5eeb4e5b426ac7dc189146e2f321529",
+        "mask": "1faf5dfa387e200f6b180cd151b79b33"
+    },
+    "Gaussians26": {
+        "img": "39b3db66bd02241520e6a8e819b64358",
+        "mask": "b0a0d7ac266eefa1a12d14c30655e769"
+    },
+    "Gaussians27": {
+        "img": "2c802c0913126952a0ae8594e03353ae",
+        "mask": "c65d5ef555f2845dc89fdc517713790e"
+    },
+    "Gaussians28": {
+        "img": "34ffa37c60db9fd13de1303382637e24",
+        "mask": "e5e7782bed3fa336d41c7faedb6eb864"
+    },
+    "Gaussians29": {
+        "img": "b01844e38ac093edb31c91333d463daa",
+        "mask": "c90e93e1ef645323afb6b742bd7f813d"
+    },
+    "Edge30": {
+        "img": "29a97256041f16e870dd7067ff157f02",
+        "mask": "9eeb417981c12ec22d3466eca100f136"
+    },
+    "Line31": {
+        "img": "36abbe8ee050dacba6b61248c8a17ca3",
+        "mask": "c5d29c96c5f29d534aa665474327bcf4"
+    },
+    "Dipole32": {
+        "img": "872b1e0227292396b044b3dc2d441c0f",
+        "mask": "9502dbb1d34c7057c670891ff362e6f8"
+    },
+    "GaborString33": {
+        "img": "db34995490a57a3f17d2ee7eb0339f2b",
+        "mask": "511efb63c14e1afc144dbd3d1b34f728"
+    },
+    "GaborString34": {
+        "img": "c69b4ca1748d9d7e0babc7a2456e9c2f",
+        "mask": "511efb63c14e1afc144dbd3d1b34f728"
+    },
+    "Noise35": {
+        "img": "1f133434ffff9ebbef26bdd19ce45d87",
+        "mask": "59071590099d21dd439896592338bf95"
+    },
+    "Orientation36": {
+        "img": "8f57a9168d05e3ce91001ebe9d211add",
+        "mask": "9fba384c3a786d6a314fa5c94033975f"
+    },
+    "Orientation37": {
+        "img": "dfc7ea2ab10ab054b00fe671494776fc",
+        "mask": "f7ee038979fd9d509aad389ac485c3fd"
+    },
+    "Plaids38": {
+        "img": "239d34e507d5a8e0aa9897971b951781",
+        "mask": "f7ee038979fd9d509aad389ac485c3fd"
+    },
+    "Plaids39": {
+        "img": "c21e5812c6dc1837557a5a32b7f8cb2d",
+        "mask": "9fba384c3a786d6a314fa5c94033975f"
+    },
+    "Disk40": {
+        "img": "fc5672879d59b22076ff1a79887df0c5",
+        "mask": "d774d1e22f9c1e5606e86cfad05391ed"
+    },
+    "Bessel41": {
+        "img": "ad3bfbbde8c3954e2975a0d9c1179be0",
+        "mask": "59071590099d21dd439896592338bf95"
+    },
+    "Checkerboard42": {
+        "img": "7caa217d376ff6e9f0cf5d30ad94d825",
+        "mask": "af73b028e644c86d1c55723752952f41"
+    },
+    "NaturalScene43": {
+        "img": "b27b8636f0a6b247cc49c5c66a808dae",
+        "mask": "59071590099d21dd439896592338bf95"
+    }
+}
\ No newline at end of file
diff --git a/tests/papers/test_bindmann2004.py b/tests/papers/test_bindmann2004.py
new file mode 100644
index 00000000..5409ce0f
--- /dev/null
+++ b/tests/papers/test_bindmann2004.py
@@ -0,0 +1,20 @@
+import json
+import os.path
+
+import pytest
+
+import stimupy.papers.bindmann2004
+from stimupy.papers.bindmann2004 import __all__ as stimlist
+from stimupy.utils import export
+
+data_dir = os.path.dirname(__file__)
+jsonfile = os.path.join(data_dir, "bindmann2004.json")
+loaded = json.load(open(jsonfile))
+
+
+@pytest.mark.parametrize("stim_name", stimlist)
+def test_stim(stim_name):
+    func = getattr(stimupy.papers.bindmann2004, stim_name)
+    stim = export.arrs_to_checksum(func(), keys=["img", "target_mask"])
+    assert stim["img"] == loaded[stim_name]["img"], "imgs are different"
+    assert stim["target_mask"] == loaded[stim_name]["mask"], "masks are different"
diff --git a/tests/papers/test_domijan2015.py b/tests/papers/test_domijan2015.py
index 77d09cf4..0b4b6fd5 100644
--- a/tests/papers/test_domijan2015.py
+++ b/tests/papers/test_domijan2015.py
@@ -22,7 +22,7 @@ def test_stim(stim_name):
     assert stim["target_mask"] == loaded[stim_name]["mask"], "masks are different"
 
 
-@pytest.mark.parametrize("stim_name, ppd", product(stimlist, (8, 10, 12, 13, 15, 20)))
+@pytest.mark.parametrize("stim_name, ppd", product(stimlist, (8, 10, 12, 14, 18, 20)))
 def test_ppd(stim_name, ppd):
     func = getattr(stimupy.papers.domijan2015, stim_name)
 
diff --git a/tests/papers/test_carney1999.py b/tests/papers/test_modelfest.py
similarity index 63%
rename from tests/papers/test_carney1999.py
rename to tests/papers/test_modelfest.py
index bbfaa6d4..389b9d0f 100644
--- a/tests/papers/test_carney1999.py
+++ b/tests/papers/test_modelfest.py
@@ -3,17 +3,17 @@
 
 import pytest
 
-import stimupy.papers.carney1999
-from stimupy.papers.carney1999 import __all__ as stimlist
+import stimupy.papers.modelfest
+from stimupy.papers.modelfest import __all__ as stimlist
 from stimupy.utils import export
 
 data_dir = os.path.dirname(__file__)
-jsonfile = os.path.join(data_dir, "carney1999.json")
+jsonfile = os.path.join(data_dir, "modelfest.json")
 loaded = json.load(open(jsonfile))
 
 
 @pytest.mark.parametrize("stim_name", stimlist)
 def test_stim(stim_name):
-    func = getattr(stimupy.papers.carney1999, stim_name)
+    func = getattr(stimupy.papers.modelfest, stim_name)
     stim = export.arrs_to_checksum(func(), keys=["img"])
     assert stim["img"] == loaded[stim_name]["img"], "imgs are different"
diff --git a/tests/test_gratings.py b/tests/test_gratings.py
index 1c3be937..25857da8 100644
--- a/tests/test_gratings.py
+++ b/tests/test_gratings.py
@@ -1,6 +1,7 @@
 import pytest
 
-from stimupy.components.gratings import resolve_grating_params, square_wave
+from stimupy.components import resolve_grating_params
+from stimupy.components.gratings import square_wave
 
 
 @pytest.mark.parametrize(