Pale blue 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 different when viewed in the periphery.
An Ambiguous Arrangement of Dots¶
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.
TODO: The are two stategies 1/ ambiguous or collapses into a blurred background
2/ the context makes it different
Perceptual and Physiological Basis¶
This phenomenon is rooted in the non-uniform distribution of photoreceptors across the retina:
- 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.
- 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.
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.
%pip install -U pip numpy
%pip install pycairo
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.
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/2.95), 1400
N_height, N_width
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.
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=}')
def disp(draw_func, N_width=N_width, N_height=N_height):
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, N_width=N_width, N_height=N_height):
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, N_width=N_width, N_height=N_height):
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.
A relavant library is lambda2color. This is a simple library to transform a given light wavelength into the corresponding RGB color. It is based on the different sensitivities to a novel color space called the CIE 193 “ XYZ” color space and defined by the CIE colour matching function for 380 - 780 nm in 5 nm intervals :

This allows to simply compute for instance the color of different monochromatic lights:

For more advanced uses, see for instance this blog post computing the color of the sky.
%pip install -U lambda2color
from lambda2color import Lambda2color, xyz_from_xy, rgb_to_luminance
# 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)
wavelengths = cs_srgb.cmf[1:, 0]
wavelengths, wavelengths.shape
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
i_wavelength = np.argmin(np.abs(wavelengths - 455))
i_wavelength, wavelengths[i_wavelength], hues[i_wavelength, :]
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, :]}')
i_wavelength = np.argmin(np.abs(wavelengths - 455))
i_wavelength, wavelengths[i_wavelength], hues[i_wavelength, :]
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, :]}')
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
spec = get_spec(500, 8)
spec, spec.shape
rgb = cs_srgb.spec_to_rgb(spec)
rgb
%pip install colormath
Note on Luminance Scaling¶
Linearly scaling CIELAB $L^*$ by the ratio of target to current luminance is incorrect because $L^*$ and relative luminance $Y$ are related non-linearly: $L^* = 116 \, (Y/Y_n)^{1/3} - 16$ for $Y/Y_n > 0.008856$, and $L^* = 903.3 \, (Y/Y_n)$ otherwise. To obtain the correct $L^*$ for a desired target luminance, we invert this relation rather than scaling proportionally.
from colormath.color_objects import sRGBColor, LabColor
from colormath.color_conversions import convert_color
from colormath.color_constants import ILLUMINANTS
def isoluminant_rgb(rgb, target_luminance=0.5):
"""Adjust RGB (0-1) to match target luminance while preserving hue/saturation.
Args:
rgb: array-like of (R, G, B) values in range [0, 1].
target_luminance: Desired relative luminance in [0, 1].
Returns:
np.ndarray: Isoluminant RGB in [0, 1].
"""
rgb_clamped = np.clip(rgb, 0, 1)
# Convert RGB to CIELAB
srgb = sRGBColor(rgb_clamped[0], rgb_clamped[1], rgb_clamped[2])
lab = convert_color(srgb, LabColor, illuminant='d65') # Use string 'd65' instead of ILLUMINANTS['2']['d65']
# Compute L* from target luminance using the CIE cube-root formula:
# L* = 116 * Y^(1/3) - 16 for Y > 0.008856, else L* = 903.3 * Y
if target_luminance > 0.008856:
new_L = 116 * target_luminance ** (1. / 3.) - 16
else:
new_L = 903.3 * target_luminance
# Create new Lab color with adjusted L*
new_lab = LabColor(new_L, lab.lab_a, lab.lab_b, illuminant='d65') # Use string 'd65'
# Convert back to RGB
new_srgb = convert_color(new_lab, sRGBColor)
new_rgb = np.array([new_srgb.rgb_r, new_srgb.rgb_g, new_srgb.rgb_b])
# Clamp to [0, 1] (in case of out-of-gamut colors)
return np.clip(new_rgb, 0, 1)
print(rgb_to_luminance(rgb))
print(isoluminant_rgb(rgb, target_luminance=0.5))
rgb_to_luminance(rgb)
rgb = isoluminant_rgb(rgb, target_luminance=.15)
rgb
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.
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' or 'polygon'
n_sides: number of polygon sides when shape_mode='polygon'
"""
rr = radius / 2.0
if shape_mode == 'polygon':
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:
- Background Rendering: An opaque background is painted using $\lambda_{ref\_mean}$ and $\lambda_{ref\_std}$.
- Grid Computation: A shifted mesh creates the hexagonal coordinates.
- 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.
def hexagonal_grid(cr, N_height, N_width, N_W, size_mag,
lambda_ref_mean, lambda_ref_std, lambda_dev, lambda_std,
shape_mode, n_sides, angle0, relative_luminance, n_steps = 10):
N_H = int(N_W*N_height/N_width)
cr.set_operator(cairo.OPERATOR_OVER)
# 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.rectangle(-1, -1, N_width + 2, N_height + 2)
cr.set_source_rgb(*rgb_ref)
cr.fill()
cr.restore()
# 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 x, y, r in zip(X.ravel(), Y.ravel(), R.ravel()):
if shape_mode=='random':
shape_mode_ = 'polygon' #np.random.choice(['circle', 'polygon', 'polygon', 'polygon'])
n_sides_ = np.random.choice([5, 6, 8])
angle0_ = np.random.uniform(0, 2*np.pi)
else:
shape_mode_ = shape_mode
n_sides_ = n_sides
angle0_ = angle0
for i_step in range(n_steps):
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_, angle0=angle0_)
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.
N_W = 12
N_W*N_height/N_width
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.
lambda_ref_mean = 471.
lambda_ref_std = 80.
lambda_ref_std = 2
lambda_dev = -0.0
lambda_std = 2.
lambda_std = 80.
size_mag = .17
relative_luminance = .95
opts = {"N_height": N_height, "N_width": N_width, # image size
"N_W": N_W, # grid size
"shape_mode": 'circle', "n_sides": 6, "angle0": 0., # shapes
"size_mag": size_mag, # size of the shapes
"relative_luminance": relative_luminance,
"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
}
@disp
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts)
Baseline Stimulus as files¶
Saving as PDF and SVG allows for high-quality reproduction of the stimulus, which is essential for both publication and experimental presentation. The baseline stimulus serves as a control against which all variations will be measured.
Let's start with the pale variant
opts_pale = {"N_height": N_height, "N_width": N_width, # image size
"N_W": N_W, # grid size
"shape_mode": 'circle', "n_sides": 6, "angle0": 0., # shapes
"size_mag": size_mag, # size of the shapes
"relative_luminance": relative_luminance,
"lambda_ref_mean": 497.50, "lambda_ref_std": lambda_ref_std,
"lambda_dev": lambda_dev, "lambda_std": lambda_std, # deviation from reference and std of wavelengths
}
@disp
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_pale)
Let's start with the pale variant
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_pale)
render_svg(draw, savepath + '_pale')
render_pdf(draw, savepath + '_pale')
And now the baseline variant in large size:
opts_big = opts.copy()
opts_big.update({"N_height": int(1400/1.618), "N_width": 1400, })
def draw(cr, N_height=int(1400/1.618), N_width=1400): cr = hexagonal_grid(cr, **opts_big)
render_pdf(draw, savepath + '_big', N_height=int(1400/1.618), N_width=1400)
render_svg(draw, savepath + '_big', N_height=int(1400/1.618), N_width=1400)
And now the baseline variant:
@disp
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts)
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts)
render_pdf(draw, savepath)
render_svg(draw, savepath)
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.
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."
opts_ = opts.copy()
# for size_mag_ in np.geomspace(0.7, 1.4, N_scan):
for size_mag_ in np.geomspace(0.10, .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_)
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.
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_)
opts_ = opts.copy()
factor = 1.10
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_)
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.
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_)
opts_ = opts.copy()
factor = 1.01
for lambda_ref_mean_ in np.geomspace(opts['lambda_ref_mean']/factor, opts['lambda_ref_mean']*factor, 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_)
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.
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_)
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.
opts_ = opts.copy()
for lambda_dev_ in np.linspace(-5, 5., 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_)
opts_ = opts.copy()
for lambda_dev_ in np.linspace(-1.5, -1.5, 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_)
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.
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_)
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.
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_W=N_)
print(f'{N_=}')
@disp
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_)
Scan Analysis: Local Shape Primitives¶
Variable: We change the geometric class of the shapes (dots or polygons).
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?
# 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 = [
('random', 6),
('circle', 6),
('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_)
Let's also save the random variant:
opts_random = opts.copy()
opts_random.update({"shape_mode": 'random', "size_mag": .32})
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts_random)
render_svg(draw, savepath + '_random')
render_pdf(draw, savepath + '_random')
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¶
%pwd
%load_ext watermark
%watermark -i -h -m -v -p numpy,cairo -r -g -b