Pale blue dots

An Ambiguous Arrangement of Dots

This notebook develops a controlled visual stimulus designed to investigate the perceptual asymmetry between foveal (central) and peripheral vision. The goal is to generate an image containing a spatial pattern that appears clear, segmented, and structured at the point of gaze (fixation) and around the macula but becomes ambiguous or collapses into a blurred background when viewed in the periphery.

Teaser image

Perceptual and Physiological Basis

This phenomenon is rooted in the non-uniform distribution of photoreceptors across the retina:

  1. The Foveal Region (Center): The macula, particularly the fovea, possesses an extremely high density of cone photoreceptors. This allows for high spatial resolution (fine detail) and precise chromatic discrimination. When we fixate on a point in this image, our cones resolve the sharp edges and specific colors of the dots, leading to clear segmentation from the background.
  2. The Peripheral Retina: As eccentricity increases, cone density drops sharply and rod photoreceptors become dominant. While rods are highly sensitive to luminance changes, they have much larger receptive fields (broader pooling) and lack chromatic specificity. In the periphery, the fine-grained structure of the dot lattice is "pooled" or smoothed out.

Consequently, a single static image can evoke two different perceptions: a structured array at fixation and a blurred background in the periphery. As a rule of thumb, the macular region (where this high-fidelity processing occurs) corresponds roughly to the apparent size of a thumb held at arm's length. Outside this zone, feature integration becomes coarser, leading to perceptual "crowding" and the collapse of local dot structure.

The following sections detail the step-by-step implementation of the stimulus and analyze how specific parameters (size, color, density) modulate this effect.

Method Step 1: Initialize Numerical Tools

We begin by importing numpy for all geometric and color computations. We configure numeric printing to ensure that precision is maintained without cluttering the output, making subsequent parameter sweeps easy to verify.

In [1]:
import numpy as np
np.set_printoptions(precision=2, suppress=True)
import os

Method Step 2: Coordinate and Color Mapping

To create the stimulus, we need to map physical properties (wavelengths in $\text{nm}$) to a digital color space ($\text{sRGB}$). We implement a Lambda2color class that utilizes standard CIE color-matching functions. This ensures that our chromatic choices are based on actual light spectra rather than arbitrary RGB values, allowing for better scientific control over the resulting visual contrast.

Method Step 3: Rendering Infrastructure

We use the pycairo library as our rendering engine. Cairo provides precise control over vector graphics and rasterization, which is essential when small differences in dot spacing or contour sharpness are expected to produce significant perceptual effects.

In [2]:
%pip install pycairo
Requirement already satisfied: pycairo in /Users/laurentperrinet/sdrive_cnrs/blog/laurentperrinet.github.io_sciblog/.venv/lib/python3.13/site-packages (1.29.0)
Note: you may need to restart the kernel to use updated packages.

Method Step 4: Define Canvas Geometry

We define the image dimensions to establish a fixed aspect ratio (approximately $1.618$, following the golden ratio). This choice determines the total spatial footprint of the lattice and allows us to consistently map pixels to visual angles, ensuring that the eccentricity range over which central and peripheral processing are compared is well-controlled.

In [3]:
import cairo
from IPython.display import Image, display
from math import pi
from io import BytesIO

N_height, N_width = int(1400/1.618), 1400
N_height, N_width = int(1400/1.414), 1400
N_height, N_width
Out[3]:
(990, 1400)

Method Step 5: Display and Export Utilities

To facilitate a rapid "tweak-and-verify" workflow, we implement helper functions for on-screen visualization and file export. By coupling stimulus generation with immediate rendering in PNG, PDF, and SVG formats, we can iteratively refine the parameters to find the exact regime where the center-periphery dissociation is most prominent.

In [4]:
figpath = '../files'
%mkdir -p {figpath}
from IPython import get_ipython
ip = get_ipython()
# print(ip.user_ns)
study_name = None
if '__vsc_ipynb_file__' in ip.user_ns:
    # https://github.com/msm1089/ipynbname/issues/17
    study_name = os.path.split(os.path.basename(ip.user_ns['__vsc_ipynb_file__']))[1]
elif '__file__' in ip.user_ns:
    study_name = ip.user_ns['__file__']#.replace('.ipynb', '')
else:
    import ipynbname
    study_name = ipynbname.name()

savepath = os.path.join(figpath, study_name.replace('.ipynb', ''))
print(f'{savepath=}')
savepath='../files/2026-07-31-pale-blue-dots'
In [5]:
def disp(draw_func):
    surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, N_width, N_height)
    ctx = cairo.Context(surface)
    draw_func(ctx, N_height=N_height, N_width=N_width)
    with BytesIO() as fileobj:
        surface.write_to_png(fileobj)
        display(Image(fileobj.getvalue(), width=N_width))

def render_pdf(draw_func, savepath):
    with cairo.PDFSurface(f"{savepath}.pdf", N_width, N_height) as surface:
        ctx = cairo.Context(surface)
        draw_func(ctx, N_height=N_height, N_width=N_width)        

def render_svg(draw_func, savepath):
    with cairo.SVGSurface(f"{savepath}.svg", N_width, N_height) as surface:
        ctx = cairo.Context(surface)
        draw_func(ctx, N_height=N_height, N_width=N_width)        

Method Step 5: Isoluminant Color Control

A critical requirement for this stimulus is the ability to manipulate hue and saturation while keeping luminance constant. This is necessary because rod and cone photoreceptors respond differently to brightness vs color. To avoid introducing luminance confounds that would make the dots globally obvious, we implement an isoluminance control mechanism using the Lambda2color class and a target luminance value.

The approximate peak sensitivity wavelengths for human cone types are:

  • S cones (short - blueish): $\approx 420\text{ nm}$
  • M cones (medium - greenish): $\approx 530\text{ nm}$
  • L cones (long - reddish): $\approx 560\text{ nm}$

By controlling these spectral properties, we can create "pale" colors that are just distinct enough for the fovea to resolve but easily pooled by the peripheral retina.

In [6]:
def xyz_from_xy(x_value: float, y_value: float) -> np.ndarray:
    """Return the vector (x, y, 1-x-y)."""
    return np.array((x_value, y_value, 1 - x_value - y_value))


