From 3d6070bd81389d12795c89f48482618da0c1362e Mon Sep 17 00:00:00 2001 From: DominiqueMakowski Date: Sat, 19 Sep 2026 09:58:40 +0100 Subject: [PATCH 1/5] Add Chromostereopsis draft: literature notes for a future illusion Placeholder submodule holding a condensed review of chromostereopsis (the illusion that red appears nearer than blue on a flat image), to brainstorm the implementation from. Nothing is exported and the submodule is not imported from the package __init__, since the API is not settled. The notes end with the open design questions: chromostereopsis has no objective depth attribute, so how it maps onto Pyllusion's difference / illusion_strength pair needs deciding before any code is written. Co-Authored-By: Claude Opus 5 --- .../Chromostereopsis/Chromostereopsis.py | 171 ++++++++++++++++++ pyllusion/Chromostereopsis/__init__.py | 6 + 2 files changed, 177 insertions(+) create mode 100644 pyllusion/Chromostereopsis/Chromostereopsis.py create mode 100644 pyllusion/Chromostereopsis/__init__.py diff --git a/pyllusion/Chromostereopsis/Chromostereopsis.py b/pyllusion/Chromostereopsis/Chromostereopsis.py new file mode 100644 index 0000000..07d218a --- /dev/null +++ b/pyllusion/Chromostereopsis/Chromostereopsis.py @@ -0,0 +1,171 @@ +""" +Chromostereopsis - research notes (DRAFT, not implemented yet). + +This module is currently a placeholder holding the literature summary that we will use to design the +``Chromostereopsis`` class. Nothing is exported yet: the open questions at the bottom need to be +settled before an API can be written. + + +Definition +---------- +Chromostereopsis ("colour stereo-depth") is the illusion that, on a *flat* image, some colours appear +nearer than others. The canonical case is saturated red and blue on a black field: most observers see +red in front and blue behind (*positive* chromostereopsis). A minority see the reverse (*negative* +chromostereopsis) and roughly 10-20% see no depth at all. + +It is a *binocular* illusion: covering one eye largely abolishes it. This is the single most important +constraint for us, because it means the effect cannot be guaranteed by the generated image alone - it +also depends on the observer's optics and on the display. Goethe (1810) first described blue as +"receding" and red/yellow as "advancing"; Einthoven (1885) gave the first optical account. + + +Mechanisms +---------- +Two families of cues combine, and they can either add up or cancel out - which is why the illusion +reverses or vanishes under some conditions. + +1. Optical / binocular (the "true" chromostereoptic cue):: + + chromatic aberration (LCA + TCA) -> red focuses behind the retina, + blue focuses in front + fovea sits ~5 deg temporal to the optical axis + Stiles-Crawford effect + pupil position shifts the effective pupil centre + | + v + red and blue images land on non-corresponding retinal points, with + opposite sign in the two eyes -> binocular disparity -> perceived depth + +2. Perceptual / monocular:: + + luminance difference -> brighter = nearer (aerial perspective) + colour contrast, borders, surround, other pictorial depth cues + +On a typical display, red is emitted much brighter than blue, so part of the classic "red in front" +report is simply the brightness cue. Thompson, May & Stone (1993) showed that equating red and blue +luminance strongly reduces the depth effect in most observers - i.e. much of the everyday illusion is +a *compound* of a chromatic stereo cue and a plain brightness cue. + +Accommodation may reinforce it: coloured text on black induces different focusing errors by hue (blue +~+0.6 D of lag vs red ~+0.2 D in a recent study), and observers then judge blue as farther. Corrective +lenses tend to *increase* the illusion. + +The neural locus is unsettled. Chromatic disparity clearly reaches cortex (evoked-potential work), but +no specific mechanism has been isolated, and there is no model that quantitatively predicts depth +magnitude for an arbitrary colour pair. + + +Factors that modulate the effect +-------------------------------- +This is the part that matters for a parametric implementation. Direction of effect, as reported: + +Colour pairing + Red/blue is the strongest and best studied pair. Warm hues (red, yellow) advance, cool hues (blue, + green) recede. Red/green is weaker than red/blue - less chromatic separation to start with. + Yellow-in-front-of-blue is a reliable variant (Kitaoka). +Luminance + Dominant modulator. Larger red-minus-blue luminance difference -> stronger effect. Equiluminance + (ideally set per observer by flicker photometry) minimises it. +Background + Dark/black background maximises positive chromostereopsis. A white background can *invert* it, so + blue appears nearer (Verhoeff 1928, confirmed later). Mid-grey washes it out. +Saturation + Higher saturation -> stronger, though this is partly confounded with luminance contrast. +Spatial structure + Sharp edges and dithered/pixelated red-blue transitions appear to enhance it relative to smooth + fills or gradients. Thin contrasting outlines at the border can flip the perceived order. +Size and viewing distance + Reported as stronger at longer viewing distances (>1 m) and for reasonably large, centrally viewed + patches. Quantitative data is thin. +Ambient light / pupil size + In bright light (small pupil) the perceived direction follows the predicted chromatic disparity; in + dim light (large, eccentrically dilating pupil) direction becomes uncorrelated with it and often + reverses (Simonet & Campbell 1990). Note this cuts against the intuition that "darker room = stronger". +Competing depth cues + Shadows, perspective, occlusion, real stereo disparity all override the colour cue. Isolating the + illusion means stripping other cues out. +Observer + Large individual differences: refractive error, spectacle wear, lens yellowing with age, ocular + dominance, stereoblindness. +Display + Panel technology, subpixel layout, calibration and gamma all change the effective red/blue luminance + ratio, so the same PNG is not the same stimulus on two screens. + + +Representative findings +----------------------- ++-------------------------------+------------------------------------+--------------------------------------+ +| Study | Stimuli | Key result | ++===============================+====================================+======================================+ +| Thompson, May & Stone (1993) | Red/blue bars, varied luminance | ~80-92% saw red nearer on black; | +| | and background (N ~ 190-225) | drops to ~40-64% when red is dimmed | +| | | or the background brightened | ++-------------------------------+------------------------------------+--------------------------------------+ +| Simonet & Campbell (1990) | Red/blue slits, varied illuminance | Direction matches optical disparity | +| | | at high illuminance only | ++-------------------------------+------------------------------------+--------------------------------------+ +| Verhoeff (1928); replicated | Red/blue on black vs white | Order reverses with background; | +| by later work | | border contrast is critical | ++-------------------------------+------------------------------------+--------------------------------------+ +| Faubert (1994, 1995) | Isoluminant colour on grey | Depth still perceived; large | +| | | individual differences incl. reversal| ++-------------------------------+------------------------------------+--------------------------------------+ +| Coloured-text accommodation | Red/green/blue/yellow 8pt text on | Blue: ~0.61 D lag, red: ~0.18 D; | +| study (2026) | black, 50 cm (N = 30) | ~77% judged blue farthest | ++-------------------------------+------------------------------------+--------------------------------------+ + + +Open questions for the Pyllusion API +------------------------------------ +Every Pyllusion illusion exposes ``difference`` (the objective, physical difference) and +``illusion_strength`` (the contextual manipulation that biases perception of it). Chromostereopsis does +not map onto that cleanly, because the perceived attribute is *depth*, and a flat PNG has no objective +depth. Candidate resolutions, to discuss: + +1. ``illusion_strength`` = the chromatic manipulation. Signed, so that positive = the red-forward + configuration and negative = the reversing configuration (e.g. swapped hues, or a light background). + Magnitude could be driven by hue separation, by saturation, or by the red-blue luminance ratio - + these need to be teased apart rather than bundled, or the parameter is uninterpretable. +2. ``difference`` = an objective *pictorial* depth cue placed in opposition to the colour cue, so the + observer's task ("which patch is in front?") has a ground truth. Options: + + - occlusion / overlap: which patch actually overlaps the other (clean, binary-ish, easy to draw); + - relative size, or a size gradient; + - blur, as a defocus cue. + + Occlusion looks like the best first candidate: it is unambiguous, continuously parameterisable via + overlap amount, and it is the cue chromostereopsis has to fight against. +3. Alternatively ``difference`` = objective luminance difference between the two patches - but luminance + is itself a driver of the illusion, so this confounds the two parameters. Probably a dead end, worth + noting so we do not revisit it. + +Further decisions: + +- Should the class offer an ``equiluminant=True`` mode? We already have ``analyze_luminance()`` in + ``pyllusion.utilities``, so matching sRGB relative luminance across the two colours is cheap. Real + equiluminance is observer-specific, so this would be an approximation and must be documented as one. +- Which geometry: bars/gratings (closest to the literature), two patches side by side (closest to the + rest of Pyllusion), or a figure/ground shape? Bars are what the effect sizes above were measured with. +- Parameters to expose for edges: sharp vs dithered transition, outline colour and thickness. +- ``get_parameters()`` should probably record background luminance and the two colours' luminances, since + the stimulus is not reproducible across displays without them. +- The docstring for the eventual class must warn that (a) the effect is binocular and will not appear in + screenshots or single-eye viewing, and (b) strength is display- and observer-dependent, so it is not a + calibrated depth manipulation the way the size illusions are calibrated size manipulations. + + +Sources to verify +----------------- +The summary above is compiled from a literature review that has not yet been checked against the +primary sources. Before any of this goes into user-facing documentation: + +- The 2026 coloured-text accommodation study is attributed to two different first authors in the source + material (Ramasubramanian et al. / Suganthan et al.) for the same numbers - find the actual citation. +- Thompson, May & Stone (1993) is quoted with both "92%" and "80-92%" for the red-nearer rate; check + which condition each refers to. +- The background-reversal replication is cited as "Winn et al. (1998), Vision Research" - confirm. +- The claim that dithered edges enhance the effect comes from an artist's pixel-art write-up, not a + controlled study. Treat as a hypothesis to test, not as a finding. +- Older references (Goethe 1810, Donders 1864, Bruecke 1868, Einthoven 1885, Verhoeff 1928, + Sundet 1978, Simonet & Campbell 1990, Faubert 1994/1995, Kitaoka 2006/2016) are given without full + bibliographic details. +""" diff --git a/pyllusion/Chromostereopsis/__init__.py b/pyllusion/Chromostereopsis/__init__.py new file mode 100644 index 0000000..ad744bf --- /dev/null +++ b/pyllusion/Chromostereopsis/__init__.py @@ -0,0 +1,6 @@ +""" +Pyllusion submodule. + +Draft: see ``Chromostereopsis.py`` for the research notes. Nothing is exported yet, and this submodule +is deliberately not imported from ``pyllusion/__init__.py``. +""" From b4c85b4fe24cc2abd08188fa920d331858231fa7 Mon Sep 17 00:00:00 2001 From: DominiqueMakowski Date: Sat, 19 Sep 2026 10:07:11 +0100 Subject: [PATCH 2/5] Chromostereopsis: prototype the two-panel swapped-colour stimulus Two square panels side by side, each a dithered coloured disc inside a dithered coloured surround with a bare annulus between them, and the colour roles swapped between panels. This suggests a way out of the "no objective depth attribute" problem noted in the previous commit: move the judged attribute from depth to size, so difference = objective size difference between the two discs (as in Delboeuf/Ebbinghaus) and illusion_strength = the signed colour assignment. Rests on chromostereopsis biasing size via size constancy, which is a hypothesis and flagged as such. Rendering it also turned up a luminance confound: with the default geometry the red-surround panel is ~49% brighter than the blue-surround one, since red is ~3x more luminous than blue in sRGB and the disc and surround have different areas. Matching disc area to surround area (radius 0.757 at gap 0.08) brings the panels to within dither noise. Still a prototype, not exported. Co-Authored-By: Claude Opus 5 --- .../Chromostereopsis/Chromostereopsis.py | 106 +++++++---- .../chromostereopsis_image.py | 169 ++++++++++++++++++ 2 files changed, 238 insertions(+), 37 deletions(-) create mode 100644 pyllusion/Chromostereopsis/chromostereopsis_image.py diff --git a/pyllusion/Chromostereopsis/Chromostereopsis.py b/pyllusion/Chromostereopsis/Chromostereopsis.py index 07d218a..1fcff68 100644 --- a/pyllusion/Chromostereopsis/Chromostereopsis.py +++ b/pyllusion/Chromostereopsis/Chromostereopsis.py @@ -114,43 +114,75 @@ +-------------------------------+------------------------------------+--------------------------------------+ -Open questions for the Pyllusion API ------------------------------------- -Every Pyllusion illusion exposes ``difference`` (the objective, physical difference) and -``illusion_strength`` (the contextual manipulation that biases perception of it). Chromostereopsis does -not map onto that cleanly, because the perceived attribute is *depth*, and a flat PNG has no objective -depth. Candidate resolutions, to discuss: - -1. ``illusion_strength`` = the chromatic manipulation. Signed, so that positive = the red-forward - configuration and negative = the reversing configuration (e.g. swapped hues, or a light background). - Magnitude could be driven by hue separation, by saturation, or by the red-blue luminance ratio - - these need to be teased apart rather than bundled, or the parameter is uninterpretable. -2. ``difference`` = an objective *pictorial* depth cue placed in opposition to the colour cue, so the - observer's task ("which patch is in front?") has a ground truth. Options: - - - occlusion / overlap: which patch actually overlaps the other (clean, binary-ish, easy to draw); - - relative size, or a size gradient; - - blur, as a defocus cue. - - Occlusion looks like the best first candidate: it is unambiguous, continuously parameterisable via - overlap amount, and it is the cue chromostereopsis has to fight against. -3. Alternatively ``difference`` = objective luminance difference between the two patches - but luminance - is itself a driver of the illusion, so this confounds the two parameters. Probably a dead end, worth - noting so we do not revisit it. - -Further decisions: - -- Should the class offer an ``equiluminant=True`` mode? We already have ``analyze_luminance()`` in - ``pyllusion.utilities``, so matching sRGB relative luminance across the two colours is cheap. Real - equiluminance is observer-specific, so this would be an approximation and must be documented as one. -- Which geometry: bars/gratings (closest to the literature), two patches side by side (closest to the - rest of Pyllusion), or a figure/ground shape? Bars are what the effect sizes above were measured with. -- Parameters to expose for edges: sharp vs dithered transition, outline colour and thickness. -- ``get_parameters()`` should probably record background luminance and the two colours' luminances, since - the stimulus is not reproducible across displays without them. -- The docstring for the eventual class must warn that (a) the effect is binocular and will not appear in - screenshots or single-eye viewing, and (b) strength is display- and observer-dependent, so it is not a - calibrated depth manipulation the way the size illusions are calibrated size manipulations. +Candidate design (two swapped panels) +------------------------------------- +Two square panels side by side, each a dithered coloured disc inside a dithered coloured surround, +separated by a bare black annulus. The two panels use the *same* two colours with the roles swapped: +one shows a red disc on a blue surround, the other a blue disc on a red surround. Prototyped in +``chromostereopsis_image.py``. + +Why this layout is appealing: the two panels are geometrically identical and use the same two colours, +so they differ only in which colour is figure and which is ground. The chromostereoptic prediction is +that the red disc advances and the blue disc recedes, giving opposite depth percepts on the two sides +from a stimulus that is otherwise balanced. + +This also suggests a way out of the "no objective depth" problem, because it lets us move the judged +attribute from depth to **size**: + +- ``difference`` = the objective size difference between the two discs (left radius relative to right), + exactly as in Delboeuf and Ebbinghaus. The observer is asked which disc is larger. +- ``illusion_strength`` = the chromatic manipulation, signed, the sign setting which panel gets the red + disc (so the predicted bias flips with it). + +The rationale is size constancy: a disc that appears nearer at the same retinal size should appear +*smaller*, so chromostereopsis should bias size judgements in a predictable direction. This is testable +with the machinery Pyllusion already has, and the anecdotal pixel-art reports that red "seems larger" +are at least consistent with a size effect existing. It is a hypothesis, though, not an established +finding - if chromostereopsis turns out not to bias size, the design collapses back to a pure depth +judgement with no ground truth. + +Measured on the prototype +------------------------- +Two things the first renders turned up: + +1. **The two panels are not luminance-matched by default.** With the default geometry the disc and the + surround have different areas, and red is about three times more luminous than blue in sRGB. The + panel with the red *surround* therefore comes out much brighter overall: mean relative luminance + 0.064 vs 0.043, a ~49% difference. Since brightness is itself a depth (and probably size) cue, this + confounds exactly the comparison we want to make. + + Fix: choose the radius so that disc area equals surround area, which makes each panel contain equal + amounts of red and blue and so equalises the two panels as wholes. With ``gap=0.08`` that radius is + ``0.757``, and it brings the two panels to 0.053 vs 0.051 (residual is dither sampling noise). + Note this only works at ``difference=0``; once the radii differ the areas cannot both be matched, so + there will be a small residual luminance difference that scales with ``difference``. Worth + quantifying and reporting in ``get_parameters()``. + +2. **The dither pattern currently differs between panels.** Making both panels use the *same* dither + pattern would remove one more source of variation, leaving colour assignment as the only difference. + Cheap to do and probably worth it. + +Remaining open questions +------------------------ +- **What does the magnitude of ``illusion_strength`` vary?** The sign is settled (which panel gets the + red disc) but the magnitude currently does nothing. Candidates: saturation of the colour pair, hue + separation, or the red:blue luminance ratio. The luminance ratio is the best-supported choice, since + it is the dominant modulator in the literature and is directly measurable - but it partly conflates + the chromatic cue with the brightness cue, which is arguably the honest thing to do given that the + everyday illusion is that compound. +- **Does chromostereopsis actually bias size?** The whole ``difference`` mapping rests on this. Needs + checking against the literature, and it may simply need piloting. +- Should the class offer an ``equiluminant=True`` mode? ``analyze_luminance()`` in + ``pyllusion.utilities`` makes matching sRGB relative luminance cheap. Real equiluminance is + observer-specific, so this would be an approximation and must be documented as one. +- Geometry: the effect sizes in the table above were measured with bars, not discs. Should we also + offer a bar/grating variant to stay comparable with the literature? +- Dither parameters to settle: cell size, density, and whether the gap annulus should be tunable. +- ``get_parameters()`` should record background luminance and both colours' luminances, since the + stimulus is not reproducible across displays without them. +- The eventual class docstring must warn that (a) the effect is binocular and will not appear in + screenshots or single-eye viewing, and (b) strength is display- and observer-dependent, so this is not + a calibrated depth manipulation the way the size illusions are calibrated size manipulations. Sources to verify diff --git a/pyllusion/Chromostereopsis/chromostereopsis_image.py b/pyllusion/Chromostereopsis/chromostereopsis_image.py new file mode 100644 index 0000000..a23b8bd --- /dev/null +++ b/pyllusion/Chromostereopsis/chromostereopsis_image.py @@ -0,0 +1,169 @@ +""" +PROTOTYPE - sketch of the two-panel chromostereopsis stimulus. Not part of the public API yet. + +Layout (one panel):: + + +-----------------------------+ + | dithered surround colour | <- e.g. blue pixels on black + | +---------------+ | + | | black gap | | <- unfilled annulus + | | +-------+ | | + | | | inner | | | <- e.g. red pixels on black + | | +-------+ | | + | +---------------+ | + +-----------------------------+ + +The full stimulus is two such panels side by side with the two colours swapped, so one panel shows a +red disc on a blue surround and the other a blue disc on a red surround. +""" +import numpy as np +import PIL.Image + + +def _chromostereopsis_panel( + size=400, + color_inner=(255, 0, 0), + color_surround=(0, 0, 255), + background=(0, 0, 0), + radius=0.58, + gap=0.08, + density=0.5, + dither_size=1, + rng=None, +): + """Draw a single dithered panel: a coloured disc inside a coloured surround, separated by a gap of + bare background. + + Parameters + ---------- + size : int + Side of the (square) panel, in pixels. + color_inner, color_surround : tuple + RGB of the dithered pixels of the disc and of the surround. + background : tuple + RGB of the undithered pixels (and of the gap annulus). + radius : float + Radius of the inner disc, as a proportion of half the panel side (1 = disc touches the edges). + gap : float + Width of the bare annulus between disc and surround, in the same units as ``radius``. + density : float + Proportion of cells that get coloured in the dithered regions (0-1). 1 gives solid fills. + dither_size : int + Side of a dither cell, in pixels. 1 gives per-pixel noise; larger values give the chunky + pixel-art look that the (anecdotal) reports say strengthens the effect. + rng : np.random.Generator + Passed in so that the two panels of a stimulus can share a generator. + """ + if rng is None: + rng = np.random.default_rng() + + # Distance of every pixel from the centre, in units of half the panel side + coords = (np.arange(size) - (size - 1) / 2) / ((size - 1) / 2) + distance = np.sqrt(coords[:, None] ** 2 + coords[None, :] ** 2) + + # Which region each pixel belongs to + is_inner = distance <= radius + is_surround = distance > radius + gap + + # Dither: independently keep each cell with probability `density`, then blow the cells up to + # `dither_size` pixels and crop back (the last cell may be partial) + n_cells = int(np.ceil(size / dither_size)) + dither = rng.random((n_cells, n_cells)) < density + dither = np.repeat(np.repeat(dither, dither_size, axis=0), dither_size, axis=1)[:size, :size] + + panel = np.zeros((size, size, 3), dtype=np.uint8) + panel[:, :] = background + panel[is_inner & dither] = color_inner + panel[is_surround & dither] = color_surround + + return panel + + +def _chromostereopsis_image( + difference=0, + illusion_strength=0, + width=800, + height=400, + color1=(255, 0, 0), + color2=(0, 0, 255), + background=(0, 0, 0), + radius=0.58, + gap=0.08, + density=0.5, + dither_size=1, + margin=0.05, + seed=None, +): + """Draw the two-panel stimulus. + + Parameters + ---------- + difference : float + Objective size difference between the two discs: the radius of the left disc relative to the + right (e.g. ``difference=0.1`` makes the left disc 10% larger). This is the attribute the + observer would be asked to judge. + illusion_strength : float + Signed. The sign sets which panel receives the disc drawn in ``color1`` (positive = left + panel), i.e. it flips the predicted direction of the depth/size bias. The magnitude currently + does nothing - see the open question in ``Chromostereopsis.py``. + width, height : int + Size of the returned image. Each panel is a square of side ``height``. + color1, color2 : tuple + The two colours. ``color1`` defaults to red, ``color2`` to blue. + background : tuple + Background, and colour of the gap annulus. The literature effect is measured on black. + radius, gap, density, dither_size + Passed to :func:`_chromostereopsis_panel`. + margin : float + Space between and around the panels, as a proportion of the panel side. + seed : int + Seed for the dither pattern, for reproducible stimuli. + """ + rng = np.random.default_rng(seed) + + panel_size = int(height * (1 - 2 * margin)) + + # Objective size difference, applied symmetrically around `radius` (as in the size illusions) + radius_left = radius * (1 + difference / 2) + radius_right = radius * (1 - difference / 2) + + # The sign of illusion_strength swaps which colour is the disc + if illusion_strength >= 0: + inner_left, surround_left = color1, color2 + else: + inner_left, surround_left = color2, color1 + inner_right, surround_right = surround_left, inner_left + + left = _chromostereopsis_panel( + size=panel_size, + color_inner=inner_left, + color_surround=surround_left, + background=background, + radius=radius_left, + gap=gap, + density=density, + dither_size=dither_size, + rng=rng, + ) + right = _chromostereopsis_panel( + size=panel_size, + color_inner=inner_right, + color_surround=surround_right, + background=background, + radius=radius_right, + gap=gap, + density=density, + dither_size=dither_size, + rng=rng, + ) + + # Paste both panels onto the canvas + image = PIL.Image.new("RGB", (width, height), color=tuple(background)) + y = (height - panel_size) // 2 + x_gap = int(panel_size * margin) + total = 2 * panel_size + x_gap + x = (width - total) // 2 + image.paste(PIL.Image.fromarray(left), (x, y)) + image.paste(PIL.Image.fromarray(right), (x + panel_size + x_gap, y)) + + return image From 2c0ffdf7d1f10457e36f86028828c43adaf3af09 Mon Sep 17 00:00:00 2001 From: DominiqueMakowski Date: Sat, 19 Sep 2026 10:14:46 +0100 Subject: [PATCH 3/5] Chromostereopsis: settle `difference`, expose the study parameters difference is now the objective size difference between the two discs (Delboeuf/Ebbinghaus semantics). illusion_strength stays deferred: only its sign is wired up, selecting which panel gets the color1 disc. Adds _chromostereopsis_parameters() following the package convention, exposing the factors from the literature review as knobs so their effect can be measured: density / density_inner / dither_size / dither_shared for patterning, luminance1 / luminance2 / equiluminant for luminance, background for contrast, color1 / color2 for the pairing, and radius / gap / area_matched for geometry. Luminance scaling is done in linear light rather than on code values, so 0.5 is really half the luminance. The dict also reports derived quantities (per-colour luminance and contrast, areas, and the predicted mean luminance of each panel), computed analytically so a condition's brightness confound can be read off without rendering it; agrees with renders to within dither sampling noise. Two things worth recording: on a black background Michelson contrast is 1 for every colour, so a non-black background is needed to manipulate contrast at all; and mid-grey #808080 is very nearly isoluminant with pure red. Both noted in the docstrings. Dither patterns are now shared between panels by default, leaving colour assignment as the only difference between them. Co-Authored-By: Claude Opus 5 --- .../Chromostereopsis/Chromostereopsis.py | 66 +++-- .../chromostereopsis_image.py | 170 +++++------- .../chromostereopsis_parameters.py | 253 ++++++++++++++++++ 3 files changed, 374 insertions(+), 115 deletions(-) create mode 100644 pyllusion/Chromostereopsis/chromostereopsis_parameters.py diff --git a/pyllusion/Chromostereopsis/Chromostereopsis.py b/pyllusion/Chromostereopsis/Chromostereopsis.py index 1fcff68..552e056 100644 --- a/pyllusion/Chromostereopsis/Chromostereopsis.py +++ b/pyllusion/Chromostereopsis/Chromostereopsis.py @@ -126,13 +126,13 @@ that the red disc advances and the blue disc recedes, giving opposite depth percepts on the two sides from a stimulus that is otherwise balanced. -This also suggests a way out of the "no objective depth" problem, because it lets us move the judged -attribute from depth to **size**: +This also gives a way out of the "no objective depth" problem, because it lets us move the judged +attribute from depth to **size**. Decided: - ``difference`` = the objective size difference between the two discs (left radius relative to right), exactly as in Delboeuf and Ebbinghaus. The observer is asked which disc is larger. -- ``illusion_strength`` = the chromatic manipulation, signed, the sign setting which panel gets the red - disc (so the predicted bias flips with it). +- ``illusion_strength`` = deferred. Only its *sign* is wired up for now, setting which panel gets the + ``color1`` disc; what the magnitude should scale is still open (see below). The rationale is size constancy: a disc that appears nearer at the same retinal size should appear *smaller*, so chromostereopsis should bias size judgements in a predictable direction. This is testable @@ -158,28 +158,56 @@ there will be a small residual luminance difference that scales with ``difference``. Worth quantifying and reporting in ``get_parameters()``. -2. **The dither pattern currently differs between panels.** Making both panels use the *same* dither - pattern would remove one more source of variation, leaving colour assignment as the only difference. - Cheap to do and probably worth it. +2. **The dither pattern differed between panels.** Now shared by default (``dither_shared``), so colour + assignment is the only difference between the two panels. + + +Exposed parameters +------------------ +``chromostereopsis_parameters.py`` exposes the factors from the literature review as knobs, so their +effect on the illusion can be measured rather than guessed. Grouped by the factor they target: + +===================== ========================================================================== +Patterning ``density``, ``density_inner``, ``dither_size``, ``dither_shared`` +Luminance ``luminance1``, ``luminance2``, ``equiluminant`` +Contrast ``background`` (raising it lowers both colours' contrast at once) +Colour pairing ``color1``, ``color2`` +Geometry ``radius``, ``gap``, ``area_matched`` +Reproducibility ``seed`` +===================== ========================================================================== + +Two notes on measuring luminance and contrast with these: + +- ``luminance1``/``luminance2`` scale in *linear light*, not in 8-bit code values, so ``0.5`` really is + half the luminance (it maps red 255 -> 188, not -> 128). ``density`` is a second, independent + luminance knob: it scales a region's mean luminance without touching its pixel colour, which makes it + a useful way to dissociate "how bright the region is" from "how bright the colour is". +- **On a black background, Michelson contrast is 1 for every colour**, so ``Contrast_Color*`` carries no + information there and a non-black ``background`` is needed to manipulate contrast at all. Useful + landmark: mid-grey ``#808080`` has relative luminance 0.216, almost exactly that of pure red (0.213), + so a mid-grey background is very nearly isoluminant with red while still being far from blue. + +The parameters dict reports the derived quantities too - each colour's relative luminance, their ratio, +each colour's contrast against the background, the disc/surround areas, and the predicted mean luminance +of each panel. The panel luminances are computed analytically from areas and densities, and agree with +rendered stimuli to within dither sampling noise (0.0518/0.0795 predicted vs 0.0527/0.0798 measured at +the defaults), so the brightness confound can be read off a condition without rendering it. Remaining open questions ------------------------ -- **What does the magnitude of ``illusion_strength`` vary?** The sign is settled (which panel gets the - red disc) but the magnitude currently does nothing. Candidates: saturation of the colour pair, hue - separation, or the red:blue luminance ratio. The luminance ratio is the best-supported choice, since - it is the dominant modulator in the literature and is directly measurable - but it partly conflates - the chromatic cue with the brightness cue, which is arguably the honest thing to do given that the - everyday illusion is that compound. +- **What does the magnitude of ``illusion_strength`` vary?** Deferred by agreement. Candidates: + saturation of the colour pair, hue separation, or the red:blue luminance ratio. The luminance ratio is + the best-supported choice, since it is the dominant modulator in the literature and is directly + measurable - but it partly conflates the chromatic cue with the brightness cue, which is arguably the + honest thing to do given that the everyday illusion is that compound. Note that whichever is chosen, + it will overlap with the parameters already exposed above, so ``illusion_strength`` is likely to end + up as a convenience wrapper over some of them rather than an independent knob. - **Does chromostereopsis actually bias size?** The whole ``difference`` mapping rests on this. Needs checking against the literature, and it may simply need piloting. -- Should the class offer an ``equiluminant=True`` mode? ``analyze_luminance()`` in - ``pyllusion.utilities`` makes matching sRGB relative luminance cheap. Real equiluminance is - observer-specific, so this would be an approximation and must be documented as one. - Geometry: the effect sizes in the table above were measured with bars, not discs. Should we also offer a bar/grating variant to stay comparable with the literature? -- Dither parameters to settle: cell size, density, and whether the gap annulus should be tunable. -- ``get_parameters()`` should record background luminance and both colours' luminances, since the - stimulus is not reproducible across displays without them. +- No ``Chromostereopsis`` class yet: the natural time to add one (with ``to_image()`` and + ``get_parameters()``) is once ``illusion_strength`` is settled. - The eventual class docstring must warn that (a) the effect is binocular and will not appear in screenshots or single-eye viewing, and (b) strength is display- and observer-dependent, so this is not a calibrated depth manipulation the way the size illusions are calibrated size manipulations. diff --git a/pyllusion/Chromostereopsis/chromostereopsis_image.py b/pyllusion/Chromostereopsis/chromostereopsis_image.py index a23b8bd..9f43047 100644 --- a/pyllusion/Chromostereopsis/chromostereopsis_image.py +++ b/pyllusion/Chromostereopsis/chromostereopsis_image.py @@ -4,11 +4,11 @@ Layout (one panel):: +-----------------------------+ - | dithered surround colour | <- e.g. blue pixels on black + | dithered surround colour | <- e.g. blue on black | +---------------+ | - | | black gap | | <- unfilled annulus + | | bare gap | | <- undithered background | | +-------+ | | - | | | inner | | | <- e.g. red pixels on black + | | | disc | | | <- e.g. red on black | | +-------+ | | | +---------------+ | +-----------------------------+ @@ -19,6 +19,8 @@ import numpy as np import PIL.Image +from .chromostereopsis_parameters import _chromostereopsis_parameters + def _chromostereopsis_panel( size=400, @@ -28,7 +30,9 @@ def _chromostereopsis_panel( radius=0.58, gap=0.08, density=0.5, - dither_size=1, + density_inner=None, + dither_size=10, + dither=None, rng=None, ): """Draw a single dithered panel: a coloured disc inside a coloured surround, separated by a gap of @@ -38,22 +42,22 @@ def _chromostereopsis_panel( ---------- size : int Side of the (square) panel, in pixels. - color_inner, color_surround : tuple - RGB of the dithered pixels of the disc and of the surround. - background : tuple - RGB of the undithered pixels (and of the gap annulus). - radius : float - Radius of the inner disc, as a proportion of half the panel side (1 = disc touches the edges). - gap : float - Width of the bare annulus between disc and surround, in the same units as ``radius``. - density : float - Proportion of cells that get coloured in the dithered regions (0-1). 1 gives solid fills. + color_inner, color_surround, background : tuple + RGB of the disc's pixels, of the surround's pixels, and of everything else. + radius, gap : float + Radius of the disc and width of the bare annulus, as a proportion of half the panel side. + density, density_inner : float + Proportion of dither cells that get coloured, in the surround and in the disc. dither_size : int - Side of a dither cell, in pixels. 1 gives per-pixel noise; larger values give the chunky - pixel-art look that the (anecdotal) reports say strengthens the effect. + Side of a dither cell, in pixels. + dither : np.ndarray + A precomputed boolean dither mask, so that the two panels of a stimulus can share one. Built + from `rng` if not given. rng : np.random.Generator - Passed in so that the two panels of a stimulus can share a generator. + Used to build the dither mask when `dither` is not supplied. """ + if density_inner is None: + density_inner = density if rng is None: rng = np.random.default_rng() @@ -61,109 +65,83 @@ def _chromostereopsis_panel( coords = (np.arange(size) - (size - 1) / 2) / ((size - 1) / 2) distance = np.sqrt(coords[:, None] ** 2 + coords[None, :] ** 2) - # Which region each pixel belongs to is_inner = distance <= radius is_surround = distance > radius + gap - # Dither: independently keep each cell with probability `density`, then blow the cells up to - # `dither_size` pixels and crop back (the last cell may be partial) - n_cells = int(np.ceil(size / dither_size)) - dither = rng.random((n_cells, n_cells)) < density - dither = np.repeat(np.repeat(dither, dither_size, axis=0), dither_size, axis=1)[:size, :size] + # Dither: a uniform draw per cell, blown up to `dither_size` pixels and cropped back. Thresholding + # a shared draw (rather than a shared boolean mask) lets the two regions use different densities + # while still being the same underlying pattern. + if dither is None: + dither = _chromostereopsis_dither(size, dither_size=dither_size, rng=rng) panel = np.zeros((size, size, 3), dtype=np.uint8) panel[:, :] = background - panel[is_inner & dither] = color_inner - panel[is_surround & dither] = color_surround + panel[is_inner & (dither < density_inner)] = color_inner + panel[is_surround & (dither < density)] = color_surround return panel -def _chromostereopsis_image( - difference=0, - illusion_strength=0, - width=800, - height=400, - color1=(255, 0, 0), - color2=(0, 0, 255), - background=(0, 0, 0), - radius=0.58, - gap=0.08, - density=0.5, - dither_size=1, - margin=0.05, - seed=None, -): +def _chromostereopsis_dither(size, dither_size=10, rng=None): + """Uniform [0, 1) draw per dither cell, upsampled to `size` x `size` pixels.""" + if rng is None: + rng = np.random.default_rng() + n_cells = int(np.ceil(size / dither_size)) + cells = rng.random((n_cells, n_cells)) + return np.repeat(np.repeat(cells, dither_size, axis=0), dither_size, axis=1)[:size, :size] + + +def _chromostereopsis_image(parameters=None, width=800, height=400, margin=0.05, **kwargs): """Draw the two-panel stimulus. Parameters ---------- - difference : float - Objective size difference between the two discs: the radius of the left disc relative to the - right (e.g. ``difference=0.1`` makes the left disc 10% larger). This is the attribute the - observer would be asked to judge. - illusion_strength : float - Signed. The sign sets which panel receives the disc drawn in ``color1`` (positive = left - panel), i.e. it flips the predicted direction of the depth/size bias. The magnitude currently - does nothing - see the open question in ``Chromostereopsis.py``. + parameters : dict + Output of :func:`_chromostereopsis_parameters`. Built from ``**kwargs`` if not given. width, height : int - Size of the returned image. Each panel is a square of side ``height``. - color1, color2 : tuple - The two colours. ``color1`` defaults to red, ``color2`` to blue. - background : tuple - Background, and colour of the gap annulus. The literature effect is measured on black. - radius, gap, density, dither_size - Passed to :func:`_chromostereopsis_panel`. + Size of the returned image. Each panel is a square, sized to fit the height. margin : float Space between and around the panels, as a proportion of the panel side. - seed : int - Seed for the dither pattern, for reproducible stimuli. + **kwargs + Passed to :func:`_chromostereopsis_parameters`. """ - rng = np.random.default_rng(seed) + if parameters is None: + parameters = _chromostereopsis_parameters(**kwargs) + rng = np.random.default_rng(parameters["Seed"]) panel_size = int(height * (1 - 2 * margin)) - # Objective size difference, applied symmetrically around `radius` (as in the size illusions) - radius_left = radius * (1 + difference / 2) - radius_right = radius * (1 - difference / 2) - - # The sign of illusion_strength swaps which colour is the disc - if illusion_strength >= 0: - inner_left, surround_left = color1, color2 - else: - inner_left, surround_left = color2, color1 - inner_right, surround_right = surround_left, inner_left - - left = _chromostereopsis_panel( - size=panel_size, - color_inner=inner_left, - color_surround=surround_left, - background=background, - radius=radius_left, - gap=gap, - density=density, - dither_size=dither_size, - rng=rng, - ) - right = _chromostereopsis_panel( - size=panel_size, - color_inner=inner_right, - color_surround=surround_right, - background=background, - radius=radius_right, - gap=gap, - density=density, - dither_size=dither_size, - rng=rng, - ) + # One shared dither pattern, or one per panel + dither = None + if parameters["Dither_Shared"] is True: + dither = _chromostereopsis_dither( + panel_size, dither_size=parameters["Dither_Size"], rng=rng + ) + + panels = [] + for side, radius in [("Left", parameters["Size_Left"]), ("Right", parameters["Size_Right"])]: + panels.append( + _chromostereopsis_panel( + size=panel_size, + color_inner=parameters["Color_Inner_" + side], + color_surround=parameters["Color_Surround_" + side], + background=parameters["Color_Background"], + radius=radius, + gap=parameters["Gap"], + density=parameters["Density"], + density_inner=parameters["Density_Inner"], + dither_size=parameters["Dither_Size"], + dither=dither, + rng=rng, + ) + ) # Paste both panels onto the canvas - image = PIL.Image.new("RGB", (width, height), color=tuple(background)) + image = PIL.Image.new("RGB", (width, height), color=tuple(parameters["Color_Background"])) y = (height - panel_size) // 2 x_gap = int(panel_size * margin) - total = 2 * panel_size + x_gap - x = (width - total) // 2 - image.paste(PIL.Image.fromarray(left), (x, y)) - image.paste(PIL.Image.fromarray(right), (x + panel_size + x_gap, y)) + x = (width - (2 * panel_size + x_gap)) // 2 + image.paste(PIL.Image.fromarray(panels[0]), (x, y)) + image.paste(PIL.Image.fromarray(panels[1]), (x + panel_size + x_gap, y)) return image diff --git a/pyllusion/Chromostereopsis/chromostereopsis_parameters.py b/pyllusion/Chromostereopsis/chromostereopsis_parameters.py new file mode 100644 index 0000000..a8b6d6a --- /dev/null +++ b/pyllusion/Chromostereopsis/chromostereopsis_parameters.py @@ -0,0 +1,253 @@ +""" +PROTOTYPE - parameters for the two-panel chromostereopsis stimulus. Not part of the public API yet. +""" +import numpy as np +import scipy.optimize + +from ..image.utilities import _color + + +# --------------------------------------------------------------------------------------------------- +# Luminance helpers +# +# Same convention as pyllusion.analyze_luminance(): linearize sRGB, then weight the channels by the +# sRGB primaries. "Relative luminance" is therefore 0 (black) to 1 (white). +# --------------------------------------------------------------------------------------------------- +def _srgb_to_linear(rgb): + rgb = np.asarray(rgb, dtype=float) / 255.0 + return np.where(rgb <= 0.04045, rgb / 12.92, np.power((rgb + 0.055) / 1.055, 2.4)) + + +def _linear_to_srgb(linear): + linear = np.clip(np.asarray(linear, dtype=float), 0, 1) + srgb = np.where(linear <= 0.0031308, linear * 12.92, 1.055 * np.power(linear, 1 / 2.4) - 0.055) + return tuple(int(round(c)) for c in np.clip(srgb * 255.0, 0, 255)) + + +def _relative_luminance(rgb): + """Relative luminance (0-1) of an RGB colour.""" + linear = _srgb_to_linear(rgb) + return float(0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]) + + +def _scale_luminance(rgb, factor): + """Scale a colour's luminance by `factor`, in linear light (so it is a real luminance scaling, not + a scaling of the 8-bit code values). Hue is preserved; the result is clipped at white.""" + return _linear_to_srgb(_srgb_to_linear(rgb) * factor) + + +# --------------------------------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------------------------------- +def _disc_area(radius): + """Area of a disc of radius `radius` clipped to the panel, in units where the panel has area 4 + (i.e. the panel runs from -1 to 1 on each axis, so `radius` is in units of half the panel side). + + Exact: for radius <= 1 the disc is inscribed; beyond that it is clipped by the four sides. + """ + radius = float(radius) + if radius <= 0: + return 0.0 + if radius <= 1: + return np.pi * radius ** 2 + if radius >= np.sqrt(2): # disc covers the whole panel + return 4.0 + # Subtract the four circular segments that stick out past the sides + segment = radius ** 2 * np.arccos(1 / radius) - np.sqrt(radius ** 2 - 1) + return np.pi * radius ** 2 - 4 * segment + + +def _radius_area_matched(gap): + """Radius at which the disc and the surround have equal area, for a given gap width. + + Matching the two areas means each panel holds equal amounts of the two colours, which is what makes + the two panels of a stimulus equally luminous overall. Exact only when difference=0. + """ + return float(scipy.optimize.brentq(lambda r: _disc_area(r) - (4 - _disc_area(r + gap)), 0.01, 1.4)) + + +# --------------------------------------------------------------------------------------------------- +# Parameters +# --------------------------------------------------------------------------------------------------- +def _chromostereopsis_parameters( + difference=0, + illusion_strength=0, + color1="red", + color2="blue", + luminance1=1.0, + luminance2=1.0, + equiluminant=False, + background="black", + radius=0.58, + gap=0.08, + area_matched=False, + density=0.5, + density_inner=None, + dither_size=10, + dither_shared=True, + seed=None, +): + """Compute the parameters of the two-panel chromostereopsis stimulus. + + Parameters + ---------- + difference : float + The objective size difference between the two discs: the radius of the left disc relative to + the right (e.g. ``difference=0.1`` makes the left disc 10% larger, ``-0.1`` 10% smaller). + This is the attribute the observer is asked to judge. + illusion_strength : float + Provisional. Only its *sign* is used, and it sets which panel receives the disc drawn in + ``color1`` (>= 0 puts it on the left). What the magnitude should scale is still open - see the + notes in ``Chromostereopsis.py`` - so for now any positive value behaves like any other. + + Colour and luminance + -------------------- + color1, color2 : str or tuple + The two colours, as a name or an RGB tuple. ``color1`` is the one whose disc position is set by + the sign of ``illusion_strength``. Defaults to the classic red/blue pair; red/green and + yellow/blue are the other pairings reported in the literature. + luminance1, luminance2 : float + Multipliers on each colour's luminance, applied in linear light so that ``0.5`` really is half + the luminance. Values that would clip past white are clipped. Lowering the red is the standard + way to weaken the illusion. + equiluminant : bool + If True, scale the brighter of the two colours down until both have the same relative + luminance, after ``luminance1``/``luminance2`` are applied. Note this is an approximation: + true equiluminance is observer-specific and would need flicker photometry. + background : str or tuple + Background, and colour of the gap annulus. Black maximises the effect; raising it toward white + is the literature's background-brightness manipulation, which weakens and can reverse the + illusion. Use a grey (e.g. ``"#808080"``) to lower the contrast of both colours at once - note + that on pure black every colour has a Michelson contrast of 1, so a non-black background is + needed for ``Contrast_Color*`` to carry any information. + + Geometry + -------- + radius : float + Radius of the discs, as a proportion of half the panel side. Ignored if ``area_matched``. + gap : float + Width of the bare annulus between disc and surround, in the same units. + area_matched : bool + If True, override ``radius`` with the value that makes disc area equal surround area, so the two + panels contain equal amounts of both colours and are equally luminous overall. This removes the + brightness confound between panels, but is exact only at ``difference=0``; the residual is + reported as ``Luminance_Panel_Ratio``. + + Dither + ------ + density : float + Proportion of dither cells that are coloured, 0-1, in the surround (and in the disc unless + ``density_inner`` is set). 1 gives solid fills. This scales the mean luminance of a region + without changing the colour of its pixels, so it is a second, independent luminance knob. + density_inner : float + Density for the disc only. Defaults to ``density``. + dither_size : int + Side of a dither cell in pixels. 1 gives per-pixel noise, larger values the chunky pixel-art + look that is anecdotally reported to strengthen the effect. + dither_shared : bool + If True (default) both panels use the same dither pattern, so colour assignment is the only + difference between them. + seed : int + Seed for the dither pattern. + """ + if density_inner is None: + density_inner = density + + # --- Colours ----------------------------------------------------------------------------------- + rgb1 = _scale_luminance(_color(color1), luminance1) + rgb2 = _scale_luminance(_color(color2), luminance2) + + luminance_1 = _relative_luminance(rgb1) + luminance_2 = _relative_luminance(rgb2) + + if equiluminant is True: + target = min(luminance_1, luminance_2) + if luminance_1 > target and luminance_1 > 0: + rgb1 = _scale_luminance(rgb1, target / luminance_1) + if luminance_2 > target and luminance_2 > 0: + rgb2 = _scale_luminance(rgb2, target / luminance_2) + luminance_1, luminance_2 = _relative_luminance(rgb1), _relative_luminance(rgb2) + + rgb_background = _color(background) + luminance_background = _relative_luminance(rgb_background) + + # Michelson contrast of each colour against the background. Bounded [-1, 1] and well defined on a + # black background, unlike Weber contrast which goes to infinity there. + def _contrast(luminance): + total = luminance + luminance_background + return (luminance - luminance_background) / total if total > 0 else 0.0 + + # --- Geometry ---------------------------------------------------------------------------------- + if area_matched is True: + radius = _radius_area_matched(gap) + + radius_left = radius * (1 + difference / 2) + radius_right = radius * (1 - difference / 2) + + # --- Which panel gets which colour ------------------------------------------------------------- + if illusion_strength >= 0: + inner_left, surround_left = rgb1, rgb2 + luminance_inner_left, luminance_surround_left = luminance_1, luminance_2 + else: + inner_left, surround_left = rgb2, rgb1 + luminance_inner_left, luminance_surround_left = luminance_2, luminance_1 + inner_right, surround_right = surround_left, inner_left + luminance_inner_right, luminance_surround_right = luminance_surround_left, luminance_inner_left + + # --- Predicted mean luminance of each panel ---------------------------------------------------- + # Analytic, so the brightness confound can be read off without rendering anything. + def _panel_luminance(radius_panel, luminance_inner, luminance_surround): + area_disc = _disc_area(radius_panel) / 4 + area_surround = 1 - _disc_area(radius_panel + gap) / 4 + area_gap = 1 - area_disc - area_surround + return ( + area_disc * (density_inner * luminance_inner + (1 - density_inner) * luminance_background) + + area_surround * (density * luminance_surround + (1 - density) * luminance_background) + + area_gap * luminance_background + ) + + panel_left = _panel_luminance(radius_left, luminance_inner_left, luminance_surround_left) + panel_right = _panel_luminance(radius_right, luminance_inner_right, luminance_surround_right) + + return { + # Objective difference (what the observer judges) + "Difference": difference, + "Size_Left": radius_left, + "Size_Right": radius_right, + # Illusion + "Illusion": "Chromostereopsis", + "Illusion_Strength": illusion_strength, + # Deliberately not Congruent/Incongruent: that needs the direction of the size bias, which is + # still a hypothesis (see the notes in Chromostereopsis.py). + "Illusion_Type": "Undetermined", + # Colours + "Color_Inner_Left": inner_left, + "Color_Surround_Left": surround_left, + "Color_Inner_Right": inner_right, + "Color_Surround_Right": surround_right, + "Color_Background": rgb_background, + "Equiluminant": equiluminant, + # Luminance and contrast + "Luminance_Color1": luminance_1, + "Luminance_Color2": luminance_2, + "Luminance_Background": luminance_background, + "Luminance_Ratio": luminance_1 / luminance_2 if luminance_2 > 0 else np.inf, + "Contrast_Color1": _contrast(luminance_1), + "Contrast_Color2": _contrast(luminance_2), + # Mean luminance of each panel, and the imbalance between them + "Luminance_Panel_Left": panel_left, + "Luminance_Panel_Right": panel_right, + "Luminance_Panel_Ratio": panel_left / panel_right if panel_right > 0 else np.inf, + # Geometry + "Radius": radius, + "Gap": gap, + "Area_Matched": area_matched, + "Area_Disc_Left": _disc_area(radius_left) / 4, + "Area_Surround_Left": 1 - _disc_area(radius_left + gap) / 4, + # Dither + "Density": density, + "Density_Inner": density_inner, + "Dither_Size": dither_size, + "Dither_Shared": dither_shared, + "Seed": seed, + } From fad0e4a1710e4437821ab6461ba4e8c864bef105 Mon Sep 17 00:00:00 2001 From: DominiqueMakowski Date: Sat, 19 Sep 2026 10:31:02 +0100 Subject: [PATCH 4/5] Chromostereopsis: match Delboeuf's geometry, document the gap parameter Sizes and distances now use Pyllusion's grid units instead of ad-hoc panel-relative ones, with defaults matching Delboeuf: size=0.25 and distance=1 give 75px discs with centres 400px apart on the default 800x600 canvas, verified identical to pyllusion.Delboeuf(). The disc maps to Delboeuf's inner circle (the judged target), the square panel to its outer circle (the context). The "black outline" between square and circle was already there as `gap`; it is now in grid units like everything else and documented as such. Raising it grows the area-matched panel with it. area_matched is replaced by size_panel=None, which computes the area-matched panel side from a closed form (no more scipy solver), so the brightness confound is off by default rather than opt-in. Two fixes found while verifying against Delboeuf: - The disc came out 73px instead of 75 because a panel-relative radius was discretised twice. The panel now takes its radius and gap in pixels, converted once from grid units the same way _coord_circle() does it. - dither_size=10 suited the old 500px panels but is ~10% of a Delboeuf-matched 102px panel, which wrecks the disc. Default is now 2px (~50 cells across, matching the proportions of the reference image), and Dither_Cells_Across is reported so the look can be held constant when rendering at other scales. Noted in the notes: matching Delboeuf makes the stimulus small, and the literature reports the effect as stronger for larger stimuli, so the matched defaults may sit at the weak end. Co-Authored-By: Claude Opus 5 --- .../Chromostereopsis/Chromostereopsis.py | 26 +++- .../chromostereopsis_image.py | 72 ++++++------ .../chromostereopsis_parameters.py | 111 ++++++++++++------ 3 files changed, 139 insertions(+), 70 deletions(-) diff --git a/pyllusion/Chromostereopsis/Chromostereopsis.py b/pyllusion/Chromostereopsis/Chromostereopsis.py index 552e056..5399638 100644 --- a/pyllusion/Chromostereopsis/Chromostereopsis.py +++ b/pyllusion/Chromostereopsis/Chromostereopsis.py @@ -172,7 +172,7 @@ Luminance ``luminance1``, ``luminance2``, ``equiluminant`` Contrast ``background`` (raising it lowers both colours' contrast at once) Colour pairing ``color1``, ``color2`` -Geometry ``radius``, ``gap``, ``area_matched`` +Geometry ``size``, ``size_panel``, ``gap``, ``distance`` Reproducibility ``seed`` ===================== ========================================================================== @@ -187,6 +187,30 @@ landmark: mid-grey ``#808080`` has relative luminance 0.216, almost exactly that of pure red (0.213), so a mid-grey background is very nearly isoluminant with red while still being far from blue. +Geometry matched to Delboeuf +---------------------------- +Sizes and distances use Pyllusion's grid units (sizes a proportion of the image height, positions -1 to +1 across the width), and the defaults are set to match Delboeuf so the two illusions can sit in the same +battery: ``size=0.25`` and ``distance=1`` give 75 px discs with centres 400 px apart on the default +800x600 canvas, identical to ``pyllusion.Delboeuf()``. The disc is the analogue of Delboeuf's inner +circle (the judged target) and the square panel of its outer circle (the context). + +``size_panel`` is left at ``None`` by default, which sets it to the area-matched value - so the +brightness confound is off by default rather than something you have to remember to switch on. The gap +("the black outline") is ``gap``, in the same grid units. Raising it grows the area-matched panel with +it, since the surround has to grow to keep its area equal to the disc's. + +Two consequences of matching Delboeuf worth keeping in mind: + +- **The stimulus is small.** Delboeuf's geometry puts the whole panel at ~102 px on the default canvas. + The literature reports chromostereopsis as stronger for larger stimuli and longer viewing distances, + so the matched defaults may well sit at the weak end of the effect. Raising ``size`` (or rendering + larger) is the easy fix, at the cost of no longer matching Delboeuf exactly - a trade-off to make + deliberately rather than by accident. +- **The dither has little room.** ``dither_size`` is in pixels and does not scale with the image, so at + ~102 px panels the default of 2 px gives ~50 cells across. Rendering larger without raising it changes + the appearance; hold ``Dither_Cells_Across`` constant instead. + The parameters dict reports the derived quantities too - each colour's relative luminance, their ratio, each colour's contrast against the background, the disc/surround areas, and the predicted mean luminance of each panel. The panel luminances are computed analytically from areas and densities, and agree with diff --git a/pyllusion/Chromostereopsis/chromostereopsis_image.py b/pyllusion/Chromostereopsis/chromostereopsis_image.py index 9f43047..f564758 100644 --- a/pyllusion/Chromostereopsis/chromostereopsis_image.py +++ b/pyllusion/Chromostereopsis/chromostereopsis_image.py @@ -19,6 +19,7 @@ import numpy as np import PIL.Image +from ..image.rescale import rescale from .chromostereopsis_parameters import _chromostereopsis_parameters @@ -27,11 +28,11 @@ def _chromostereopsis_panel( color_inner=(255, 0, 0), color_surround=(0, 0, 255), background=(0, 0, 0), - radius=0.58, - gap=0.08, + radius=37.5, + gap=6, density=0.5, density_inner=None, - dither_size=10, + dither_size=2, dither=None, rng=None, ): @@ -45,7 +46,9 @@ def _chromostereopsis_panel( color_inner, color_surround, background : tuple RGB of the disc's pixels, of the surround's pixels, and of everything else. radius, gap : float - Radius of the disc and width of the bare annulus, as a proportion of half the panel side. + Radius of the disc and width of the bare annulus, in pixels (fractional is fine). Kept in + pixels rather than panel-relative units so that the disc lands on exactly the diameter the grid + geometry asks for, instead of being discretised twice. density, density_inner : float Proportion of dither cells that get coloured, in the surround and in the disc. dither_size : int @@ -61,8 +64,8 @@ def _chromostereopsis_panel( if rng is None: rng = np.random.default_rng() - # Distance of every pixel from the centre, in units of half the panel side - coords = (np.arange(size) - (size - 1) / 2) / ((size - 1) / 2) + # Distance of every pixel from the centre of the panel, in pixels + coords = np.arange(size) - (size - 1) / 2 distance = np.sqrt(coords[:, None] ** 2 + coords[None, :] ** 2) is_inner = distance <= radius @@ -82,7 +85,7 @@ def _chromostereopsis_panel( return panel -def _chromostereopsis_dither(size, dither_size=10, rng=None): +def _chromostereopsis_dither(size, dither_size=2, rng=None): """Uniform [0, 1) draw per dither cell, upsampled to `size` x `size` pixels.""" if rng is None: rng = np.random.default_rng() @@ -91,7 +94,7 @@ def _chromostereopsis_dither(size, dither_size=10, rng=None): return np.repeat(np.repeat(cells, dither_size, axis=0), dither_size, axis=1)[:size, :size] -def _chromostereopsis_image(parameters=None, width=800, height=400, margin=0.05, **kwargs): +def _chromostereopsis_image(parameters=None, width=800, height=600, **kwargs): """Draw the two-panel stimulus. Parameters @@ -99,9 +102,9 @@ def _chromostereopsis_image(parameters=None, width=800, height=400, margin=0.05, parameters : dict Output of :func:`_chromostereopsis_parameters`. Built from ``**kwargs`` if not given. width, height : int - Size of the returned image. Each panel is a square, sized to fit the height. - margin : float - Space between and around the panels, as a proportion of the panel side. + Size of the returned image. Defaults match the other illusions. The panels are sized and placed + from the grid-unit geometry in ``parameters``, as in the rest of Pyllusion: sizes scale with the + height, positions run from -1 to 1 across the width. **kwargs Passed to :func:`_chromostereopsis_parameters`. """ @@ -109,7 +112,10 @@ def _chromostereopsis_image(parameters=None, width=800, height=400, margin=0.05, parameters = _chromostereopsis_parameters(**kwargs) rng = np.random.default_rng(parameters["Seed"]) - panel_size = int(height * (1 - 2 * margin)) + + # Grid units -> pixels, the same way _coord_circle() does it: sizes against the height, positions + # against the width. + panel_size = int(rescale(parameters["Size_Panel"], to=[0, height], scale=[0, 2])) # One shared dither pattern, or one per panel dither = None @@ -118,30 +124,24 @@ def _chromostereopsis_image(parameters=None, width=800, height=400, margin=0.05, panel_size, dither_size=parameters["Dither_Size"], rng=rng ) - panels = [] - for side, radius in [("Left", parameters["Size_Left"]), ("Right", parameters["Size_Right"])]: - panels.append( - _chromostereopsis_panel( - size=panel_size, - color_inner=parameters["Color_Inner_" + side], - color_surround=parameters["Color_Surround_" + side], - background=parameters["Color_Background"], - radius=radius, - gap=parameters["Gap"], - density=parameters["Density"], - density_inner=parameters["Density_Inner"], - dither_size=parameters["Dither_Size"], - dither=dither, - rng=rng, - ) - ) - - # Paste both panels onto the canvas image = PIL.Image.new("RGB", (width, height), color=tuple(parameters["Color_Background"])) - y = (height - panel_size) // 2 - x_gap = int(panel_size * margin) - x = (width - (2 * panel_size + x_gap)) // 2 - image.paste(PIL.Image.fromarray(panels[0]), (x, y)) - image.paste(PIL.Image.fromarray(panels[1]), (x + panel_size + x_gap, y)) + + for side in ["Left", "Right"]: + panel = _chromostereopsis_panel( + size=panel_size, + color_inner=parameters["Color_Inner_" + side], + color_surround=parameters["Color_Surround_" + side], + background=parameters["Color_Background"], + radius=rescale(parameters["Size_" + side], to=[0, height], scale=[0, 2]) / 2, + gap=rescale(parameters["Gap"], to=[0, height], scale=[0, 2]), + density=parameters["Density"], + density_inner=parameters["Density_Inner"], + dither_size=parameters["Dither_Size"], + dither=dither, + rng=rng, + ) + # Paste centred on the panel's grid position + x = int(rescale(parameters["Position_" + side], to=[0, width], scale=[-1, 1])) + image.paste(PIL.Image.fromarray(panel), (x - panel_size // 2, (height - panel_size) // 2)) return image diff --git a/pyllusion/Chromostereopsis/chromostereopsis_parameters.py b/pyllusion/Chromostereopsis/chromostereopsis_parameters.py index a8b6d6a..c44571e 100644 --- a/pyllusion/Chromostereopsis/chromostereopsis_parameters.py +++ b/pyllusion/Chromostereopsis/chromostereopsis_parameters.py @@ -2,7 +2,6 @@ PROTOTYPE - parameters for the two-panel chromostereopsis stimulus. Not part of the public API yet. """ import numpy as np -import scipy.optimize from ..image.utilities import _color @@ -57,13 +56,17 @@ def _disc_area(radius): return np.pi * radius ** 2 - 4 * segment -def _radius_area_matched(gap): - """Radius at which the disc and the surround have equal area, for a given gap width. +def _size_panel_area_matched(size, gap): + """Side of the panel at which the disc and the surround have equal area, given a disc of diameter + `size` and an annulus of width `gap` (both in grid units). Matching the two areas means each panel holds equal amounts of the two colours, which is what makes - the two panels of a stimulus equally luminous overall. Exact only when difference=0. + the two panels of a stimulus equally luminous overall. Solving + ``pi * r**2 == S**2 - pi * (r + gap)**2`` for the panel side S gives the closed form below. Exact + only when difference=0. """ - return float(scipy.optimize.brentq(lambda r: _disc_area(r) - (4 - _disc_area(r + gap)), 0.01, 1.4)) + radius = size / 2 + return float(np.sqrt(np.pi * (radius ** 2 + (radius + gap) ** 2))) # --------------------------------------------------------------------------------------------------- @@ -72,27 +75,34 @@ def _radius_area_matched(gap): def _chromostereopsis_parameters( difference=0, illusion_strength=0, + size=0.25, + size_panel=None, + gap=0.02, + distance=1, color1="red", color2="blue", luminance1=1.0, luminance2=1.0, equiluminant=False, background="black", - radius=0.58, - gap=0.08, - area_matched=False, density=0.5, density_inner=None, - dither_size=10, + dither_size=2, dither_shared=True, seed=None, ): """Compute the parameters of the two-panel chromostereopsis stimulus. + Sizes and distances use the same grid units as the rest of Pyllusion: sizes are a proportion of the + image height (``size=2`` spans the full height) and positions run from -1 to 1 across the width. + The defaults are chosen to match the Delboeuf illusion, so the two can be used side by side in the + same battery: ``size=0.25`` and ``distance=1`` give 75 px discs with centres 400 px apart on the + default 800x600 canvas, exactly like ``pyllusion.Delboeuf()``. + Parameters ---------- difference : float - The objective size difference between the two discs: the radius of the left disc relative to + The objective size difference between the two discs: the diameter of the left disc relative to the right (e.g. ``difference=0.1`` makes the left disc 10% larger, ``-0.1`` 10% smaller). This is the attribute the observer is asked to judge. illusion_strength : float @@ -100,6 +110,22 @@ def _chromostereopsis_parameters( ``color1`` (>= 0 puts it on the left). What the magnitude should scale is still open - see the notes in ``Chromostereopsis.py`` - so for now any positive value behaves like any other. + Geometry + -------- + size : float + Diameter of the discs, in grid units. Matches Delboeuf's inner circle default of 0.25. + size_panel : float + Side of the square panels, in grid units. Left at ``None`` (the default) it is set to the value + that makes disc area equal surround area, so each panel holds equal amounts of both colours and + the two panels are equally luminous overall. Setting it by hand reintroduces that brightness + difference, which is reported as ``Luminance_Panel_Ratio``. The automatic match is exact only at + ``difference=0``; past that a residual remains, also reported there. + gap : float + Width of the bare background annulus between each disc and its surround ("the black outline"), + in grid units. Set to 0 for the disc to abut the surround directly. + distance : float + Distance between the centres of the two panels, in grid units. Matches Delboeuf's default of 1. + Colour and luminance -------------------- color1, color2 : str or tuple @@ -121,18 +147,6 @@ def _chromostereopsis_parameters( that on pure black every colour has a Michelson contrast of 1, so a non-black background is needed for ``Contrast_Color*`` to carry any information. - Geometry - -------- - radius : float - Radius of the discs, as a proportion of half the panel side. Ignored if ``area_matched``. - gap : float - Width of the bare annulus between disc and surround, in the same units. - area_matched : bool - If True, override ``radius`` with the value that makes disc area equal surround area, so the two - panels contain equal amounts of both colours and are equally luminous overall. This removes the - brightness confound between panels, but is exact only at ``difference=0``; the residual is - reported as ``Luminance_Panel_Ratio``. - Dither ------ density : float @@ -143,7 +157,11 @@ def _chromostereopsis_parameters( Density for the disc only. Defaults to ``density``. dither_size : int Side of a dither cell in pixels. 1 gives per-pixel noise, larger values the chunky pixel-art - look that is anecdotally reported to strengthen the effect. + look that is anecdotally reported to strengthen the effect. This is a pixel quantity, so it does + not scale with the image: at the default 800x600 the panels are ~102 px, and the default of 2 + gives ~50 dither cells across a panel. Render bigger and you must raise it to keep the same + look - ``Dither_Cells_Across`` in the returned dict is the scale-invariant number to hold + constant. dither_shared : bool If True (default) both panels use the same dither pattern, so colour assignment is the only difference between them. @@ -177,12 +195,30 @@ def _contrast(luminance): total = luminance + luminance_background return (luminance - luminance_background) / total if total > 0 else 0.0 - # --- Geometry ---------------------------------------------------------------------------------- + # --- Geometry (grid units) --------------------------------------------------------------------- + area_matched = size_panel is None if area_matched is True: - radius = _radius_area_matched(gap) + size_panel = _size_panel_area_matched(size, gap) + + size_left = size * (1 + difference / 2) + size_right = size * (1 - difference / 2) + + position_left, position_right = -(distance / 2), (distance / 2) - radius_left = radius * (1 + difference / 2) - radius_right = radius * (1 - difference / 2) + # Panel side in pixels at the default render height, only used to report Dither_Cells_Across + size_panel_px = size_panel / 2 * 600 + + # The area computations work in units of half the panel side, so convert + radius_left = size_left / size_panel + radius_right = size_right / size_panel + gap_relative = 2 * gap / size_panel + + if max(radius_left, radius_right) + gap_relative > 1: + raise ValueError( + "Pyllusion error: the disc (size=%.3f) plus its gap (%.3f) does not fit inside the panel " + "(size_panel=%.3f). Increase size_panel, or leave it as None to have it computed." + % (max(size_left, size_right), gap, size_panel) + ) # --- Which panel gets which colour ------------------------------------------------------------- if illusion_strength >= 0: @@ -198,7 +234,7 @@ def _contrast(luminance): # Analytic, so the brightness confound can be read off without rendering anything. def _panel_luminance(radius_panel, luminance_inner, luminance_surround): area_disc = _disc_area(radius_panel) / 4 - area_surround = 1 - _disc_area(radius_panel + gap) / 4 + area_surround = 1 - _disc_area(radius_panel + gap_relative) / 4 area_gap = 1 - area_disc - area_surround return ( area_disc * (density_inner * luminance_inner + (1 - density_inner) * luminance_background) @@ -212,8 +248,8 @@ def _panel_luminance(radius_panel, luminance_inner, luminance_surround): return { # Objective difference (what the observer judges) "Difference": difference, - "Size_Left": radius_left, - "Size_Right": radius_right, + "Size_Left": size_left, + "Size_Right": size_right, # Illusion "Illusion": "Chromostereopsis", "Illusion_Strength": illusion_strength, @@ -238,16 +274,25 @@ def _panel_luminance(radius_panel, luminance_inner, luminance_surround): "Luminance_Panel_Left": panel_left, "Luminance_Panel_Right": panel_right, "Luminance_Panel_Ratio": panel_left / panel_right if panel_right > 0 else np.inf, - # Geometry - "Radius": radius, + # Geometry, in grid units + "Size": size, + "Size_Panel": size_panel, "Gap": gap, + "Distance": distance, + "Position_Left": position_left, + "Position_Right": position_right, "Area_Matched": area_matched, "Area_Disc_Left": _disc_area(radius_left) / 4, - "Area_Surround_Left": 1 - _disc_area(radius_left + gap) / 4, + "Area_Surround_Left": 1 - _disc_area(radius_left + gap_relative) / 4, + # Geometry, in units of half the panel side (used for the area computations above) + "Radius_Left": radius_left, + "Radius_Right": radius_right, + "Gap_Relative": gap_relative, # Dither "Density": density, "Density_Inner": density_inner, "Dither_Size": dither_size, + "Dither_Cells_Across": size_panel_px / dither_size if dither_size else np.nan, "Dither_Shared": dither_shared, "Seed": seed, } From 18a1baa93f71cc6a3c400067cd4ee39560f2d3e0 Mon Sep 17 00:00:00 2001 From: DominiqueMakowski Date: Sat, 19 Sep 2026 10:40:33 +0100 Subject: [PATCH 5/5] Chromostereopsis: panels fill their half by default, act as background The discs stay pinned to Delboeuf's geometry; only the panels change. size_panel now takes three forms: None (the default) fills each half, its side equal to the centre-to-centre distance in pixels, so the surround is the background of the stimulus rather than a patch floating on black; "match" gives the area-matched size; a float is anything in between. Resolving the automatic size needs the aspect ratio, since Pyllusion sizes scale with height and positions with width, so the parameters function now takes width/height and the image function forwards its own. Two consequences, both measured and documented rather than hidden: - Filling the halves puts the surround at ~35x the disc area, so the red-surround half is 2.7x brighter than the blue-surround half (0.069 vs 0.025) - worse than the imbalance the area match was introduced to fix. equiluminant=True removes it exactly at any panel size (measured ratio 0.99), because equal-luminance colours make the two panels hold the same coloured area just swapped. For large panels it is effectively required, not optional. - The panels now touch at the midline, putting a hard red|blue edge down the centre. Border contrast is reported in the literature as able to reverse perceived depth, so this is not neutral; size_panel slightly below the automatic value inserts a gutter. Also fixes two things found while verifying against Delboeuf: - `difference` used a symmetric +/- split, but Delboeuf defines it on area (smaller disc stays at size, larger scales by sqrt(1+|d|)). The two now agree exactly at every difference, which is the whole point of matching. difference=0.3 gave a 1.35 diameter ratio, now 1.14 as in Delboeuf. - The disc lost a pixel (74 instead of 75) on even-sized panels, because the centre fell between pixels. The disc centre is now placed on a pixel, giving 75px at every panel size. Co-Authored-By: Claude Opus 5 --- .../Chromostereopsis/Chromostereopsis.py | 43 +++++++---- .../chromostereopsis_image.py | 9 ++- .../chromostereopsis_parameters.py | 74 ++++++++++++++----- 3 files changed, 91 insertions(+), 35 deletions(-) diff --git a/pyllusion/Chromostereopsis/Chromostereopsis.py b/pyllusion/Chromostereopsis/Chromostereopsis.py index 5399638..59dccdd 100644 --- a/pyllusion/Chromostereopsis/Chromostereopsis.py +++ b/pyllusion/Chromostereopsis/Chromostereopsis.py @@ -195,21 +195,34 @@ 800x600 canvas, identical to ``pyllusion.Delboeuf()``. The disc is the analogue of Delboeuf's inner circle (the judged target) and the square panel of its outer circle (the context). -``size_panel`` is left at ``None`` by default, which sets it to the area-matched value - so the -brightness confound is off by default rather than something you have to remember to switch on. The gap -("the black outline") is ``gap``, in the same grid units. Raising it grows the area-matched panel with -it, since the surround has to grow to keep its area equal to the disc's. - -Two consequences of matching Delboeuf worth keeping in mind: - -- **The stimulus is small.** Delboeuf's geometry puts the whole panel at ~102 px on the default canvas. - The literature reports chromostereopsis as stronger for larger stimuli and longer viewing distances, - so the matched defaults may well sit at the weak end of the effect. Raising ``size`` (or rendering - larger) is the easy fix, at the cost of no longer matching Delboeuf exactly - a trade-off to make - deliberately rather than by accident. -- **The dither has little room.** ``dither_size`` is in pixels and does not scale with the image, so at - ~102 px panels the default of 2 px gives ~50 cells across. Rendering larger without raising it changes - the appearance; hold ``Dither_Cells_Across`` constant instead. +Only the *discs* are tied to Delboeuf. The panels are sized separately by ``size_panel``, which by +default fills each half of the image (side equal to the centre-to-centre distance in pixels), so the +surround acts as the background of the stimulus rather than as a patch floating on black. Setting it to +``"match"`` shrinks it to the area-matched size, and any float in between brings the surround closer to +the disc. The gap ("the black outline") is ``gap``, in the same grid units; raising it grows the +area-matched panel with it, since the surround has to keep its area equal to the disc's. + +Three consequences worth keeping in mind: + +- **Filling the halves reinstates the brightness confound, badly.** The area match only holds at one + particular panel size. At the default filling size the surround is ~35x the disc area, so the panel + with the red surround is about **2.7x brighter** than the panel with the blue one (measured half + luminances 0.069 vs 0.025). That is far larger than the ~1.5x imbalance that prompted the area match + in the first place. + + ``equiluminant=True`` removes it exactly, at *any* panel size: if the two colours have equal + luminance then the two panels hold the same coloured area, just swapped, so the imbalance cannot + arise geometrically (measured ratio 0.99 at the filling size). For a study using large panels this is + effectively required rather than optional, and it is the one setting that lets the panel size be + varied freely without confounding it with brightness. +- **The two panels touch at the midline**, since the default side is exactly the centre distance. That + puts a hard red|blue vertical edge down the centre of the image. Border contrast is one of the things + the literature reports as able to *reverse* perceived depth, so this edge is not neutral. Dropping + ``size_panel`` slightly below the automatic value (e.g. 1.25 instead of 1.33 at 800x600) inserts a + background gutter between them. +- **The dither scale does not follow the panel.** ``dither_size`` is in pixels, so the same value gives + ~200 cells across a filling panel but ~50 across an area-matched one. Hold ``Dither_Cells_Across`` + constant rather than ``dither_size`` when comparing across panel sizes. The parameters dict reports the derived quantities too - each colour's relative luminance, their ratio, each colour's contrast against the background, the disc/surround areas, and the predicted mean luminance diff --git a/pyllusion/Chromostereopsis/chromostereopsis_image.py b/pyllusion/Chromostereopsis/chromostereopsis_image.py index f564758..40d370f 100644 --- a/pyllusion/Chromostereopsis/chromostereopsis_image.py +++ b/pyllusion/Chromostereopsis/chromostereopsis_image.py @@ -64,8 +64,10 @@ def _chromostereopsis_panel( if rng is None: rng = np.random.default_rng() - # Distance of every pixel from the centre of the panel, in pixels - coords = np.arange(size) - (size - 1) / 2 + # Distance of every pixel from the centre of the panel, in pixels. The centre is put *on* a pixel + # (rather than at (size - 1) / 2, which falls between pixels for an even panel) so that the disc + # comes out at the same diameter whatever the panel's parity - otherwise an even panel loses a pixel. + coords = np.arange(size) - size // 2 distance = np.sqrt(coords[:, None] ** 2 + coords[None, :] ** 2) is_inner = distance <= radius @@ -109,7 +111,8 @@ def _chromostereopsis_image(parameters=None, width=800, height=600, **kwargs): Passed to :func:`_chromostereopsis_parameters`. """ if parameters is None: - parameters = _chromostereopsis_parameters(**kwargs) + # width/height are forwarded because the automatic `size_panel` needs the aspect ratio + parameters = _chromostereopsis_parameters(width=width, height=height, **kwargs) rng = np.random.default_rng(parameters["Seed"]) diff --git a/pyllusion/Chromostereopsis/chromostereopsis_parameters.py b/pyllusion/Chromostereopsis/chromostereopsis_parameters.py index c44571e..c96af62 100644 --- a/pyllusion/Chromostereopsis/chromostereopsis_parameters.py +++ b/pyllusion/Chromostereopsis/chromostereopsis_parameters.py @@ -79,6 +79,8 @@ def _chromostereopsis_parameters( size_panel=None, gap=0.02, distance=1, + width=800, + height=600, color1="red", color2="blue", luminance1=1.0, @@ -102,9 +104,12 @@ def _chromostereopsis_parameters( Parameters ---------- difference : float - The objective size difference between the two discs: the diameter of the left disc relative to - the right (e.g. ``difference=0.1`` makes the left disc 10% larger, ``-0.1`` 10% smaller). - This is the attribute the observer is asked to judge. + The objective size difference between the two discs, and the attribute the observer is asked to + judge. Defined exactly as in Delboeuf and Ebbinghaus, so the same value means the same thing + across the three: the smaller disc stays at ``size`` and the larger one is scaled by + ``sqrt(1 + abs(difference))``, which makes ``difference`` a difference in *area* rather than in + diameter (``difference=1`` gives a disc of twice the area, i.e. 1.41x the diameter). Positive + puts the larger disc on the left, negative on the right. illusion_strength : float Provisional. Only its *sign* is used, and it sets which panel receives the disc drawn in ``color1`` (>= 0 puts it on the left). What the magnitude should scale is still open - see the @@ -114,17 +119,34 @@ def _chromostereopsis_parameters( -------- size : float Diameter of the discs, in grid units. Matches Delboeuf's inner circle default of 0.25. - size_panel : float - Side of the square panels, in grid units. Left at ``None`` (the default) it is set to the value - that makes disc area equal surround area, so each panel holds equal amounts of both colours and - the two panels are equally luminous overall. Setting it by hand reintroduces that brightness - difference, which is reported as ``Luminance_Panel_Ratio``. The automatic match is exact only at - ``difference=0``; past that a residual remains, also reported there. + size_panel : float or str + Side of the square panels. Three ways to set it: + + - ``None`` (the default): each panel fills its half of the image, its side equal to the distance + between the two disc centres in pixels, so the panels abut at the midline and act as the + background of the stimulus rather than as patches floating on it. + - ``"match"``: the side at which disc area equals surround area, so each panel holds equal + amounts of both colours and the two panels are equally luminous. Much smaller than the disc + suggests (about 1.36x the disc diameter), and the only setting that removes the brightness + imbalance geometrically. + - a float, in grid units, for anything in between - e.g. to bring the surround closer to the + disc without going all the way down to the area-matched size. + + Beware: any panel larger than ``"match"`` makes the surround bigger than the disc, so the panel + with the red surround is brighter than the panel with the blue one. At the default filling size + that is about a 2.7x difference. ``Luminance_Panel_Ratio`` reports it, and ``equiluminant=True`` + removes it exactly at any panel size (with equal-luminance colours the two panels hold the same + coloured area, just swapped). gap : float Width of the bare background annulus between each disc and its surround ("the black outline"), in grid units. Set to 0 for the disc to abut the surround directly. distance : float Distance between the centres of the two panels, in grid units. Matches Delboeuf's default of 1. + width, height : int + The size the stimulus will be rendered at. Only used to resolve the automatic ``size_panel``: + Pyllusion's grid is anisotropic (sizes scale with the height, positions with the width), so + making a square's side equal a horizontal distance needs the aspect ratio. Pass the same values + to the image function. Colour and luminance -------------------- @@ -196,17 +218,35 @@ def _contrast(luminance): return (luminance - luminance_background) / total if total > 0 else 0.0 # --- Geometry (grid units) --------------------------------------------------------------------- - area_matched = size_panel is None - if area_matched is True: + if size_panel is None: + # Fill each half: side equal to the centre-to-centre distance in pixels. Positions scale with + # the width and sizes with the height, hence the aspect ratio. + size_panel_mode = "fill" + size_panel = distance * width / height + elif isinstance(size_panel, str): + if size_panel != "match": + raise ValueError( + "Pyllusion error: size_panel must be a number, None (fill each half) or 'match' " + "(equal disc and surround areas), not '%s'." % size_panel + ) + size_panel_mode = "match" size_panel = _size_panel_area_matched(size, gap) - - size_left = size * (1 + difference / 2) - size_right = size * (1 - difference / 2) + else: + size_panel_mode = "manual" + + # Same rule as _delboeuf_parameters_sizeinner(), so that `difference` means the same thing in both + # illusions: the smaller disc stays at `size` and the larger one grows by sqrt(1 + |difference|), + # i.e. `difference` is expressed in terms of *area*, not diameter. + size_bigger = np.sqrt(1 + np.abs(difference)) * size + if difference > 0: # right is the smaller one + size_left, size_right = size_bigger, size + else: + size_left, size_right = size, size_bigger position_left, position_right = -(distance / 2), (distance / 2) - # Panel side in pixels at the default render height, only used to report Dither_Cells_Across - size_panel_px = size_panel / 2 * 600 + # Panel side in pixels, used to report Dither_Cells_Across and to check it fits + size_panel_px = size_panel / 2 * height # The area computations work in units of half the panel side, so convert radius_left = size_left / size_panel @@ -281,7 +321,7 @@ def _panel_luminance(radius_panel, luminance_inner, luminance_surround): "Distance": distance, "Position_Left": position_left, "Position_Right": position_right, - "Area_Matched": area_matched, + "Size_Panel_Mode": size_panel_mode, "Area_Disc_Left": _disc_area(radius_left) / 4, "Area_Surround_Left": 1 - _disc_area(radius_left + gap_relative) / 4, # Geometry, in units of half the panel side (used for the area computations above)