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.
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 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/1.414), 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):
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.
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)
# # 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, :]}')
# 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
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
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', '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:
- 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_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.
N = 5
N_H, N_W = int(N*N_height/N_width), N
N*N_height/N_width, N_H, N_W
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 = 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)
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.
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts)
render_pdf(draw, savepath)
render_svg(draw, savepath)
def draw(cr, N_height=N_height, N_width=N_width): cr = hexagonal_grid(cr, **opts)
render_pdf(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.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_)
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.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_)
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()
# 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_)
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(-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_)
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_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_)
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?
# 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_)
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