class Lambda2color:
    """A class representing a colour system.

    A colour system defined by the CIE x, y and z=1-x-y coordinates of
    its three primary illuminants and its "white point".

    """

    def __init__(
        self, red: np.ndarray, green: np.ndarray, blue: np.ndarray, white: np.ndarray):
        """Initialise the ColourSystem object.

        Pass vectors (ie NumPy arrays of shape (3,)) for each of the
        red, green, blue  chromaticities and the white illuminant
        defining the colour system.

        """
        # Chromaticities
        self.red, self.green, self.blue = red, green, blue
        self.white = white
        # The chromaticity matrix (rgb -> xyz) and its inverse
        self.chromaticity_matrix = np.vstack((self.red, self.green, self.blue)).T
        self.inv_chromaticity_matrix = np.linalg.inv(self.chromaticity_matrix)
        # White scaling array
        self.wscale = self.inv_chromaticity_matrix.dot(self.white)

        # xyz -> rgb transformation matrix
        self.transformation_matrix = self.inv_chromaticity_matrix / self.wscale[:, np.newaxis]

        # the CIE colour matching function for 380 - 780 nm in 5 nm intervals
        cmf_str = """380 0.0014 0.0000 0.0065
        385 0.0022 0.0001 0.0105
        390 0.0042 0.0001 0.0201
        395 0.0076 0.0002 0.0362
        400 0.0143 0.0004 0.0679
        405 0.0232 0.0006 0.1102
        410 0.0435 0.0012 0.2074
        415 0.0776 0.0022 0.3713
        420 0.1344 0.0040 0.6456
        425 0.2148 0.0073 1.0391
        430 0.2839 0.0116 1.3856
        435 0.3285 0.0168 1.6230
        440 0.3483 0.0230 1.7471
        445 0.3481 0.0298 1.7826
        450 0.3362 0.0380 1.7721
        455 0.3187 0.0480 1.7441
        460 0.2908 0.0600 1.6692
        465 0.2511 0.0739 1.5281
        470 0.1954 0.0910 1.2876
        475 0.1421 0.1126 1.0419
        480 0.0956 0.1390 0.8130
        485 0.0580 0.1693 0.6162
        490 0.0320 0.2080 0.4652
        495 0.0147 0.2586 0.3533
        500 0.0049 0.3230 0.2720
        505 0.0024 0.4073 0.2123
        510 0.0093 0.5030 0.1582
        515 0.0291 0.6082 0.1117
        520 0.0633 0.7100 0.0782
        525 0.1096 0.7932 0.0573
        530 0.1655 0.8620 0.0422
        535 0.2257 0.9149 0.0298
        540 0.2904 0.9540 0.0203
        545 0.3597 0.9803 0.0134
        550 0.4334 0.9950 0.0087
        555 0.5121 1.0000 0.0057
        560 0.5945 0.9950 0.0039
        565 0.6784 0.9786 0.0027
        570 0.7621 0.9520 0.0021
        575 0.8425 0.9154 0.0018
        580 0.9163 0.8700 0.0017
        585 0.9786 0.8163 0.0014
        590 1.0263 0.7570 0.0011
        595 1.0567 0.6949 0.0010
        600 1.0622 0.6310 0.0008
        605 1.0456 0.5668 0.0006
        610 1.0026 0.5030 0.0003
        615 0.9384 0.4412 0.0002
        620 0.8544 0.3810 0.0002
        625 0.7514 0.3210 0.0001
        630 0.6424 0.2650 0.0000
        635 0.5419 0.2170 0.0000
        640 0.4479 0.1750 0.0000
        645 0.3608 0.1382 0.0000
        650 0.2835 0.1070 0.0000
        655 0.2187 0.0816 0.0000
        660 0.1649 0.0610 0.0000
        665 0.1212 0.0446 0.0000
        670 0.0874 0.0320 0.0000
        675 0.0636 0.0232 0.0000
        680 0.0468 0.0170 0.0000
        685 0.0329 0.0119 0.0000
        690 0.0227 0.0082 0.0000
        695 0.0158 0.0057 0.0000
        700 0.0114 0.0041 0.0000
        705 0.0081 0.0029 0.0000
        710 0.0058 0.0021 0.0000
        715 0.0041 0.0015 0.0000
        720 0.0029 0.0010 0.0000
        725 0.0020 0.0007 0.0000
        730 0.0014 0.0005 0.0000
        735 0.0010 0.0004 0.0000
        740 0.0007 0.0002 0.0000
        745 0.0005 0.0002 0.0000
        750 0.0003 0.0001 0.0000
        755 0.0002 0.0001 0.0000
        760 0.0002 0.0001 0.0000
        765 0.0001 0.0000 0.0000
        770 0.0001 0.0000 0.0000
        775 0.0001 0.0000 0.0000
        780 0.0000 0.0000 0.0000"""
        cmf = np.zeros((len(cmf_str.split("\n")), 4))
        for i, line in enumerate(cmf_str.split("\n")):
            cmf[i, :] = np.fromstring(line, sep=" ")
        self.cmf = cmf

    def xyz_to_rgb(self, xyz: np.ndarray) -> np.ndarray:
        """Transform from xyz to rgb representation of colour.

        The output rgb components are normalized on their maximum
        value. If xyz is out the rgb gamut, it is desaturated until it
        comes into gamut.

        Fractional rgb components are returned.

        """
        rgb = np.tensordot(xyz, self.transformation_matrix.T, axes=1)

        if np.any(rgb < 0):
            # We're not in the RGB gamut: approximate by desaturating
            rgb -= np.min(rgb)
        if not np.all(rgb == 0):
            # Normalize the rgb vector
            rgb /= np.max(rgb)
        return rgb

    def spec_to_xyz(self, spec) -> np.ndarray:
        """Convert a spectrum to an xyz point.

        The last dimension of the spectrum *must* be on the same grid of
        points as the colour-matching function self.cmf, that is,

                    380-780 nm in 5 nm steps.

        """
        if np.isscalar(spec):
            # Handle single wavelength input by creating a one-hot spectrum
            spec_array = np.zeros(len(self.cmf))
            idx = np.where(self.cmf[:, 0] == spec)[0]
            if idx.size > 0:
                spec_array[idx[0]] = 1.0
            else:
                # Wavelength not found in the 5nm grid, return zeros
                return np.zeros(3)
            spec = spec_array

        xyz = np.tensordot(spec, self.cmf[:, 1:], axes=1)
        den = np.sum(xyz)
        if den == 0.0:
            return xyz
        return xyz / den

    def spec_to_rgb(self, spec) -> np.ndarray:
        """Convert a spectrum to an rgb value.

        The last dimension of the spectrum *must* be on the same grid of
        points as the colour-matching function self.cmf, that is,

                    380-780 nm in 5 nm steps.

        """
        xyz = self.spec_to_xyz(spec)
        return self.xyz_to_rgb(xyz)
