Source code for psychos.visual.synthetic

from typing import Tuple, Optional, Union, Literal, Callable

try:
    import numpy as np
except ImportError:
    raise ImportError(
        "The 'numpy' package is required to use the 'synthetic' stimuli module. "
        "You can install it using 'pip install numpy'."
    )

from ..utils import Color
from ..types import ColorType

__all__ = ["gabor_2d", "gabor_3d", "reescale"]

[docs] def reescale( arr: "np.ndarray", vmin: float = -1.0, vmax: float = 1.0, method: Literal["normalize", "clip"] = "normalize", ) -> "np.ndarray": """ Rescale a numpy array based on the specified method. Parameters ---------- arr : np.ndarray Input array to be rescaled. vmin : float, optional Minimum value for normalization or clipping (default is -1.0). vmax : float, optional Maximum value for normalization or clipping (default is 1.0). method : Literal["none", "normalize", "clip"], optional Rescaling method: - 'none': return the array as is. - 'normalize': scale the array so its min maps to vmin and max to vmax. - 'clip': clip the array values to lie between vmin and vmax. Returns ------- np.ndarray The rescaled array. """ if method == "normalize": arr_min, arr_max = arr.min(), arr.max() if np.isclose(arr_min, arr_max): return np.full_like(arr, (vmin + vmax) / 2.0) return (arr - arr_min) / (arr_max - arr_min) * (vmax - vmin) + vmin elif method == "clip": return np.clip(arr, vmin, vmax) else: raise ValueError("Method must be one of 'normalize', or 'clip'")
[docs] def gabor_2d( size: Tuple[int, int] = (128, 128), spatial_frequency: float = 5.0, # cycles per image width orientation: float = 45.0, # in degrees, [0..360] phase: float = 0.0, # fraction of 2*pi in [0..1] sigma: Optional[ float ] = 0.25, # normalized (fraction of min dimension); if None, no envelope is applied. gamma: float = 1.0, # aspect ratio (vertical stretch) contrast: float = 1.0, baseline: float = 0.0, center: Optional[Tuple[float, float]] = None, rescale: Optional[ Literal["none", "normalize", "clip"] ] = None, # 'none', 'normalize', or 'clip' vmin: float = 0.0, vmax: float = 1.0, return_envelope: bool = False, ) -> Union["np.ndarray", Tuple["np.ndarray", "np.ndarray"]]: """ Generate a 2D Gabor patch as a numpy array (grayscale), with an optional Gaussian envelope. Parameters ---------- size : Tuple[int, int] (height, width) of the output array in pixels. spatial_frequency : float Cycles per entire image width. For orientation=0, this yields exactly `spatial_frequency` cycles from left to right. orientation : float Gabor orientation in degrees, [0..360]. Note: The function rotates the carrier by (360 - orientation) degrees. phase : float Fraction of 2*pi for the carrier phase, in [0..1]. sigma : Optional[float] Standard deviation of the Gaussian envelope, as a fraction of the minimum dimension. If None, no envelope is applied (i.e., the envelope is 1 everywhere). gamma : float Aspect ratio of the Gaussian envelope (width vs. height). If 1.0, the envelope is circular; values < 1 or > 1 yield an elliptical envelope. contrast : float Contrast (amplitude) of the sinusoidal carrier. baseline : float Baseline offset intensity added to the Gabor. center : Optional[Tuple[float, float]] (y_center, x_center) specifying the center in pixel coordinates. If None, defaults to the image center ((h/2), (w/2)). rescale : Literal["none", "normalize", "clip"] Rescaling method: - 'none': return the raw computed values. - 'normalize': linearly scale the array so that its min maps to vmin and max to vmax. - 'clip': clip values to the range [vmin, vmax]. vmin : float Minimum value for normalization or clipping. Ignored if rescale=='none'. vmax : float Maximum value for normalization or clipping. Ignored if rescale=='none'. return_envelope : bool If True, return a tuple (gabor, envelope), where envelope is the Gaussian envelope used (useful for an alpha channel). If sigma is None, envelope is an array of ones. Returns ------- Union[np.ndarray, Tuple[np.ndarray, np.ndarray]] If return_envelope is False, returns a 2D array containing the Gabor pattern. Otherwise, returns a tuple: (gabor, envelope). Example ------- >>> import matplotlib.pyplot as plt >>> gabor, env = gabor_2d(size=(256, 256), spatial_frequency=2.0, orientation=45.0, ... phase=0.25, sigma=0.25, gamma=1.0, contrast=1.0, ... baseline=0.0, rescale='normalize', vmin=-1.0, vmax=1.0, ... return_envelope=True) >>> plt.figure(figsize=(10, 5)) >>> plt.subplot(1, 2, 1) >>> plt.imshow(gabor, cmap='gray', origin='lower') >>> plt.title("Gabor Patch") >>> plt.subplot(1, 2, 2) >>> plt.imshow(env, cmap='gray', origin='lower') >>> plt.title("Envelope (Alpha Channel)") >>> plt.show() """ h, w = size # Convert orientation and phase to radians. # Using (360 - orientation) rotates the carrier in the expected direction. theta = np.deg2rad(360 - orientation) phase_rad = 2.0 * np.pi * phase # Default center is the midpoint of the array if center is None: center = (h / 2.0, w / 2.0) cy, cx = center # Create grid of coordinates y_idx = np.arange(h) - cy x_idx = np.arange(w) - cx y_mesh, x_mesh = np.meshgrid(y_idx, x_idx, indexing="xy") # Rotate coordinate system x_prime = x_mesh * np.cos(theta) + y_mesh * np.sin(theta) y_prime = -x_mesh * np.sin(theta) + y_mesh * np.cos(theta) # Convert spatial frequency from cycles/image width to cycles/pixel freq_px = spatial_frequency / float(w) # Compute the sinusoidal carrier carrier = np.cos(2.0 * np.pi * freq_px * x_prime + phase_rad) # Compute the envelope if sigma is provided; otherwise, use ones if sigma is not None: sigma_px = sigma * min(h, w) envelope = np.exp(-0.5 * (x_prime**2 + (gamma * y_prime) ** 2) / (sigma_px**2)) gabor = contrast * envelope * carrier + baseline else: envelope = np.ones_like(carrier) gabor = contrast * carrier + baseline # Apply rescaling if needed if rescale: gabor = reescale(gabor, vmin, vmax, method=rescale) if return_envelope: return gabor, envelope return gabor
[docs] def gabor_3d( size: Tuple[int, int] = (256, 256), spatial_frequency: float = 8.0, # cycles per image width orientation: float = 45.0, # in degrees, [0..360] phase: float = 0.0, # fraction of 2*pi in [0..1] sigma: Optional[ float ] = 0.15, # normalized (fraction of min dimension); if None, no envelope is applied. gamma: float = 1.0, # aspect ratio (vertical stretch) contrast: float = 1.0, center: Optional[Tuple[float, float]] = None, cmap: Union[str, Callable[[np.ndarray], np.ndarray], Tuple[ColorType, ColorType], list] = ( "black", "white", ), # alpha_channel can be "envelope", None, or a numpy array with shape matching the 2D gabor. alpha_channel: Union[Literal["envelope"], None, np.ndarray] = "envelope", **kwargs, # additional kwargs passed to gabor_2d (e.g., baseline, rescale, etc.) ) -> np.ndarray: """ Generate a 3D (color) Gabor patch as a numpy array by mapping a grayscale 2D Gabor (computed via gabor_2d) into RGB values using a colormap. An optional alpha channel may be included. Parameters ---------- size : Tuple[int, int] (height, width) of the output image in pixels. spatial_frequency : float Cycles per entire image width. orientation : float Gabor orientation in degrees, [0..360]. phase : float Fraction of 2*pi for the carrier phase, in [0..1]. sigma : Optional[float] Standard deviation of the Gaussian envelope as a fraction of the minimum dimension. If None, no envelope is applied (envelope defaults to ones). gamma : float Aspect ratio of the Gaussian envelope. contrast : float Contrast (amplitude) of the sinusoidal carrier. center : Optional[Tuple[float, float]] Center (y, x) in pixel coordinates. Defaults to image center if None. cmap : Union[str, Callable[[np.ndarray], np.ndarray], Tuple[ColorType, ColorType], list] Colormap used to map normalized grayscale values to colors. If a string, it must be a valid matplotlib colormap name. Alternatively, a callable mapping an array in [0,1] to an RGBA image, or a tuple/list of two colors (low and high) for linear two-color interpolation. alpha_channel : Union[Literal["envelope"], None, np.ndarray] Determines the alpha channel: - "envelope": use the Gaussian envelope computed in gabor_2d. - None: do not include an alpha channel (output will be RGB). - A numpy array: must have the same shape as the 2D gabor; used as the alpha channel. **kwargs Additional keyword arguments passed to gabor_2d (e.g., baseline, rescale). Returns ------- np.ndarray If an alpha channel is provided, returns an RGBA image (height x width x 4); otherwise, returns an RGB image (height x width x 3). Example ------- >>> import matplotlib.pyplot as plt >>> # Using a matplotlib colormap: >>> gabor_color = gabor_3d(size=(256, 256), spatial_frequency=2.0, orientation=45.0, ... phase=0.25, sigma=0.25, gamma=1.0, contrast=1.0, ... cmap='viridis', alpha_channel="envelope", ... baseline=0.0, rescale='normalize', vmin=0.0, vmax=1.0) >>> plt.imshow(gabor_color, origin='lower') >>> plt.title("3D Gabor Patch with Envelope as Alpha") >>> plt.show() >>> # Using two-color interpolation: >>> gabor_color2 = gabor_3d(size=(256, 256), spatial_frequency=2.0, orientation=45.0, ... phase=0.25, sigma=0.25, gamma=1.0, contrast=1.0, ... cmap=('black', 'white'), alpha_channel="envelope") >>> plt.imshow(gabor_color2, origin='lower') >>> plt.title("3D Gabor Patch (Black-to-White)") >>> plt.show() """ # Obtain 2D grayscale Gabor and envelope via gabor_2d (pass extra kwargs if needed) gabor_gray, envelope = gabor_2d( size=size, spatial_frequency=spatial_frequency, orientation=orientation, phase=phase, sigma=sigma, gamma=gamma, contrast=contrast, center=center, return_envelope=True, **kwargs, ) # Normalize the grayscale image to [0,1] for color mapping if np.isclose(gabor_gray.min(), gabor_gray.max()): norm_gray = np.full_like(gabor_gray, 0.5) else: norm_gray = (gabor_gray - gabor_gray.min()) / (gabor_gray.max() - gabor_gray.min()) # Reapply contrast after normalization. if contrast != 1.0: norm_gray = 0.5 + contrast * (norm_gray - 0.5) # Map normalized grayscale values to color (RGBA) if isinstance(cmap, str): try: from matplotlib.pyplot import get_cmap except ImportError: raise ImportError( "The 'matplotlib' package is required to use a colormap string. " "You can install it using 'pip install matplotlib'." ) cmap_func = get_cmap(cmap) colored = cmap_func(norm_gray) # returns an RGBA image (h, w, 4) elif callable(cmap): colored = cmap(norm_gray) # If the callable returns RGB only, add an alpha channel of ones if colored.shape[2] == 3: colored = np.dstack([colored, np.ones_like(colored[..., 0])]) elif isinstance(cmap, (tuple, list)) and len(cmap) == 2: # Two-color interpolation: parse each color via Color().to_rgb() col1 = np.array(Color(cmap[0]).to_rgb()) col2 = np.array(Color(cmap[1]).to_rgb()) # Interpolate: for each pixel, color = (1 - v)*col1 + v*col2 # norm_gray has shape (h, w); reshape for broadcasting. colored_rgb = (1 - norm_gray[..., None]) * col1 + norm_gray[..., None] * col2 # Add alpha channel of ones (will be replaced if requested) colored = np.dstack([colored_rgb, np.ones_like(norm_gray)]) else: raise ValueError( "cmap must be a valid colormap string, callable, or a tuple/list of two colors." ) # Adjust the alpha channel as specified if alpha_channel == "envelope": colored[..., 3] = envelope # override the alpha channel with the envelope elif alpha_channel is None: colored = colored[..., :3] # drop alpha channel elif isinstance(alpha_channel, np.ndarray): if alpha_channel.shape != gabor_gray.shape: raise ValueError( "Provided alpha_channel array must match the shape of the gabor image." ) colored[..., 3] = alpha_channel else: raise ValueError("alpha_channel must be 'envelope', None, or a numpy array.") return colored