diff --git a/docs/reference/demos/stimuli/todorovics.md b/docs/reference/demos/stimuli/todorovics.md index 2227421..4e32b0e 100644 --- a/docs/reference/demos/stimuli/todorovics.md +++ b/docs/reference/demos/stimuli/todorovics.md @@ -228,10 +228,10 @@ display(ui, out) ``` ## Rectangle, two-sided -{py:func}`stimupy.stimuli.todorovics.two_sided_rectangle` +{py:func}`stimupy.stimuli.todorovics.rectangle_two_sided` ```{code-cell} ipython3 -from stimupy.stimuli.todorovics import two_sided_rectangle +from stimupy.stimuli.todorovics import rectangle_two_sided # Define widgets w_height = iw.IntSlider(value=10, min=1, max=20, description="height [deg]") @@ -281,7 +281,7 @@ def show_two_sided_rectangle( intback2=None, add_mask=False, ): - stim = two_sided_rectangle( + stim = rectangle_two_sided( visual_size=(height, width), ppd=ppd, target_size=(theight, twidth), @@ -500,10 +500,10 @@ display(ui, out) ``` ## Cross, two-sided -{py:func}`stimupy.stimuli.todorovics.two_sided_cross` +{py:func}`stimupy.stimuli.todorovics.cross_two_sided` ```{code-cell} ipython3 -from stimupy.stimuli.todorovics import two_sided_cross +from stimupy.stimuli.todorovics import cross_two_sided # Define widgets w_height = iw.IntSlider(value=10, min=1, max=20, description="height [deg]") @@ -549,7 +549,7 @@ def show_two_sided_cross( intback2=None, add_mask=False, ): - stim = two_sided_cross( + stim = cross_two_sided( visual_size=(height, width), ppd=ppd, cross_size=(theight, twidth), @@ -661,10 +661,10 @@ display(ui, out) ``` ## Equal, two-sided -{py:func}`stimupy.stimuli.todorovics.two_sided_equal` +{py:func}`stimupy.stimuli.todorovics.equal_two_sided` ```{code-cell} ipython3 -from stimupy.stimuli.todorovics import two_sided_equal +from stimupy.stimuli.todorovics import equal_two_sided # Define widgets w_height = iw.IntSlider(value=10, min=1, max=20, description="height [deg]") @@ -706,7 +706,7 @@ def show_two_sided_equal( intback2=None, add_mask=False, ): - stim = two_sided_equal( + stim = equal_two_sided( visual_size=(height, width), ppd=ppd, cross_size=(theight, twidth), diff --git a/stimupy/stimuli/todorovics.py b/stimupy/stimuli/todorovics.py index 0f1956f..72f2fb1 100644 --- a/stimupy/stimuli/todorovics.py +++ b/stimupy/stimuli/todorovics.py @@ -1,3 +1,5 @@ +import itertools + import numpy as np from stimupy.components.shapes import cross as cross_shape @@ -7,12 +9,12 @@ __all__ = [ "rectangle_generalized", "rectangle", + "rectangle_two_sided", "cross_generalized", "cross", + "cross_two_sided", "equal", - "two_sided_rectangle", - "two_sided_cross", - "two_sided_equal", + "equal_two_sided", ] @@ -53,7 +55,7 @@ def rectangle_generalized( intensity value for background intensity_target : float intensity value for target - intensity_covers : float + intensity_covers : Sequence[Number, ...] or Number intensity value for covers Returns @@ -122,11 +124,16 @@ def rectangle_generalized( 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") + if isinstance(intensity_covers, (float, int)): + int_cov = [ + intensity_covers, + ] + else: + int_cov = list(intensity_covers) + int_cov = itertools.cycle(int_cov) for i in range(len(covers_x)): - img[cy[i] : cy[i] + cheight, cx[i] : cx[i] + cwidth] = intensity_covers + img[cy[i] : cy[i] + cheight, cx[i] : cx[i] + cwidth] = next(int_cov) mask[cy[i] : cy[i] + cheight, cx[i] : cx[i] + cwidth] = 0 if cy[i] + cheight > shape[0] or cx[i] + cwidth > shape[1]: raise ValueError("Covers do not fully fit into stimulus") @@ -183,7 +190,7 @@ def rectangle( intensity value for background intensity_target : float intensity value for target - intensity_covers : float + intensity_covers : Sequence[Number, ...] or Number intensity value for covers Returns @@ -244,12 +251,103 @@ def rectangle( return stim +def rectangle_two_sided( + visual_size=None, + ppd=None, + shape=None, + target_size=None, + covers_size=None, + covers_offset=None, + intensity_backgrounds=(0.0, 1.0), + intensity_target=0.5, + intensity_covers=(1.0, 0.0), +): + """ + Two-sided Todorovic's illusion with rectangular target in the center and four + rectangular covers added symmetrically around target center + + 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 + target_size : float or (float, float) + size of the target in degrees of visual angle (height, width) + covers_size : float or (float, float) + size of covers in degrees of visual angle (height, width) + covers_offset : float or (float, float) + distance from cover center to target center in (y, x) + intensity_background : Sequence[Number, Number] + intensity values for backgrounds + intensity_target : float + intensity value for target + intensity_covers : Sequence[Number, Number] + intensity values for covers + + Returns + ------- + dict[str, Any] + dict with the stimulus (key: "img"), + mask with integer index for the target (key: "target_mask"), + and additional keys containing stimulus parameters + + References + ---------- + Blakeslee, B., & McCourt, M. E. (1999). + A multiscale spatial filtering account + of the White effect, simultaneous brightness contrast and grating induction. + Vision Research, 39, 4361-4377. + Pessoa, L., Baratoff, G., Neumann, H., & Todorovic, D. (1998). + Lightness and junctions: variations on White's display. + Investigative Ophthalmology and Visual Science (Supplement), 39, S159. + Todorovic, D. (1997). + Lightness and junctions. Perception, 26, 379-395. + """ + + # Resolve resolution + shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd) + + stim1 = rectangle( + visual_size=(visual_size[0], visual_size[1] / 2), + ppd=ppd, + target_size=target_size, + covers_size=covers_size, + covers_offset=covers_offset, + intensity_background=intensity_backgrounds[0], + intensity_target=intensity_target, + intensity_covers=intensity_covers[0], + ) + + stim2 = rectangle( + visual_size=(visual_size[0], visual_size[1] / 2), + ppd=ppd, + target_size=target_size, + covers_size=covers_size, + covers_offset=covers_offset, + intensity_background=intensity_backgrounds[1], + intensity_target=intensity_target, + intensity_covers=intensity_covers[1], + ) + + stim = stack_dicts(stim1, stim2) + del stim["intensity_background"] + del stim["target_position"] + stim["intensity_backgrounds"] = intensity_backgrounds + stim["intensity_covers"] = intensity_covers + stim["target_positions"] = (stim1["target_position"], stim2["target_position"]) + stim["shape"] = shape + stim["visual_size"] = visual_size + return stim + + def cross_generalized( visual_size=None, ppd=None, shape=None, cross_size=None, - cross_arm_ratios=1, cross_thickness=None, covers_size=None, covers_x=None, @@ -270,8 +368,6 @@ def cross_generalized( shape [height, width] of grating, in pixels cross_size : float or (float, float) size of target cross in visual angle - cross_arm_ratios : float or (float, float) - ratio used to create arms (up-down, left-right) cross_thickness : float thickness of target cross in visual angle covers_size : float or (float, float) @@ -284,7 +380,7 @@ def cross_generalized( intensity value for background intensity_target : float intensity value for target - intensity_covers : float + intensity_covers : Sequence[Number, ...] or Number intensity value for covers Returns @@ -338,7 +434,7 @@ def cross_generalized( visual_size=cross_size, ppd=ppd, cross_size=cross_size, - cross_arm_ratios=cross_arm_ratios, + cross_arm_ratios=1, cross_thickness=cross_thickness, intensity_background=intensity_background, intensity_cross=intensity_target, @@ -359,8 +455,16 @@ def cross_generalized( cx = np.round(cx).astype(int) cy = np.round(cy).astype(int) + if isinstance(intensity_covers, (float, int)): + int_cov = [ + intensity_covers, + ] + else: + int_cov = list(intensity_covers) + int_cov = itertools.cycle(int_cov) + for i in range(len(covers_x)): - img[cy[i] : cy[i] + cheight, cx[i] : cx[i] + cwidth] = intensity_covers + img[cy[i] : cy[i] + cheight, cx[i] : cx[i] + cwidth] = next(int_cov) mask[cy[i] : cy[i] + cheight, cx[i] : cx[i] + cwidth] = 0 if cy[i] + cheight > shape[0] or cx[i] + cwidth > shape[1]: raise ValueError("Covers do not fully fit into stimulus") @@ -411,7 +515,7 @@ def cross( intensity value for background intensity_target : float intensity value for target - intensity_covers : float + intensity_covers : Sequence[Number, ...] or Number intensity value for covers Returns @@ -464,7 +568,6 @@ def cross( visual_size=visual_size, ppd=ppd, cross_size=cross_size, - cross_arm_ratios=1.0, cross_thickness=ct, covers_size=covers_size, covers_x=(x1, x2, x2, x1), @@ -476,17 +579,18 @@ def cross( return stim -def equal( +def cross_two_sided( visual_size=None, ppd=None, shape=None, cross_size=None, cross_thickness=None, - intensity_background=0.0, + covers_size=None, + intensity_backgrounds=(0.0, 1.0), intensity_target=0.5, - intensity_covers=1.0, + intensity_covers=(1.0, 0.0), ): - """Cross target and four rectangular covers added at inner cross corners + """Two-sided with cross target and four rectangular covers added at inner cross corners Parameters ---------- @@ -500,86 +604,9 @@ def equal( size of target cross in visual angle cross_thickness : float thickness of target cross in visual angle - intensity_background : float - intensity value for background - intensity_target : float - intensity value for target - intensity_covers : float - intensity value for covers - - Returns - ------- - dict[str, Any] - dict with the stimulus (key: "img"), - mask with integer index for the target (key: "target_mask"), - and additional keys containing stimulus parameters - - References - ---------- - Blakeslee, B., & McCourt, M. E. (1999). - A multiscale spatial filtering account - of the White effect, simultaneous brightness contrast and grating induction. - Vision Research, 39, 4361-4377. - Pessoa, L., Baratoff, G., Neumann, H., & Todorovic, D. (1998). - Lightness and junctions: variations on White's display. - Investigative Ophthalmology and Visual Science (Supplement), 39, S159. - Todorovic, D. (1997). - Lightness and junctions. Perception, 26, 379-395. - """ - if cross_size is None: - raise ValueError("equal() missing argument 'cross_size' which is not 'None'") - if cross_thickness is None: - raise ValueError("equal() missing argument 'cross_thickness' which is not 'None'") - - if isinstance(cross_size, (float, int)): - cross_size = (cross_size, cross_size) - - covers_size = ((cross_size[0] - cross_thickness) / 2, (cross_size[1] - cross_thickness) / 2) - - stim = cross( - visual_size=visual_size, - ppd=ppd, - shape=shape, - cross_size=cross_size, - cross_thickness=cross_thickness, - covers_size=covers_size, - intensity_background=intensity_background, - intensity_target=intensity_target, - intensity_covers=intensity_covers, - ) - return stim - - -def two_sided_rectangle( - visual_size=None, - ppd=None, - shape=None, - target_size=None, - covers_size=None, - covers_offset=None, - intensity_backgrounds=(0.0, 1.0), - intensity_target=0.5, - intensity_covers=(1.0, 0.0), -): - """ - Two-sided Todorovic's illusion with rectangular target in the center and four - rectangular covers added symmetrically around target center - - 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 - target_size : float or (float, float) - size of the target in degrees of visual angle (height, width) covers_size : float or (float, float) size of covers in degrees of visual angle (height, width) - covers_offset : float or (float, float) - distance from cover center to target center in (y, x) - intensity_background : Sequence[Number, Number] + intensity_backgrounds : Sequence[Number, Number] intensity values for backgrounds intensity_target : float intensity value for target @@ -609,23 +636,23 @@ def two_sided_rectangle( # Resolve resolution shape, visual_size, ppd = resolution.resolve(shape=shape, visual_size=visual_size, ppd=ppd) - stim1 = rectangle( + stim1 = cross( visual_size=(visual_size[0], visual_size[1] / 2), ppd=ppd, - target_size=target_size, + cross_size=cross_size, + cross_thickness=cross_thickness, covers_size=covers_size, - covers_offset=covers_offset, intensity_background=intensity_backgrounds[0], intensity_target=intensity_target, intensity_covers=intensity_covers[0], ) - stim2 = rectangle( + stim2 = cross( visual_size=(visual_size[0], visual_size[1] / 2), ppd=ppd, - target_size=target_size, + cross_size=cross_size, + cross_thickness=cross_thickness, covers_size=covers_size, - covers_offset=covers_offset, intensity_background=intensity_backgrounds[1], intensity_target=intensity_target, intensity_covers=intensity_covers[1], @@ -633,27 +660,24 @@ def two_sided_rectangle( stim = stack_dicts(stim1, stim2) del stim["intensity_background"] - del stim["target_position"] stim["intensity_backgrounds"] = intensity_backgrounds stim["intensity_covers"] = intensity_covers - stim["target_positions"] = (stim1["target_position"], stim2["target_position"]) stim["shape"] = shape stim["visual_size"] = visual_size return stim -def two_sided_cross( +def equal( visual_size=None, ppd=None, shape=None, cross_size=None, cross_thickness=None, - covers_size=None, - intensity_backgrounds=(0.0, 1.0), + intensity_background=0.0, intensity_target=0.5, - intensity_covers=(1.0, 0.0), + intensity_covers=1.0, ): - """Two-sided with cross target and four rectangular covers added at inner cross corners + """Cross target and four rectangular covers added at inner cross corners Parameters ---------- @@ -667,14 +691,12 @@ def two_sided_cross( size of target cross in visual angle cross_thickness : float thickness of target cross in visual angle - covers_size : float or (float, float) - size of covers in degrees of visual angle (height, width) - intensity_backgrounds : Sequence[Number, Number] - intensity values for backgrounds + intensity_background : float + intensity value for background intensity_target : float intensity value for target - intensity_covers : Sequence[Number, Number] - intensity values for covers + intensity_covers : float + intensity value for covers Returns ------- @@ -695,42 +717,52 @@ def two_sided_cross( Todorovic, D. (1997). Lightness and junctions. Perception, 26, 379-395. """ + if cross_size is None: + raise ValueError("equal() missing argument 'cross_size' which is not 'None'") + if cross_thickness is None: + raise ValueError("equal() missing argument 'cross_thickness' which is not 'None'") + + if isinstance(cross_size, (float, int)): + cross_size = (cross_size, cross_size) # 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") - stim1 = cross( - visual_size=(visual_size[0], visual_size[1] / 2), + # Calculate placement of covers for generalized function: + ppd = np.unique(ppd)[0] + c1 = (np.ceil(cross_size[0] / 2 * ppd - cross_thickness / 2 * ppd + 1)) / ppd + c2 = (np.ceil(cross_size[1] / 2 * ppd - cross_thickness / 2 * ppd + 1)) / ppd + covers_size = (c1, c2) + + # covers_size = ((cross_size[0] - cross_thickness) / 2, (cross_size[1] - cross_thickness) / 2) + + stim = cross( + visual_size=visual_size, ppd=ppd, + shape=shape, cross_size=cross_size, cross_thickness=cross_thickness, covers_size=covers_size, - intensity_background=intensity_backgrounds[0], + intensity_background=intensity_background, intensity_target=intensity_target, - intensity_covers=intensity_covers[0], + intensity_covers=intensity_covers, ) - stim2 = cross( - visual_size=(visual_size[0], visual_size[1] / 2), + window = rectangle_shape( + visual_size=visual_size, ppd=ppd, - cross_size=cross_size, - cross_thickness=cross_thickness, - covers_size=covers_size, - intensity_background=intensity_backgrounds[1], - intensity_target=intensity_target, - intensity_covers=intensity_covers[1], + shape=shape, + rectangle_size=cross_size, ) - stim = stack_dicts(stim1, stim2) - del stim["intensity_background"] - stim["intensity_backgrounds"] = intensity_backgrounds - stim["intensity_covers"] = intensity_covers - stim["shape"] = shape - stim["visual_size"] = visual_size + stim["img"] = np.where(window["shape_mask"], stim["img"], intensity_background) + stim["target_mask"] = np.where(window["shape_mask"], stim["target_mask"], 0).astype(int) return stim -def two_sided_equal( +def equal_two_sided( visual_size=None, ppd=None, shape=None, @@ -840,12 +872,12 @@ def overview(**kwargs): stimuli = { "todorovic_rectangle": rectangle(**default_params, **rectangle_params, covers_offset=1.5), "todorovic_rectangle_general": rectangle_generalized(**default_params, **rectangle_params, target_position=3.5, covers_x=(2, 6), covers_y=(2, 6)), + "todorovic_rectangle_2sided": rectangle_two_sided(**default_params, **rectangle_params, covers_offset=1), "todorovic_cross": cross(**default_params, **cross_params, covers_size=2), "todorovic_cross_general": cross_generalized(**default_params, **cross_params, covers_size=2, covers_x=(2, 6), covers_y=(2, 6)), + "todorovic_cross_2sided": cross_two_sided(**default_params, **cross_params, covers_size=1), "todorovic_equal": equal(**default_params, **cross_params,), - "todorovic_rectangle_2sided": two_sided_rectangle(**default_params, **rectangle_params, covers_offset=1), - "todorovic_cross_2sided": two_sided_cross(**default_params, **cross_params, covers_size=1), - "todorovic_equal_2sided": two_sided_equal(**default_params, **cross_params,), + "todorovic_equal_2sided": equal_two_sided(**default_params, **cross_params,), } # fmt: on @@ -856,4 +888,4 @@ def overview(**kwargs): from stimupy.utils import plot_stimuli stims = overview() - plot_stimuli(stims, mask=True, save=None) + plot_stimuli(stims, mask=False, save=None)