In [7]:
# # Define a standard white illuminant (D65)
# illuminant_D65 = xyz_from_xy(0.3127, 0.3291)

# # Initialize color space conversion for sRGB
# cs_srgb = Lambda2color(
#     red=xyz_from_xy(0.64, 0.33),
#     green=xyz_from_xy(0.30, 0.60),
#     blue=xyz_from_xy(0.15, 0.06),
#     white=illuminant_D65
# )

# # Convert wavelengths to RGB
# wavelengths = cs_srgb.cmf[1:, 0]
# N_wavelengths = len(wavelengths)
# hues = np.zeros((N_wavelengths, 3))
# for i_wavelength in range(N_wavelengths):
#     spec = np.zeros((N_wavelengths+1))
#     spec[i_wavelength] = 1
#     hues[i_wavelength, :] = cs_srgb.spec_to_rgb(spec)

# # give for S, M and L cones' peak sensitivities
# for spec, color in [(445, 'blue'), (555, 'green'), (600, 'red')]:
#   i_wavelength = np.argmin(np.abs(wavelengths - spec))
#   print(f' RGB for the {color=} at wavelength {wavelengths[i_wavelength]} nm: {hues[i_wavelength, :]}')
In [8]:
# a standard white:
illuminant_D65 = xyz_from_xy(0.3127, 0.3291)
# color conversion class
cs_srgb = Lambda2color(red=xyz_from_xy(0.64, 0.33),
                       green=xyz_from_xy(0.30, 0.60),
                       blue=xyz_from_xy(0.15, 0.06),
                       white=illuminant_D65)
In [9]:
wavelengths = cs_srgb.cmf[1:, 0]
wavelengths, wavelengths.shape
Out[9]:
(array([385., 390., 395., 400., 405., 410., 415., 420., 425., 430., 435.,
        440., 445., 450., 455., 460., 465., 470., 475., 480., 485., 490.,
        495., 500., 505., 510., 515., 520., 525., 530., 535., 540., 545.,
        550., 555., 560., 565., 570., 575., 580., 585., 590., 595., 600.,
        605., 610., 615., 620., 625., 630., 635., 640., 645., 650., 655.,
        660., 665., 670., 675., 680., 685., 690., 695., 700., 705., 710.,
        715., 720., 725., 730., 735., 740., 745., 750., 755., 760., 765.,
        770., 775., 780.]),
 (80,))
In [10]:
N_wavelengths = len(wavelengths)
hues = np.zeros((N_wavelengths, 3))
for i_wavelength in range(N_wavelengths):
    spec = np.zeros((N_wavelengths+1))
    spec[i_wavelength] = 1
    hues[i_wavelength, :] = cs_srgb.spec_to_rgb(spec)
hues.shape
Out[10]:
(80, 3)
In [11]:
i_wavelength = np.argmin(np.abs(wavelengths - 455))
i_wavelength, wavelengths[i_wavelength], hues[i_wavelength, :]
Out[11]:
(np.int64(14), np.float64(455.0), array([0.16, 0.  , 1.  ]))
In [12]:
for spec, color in [(445, 'blue'), (555, 'green'), (600, 'red')]:
  i_wavelength = np.argmin(np.abs(wavelengths - spec))
  print(f' RGB for the {color=} at wavelength {wavelengths[i_wavelength]} nm: {hues[i_wavelength, :]}')
 RGB for the color='blue' at wavelength 445.0 nm: [0.21 0.   1.  ]
 RGB for the color='green' at wavelength 555.0 nm: [0.02 1.   0.  ]
 RGB for the color='red' at wavelength 600.0 nm: [1.   0.15 0.  ]
In [13]:
i_wavelength = np.argmin(np.abs(wavelengths - 455))
i_wavelength, wavelengths[i_wavelength], hues[i_wavelength, :]
Out[13]:
(np.int64(14), np.float64(455.0), array([0.16, 0.  , 1.  ]))
In [14]:
for spec, color in [(445, 'blue'), (555, 'green'), (600, 'red')]:
  i_wavelength = np.argmin(np.abs(wavelengths - spec))
  print(f' RGB for the {color=} at wavelength {wavelengths[i_wavelength]} nm: {hues[i_wavelength, :]}')
 RGB for the color='blue' at wavelength 445.0 nm: [0.21 0.   1.  ]
 RGB for the color='green' at wavelength 555.0 nm: [0.02 1.   0.  ]
 RGB for the color='red' at wavelength 600.0 nm: [1.   0.15 0.  ]
In [15]:
def get_spec(lambda_dev, lambda_std):
    """Return a spectrum corresponding to a Gaussian distribution of wavelengths.

    The mean wavelength is lambda_dev and the standard deviation is lambda_std.
    The output spectrum is normalized to have a maximum value of 1.

    """
    wavelengths = cs_srgb.cmf[:, 0]
    spec = np.exp(-0.5 * ((wavelengths - lambda_dev) / lambda_std) ** 2)
    # spec /= np.sum(spec)
    return spec
In [16]:
spec = get_spec(500, 8)
spec, spec.shape
Out[16]:
(array([0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ,
        0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.01, 0.04, 0.17,
        0.46, 0.82, 1.  , 0.82, 0.46, 0.17, 0.04, 0.01, 0.  , 0.  , 0.  ,
        0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ,
        0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ,
        0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ,
        0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  , 0.  ,
        0.  , 0.  , 0.  , 0.  ]),
 (81,))
In [17]:
rgb = cs_srgb.spec_to_rgb(spec)
rgb
Out[17]:
array([0.  , 1.  , 0.69])
In [18]:
def rgb_to_luminance(rgb):
    """Convert RGB (0-1) to relative luminance (0-1) using WCAG formula.

    Args:
        rgb: array-like of (R, G, B) values in range [0, 1].

    Returns:
        float: Relative luminance in range [0, 1].
    """
    # rgb = np.asarray(rgb, dtype=np.float32) / 255.0
    mask = rgb <= 0.03928
    rgb = np.where(mask, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
    return np.dot(rgb, np.array([0.2126, 0.7152, 0.0722])).astype(np.float32)

def isoluminant_rgb(rgb, target_luminance=0.15):
    """Adjust RGB to match a target luminance while preserving hue/saturation.

    Args:
        rgb: Tuple or array-like of (R, G, B) values in range [0, 1].
        target_luminance: Desired relative luminance in range [0, 1].

    Returns:
        tuple: Isoluminant RGB values clamped to [0, 255].
    """
    current_luminance = rgb_to_luminance(rgb)

    scale = target_luminance / current_luminance
    # print(f"Scale: {scale}, Target: {target_luminance}, Current: {current_luminance}")
    rgb_scaled = np.clip(rgb*scale, 0, 1)
    return rgb_scaled
In [19]:
print(rgb_to_luminance(rgb))  
print(isoluminant_rgb(rgb, target_luminance=0.5))  
0.7464193
[0.   0.67 0.46]
In [20]:
rgb_to_luminance(rgb)
Out[20]:
np.float32(0.7464193)
In [21]:
rgb = isoluminant_rgb(rgb, target_luminance=.15)
rgb
Out[21]:
array([0.  , 0.2 , 0.14])

Method Step 6: Local Shape Primitives

We define the geometric primitive used at each lattice site. The choice of shape—ranging from smooth circles to angular polygons—modulates the amount of "corner energy" and orientation information present in the image. High-contrast edges and corners are strong anchors for foveal vision but may be processed differently in the periphery, affecting how the visual system groups these local elements into a global structure.

In [22]:
def do_shape(cr, x, y, radius, shape_mode='circle', n_sides=6, angle0=0.0):
    """
    Draw either a circle or a regular polygon centered at (x, y).

    shape_mode: 'circle', 'polygon', or 'diagonal'
    n_sides: number of polygon sides when shape_mode='polygon'
    """
    rr = radius / 2.0

    if shape_mode == 'diagonal':
        # Diagonal mode = rotated square (diamond-like look).
        n_sides = 4
        angle0 = pi / 4

    if shape_mode in ('polygon', 'diagonal'):
        n_sides = int(max(3, n_sides))
        cr.new_sub_path()
        for k in range(n_sides):
            angle = angle0 + 2.0 * pi * k / n_sides
            px = x + rr * np.cos(angle)
            py = y + rr * np.sin(angle)
            if k == 0:
                cr.move_to(px, py)
            else:
                cr.line_to(px, py)
        cr.close_path()
    else:
        cr.save()
        cr.translate(x, y)
        cr.scale(rr, rr)
        cr.arc(0.0, 0.0, 1.0, 0.0, 2.0 * pi)
        cr.restore()

Method Step 7: Hexagonal Lattice Synthesis

The final stimulus is constructed by arranging the shape primitives on a hexagonal grid. A hexagonal arrangement is chosen because it provides a more uniform sampling of space compared to a square grid, reducing directional bias in peripheral pooling.

The synthesis pipeline follows these steps:

  1. Background Rendering: An opaque background is painted using $\lambda_{ref\_mean}$ and $\lambda_{ref\_std}$.
  2. Grid Computation: A shifted mesh creates the hexagonal coordinates.
  3. Dot Application: Each dot is rendered with a color derived from the deviation $\lambda_{dev}$, ensuring it remains isoluminant with the background.

This spatial layout interacts directly with the eccentricity-dependent resolution of the human eye, forming the basis for the ambiguity effect.

In [23]:
def hexagonal_grid(cr, N_height, N_width, N_H, N_W, size_mag, 
                   lambda_ref_mean, lambda_ref_std, lambda_dev, lambda_std, 
                   operator, shape_mode, n_sides, relative_luminance, n_steps = 10):

    # the two colors we use: the reference color and the color of the shapes
    spec = get_spec(lambda_ref_mean, lambda_ref_std)
    rgb_ref = cs_srgb.spec_to_rgb(spec)

    spec = get_spec(lambda_ref_mean + lambda_dev, lambda_std)
    rgb = cs_srgb.spec_to_rgb(spec)

    min_lum = min((rgb_to_luminance(rgb_ref), rgb_to_luminance(rgb)))
    target_luminance = relative_luminance * min_lum

    rgb_ref = isoluminant_rgb(rgb_ref, target_luminance=target_luminance)
    rgb = isoluminant_rgb(rgb, target_luminance=target_luminance)

    # Paint an explicit opaque background rectangle first.
    # We expand it slightly beyond the boundaries to prevent any anti-aliasing transparency gaps at the edges.
    cr.save()
    cr.set_operator(cairo.OPERATOR_SOURCE)
    cr.rectangle(-1, -1, N_width + 2, N_height + 2)
    cr.set_source_rgb(*rgb_ref)
    cr.fill()
    cr.restore()

    cr.set_operator(operator)

    # Compute the grid
    # https://laurentperrinet.github.io/sciblog/posts/2020-04-16-creating-an-hexagonal-grid.html
    width_v, height_v = np.meshgrid(np.linspace(0, N_width, N_W+2)[1:-1],
                                    np.linspace(0, N_height, N_H+2)[1:-1], sparse=False, indexing='xy')

    width_v[::2, :] += N_width/N_W/4  # shift every second row by half a column
    width_v[1::2, :] -= N_width/N_W/4  # shift every second row by half a column

    # convert to cartesian coordinates
    X = width_v
    Y = height_v
    R = size_mag * N_height / N_H * np.ones_like(X)  # constant radius

    # draw
    for i_step in range(n_steps):
        for x, y, r in zip(X.ravel(), Y.ravel(), R.ravel()):
            cr.save()
            cr.set_source_rgba(rgb[0], rgb[1], rgb[2], (i_step/n_steps)**2)
            do_shape(cr, x, y, r * (1 - i_step/n_steps*.5), 
                     shape_mode=shape_mode, n_sides=n_sides)
            cr.fill()
            cr.restore()

    print(f'{rgb_ref=}, {rgb=}, {lambda_ref_mean=:.2f}, {lambda_ref_std=:.2f}, {lambda_dev=:.2f}, {lambda_std=:.2f}')
    return cr

Results and Analysis

In this section, we evaluate the stimulus by performing systematic one-parameter sweeps. By isolating a single variable at a time (e.g., size or hue), we can determine exactly how each property contributes to the center-periphery dissociation. Each analysis follows a structured approach: identifying the manipulated variable, describing its implementation, and interpreting the resulting perceptual transition through the lens of visual physiology.

Method Step 8: Establishing Lattice Density

We define the lattice density by setting the relationship between the image dimensions and the number of grid cells ($N_W$). Tuning this density is critical because peripheral pooling is highly scale-dependent. If the dots are too sparse, they will be visible everywhere; if they are too dense, they may fuse even in the center. The goal is to find a "sweet spot" where foveal vision resolves individual elements while peripheral vision perceives a unified texture.

In [24]:
N = 5
N_H, N_W = int(N*N_height/N_width), N
N*N_height/N_width, N_H, N_W
Out[24]:
(3.5357142857142856, 3, 5)

Method Step 9: Define Baseline Parameters

Before exploring parameter space, we establish a stable baseline configuration. We specify default values for wavelength means ($\lambda_{ref\_mean}$), spreads ($\lambda_{ref\_std}, \lambda_{std}$), dot size (size_mag), and luminance. This reference point allows us to quantify the effect of subsequent changes and ensures that our comparisons are consistent.

In [ ]:
lambda_ref_mean = 491.5
lambda_ref_mean = 525.
lambda_ref_std = 50.
lambda_dev = -2.
lambda_std = 2.
opts = {"N_height": N_height, "N_width": N_width, # image size
        "N_H": N_H, "N_W": N_W, # grid size
        "shape_mode": 'circle', "n_sides": 6, # shapes
        "size_mag": .80, # size of the shapes
        "relative_luminance": .98, 
        "lambda_ref_mean": lambda_ref_mean, "lambda_ref_std": lambda_ref_std,
        "lambda_dev": lambda_dev, "lambda_std": lambda_std, # deviation from reference and std of wavelengths
        "operator": cairo.OPERATOR_OVER, # blending mode
        }

@disp
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image

Baseline Stimulus Interpretation

The baseline image serves as the control for all later experiments. When viewing this output, fixate on any single dot in the center of the field and then shift your attention to the edges without moving your eyes.

Expected Observation: You should perceive clear, distinct dots at the point of fixation, while the periphery appears more blended or texture-like. This confirms that the combination of current $\lambda$ settings and grid density is sufficient to evoke the intended center-periphery dissociation, consistent with foveal cone resolution versus peripheral rod pooling.

In [26]:
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts)
render_pdf(draw, savepath)
render_svg(draw, savepath)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
In [27]:
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts)
render_pdf(draw, savepath)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00

Parameter Sweeps

We now systematically vary one control variable while keeping all others fixed. This isolates the causal effect of each parameter on the ambiguity illusion and reveals the boundaries where the foveal/peripheral distinction is most pronounced.

In [28]:
N_scan = 9

Scan Analysis: Dot Size (size_mag)

Variable: We vary the relative size of the dots on a geometric scale. Implementation: The size_mag parameter is scaled across a range (e.g., $0.1$ to $0.4$), affecting the radius of every dot in the lattice. Perceptual Effect: Very small dots may fall below the visibility threshold globally, while very large dots become robustly visible even in the periphery due to their high local contrast.

Verification: The most effective "ambiguity" occurs at intermediate sizes where foveal sampling resolves the fine contours of a dot, but peripheral integration smooths them into a continuous texture. Look for the size where the center is clearly "dotted" but the edges feel "cloudy."

In [29]:
opts_ = opts.copy()
# for size_mag_ in np.geomspace(0.7, 1.4, N_scan): 
for size_mag_ in np.geomspace(0.6, 1.30, N_scan): 
    opts_.update(size_mag=size_mag_)
    print(f'{size_mag_=:.2e}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
size_mag_=6.00e-01
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
size_mag_=6.61e-01
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
size_mag_=7.28e-01
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
size_mag_=8.02e-01
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
size_mag_=8.83e-01
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
size_mag_=9.73e-01
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
size_mag_=1.07e+00
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
size_mag_=1.18e+00
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
size_mag_=1.30e+00
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image

Scan Analysis: Relative luminance

Variable: We scale the relative_luminance factor that controls the isoluminant brightness of both dots and background. Implementation: relative_luminance is swept linearly from $0.1$ to $1.1$, multiplying the minimum luminance of the two colors. At low values, both dot and background are dark; at high values, they become brighter. Perceptual Effect: The relative luminance determines how strongly the dot pattern stands out. Too dim, and the lattice is unresolved even at fixation. Too bright, and dots become visible everywhere, destroying the center-periphery dissociation. An intermediate range allows foveal vision to resolve individual elements while peripheral vision perceives a unified texture.

Verification: Look for the luminance level where the dot lattice is clearly segmented at fixation but collapses into a blurred background toward the edges.

In [30]:
opts_ = opts.copy()
for relative_luminance_ in np.linspace(0.15, 1.1, N_scan): 
    opts_.update(relative_luminance=relative_luminance_)
    print(f'{relative_luminance_=:.2f}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
relative_luminance_=0.15
rgb_ref=array([0.  , 0.13, 0.14]), rgb=array([0.  , 0.13, 0.15]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.27
rgb_ref=array([0.  , 0.23, 0.25]), rgb=array([0.  , 0.24, 0.27]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.39
rgb_ref=array([0.  , 0.33, 0.37]), rgb=array([0.  , 0.34, 0.39]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.51
rgb_ref=array([0.  , 0.44, 0.48]), rgb=array([0.  , 0.45, 0.51]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.62
rgb_ref=array([0.  , 0.54, 0.59]), rgb=array([0.  , 0.55, 0.62]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.74
rgb_ref=array([0.  , 0.64, 0.7 ]), rgb=array([0.  , 0.66, 0.74]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.86
rgb_ref=array([0.  , 0.74, 0.82]), rgb=array([0.  , 0.76, 0.86]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.98
rgb_ref=array([0.  , 0.85, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=1.10
rgb_ref=array([0.  , 0.95, 1.  ]), rgb=array([0.  , 0.97, 1.  ]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
In [31]:
opts_ = opts.copy()
factor = 1.05
for relative_luminance_ in np.geomspace(opts['relative_luminance']/factor, opts['relative_luminance']*factor, N_scan): 
    opts_.update(relative_luminance=relative_luminance_)
    print(f'{relative_luminance_=:.2f}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
relative_luminance_=0.93
rgb_ref=array([0.  , 0.8 , 0.88]), rgb=array([0.  , 0.83, 0.93]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.94
rgb_ref=array([0.  , 0.81, 0.89]), rgb=array([0.  , 0.84, 0.94]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.96
rgb_ref=array([0.  , 0.82, 0.91]), rgb=array([0.  , 0.85, 0.96]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.97
rgb_ref=array([0.  , 0.83, 0.92]), rgb=array([0.  , 0.86, 0.97]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.98
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=0.99
rgb_ref=array([0.  , 0.85, 0.94]), rgb=array([0.  , 0.88, 0.99]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=1.00
rgb_ref=array([0.  , 0.86, 0.95]), rgb=array([0.  , 0.89, 1.  ]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=1.02
rgb_ref=array([0.  , 0.88, 0.96]), rgb=array([0. , 0.9, 1. ]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
relative_luminance_=1.03
rgb_ref=array([0.  , 0.89, 0.97]), rgb=array([0.  , 0.91, 1.  ]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image

Scan Analysis: Mean Background Hue ($\lambda_{ref\_mean}$)

Variable: We rotate the mean wavelength of the background across a range of the visible spectrum. Implementation: $\lambda_{ref\_mean}$ is varied linearly (e.g., $470\text{nm}$ to $580\text{nm}$), shifting the baseline color from blue-green towards yellow. Perceptual Effect: Certain hue sectors enhance the separation between dots and background, while others reduce it. This is due to the specific sensitivity of cone-opponent pathways, which dominate chromatic discrimination at fixation but are less effective in the periphery.

Verification: Note how some colors make the dot pattern "pop" more centrally, while other hues cause the entire image to feel flatter or more unified across the field.

In [32]:
opts_ = opts.copy()
# for lambda_ref_mean_ in np.linspace(450., 650., N_scan, endpoint=False): 
for lambda_ref_mean_ in np.linspace(470., 580., N_scan): 
    opts_.update(lambda_ref_mean=lambda_ref_mean_)
    print(f'{lambda_ref_mean_=:.2f}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
lambda_ref_mean_=470.00
rgb_ref=array([0.  , 0.16, 0.3 ]), rgb=array([0.  , 0.08, 0.98]), lambda_ref_mean=470.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=483.75
rgb_ref=array([0.  , 0.37, 0.49]), rgb=array([0.  , 0.49, 0.98]), lambda_ref_mean=483.75, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=497.50
rgb_ref=array([0.  , 0.96, 0.89]), rgb=array([0.  , 0.98, 0.81]), lambda_ref_mean=497.50, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=511.25
rgb_ref=array([0.  , 0.97, 0.59]), rgb=array([0.  , 0.98, 0.5 ]), lambda_ref_mean=511.25, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=525.00
rgb_ref=array([0.07, 0.98, 0.33]), rgb=array([0.  , 0.98, 0.36]), lambda_ref_mean=525.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=538.75
rgb_ref=array([0.29, 0.96, 0.18]), rgb=array([0.  , 0.98, 0.23]), lambda_ref_mean=538.75, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=552.50
rgb_ref=array([0.53, 0.9 , 0.07]), rgb=array([0.03, 0.98, 0.  ]), lambda_ref_mean=552.50, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=566.25
rgb_ref=array([0.81, 0.85, 0.  ]), rgb=array([0.6 , 0.98, 0.  ]), lambda_ref_mean=566.25, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=580.00
rgb_ref=array([0.75, 0.53, 0.  ]), rgb=array([0.98, 0.55, 0.  ]), lambda_ref_mean=580.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
In [33]:
opts_ = opts.copy()
# for lambda_ref_mean_ in np.linspace(450., 650., N_scan, endpoint=False): 
for lambda_ref_mean_ in np.geomspace(opts['lambda_ref_mean']/1.001, opts['lambda_ref_mean']*1.001, N_scan): 
    
    opts_.update(lambda_ref_mean=lambda_ref_mean_)
    print(f'{lambda_ref_mean_=:.2f}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
lambda_ref_mean_=490.51
rgb_ref=array([0.  , 0.81, 0.9 ]), rgb=array([0.  , 0.84, 0.98]), lambda_ref_mean=490.51, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=490.63
rgb_ref=array([0.  , 0.82, 0.91]), rgb=array([0.  , 0.85, 0.98]), lambda_ref_mean=490.63, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=490.75
rgb_ref=array([0.  , 0.83, 0.92]), rgb=array([0.  , 0.86, 0.98]), lambda_ref_mean=490.75, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=490.88
rgb_ref=array([0.  , 0.84, 0.92]), rgb=array([0.  , 0.86, 0.98]), lambda_ref_mean=490.88, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=491.00
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=491.12
rgb_ref=array([0.  , 0.85, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.12, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=491.25
rgb_ref=array([0.  , 0.86, 0.94]), rgb=array([0.  , 0.88, 0.98]), lambda_ref_mean=491.25, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=491.37
rgb_ref=array([0.  , 0.86, 0.94]), rgb=array([0.  , 0.88, 0.98]), lambda_ref_mean=491.37, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_mean_=491.49
rgb_ref=array([0.  , 0.87, 0.94]), rgb=array([0.  , 0.89, 0.98]), lambda_ref_mean=491.49, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image

Scan Analysis: Background Wavelength Spread ($\lambda_{ref\_std}$)

Variable: We vary the standard deviation of wavelengths used to generate the background color. Implementation: $\lambda_{ref\_std}$ is adjusted to control how "pure" or "broadband" the background spectrum is. Perceptual Effect: Low spread results in a highly saturated, pure hue that can create strong global contrast. High spread creates a more neutral, desaturated background. Moderate spread often preserves the central parsing advantage by preventing any single chromatic channel from dominating the peripheral pooling.

Verification: Observe if increasing the spread makes the periphery feel "smoother" or "noisier." The ideal setting prevents the background from appearing as a flat wall of color.

In [41]:
opts_ = opts.copy()
# for lambda_ref_std_ in np.linspace(120., 80, N_scan, endpoint=False): 
factor = 1.05
factor = 2
for lambda_ref_std_ in np.geomspace(opts['lambda_ref_std']/factor, opts['lambda_ref_std']*factor, N_scan): 
    opts_.update(lambda_ref_std=lambda_ref_std_)
    print(f'{lambda_ref_std_=:.2e}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
lambda_ref_std_=2.50e+01
rgb_ref=array([0.  , 0.86, 0.98]), rgb=array([0.  , 0.86, 0.97]), lambda_ref_mean=491.00, lambda_ref_std=25.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_std_=2.97e+01
rgb_ref=array([0.  , 0.86, 0.98]), rgb=array([0.  , 0.86, 0.97]), lambda_ref_mean=491.00, lambda_ref_std=29.73, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_std_=3.54e+01
rgb_ref=array([0.  , 0.87, 0.98]), rgb=array([0.  , 0.86, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=35.36, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_std_=4.20e+01
rgb_ref=array([0.  , 0.86, 0.96]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=42.04, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_std_=5.00e+01
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_std_=5.95e+01
rgb_ref=array([0.  , 0.83, 0.89]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=59.46, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_std_=7.07e+01
rgb_ref=array([0.08, 0.8 , 0.85]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=70.71, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_std_=8.41e+01
rgb_ref=array([0.24, 0.77, 0.79]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=84.09, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_ref_std_=1.00e+02
rgb_ref=array([0.37, 0.73, 0.73]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=100.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image

Scan Analysis: Mean Chromatic Deviation ($\lambda_{dev}$)

Variable: We adjust the average shift in wavelength between the dots and the background. Implementation: $\lambda_{dev}$ is varied to change how far the dot color "moves" away from the reference background hue on the spectrum. Perceptual Effect: This directly controls the chromatic contrast. A small deviation makes the dots blend into the background, while a large deviation creates high salience everywhere. The ambiguity effect is strongest when $\lambda_{dev}$ is just enough to be resolved by cones at fixation but falls within the pooling threshold of peripheral rods.

Verification: Look for the value where dots are clearly visible when looked at directly, but seem to "vanish" or merge into the background in the periphery.

In [35]:
opts_ = opts.copy()
for lambda_dev_ in np.linspace(-2, 0., N_scan): 
    opts_.update(lambda_dev=lambda_dev_)
    print(f'{lambda_dev_=:.2f}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
lambda_dev_=-2.00
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_dev_=-1.75
rgb_ref=array([0.  , 0.86, 0.95]), rgb=array([0.  , 0.88, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-1.75, lambda_std=2.00
No description has been provided for this image
lambda_dev_=-1.50
rgb_ref=array([0.  , 0.88, 0.97]), rgb=array([0.  , 0.89, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-1.50, lambda_std=2.00
No description has been provided for this image
lambda_dev_=-1.25
rgb_ref=array([0.  , 0.89, 0.98]), rgb=array([0.  , 0.89, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-1.25, lambda_std=2.00
No description has been provided for this image
lambda_dev_=-1.00
rgb_ref=array([0.  , 0.89, 0.98]), rgb=array([0.  , 0.88, 0.96]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-1.00, lambda_std=2.00
No description has been provided for this image
lambda_dev_=-0.75
rgb_ref=array([0.  , 0.89, 0.98]), rgb=array([0.  , 0.88, 0.95]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-0.75, lambda_std=2.00
No description has been provided for this image
lambda_dev_=-0.50
rgb_ref=array([0.  , 0.89, 0.98]), rgb=array([0.  , 0.87, 0.93]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-0.50, lambda_std=2.00
No description has been provided for this image
lambda_dev_=-0.25
rgb_ref=array([0.  , 0.89, 0.98]), rgb=array([0.  , 0.86, 0.92]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-0.25, lambda_std=2.00
No description has been provided for this image
lambda_dev_=0.00
rgb_ref=array([0.  , 0.89, 0.98]), rgb=array([0.  , 0.85, 0.9 ]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=0.00, lambda_std=2.00
No description has been provided for this image

Scan Analysis: Dot Wavelength Spread ($\lambda_{std}$)

Variable: We vary the standard deviation of wavelengths for the dots themselves. Implementation: $\lambda_{std}$ is adjusted to control the chromatic heterogeneity across the dot array. Perceptual Effect: Low spread creates a uniform set of dots, while high spread introduces local color variations. High variability can raise global salience (making the pattern more "obvious" everywhere), whereas moderate spread can mimic natural textures and better preserve the central parsing advantage over peripheral fusion.

Verification: Note if increasing $\lambda_{std}$ makes the periphery feel "busier" or if it helps the dots blend more effectively into a coarse texture.

In [36]:
opts_ = opts.copy()
for lambda_std_ in np.geomspace(opts['lambda_std']/factor, opts['lambda_std']*factor, N_scan): 
    opts_.update(lambda_std=lambda_std_)
    print(f'{lambda_std_=:.2e}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
lambda_std_=1.90e+00
rgb_ref=array([0.  , 0.85, 0.94]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=1.90
No description has been provided for this image
lambda_std_=1.93e+00
rgb_ref=array([0.  , 0.85, 0.94]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=1.93
No description has been provided for this image
lambda_std_=1.95e+00
rgb_ref=array([0.  , 0.85, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=1.95
No description has been provided for this image
lambda_std_=1.98e+00
rgb_ref=array([0.  , 0.85, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=1.98
No description has been provided for this image
lambda_std_=2.00e+00
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
lambda_std_=2.02e+00
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.02
No description has been provided for this image
lambda_std_=2.05e+00
rgb_ref=array([0.  , 0.84, 0.92]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.05
No description has been provided for this image
lambda_std_=2.07e+00
rgb_ref=array([0.  , 0.84, 0.92]), rgb=array([0.  , 0.86, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.07
No description has been provided for this image
lambda_std_=2.10e+00
rgb_ref=array([0.  , 0.84, 0.92]), rgb=array([0.  , 0.86, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.10
No description has been provided for this image

Scan Analysis: Lattice Density ($N_W$)

Variable: We vary the spatial frequency of the dot pattern. Implementation: The number of columns $N_W$ is changed across a logarithmic scale, effectively altering the spacing between dots relative to the image size. Perceptual Effect:

  • Sparse Spacing: Dots are far apart and typically resolve as distinct elements everywhere in the field.
  • Dense Spacing: The pattern may fuse into a single mass even at fixation. The most informative regime is near the boundary where central segmentation remains available but peripheral parsing degrades due to larger receptive-field pooling.

Verification: Identify the density where you can still see individual dots in the center, but the periphery looks like a continuous shaded region rather than a collection of points.

In [37]:
opts_ = opts.copy()
Ns = np.unique([int(k) for k in N_W * np.logspace(-1, 1, N_scan, base=2)]).astype(int)
for N_ in Ns: 
    opts_.update(N_H=int(N_*N_height/N_width), N_W=N_)
    print(f'{N_=}')
    @disp
    def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
N_=np.int64(2)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
N_=np.int64(3)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
N_=np.int64(4)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
N_=np.int64(5)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
N_=np.int64(7)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
N_=np.int64(8)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
N_=np.int64(10)
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image

Scan Analysis: Local Shape Primitives

Variable: We change the geometric class of the dots (Circles $\rightarrow$ Polygons $\rightarrow$ Diagonals). Implementation: The shape_mode and n_sides parameters are varied to introduce different contour types and corner energy. Perceptual Effect:

  • Circular Shapes: Provide smooth contours, which reduce explicit local anchors and often maximize the "texture" feel in the periphery.
  • Angular/Polygonal Shapes: Increase corner energy and provide orientation cues. This can improve fixation-locked parsing but may also make the patterns more robust (less ambiguous) in the periphery if the angular features are too strong.

Verification: Compare how circles versus hexagons or triangles affect your ability to "lose" the pattern in the periphery. Do sharper corners make it easier or harder for the image to collapse into a texture?

In [38]:
# Exploration of different shape families
opts_ = opts.copy()
# opts_.update(size_mag=.5, N_H=max(6, N_H//2), N_W=max(6, N_W//2))

shape_configs = [
    ('circle', 6),
    ('diagonal', 4),
    ('polygon', 3),
    ('polygon', 4),
    ('polygon', 5),
    ('polygon', 6),
    ('polygon', 8),
]

for shape_mode_, n_sides_ in shape_configs:
    opts_.update(shape_mode=shape_mode_, n_sides=n_sides_)
    print(f'shape_mode={shape_mode_}, n_sides={n_sides_}')

    @disp
    def draw(cr, N_height=N_height, N_width=N_width):
        cr = hexagonal_grid(cr, **opts_)
shape_mode=circle, n_sides=6
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
shape_mode=diagonal, n_sides=4
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
shape_mode=polygon, n_sides=3
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
shape_mode=polygon, n_sides=4
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
shape_mode=polygon, n_sides=5
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
shape_mode=polygon, n_sides=6
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image
shape_mode=polygon, n_sides=8
rgb_ref=array([0.  , 0.84, 0.93]), rgb=array([0.  , 0.87, 0.98]), lambda_ref_mean=491.00, lambda_ref_std=50.00, lambda_dev=-2.00, lambda_std=2.00
No description has been provided for this image

Discussion and Conclusion

The results of these parameter sweeps demonstrate that the ambiguity illusion is most potent when the stimulus sits precisely at a perceptual threshold. By jointly tuning dot size, chromatic deviation ($\lambda_{dev}$), lattice density, and shape complexity, we can create an image where central segmentation is reliable while peripheral structure remains unstable.

This behavior is entirely coherent with retinal physiology:

  • Foveal/Parafoveal Processing: High cone density enables the fine spatial and chromatic discrimination required to resolve individual dots against the background.
  • Peripheral Processing: Rod dominance and larger receptive field pooling lead to a "smoothing" effect, where the dot lattice is perceived as a coarse, continuous texture.

In practical terms, the illusion's strength depends on preventing the dots from becoming either globally invisible (too small/low contrast) or globally obvious (too large/high contrast). The macular scale—roughly the apparent size of a thumb at arm's length—provides a reliable perceptual anchor for where this high-fidelity parsing should remain stable.

Future Directions: The next logical step is to move from qualitative observation to quantitative psychophysics. This would involve measuring dot detectability as a function of eccentricity and mapping the precise multi-dimensional parameter region that maximizes the delta between central and peripheral perception.

some book keeping for the notebook

In [39]:
%pwd
Out[39]:
'/Users/laurentperrinet/sdrive_cnrs/blog/laurentperrinet.github.io_sciblog/posts'
In [40]:
%load_ext watermark
%watermark -i -h -m -v -p numpy,cairo  -r -g -b
Python implementation: CPython
Python version       : 3.13.14
IPython version      : 8.30.0

numpy: 2.5.1
cairo: unknown

Compiler    : Clang 21.0.0 (clang-2100.0.123.102)
OS          : Darwin
Release     : 25.5.0
Machine     : arm64
Processor   : arm
CPU cores   : 10
Architecture: 64bit

Hostname: obiwan.local

Git hash: a7eda7ca5da045cd11f455f26811833e3cc49518

Git repo: https://github.com/laurentperrinet/sciblog

Git branch: master