ssvep.stim — stimulus design¶
Part of the API reference, generated from the docstrings in src/ssvep.
Flicker math and design-time validation¶
ssvep.stim.flicker ¶
Flicker math + design-time validation (pure; no pyglet, no GL).
Everything timing-critical about an SSVEP stimulus reduces to: what luminance should this
target show on frame N of a display refreshing at R Hz? Keeping that here — as plain,
deterministic functions of a frame counter — means the renderer is a thin shell and the
important behaviour is unit-testable without a display (see tests: an FFT of
frame_luminance_sequence must peak at the declared frequency, square included).
Conventions
- Luminance is normalised to
[0, 1](0 = black, 1 = max). The renderer maps this to pixel values with :func:gamma_encode. - Phase is in cycles (0..1), matching the manifest.
phase=0starts a sine at its mean rising through zero; a trial resets the frame counter so phase is reproducible. - Time is derived from the frame counter,
t = frame / refresh_hz— never wall-clock — so the waveform is deterministic and vsync-locked. refresh_hzeverywhere in this module is the frame clock: the ratet = frame / refreshis computed at. The renderer clocks on the display's advertised mode rate (an integer, e.g. 240), falling back to the manifest'sdisplay.refresh_hz; the builder validates atdisplay.refresh_hz(:meth:ssvep.stim.builder.RunSpec.frame_clock_hz). A measured refresh never belongs here. A panel that measures 239.978 Hz still shows frame N of a 240-clock waveform on its N-th vsync, so which frames are on, how many frames a cycle spans and whether a target is frame-exact are all fixed by the clock. The renderer indexes frames by the refresh they are shown at, counted on the flip clock (#544), so a panel slightly off its mode keeps every presented frequency and instead repeats or skips one frame whenever it drifts a whole refresh (about once every 45 s at the ~ -90 ppm measured on the lab rig). Feeding a measured rate into this arithmetic makes an exact divisor look fractional — 240/10 = 24 frames becomes 23.999 — and flags every target.- Neither waveform quantises the frequency. The renderer samples the ideal waveform at each
frame time, so the mean presented rate is exactly
freq_hzfor sine and square alike. What a square selection can suffer is interval jitter: whenrefresh / freq_hzis not a whole number its cycles alternate between the two frame counts that bracket it (:func:is_frame_exact), and a contrast reversal, which switches on both edges, needs an even whole number (:func:is_reversal_frame_exact).
PatternDensity
dataclass
¶
Light–dark pairs a patterned target draws, against Harding et al.'s (2005) limit.
Source code in src/ssvep/stim/flicker.py
ChromaticSeparation
dataclass
¶
The most widely separated pair of colours a target alternates between (#332 review).
Source code in src/ssvep/stim/flicker.py
describe ¶
One clause, no sentence break (the reversal path splits messages on ". ").
Source code in src/ssvep/stim/flicker.py
ChromaticFlicker
dataclass
¶
A target's flicker involves colour inside Fisher et al.'s (2026, Table 4) 3–60 Hz range.
Source code in src/ssvep/stim/flicker.py
RefreshEstimate
dataclass
¶
A display refresh estimated from vsync'd flip timestamps (see :func:estimate_refresh).
Source code in src/ssvep/stim/flicker.py
frames_per_cycle ¶
Display frames per flicker cycle, refresh / freq (non-integer allowed for sine).
Source code in src/ssvep/stim/flicker.py
is_frame_exact ¶
True if every on/off cycle spans the same whole number of frames: refresh/freq is an integer.
This is the square-wave half of the rule :func:is_reversal_frame_exact states for reversals.
The renderer samples the ideal square at each frame time rather than rounding the frequency, so
the mean rate is exact at any frequency — 9 Hz at 240 Hz really is 9.000 Hz (measured on the
bench, and reproduced from :func:frame_luminance_sequence with no display). What is not exact
at 26.67 frames/cycle is the interval: cycles alternate 26 and 27 frames long, a one-frame
jitter that smears energy into sidebands. A whole number of frames/cycle removes it, odd or
even (9.6 Hz = 25 frames is exact for on/off flicker, though not for a reversal).
refresh_hz is the frame clock, never a measured rate (module docstring).
Source code in src/ssvep/stim/flicker.py
is_renderable ¶
A frequency is renderable only below Nyquist of the display (freq < refresh/2).
response_freq_hz ¶
Frequency the evoked response appears at for a target flickering at freq_hz.
For ordinary on/off flicker that is freq_hz. For a contrast-reversing pattern it is
2 * freq_hz (:data:REVERSAL_RATE_MULTIPLE) — the reversal rate. Decoder references,
photosensitivity classification and any "what frequency is this?" question want this, not the
manifest's freq_hz.
Source code in src/ssvep/stim/flicker.py
reversal_selector_spec ¶
The square selection that drives a pattern reversal, whatever type is declared.
A reversal swaps between two textures; there is no partial state to interpolate, so the choice
is a hard square selection on the flicker frequency even when the manifest says sine. The
renderer selects the phase with this; the validator uses it to reason about what will actually
be shown. Keeping one function means the two cannot disagree.
Source code in src/ssvep/stim/flicker.py
stimulus_reverses ¶
Is this manifest stimulus actually drawn as a contrast reversal (response at 2f)?
Not just its pattern_reversal flag: the renderer only reverses a textured fill
(:data:REVERSIBLE_PATTERNS), and draws anything else as plain flicker at f whatever the
flag says. The decoders reference what was presented, so they ask this, not the raw flag.
Source code in src/ssvep/stim/flicker.py
freq_key ¶
manifest_reversal_by_freq ¶
{declared freq_hz: reverses?} for every target — the per-target form of the flag.
Keyed by declared frequency because that is how a trial names its target: the marker stream
carries target_freq_hz, and both decoders' classes are the declared frequencies. Raises
ValueError when two targets share a frequency but only one of them reverses — a trial at
that frequency then has two possible response frequencies and nothing says which, and the
builder refuses the design for the same reason (:meth:ssvep.stim.builder.RunSpec.validate).
Source code in src/ssvep/stim/flicker.py
reverses_at ¶
Whether the target at declared freq_hz reverses, for a flag that is either one bool
for the whole run or a :func:manifest_reversal_by_freq mapping. KeyError for a frequency
the mapping does not declare: the decoders never guess a target's design.
Source code in src/ssvep/stim/flicker.py
summarize_reversal ¶
True if every flag is set, False if none is (or there are none), None if mixed.
Source code in src/ssvep/stim/flicker.py
response_collisions ¶
(freq_a, freq_b, response_hz) for every two targets that evoke the same response.
Only a mixed run can produce one: a reversing target at f and a non-reversing one at 2f
both respond at 2f, so they are one class to any frequency decoder. "Same" is within
tol_hz (:data:SAME_RESPONSE_TOL_HZ), not float equality, and only a reversing and a
non-reversing target are compared: two targets of one kind at one frequency are a duplicated
target, which is not what this is about. freq_a is the reversing one, and response_hz
its response.
Source code in src/ssvep/stim/flicker.py
manifest_decode_reversal ¶
What a decoder takes for a manifest's stimuli: one bool when every target reverses or
none does, else the per-target :func:manifest_reversal_by_freq mapping.
The one manifest-to-decoder rule, shared by the offline batch, the one-recording script, the
synthetic generator and the live Run tab, so none of them can collapse a mixed run to a
whole-run flag again. Raises ValueError where :func:manifest_reversal_by_freq does.
Source code in src/ssvep/stim/flicker.py
manifest_pattern_reversal ¶
Does a manifest's stimulus list describe contrast-reversing targets?
True if every target reverses, False if none does, and None if they are mixed.
"Reverses" is :func:stimulus_reverses — what the renderer draws, not the raw flag. A mixed
run is decoded per target (:func:manifest_reversal_by_freq): each target's references at its
own response frequency. This summary is what the records and the operator notes say.
This exists so the manifest-to-flag step has one definition. Both real callers — the live
decoder in the Run tab and the offline batch pipeline — read pattern_reversal from the
same place, and neither of them read it at all until an adversarial review pointed out that the
reversal-rate fix was threaded through the library and never reached the application.
Source code in src/ssvep/stim/flicker.py
reversal_frames_per_half_cycle ¶
Display frames between successive reversals, refresh / (2*freq) (may be fractional).
is_reversal_frame_exact ¶
True if every reversal lands on a frame boundary: frames/cycle is an even integer.
An exact refresh divisor is necessary but not sufficient: 9.6 Hz at 240 Hz is a clean divisor
(25 frames/cycle) yet its reversals alternate 13 and 12 frames apart, a one-frame jitter. The
mean reversal rate stays exact; the interval does not. Even frames/cycle removes it. (On/off
flicker only needs a whole number — :func:is_frame_exact.)
tol is in frames (:data:FRAME_EXACT_TOL); refresh_hz is the frame clock, never a
measured rate (module docstring).
Source code in src/ssvep/stim/flicker.py
raising_factors ¶
raising_factors(*, area_fraction=None, red_blue=False, stripes_cpd=None, luminance_cd_m2=None, saturated_red=False, array_area_fraction=None, chromatic_separation=None)
The Fisher et al. 2026 (Table 4) conditions a target meets, each named as a message says it.
The prose half of :func:raising_factor_codes; see there for the rules.
Source code in src/ssvep/stim/flicker.py
raising_factor_codes ¶
The same conditions as :func:raising_factors, as :data:FACTOR_CODES (#286 P5).
photosensitivity_risk ¶
photosensitivity_risk(freq_hz, *, area_fraction=None, high_contrast=True, pattern_reversal=False, red_blue=False, stripes_cpd=None, luminance_cd_m2=None, saturated_red=False, array_area_fraction=None, chromatic_separation=None, unknown=())
Grade the seizure-provocation risk of a flicker frequency, relative to other designs (#332).
The grades are "low" < "moderate" < "elevated" < "high". Frequency sets the base:
====================== ========================================================
below 3 Hz, above 60 low (outside Fisher et al. 2026's 3–60 Hz band)
3–10 Hz, 25–30 Hz elevated
10–25 Hz (the peak) high at high contrast, else elevated
30–60 Hz moderate
====================== ========================================================
Inside 3–60 Hz each of :func:raising_factors the target meets (a large area, its own or the
array's; two alternating colours at least 60% of the widest sRGB separation apart in CIE
1976 u′v′, as red–blue is, or saturated red; 1–8 cyc/deg stripes; a luminance of 20 cd/m² or more,
presumed when not declared) raises the grade one step, up to high. Outside the band nothing
raises it: every Table 4 condition is about flashes in it. unknown names inputs the caller
could not determine (e.g. a stripe pattern's cycles per degree with no viewing geometry); the
message states them as unknown, and they do not raise the grade.
This is a design-time warning aid, not a medical guarantee, and never a refusal: "low"
means lower risk, never "safe" (see :data:PROVOCATIVE_HI_HZ). No grade is lower than the
toolbox's former three-level rule gave anywhere (tests/test_photosensitivity_wording.py).
pattern_reversal is not cosmetic. A reversing pattern is presented at
:func:response_freq_hz = 2 * freq_hz, so an 8–12 Hz reversing target is really a
16–24 Hz stimulus and belongs in the peak band. Classifying it on its declared frequency
understates the risk for every contrast-reversing stimulus, which is what this argument exists
to stop.
Source code in src/ssvep/stim/flicker.py
446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 | |
pattern_density ¶
Count the light–dark pairs a checkerboard or grating target draws, the way the renderer draws it.
What is counted. The renderer puts :func:ssvep.stim.patterns.cycles_across cycles across
the target's width: spatial_freq_cpd × width_deg in deg units, and
spatial_freq_cpd itself in norm units (width_deg=None), where the manifest field
already means cycles across the target. A missing spatial_freq_cpd draws 1.0, as the renderer
does. One cycle is one light–dark pair. A grating varies along the width only, so that is its
count. A checkerboard's checks are square, so its height holds height_over_width times as
many pairs — and Harding et al.'s criterion is "in any orientation", so a checkerboard also counts
its diagonal: the count reported is the worst of all three.
The diagonal formula. A checkerboard's same-coloured checks line up corner to corner into
diagonal rows, and across the other diagonal those rows alternate light/dark — but a single
straight 45-degree line can't run further than the board's shorter side before it leaves the
board. With nx = 2 × width_pairs checks across the width and ny = 2 × height_pairs down
the height, such a line crosses at most min(nx, ny) checks in each axis: 2 × min(nx, ny) −
1 checks in total (a staircase, one check across then one down, alternating light/dark each
step), i.e. 2 × min(width_pairs, height_pairs) − 0.5 diagonal pairs. For a square board of
n pairs per side (height_pairs == width_pairs == n) that is 2n − 0.5 — nearly twice
the count along either side (a 4-pair board shows about 7.5). On a non-square board the diagonal
is bounded by the shorter side alone, so it is smaller than width_pairs + height_pairs −
0.5 once the two differ — that sum counts every diagonal row the board has, not what a
single line crossing it actually sees.
The limit. Harding et al. (2005) give five pairs for a pattern that flashes, oscillates or
reverses (modulated) and eight for a steady one. Their consensus also states the criterion
for a pattern subtending more than 0.006 sr, with its lightest stripe above 50 cd/m², shown for at
least 0.5 s. The warning is not conditioned on those: a design rarely records luminance in
cd/m², the targets of a multi-target array add up in area, and an SSVEP trial lasts longer than
0.5 s. So this is the conservative reading.
Source code in src/ssvep/stim/flicker.py
chroma_distance ¶
How far a linear-light RGB colour sits from neutral grey in CIE 1976 u′v′ (0 for grey or black).
is_saturated_red ¶
R/(R+G+B) >= SATURATED_RED_RATIO on linear values, for a colour brighter than black.
uv_separation ¶
The Euclidean distance between two linear-light RGB colours in CIE 1976 u′v′.
None when either is darker than :data:BLACK_LUMINANCE (relative luminance Y < 0.001):
chromaticity is undefined at black and unstable just above it (a rounding error in one channel
of a near-black colour moves it anywhere on the diagram), so a pair with a near-black state has
no separation and never raises the grade on it. Red flashing on black is covered by the
saturated-red factor instead, and any colour on black by the colour-flash warning.
Source code in src/ssvep/stim/flicker.py
chromatic_separation ¶
The widest-separated alternating pair a target shows, or None if no pair has one.
The pairs are the ones :func:colour_involvement tests for red–blue: each (a, b) in
states (:func:flicker_colour_states), and the declared (color, background), so low
contrast cannot pull both states toward grey and switch the factor off. A pair with a state
darker than :data:BLACK_LUMINANCE has no separation (:func:uv_separation).
Source code in src/ssvep/stim/flicker.py
flicker_colour_states ¶
flicker_colour_states(color, background_rgb, *, pattern='solid', pattern_reversal=False, contrast=1.0)
The linear-light colours a target alternates between: one (a, b) pair per kind of location.
Mirrors the renderer (ssvep.stim.renderer._TargetRenderer), whose colour maths is
:func:target_rgb and :mod:ssvep.stim.patterns:
- solid:
background + L·(color − background), withLswinging0.5 ± contrast/2. One pair, the two extremes. - checkerboard/grating, reversing: every check swaps between
colorandbackground.contrastdoes not enter; a reversal is a hard swap between two textures. One pair. - checkerboard/grating, on/off: the whole textured patch is dimmed by
Ltoward black, not toward the background, so thecolorchecks swingL·colorand the background checksL·background. Two pairs.
A sine sweeps every colour between a pair's two extremes, and those in-between colours are mixtures of the two, so the extremes are what a colour test needs.
Source code in src/ssvep/stim/flicker.py
chromatic_flicker ¶
Is this chromatic flicker in the colour-flash range? None if not.
Chromatic means colour is involved in what flickers: some colour among states (the pairs
from :func:flicker_colour_states), or among declared (the target's own color and the
display background, passed as (color, background)), sits more than
:data:CHROMATIC_UV_DISTANCE from neutral grey in CIE 1976 u′v′. So a flickering target is
achromatic only when its colour, its background and everything it shows are black, white or grey.
That is deliberately not "the two states differ in chromaticity". On a black background a coloured target swings between black, which has no chromaticity, and its colour; at part contrast it swings between a dim and a bright version of one colour, the same chromaticity twice. A test on the difference between the two states would therefore pass a saturated red target flashing on black, the case the literature names first. When the two states do differ in chromaticity (red checks reversing with blue ones), at least one of them is a colour, so this test covers that too.
declared is there so that contrast cannot switch the warning off. At low contrast on a
faintly coloured background, the two states can both land near grey (white on a dark red field
at contrast 0.2 swings between two pale pinks) while the field around them stays red. Counting
the declared colours as well is the conservative reading: a target that flickers or reverses in
the band with a colour in its design warns, at any contrast above 0. (A target at contrast 0
that does not reverse is steady, and the caller does not ask.)
The band is :data:COLOUR_FLASH_LO_HZ–:data:COLOUR_FLASH_HI_HZ (Fisher et al. 2026,
Table 4). A reversing target is tested at both f and 2f and is in range if either is, the
same "worse of the two" rule :func:photosensitivity_risk applies: each check alternates once
per cycle, and the pattern reverses twice.
Stronger wording when a colour involved is saturated red (:func:is_saturated_red), and
stronger still when a location alternates between saturated red and a blue-dominant colour (the
pairs in states, and the declared colour against the declared background).
Messages use ASCII u'v', not the prime character: a :class:ValidationReport is printed to
Windows consoles and pipes in cp1252, which has no prime.
Source code in src/ssvep/stim/flicker.py
903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 | |
colour_involvement ¶
Which kind of colour a flickering target involves, at any frequency: None when
everything it shows, its declared colour and its background are black, white or grey; otherwise
"chromatic", "saturated red" or "red–blue".
The frequency-independent half of :func:chromatic_flicker, with the same tests on the same
colours (states from :func:flicker_colour_states, declared as (color, background))
and the same three kinds, so that inside Fisher et al.'s band the two can never disagree about a
target. It exists for a limit that is not Fisher et al.'s band: a site's approval envelope can
confine colour to a band of its own, or rule out saturated red at every frequency (#161), and
that check must classify colour exactly as the warning does.
Source code in src/ssvep/stim/flicker.py
sine_luminance ¶
Sinusoidal luminance in [0,1]: mean + (depth/2)·sin(2π(f·t + phase)). Vectorised.
Source code in src/ssvep/stim/flicker.py
square_luminance ¶
Two-level square luminance: high while within the duty fraction of the cycle.
This is the ideal square wave at exactly freq_hz, evaluated at t — no frequency rounding.
A frame exactly on an edge takes the new state (:data:SQUARE_EDGE_EPS_CYCLES), which is what
makes a whole number of frames/cycle genuinely jitter-free (:func:is_frame_exact).
Source code in src/ssvep/stim/flicker.py
gamma_encode ¶
Map linear luminance [0,1] → display pixel value [0,1] using the inverse gamma.
Displays are ~gamma-2.2; to make perceived/linear luminance sinusoidal we raise to
1/gamma. Set gamma=1.0 if the monitor/LUT is already linearised.
Source code in src/ssvep/stim/flicker.py
frame_luminance_sequence ¶
frame_luminance_sequence(freq_hz, refresh_hz, n_frames, *, kind='sine', phase=0.0, depth=1.0, mean=0.5, duty=0.5, gamma=1.0)
Per-frame luminance the renderer will show for n_frames (t = frame / refresh).
Returns a (n_frames,) array. This is exactly what the pyglet renderer samples, so a
spectral test on it verifies the timing-critical output. gamma defaults to 1.0 (raw
linear luminance) so tests see the pure waveform; the renderer passes the display gamma.
Source code in src/ssvep/stim/flicker.py
luminance_at_frame ¶
Scalar luminance in [0,1] for a manifest flicker block at a given frame.
Honours type (sine/square), freq_hz, phase, contrast (=modulation depth
about mean 0.5) and duty (square only). This is the single per-frame luminance the
renderer samples for a solid target — kept here so the contrast/duty maths is unit-tested
without a display. t = frame / refresh: a pure function of the frame index, which the
renderer sets to the refresh the frame is shown at, counted on the flip clock from stim onset
(#544, :class:ssvep.runtime.presentation.RefreshIndex), not to the count of frames drawn.
Source code in src/ssvep/stim/flicker.py
target_rgb ¶
Combine a scalar luminance with a base colour → an 8-bit gamma-encoded (r,g,b).
The flicker modulates each channel linearly (in linear-light) between the background
(luminance=0) and the target color (luminance=1), then gamma-encodes to pixels::
rgb_linear = background + luminance · (color − background)
pixel = round(gamma_encode(rgb_linear) · 255)
So a white target on a black background reproduces the historic grayscale flicker, while a
coloured target flickers between the background and that colour. color may be RGB or
RGBA (alpha ignored). Pure + unit-tested; the renderer is a thin wrapper over this.
Source code in src/ssvep/stim/flicker.py
is_dropped_frame ¶
True if an inter-frame interval overshot the expected period by more than tol.
dt beyond (1+tol)/refresh means at least one refresh was missed — the renderer
logs these (an SSVEP killer if frequent).
Source code in src/ssvep/stim/flicker.py
refresh_suspect ¶
Why a refresh measurement should not be trusted as the panel's rate, or None.
Two ways a measurement can look precise and be wrong, both caught against the mode the driver advertises (which is also what the renderer clocks on). Either way, the renderer would not achieve the frame clock the design was validated at, and the value must not be recorded as the panel's rate.
- mode — the fitted refresh itself is far from the advertised mode: the swaps ran on some other clock, because the window was composited and clamped to another monitor's refresh (a 240 Hz secondary once measured 60), or because the driver's claim is wrong.
- throttled — the swaps sat on the advertised grid but skipped refreshes: completed swaps per second are well below the mode (not fullscreen, or a loop rendering at half rate). This is the case a median-seeded estimate used to lock onto as a sub-harmonic and report as 119.99 Hz with a ppm-level SE and no drops.
Source code in src/ssvep/stim/flicker.py
estimate_refresh ¶
Estimate a display's refresh from the timestamps of consecutive vsync'd buffer swaps.
Frame count over elapsed time, not a median of per-frame rates. Each flip is assigned the
whole number of refresh periods since the first one (so a missed refresh counts as the two
periods it spans, rather than dragging a mean down), and the rate is the least-squares slope of
that count against time over the whole window. Per-frame timestamp jitter then only enters
through how well the line is pinned down, which improves with window length; a median of
per-frame rates improves only as 1/sqrt(n), and read 2100 ppm off on the lab rig (#235).
Fitting rather than reading the two endpoints means a single late timestamp at either end cannot
move the estimate. This is the same argument the flik-o-meter guide makes for fitting the
photodiode timebase over a long window: latency shifts the intercept, not the slope.
expected_hz (the advertised mode rate, when known) seeds the unwrap. Without it the
provisional period is the median interval, and when half or more of the intervals span two
refreshes — a composited window clamped to half rate, or a loop that misses every other vsync —
the median IS two periods, the whole window unwraps onto a sub-harmonic grid, and the result is
refresh/2 with a ppm-level SE and dropped == 0: precise, and wrong. With the hint, when the
intervals lie on whole multiples of the expected period, that period seeds the unwrap instead,
the skipped refreshes are counted as drops, and flip_hz shows the swaps that actually
happened; :func:refresh_suspect turns that into a flag. When the intervals do not lie on the
expected grid (the driver's claim is simply wrong), the median seeds as before, so a panel
really running some other rate is measured at that rate rather than forced onto the claim.
Returns a :class:RefreshEstimate. se_hz is a conservative bound — the standard error the
plain endpoint count would have, hz * sqrt(2) * sigma / duration, from the timestamps'
scatter about the fitted grid; the fit does no worse. It cannot see latency that drifts
uniformly across the window: a linear latency ramp is indistinguishable, from timestamps alone,
from a slightly different refresh. It does check drift that is NOT uniform (a cold start that
settles, a queue that fills): the two halves of the window are fitted separately, and if their
rates differ by more than :data:_DRIFT_SIGMAS pooled SEs, se_hz is widened to at least that
difference and drift is set. jitter_ms is the spread of single intervals, reported so it
is never mistaken for the uncertainty of hz — the old std_hz read as a +- on the
estimate and was not one.
Raises ValueError for fewer than 3 timestamps or any that are not strictly increasing.
Source code in src/ssvep/stim/flicker.py
1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 | |
Run builder¶
ssvep.stim.builder ¶
Run builder data-model: configure one SSVEP run and emit its manifest.
This is the UI-agnostic core of the stimulus builder (the PySide6 Design GUI will drive these
objects). It models targets, timing, conditions, acquisition, and markers; validates the
design at build time (renderability + photosensitivity — DESIGN_PRINCIPLES #4, COMPLIANCE R6);
and serialises to a schema-valid run manifest — the single source of truth analysis reads. The
central model is :class:RunSpec (one run). The session level — an ordered set of runs driven
by a protocol — is :mod:ssvep.runtime.session (#43); a session-protocol references the run
manifests a RunSpec emits.
ImageSpec
dataclass
¶
A picture filling a target — manifest stimuli[].image (v1.4, #256).
path and fit are the designer's choices; everything else is derived from the file and
recorded so the stimulus stays describable, and its identity fingerprinted, when the file is not
present (AGENTS.md §2.2). Build one with :func:describe_image rather than by hand, and again
whenever the run's background or gamma changes: :meth:RunSpec.validate re-derives the numbers
from the file and refuses a spec whose recorded ones no longer match it.
Source code in src/ssvep/stim/builder.py
Timing
dataclass
¶
Per-trial timing. stim_s is the usable (decodable) stimulation window.
onset_offset_s is a visual-latency lead-in that is added on top of stim_s: the
target flickers for stim_s + onset_offset_s and analysis discards the lead-in, so the
operator's "4 s stimulation" yields a full 4 s of usable steady-state data (flickering 4.14 s).
See :data:ssvep.DEFAULT_ONSET_OFFSET_S for why.
Source code in src/ssvep/stim/builder.py
presented_stim_s
property
¶
What actually flickers per trial: usable window + the discarded visual-latency lead-in.
MontageChannel
dataclass
¶
Source code in src/ssvep/stim/builder.py
position_pct
class-attribute
instance-attribute
¶
Head-relative 10-10 position (lateral_pct, height_pct), when the montage is defined
that way rather than in centimetres (see :func:occipital_ssvep_montage).
lateral_pct is the distance from the midline as a percentage of the left→right
preauricular arc, signed left(-)/right(+); height_pct is the distance up from the
inion as a percentage of the nasion→inion arc. Both are the 10-10 system's own units,
so they are head-size independent — position_2d is the same point in cm on the
:data:REFERENCE_HEAD_CIRCUMFERENCE_CM reference head, and is what the discrete channel-array
plots use. Fixed-geometry carriers (the Unicorn, the actiCAPs) leave this None: their
electrodes are moulded or held in a cap, not measured onto the scalp.
Run
dataclass
¶
Run-identity block (manifest run, was experiment pre-1.3). Names one run + its BIDS task.
Source code in src/ssvep/stim/builder.py
RunSpec
dataclass
¶
One run's design — emits a run manifest via :meth:to_manifest (was Protocol pre-#43).
A run is one continuous period of data collection (AGENTS.md §2.1). A session composes an
ordered set of these by reference; see :mod:ssvep.runtime.session.
Source code in src/ssvep/stim/builder.py
286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 | |
is_resting
property
¶
True for the tone-cued eyes-open/eyes-closed resting paradigm (no flicker at all).
is_fpvs
property
¶
True for fast periodic visual stimulation (#494): a stream of pictures, one per cycle.
uses_early_stop
property
¶
True when the run declares live early stopping (design.early_stop, #23).
is_free_choice
property
¶
True when the participant chooses each trial's target (cue_mode == "free").
shows_feedback
property
¶
True when the run shows the participant feedback between trials (mode not none).
frame_clock_hz ¶
The refresh all frame arithmetic is done at: display.refresh_hz, never the measured rate.
The rule. Frames per cycle, frame-exactness (on/off and reversal jitter), sine sampling
density and renderability are all computed at the rate the renderer's frame clock runs at,
t = frame / refresh. The renderer takes that from the display's advertised mode rate (an
integer; FlickerRenderer.__init__) and falls back to display.refresh_hz; on the rig a
design is for, the two are the same number, and the renderer says so on stderr when they are
not. display.measured_refresh_hz does not enter: a panel measuring 239.978 Hz still shows
frame N of a 240-clock waveform on its N-th vsync, so it changes how long each frame lasts
in wall-clock time, and no frame count. Validating at the measured rate made every exact
divisor look fractional (240/10 = 24 frames became 23.999) and flagged every reversal target
as jittering, which is why fixing the refresh measurement (#235) needed this rule (#230).
What the measured rate IS used for: :meth:validate checks it describes the same display
mode as refresh_hz (:data:REFRESH_MODE_TOL), and it is the panel's physical rate for
provenance (:meth:effective_refresh_hz, BIDS ScreenRefreshRate).
Source code in src/ssvep/stim/builder.py
schema_version ¶
The manifest version this run is — the highest feature in use, never lower than declared.
Derived, not stored, for the reason :data:MANIFEST_VERSION_IMAGES gives: a run with no image
target emits :attr:manifest_version unchanged, so building or re-saving an existing design
does not bump its version, or its fingerprint, just because this build can do more.
Source code in src/ssvep/stim/builder.py
effective_refresh_hz ¶
The panel's best-known physical rate: the MEASURED rate if present, else the nominal one.
For reporting what the panel does (the Design tab's rig readout; BIDS takes the same
preference). Not for frame arithmetic — that is :meth:frame_clock_hz.
Source code in src/ssvep/stim/builder.py
validate ¶
Design-time checks: renderability (errors) + fidelity/photosensitivity (warnings).
Frame arithmetic is judged at :meth:frame_clock_hz (display.refresh_hz); a measured
refresh is checked for agreement with it, not substituted for it. A resting protocol has no
flicker, so the per-target renderability/photosensitivity checks don't apply and are replaced
by block-design checks (see :meth:_validate_resting).
Source code in src/ssvep/stim/builder.py
424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 | |
to_manifest ¶
Serialise to a manifest dict conforming to run_manifest.schema.json.
The result shares no mutable structure with the spec (#499): nested blocks (design.fpvs
with its realised sequence, condition params, factors level lists, early_stop,
acquisition...) are deep-copied once at the end, so a caller that edits the manifest
cannot change the spec or any later to_manifest().
Source code in src/ssvep/stim/builder.py
1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 | |
save ¶
Validate (raise on any error) and write the manifest to path.
Source code in src/ssvep/stim/builder.py
TargetFlickerGrade
dataclass
¶
What :func:manifest_photosensitivity says about one target of an as-run manifest.
Source code in src/ssvep/stim/builder.py
UnknownHeadsetError ¶
Bases: ValueError
A carrier name that is not built in. Raised rather than substituting a default montage: a recording set up on the wrong wiring cannot be un-made.
describe_image ¶
The :class:ImageSpec for the picture at path (relative to asset_dir), on display.
Everything but path and fit is measured from the file (:func:ssvep.stim.images.describe)
on display's background and gamma — so describe it again after changing either;
:meth:RunSpec.validate refuses recorded numbers the file no longer produces. path must
already be in manifest form (relative, /-separated); raises
:class:~ssvep.stim.images.ImageAssetError if it is not, and :class:FileNotFoundError if
asset_dir does not have it.
Source code in src/ssvep/stim/builder.py
target_height_over_width ¶
A target's on-screen height ÷ width, which scales a checkerboard's count down its height.
From the declared resolution and geometry when they are set; in deg units the tangent projection alone gives it (the pixel density cancels). In norm units with no declared resolution the on-screen ratio depends on the panel's aspect, which is unknown, so this takes the size ratio itself: what a square panel would draw, and an upper bound on what any landscape panel draws (its height spans fewer pixels than its width), so the count is never lower than on screen. 1.0 when the size is unknown too.
Module-level, and reading only t.position_xy and t.size and the display's units,
resolution_px, viewing_distance_cm and screen_diagonal_cm, so that the Harding
warning (:meth:RunSpec.validate) and a site's approval envelope (:mod:ssvep.runtime.approval,
which reads a manifest rather than a :class:RunSpec) count a checkerboard with one function.
Source code in src/ssvep/stim/builder.py
acquisition_to_dict ¶
Serialise an :class:Acquisition to the manifest acquisition block (schema-conformant).
Pulled out of :meth:RunSpec.to_manifest so it has exactly one implementation: a run built on
Build run goes through it, and so does the Build Protocol "Set acquisition for all runs…" action
(#121), which writes this same shape straight into every embedded run's manifest.
Source code in src/ssvep/stim/builder.py
target_photosensitivity ¶
One target's graded photosensitivity risk (:func:flicker.photosensitivity_risk, #332).
The one place a target is graded: :meth:RunSpec.validate warns with it, and
:func:manifest_photosensitivity stamps a recording with it (#286 P5), so the builder's warning
and the recording's stamp cannot disagree about a design. array_area_fraction is
:func:_array_area_fraction over every target of the run. A reversal is rated on its flag,
whether or not the renderer draws one (a precaution, never a lower rating).
Source code in src/ssvep/stim/builder.py
display_from_manifest ¶
The manifest's display block as a :class:Display, with no builder defaults.
A key the manifest leaves out stays unset (None), never the lab rig's default geometry: a
grade taken from the manifest must be the grade of what the manifest says, so an undeclared
viewing distance is unknown here exactly as it is to the renderer. units and gamma
default as the renderer defaults them (norm, 2.2), and the background as the approval
envelope reads it (black).
Source code in src/ssvep/stim/builder.py
target_from_manifest ¶
One manifest stimuli[] entry as a :class:Target, for grading (the picture is not read).
Raises KeyError/TypeError/ValueError on an entry that does not say what it flickers
at: the caller decides what an ungradable target means (it never means a lower grade).
Source code in src/ssvep/stim/builder.py
manifest_photosensitivity ¶
Grade every target of a run manifest as :meth:RunSpec.validate grades it (#286 P5, D13).
For a recording, manifest is the as-run one its sidecar embeds, so the grade is of what
the as-run manifest declares was presented. A resting run presents no flicker and grades nothing ([]). A legacy manifest is
canonicalized first. Raises (KeyError/TypeError/ValueError) when a target cannot be
read; the caller treats that as ungradable, never as a lower grade.
Source code in src/ssvep/stim/builder.py
grid_positions ¶
Row-major normalised (x, y) centres for an n_cols × n_rows grid in [-spread, spread].
Source code in src/ssvep/stim/builder.py
montage_pct_to_cm ¶
montage_pct_to_cm(lateral_pct, height_pct, nasion_inion_cm=REFERENCE_NASION_INION_ARC_CM, preauricular_cm=REFERENCE_PREAURICULAR_ARC_CM)
A 10-10 percentage position as tape-measure centimetres on a head of the given arc lengths.
Returns (x_cm, y_cm) — x left(-)/right(+) of the midline, y up from the inion — which
is exactly what the operator measures: up the midline first, then out from that point. Defaults
are the :data:REFERENCE_HEAD_CIRCUMFERENCE_CM head the montage was originally drawn on.
Source code in src/ssvep/stim/builder.py
occipital_ssvep_montage ¶
Free electrode occipital SSVEP montage — the 8-channel occipital array for the OpenBCI Cyton with free (unmounted) electrodes.
The montage is defined in 10-10 units (:data:OCCIPITAL_SSVEP_MONTAGE_PCT) so it transfers
across head sizes; position_pct carries that definition and position_2d is the same point
in cm on the reference head — x left(-)/right(+) of the midline, y up from the inion,
so the impedance map renders nose-up with the participant's left on the left.
E1..E8 are the Cyton's 8 EEG channels, labelled by their wire colour
(:data:CYTON_CHANNEL_WIRE_COLOURS) because on a free-electrode array the colour is the only
thing telling one electrode from another. Reference and ground are not EEG channels and so do not
appear here: white/SRB goes to the left earlobe and black/BIAS to the right
(:data:OCCIPITAL_SSVEP_REFERENCE, :data:OCCIPITAL_SSVEP_GROUND).
Source code in src/ssvep/stim/builder.py
occipital_1010_montage ¶
Occipital 10-10 montage — P1 PO3 O1 POz Oz P2 PO4 O2 on an OpenBCI Cyton, in board
order 1..8, with the same ear reference and ground as :func:occipital_ssvep_montage.
Unlike that montage, this one is defined by its site names: "PO3" already says exactly where the
electrode goes, in a system every EEG lab and every cap already implements — so there is no
position_pct to record, and nothing here needs a reference head. The gain is comparability
(results line up with the SSVEP literature, and the channels mean something to a reader who has
never seen this toolbox) and the ability to place it from a cap; the cost is less density right
over the occipital pole, because the array has to land on sites the 10-10 grid happens to provide.
label is the 10-10 site — the electrode's identity here — but the wire colours are the same
Cyton ribbon order (:data:CYTON_CHANNEL_WIRE_COLOURS), so channel 3 is still the blue lead.
position_2d reuses :func:acticap_64ch_montage's coordinates for every site, so this montage
and the 64-channel cap plot identically rather than drifting apart in two hand-typed tables.
Source code in src/ssvep/stim/builder.py
electrocap_8ch_montage ¶
Electro-Cap 8-ch (10-20) — Cz Pz P7 P3 P4 P8 O1 O2 on an OpenBCI Cyton, in board
order 1..8, with the same ear reference and ground as :func:occipital_ssvep_montage.
Defined by its site names, exactly like :func:occipital_1010_montage and for the same reason —
a site name is its own definition, so there is no position_pct to record and no reference
head to assume. position_2d is likewise reused from :func:acticap_64ch_montage so all three
capped montages plot identically.
P7/P8 are the sites older caps silkscreen T5/T6
(:data:ELECTROCAP_8CH_LEGACY_NAMES); the modern names are what gets recorded. Wire colours are
the usual Cyton ribbon order (:data:CYTON_CHANNEL_WIRE_COLOURS).
This is a wider montage than the toolbox's other 8-channel carriers: only O1/O2 are in
the validated occipital-8 (:data:ssvep.spatial.OCCIPITAL_PRIORITY's first entries), and
Cz/P7/P8 are not on that list at all. So it is the right pick when the run wants
parietal and temporal coverage too, and the wrong one when every channel should be buying SSVEP
(see :data:OCCIPITAL_1010_HEADSET). docs/MONTAGE_ELECTROCAP.md §1 is the trade.
Source code in src/ssvep/stim/builder.py
unicorn_hybrid_black_montage ¶
The g.tec Unicorn Hybrid Black's fixed 8-channel montage — Fz C3 Cz C4 Pz PO7 Oz PO8
(standard 10-20 positions) in the device's channel order 1..8 (see the Unicorn integration
guide, validated on hardware 2026-07-07).
Unlike the OpenBCI carriers, the Unicorn's electrodes are moulded into the headset, so this montage
is fixed (not editable). Positions are an approximate 2D scalp projection with the same convention
as :func:occipital_ssvep_montage — x = left(-)/right(+) of the midline, y =
anterior(+)/posterior(-), Cz at the origin — so the impedance map renders nose-up. label is the
electrode's 10-20 name (the Unicorn labels channels by site, not by wire colour).
Source code in src/ssvep/stim/builder.py
acticap_64ch_montage ¶
Brain Products actiCAP 64Ch Standard-2 layout, in the actiCHamp's channel order 1..64.
Positions are an approximate 2D scalp projection in the same convention as the other montages —
x = left(-)/right(+) of the midline, y = anterior(+)/posterior(-), Cz at the origin, in cm
on a ~9.5 cm scalp disc — so the impedance map renders nose-up with the correct hemispheres. Labels
are the 10-10 site names; channel is the amplifier channel index. Schematic (recognisable), not
digitised positions — a proper digitiser layout can replace this later. (Ground/reference are the
actiCHamp's dedicated GND/REF pins, not EEG channels, so they're not listed.)
Source code in src/ssvep/stim/builder.py
acticap_32ch_posterior_montage ¶
A 32-channel posterior actiCAP layout for the actiCHamp — every 10-10 site at or behind the TP/CP row, plus FCz and Cz as midline anchors. Not a stock actiCAP montage: the electrodes are plugged into the posterior holders of the 64-holder cap, concentrating coverage over visual cortex.
Channel order is the amplifier's, as wired at the cap: ch1 FCz, ch2 Cz, then complete rows sweeping left→right, rows running anterior→posterior. PO9/PO10 belong to the O row (they sit at occipital height), not the PO row. That fills 1..32 exactly — the 64Ch Standard-2 cap has precisely 30 holders from the TP/CP row back, and 30 + FCz + Cz = 32.
Positions reuse :func:acticap_64ch_montage's coordinates for every shared site, so the two
montages plot identically; FCz is new (midline at the FC row's y). Same convention as the
other montages — x = left(-)/right(+), y = anterior(+)/posterior(-), Cz at the origin, cm.
The actiCHamp is reference-free, so this montage decodes with a common-average reference and an
occipital ROI automatically (32 > :data:ssvep.spatial.BIG_MONTAGE); it carries the whole validated
occ-8 (O1 Oz O2 POz PO3 PO4 PO7 PO8), so no position-based fallback is needed. ⚠️ The amp still
streams 64 channels with only module 1 populated (33-64 rail at +full-scale) — the LSL bridge
truncates to this montage's length; see :func:ssvep.runtime.actichamp.stream_to_lsl.
Source code in src/ssvep/stim/builder.py
device_for_board ¶
Reverse-lookup a device display name from a BrainFlow board id (for loading manifests).
device_electrode_types ¶
Electrode types this device can use, or None if it is not constrained.
A device whose electrodes are fixed in hardware (the actiCHamp's actiCAP, the Unicorn's moulded-in dry contacts) implies its electrode type; a flexible OpenBCI board does not.
Source code in src/ssvep/stim/builder.py
device_preps ¶
Skin/electrode preps this device allows, or None if it is not constrained.
device_headsets ¶
The electrode carriers this device can wear, or None if it accepts any of them.
None = the OpenBCI boards, which take any of the interchangeable OpenBCI carriers. A list =
the device constrains the choice: one entry means the montage is fixed in hardware (the Unicorn's
moulded-in electrodes) and the Design tab locks it; several means the operator must pick which cap
is on the participant (the actiCHamp's 64-ch vs 32-ch posterior actiCAP, or a saved one).
Source code in src/ssvep/stim/builder.py
device_fixed_headset ¶
The carrier for a device whose montage is fixed in hardware, else None.
Only devices with exactly one allowed carrier are "fixed" — a device offering a choice (the
actiCHamp) returns None here. Use :func:device_headsets to restrict a pick-list.
Source code in src/ssvep/stim/builder.py
device_takes_user_carriers ¶
Whether the Design tab also offers a lab's saved carriers (montage editor) for this device.
True for a device whose stock layouts are only some of what can be worn on it (the
actiCHamp's caps); the OpenBCI boards already take every carrier, and a moulded-in device takes
none.
Source code in src/ssvep/stim/builder.py
canonical_headset ¶
The current name for a carrier, mapping any pre-rename alias forward. Unknown names pass through unchanged — an unrecognised carrier is data to preserve, not an error to raise.
Source code in src/ssvep/stim/builder.py
headset_electrode_type ¶
The electrode kind implied by a carrier, or None for a carrier we don't recognise.
The single place the derivation lives, so the Design tab, the protocol-level acquisition dialog and the per-session override all record the same string for the same headset (#147).
Source code in src/ssvep/stim/builder.py
headset_reference_ground ¶
(reference, ground) implied by a carrier, or (None, None) when it implies neither.
Source code in src/ssvep/stim/builder.py
board_reference_ground ¶
(reference, ground) fixed by the amplifier, or (None, None) if it fixes neither.
Deliberately a fallback behind :func:headset_reference_ground: where the carrier states a
scalp site, that is the better answer, and this only fills the gap the carrier leaves.
Source code in src/ssvep/stim/builder.py
unknown_headset_message ¶
The one sentence every refusal of a non-built-in carrier name uses.
Source code in src/ssvep/stim/builder.py
headset_montage ¶
The montage of a built-in carrier. Raises :class:UnknownHeadsetError for any other name,
never a default: a lab's own carrier comes from its saved file or the manifest's own copy.
Source code in src/ssvep/stim/builder.py
example_protocol ¶
The worked 9-target, 36–44 Hz protocol (à la schemas/manifest_example.json), on the go-forward OpenBCI Cyton 8-ch occipital montage. Mirrors the pilot's operating band.
Source code in src/ssvep/stim/builder.py
jfpm_phase_cycles ¶
JFPM phase schedule for n targets, returned in cycles (the manifest unit).
Joint frequency–phase modulation (Chen et al. 2015) assigns target k the phase
k · step radians (canonical step 0.35π). The manifest stores phase in cycles, so this
converts: phase_cycles = (k · step_rad) / (2π) (wrapped to [0,1)).
Source code in src/ssvep/stim/builder.py
preset_high_freq_9class ¶
Preset: the worked high-frequency 9-class protocol (alias of :func:example_protocol).
preset_alpha_9class ¶
Preset: the 9-target grid of :func:example_protocol, but flickering in the alpha band
(8–12 Hz, 0.5 Hz steps) instead of 36–44 Hz.
Purpose: a fair test for the dry/prototype headsets. The high-frequency (36–44 Hz) response is tiny, and on 2026-07-14 both dry systems tested decoded that band at chance; alpha-band SSVEP is an order of magnitude larger, so it separates "electrode can't reach the cortex" from "the response is just small up here". Same layout/timing/montage as the high-freq preset so the only changed factor is stimulation frequency.
⚠️ Two caveats, both surfaced by validate() / worth stating to the operator:
* Photosensitivity (COMPLIANCE R6). 8–12 Hz sits inside the 3–60 Hz band the
photosensitivity check grades (Fisher et al. 2026), straddling the start of its 10–25 Hz peak,
so validate() grades it high (elevated below 10 Hz only when the display declares a
luminance below 20 cd/m2; undeclared, 20 or more is presumed). This is a template to
adapt, not to run as-is; where an ethics approval governs the study, whether it covers
this band is that approval's question (AGENTS.md §4).
* Endogenous-alpha confound. These frequencies overlap the participant's own ~10 Hz alpha
rhythm (strongest eyes-open, occipital), which can inflate or contaminate the SSVEP — the
opposite trade-off from the high-freq band. Interpret decode accuracy with that in mind.
Source code in src/ssvep/stim/builder.py
preset_jfpm_speller ¶
Preset: a Chen et al. 2015-style JFPM speller (default 40 targets, 8–15.8 Hz, 0.2 Hz steps).
Frequencies step by df from f0; phases follow the JFPM schedule. NOTE: these low
frequencies fall in the 3–60 Hz band the photosensitivity check grades, and most of them in
its 10–25 Hz peak, so validate() will warn (high; elevated below 10 Hz only when the display
declares a luminance below 20 cd/m2).
Provided as a canonical template to adapt, not to run as-is.
Source code in src/ssvep/stim/builder.py
preset_checkerboard_reversal ¶
Preset: a 4-target contrast-reversing checkerboard example at 30–36 Hz declared.
Its 60–72 Hz reversal rate is at or above the top of the 3–60 Hz band the photosensitivity
check grades, but its 30–36 Hz local alternation is inside it, so validate() grades it
moderate, one step up (elevated) while the display's luminance is undeclared and so presumed
20 cd/m2 or more: lower risk than below 30 Hz, with {risk_remains}
(flicker.ELEVATED_HI_HZ).
Pattern-reversal (the two phases swap at the flicker frequency) with no net luminance change — the classic pattern-reversal SSVEP/VEP stimulus. Uses degrees-of-visual-angle geometry.
Source code in src/ssvep/stim/builder.py
preset_image_keys ¶
Preset: four generated arrow-key pictures as image targets (manifest v1.4, #256/#259).
Demonstrates a picture target end-to-end without shipping any source image: each key is drawn
in code (:func:_generate_key_glyph) into a fixed scratch directory
(:func:_image_keys_bundle_dir), regenerated deterministically on every load, and described
(:func:describe_image) against this preset's own display block. A template to adapt — swap in
real key art at the same size/fit — not a design to record a participant under.
Source code in src/ssvep/stim/builder.py
preset_resting_state ¶
Preset: the tone-cued eyes-open / eyes-closed resting run — the odd duck with no flicker.
Resting is a paradigm, not a stimulus layout, so it carries paradigm='resting' and an empty
stimuli list; its structure lives in design.resting (the manifest's authoritative record of
it). Everything else about a protocol still applies unchanged — the device/montage/sampling rate
chosen on the Design tab, the operator/consent/subject captured at Setup, the impedance check —
which is the whole reason it's expressed as a manifest rather than a special-case dialog: it locks,
records, and saves through exactly the same path as an SSVEP run.
Two jobs (see ssvep.stim.resting / ssvep.analysis.resting_paf):
* PAF calibration — the participant's individual peak alpha frequency, so SSVEP designs can
steer flicker away from PAF and its harmonics.
* Negative control + posterior QC — a well-behaved decoder must sit at chance on data with no
flicker in it, and eyes-closed alpha should exceed eyes-open (reactivity ratio > 1). A ratio
near 1 condemns the posterior montage before an SSVEP run is wasted on it (a lesson from
early dry-headset bench testing).
Literature defaults (Klimesch 1999; Barry et al. 2007): 6 × 60 s alternating from eyes-open → 3 min eyes-closed. Grey background + central fixation cross during eyes-open blocks.
⚠️ It adds two things an approval written for visual flicker alone may not cover: a resting recording and auditory stimulation (the cue tones). Where an ethics approval governs collection, check that it covers both before recording anyone (AGENTS.md §4).
Source code in src/ssvep/stim/builder.py
fpvs_run ¶
fpvs_run(fpvs_block, *, name, display, task=fpvs_mod.TASK, description=None, acquisition=None, asset_dir=None)
An FPVS run (#494) around a design.fpvs block (:func:ssvep.stim.fpvs.make_fpvs_block).
Declares what the rest of the manifest has to say about the stream, derived from the block so
it cannot disagree with it: the single stream stimulus the flicker-risk grading reads
(:func:ssvep.stim.fpvs.stream_stimulus, the stream's worst case), one condition at the base
rate, one "trial" per sequence (stim_s = the analysed sequence, the fade-in as the lead-in
analysis discards, the fade-out and gap as the inter-trial interval), and the FPVS marker
segments. Not on the Design tab's preset list: an FPVS design is a pair of item lists, which
the Design tab's FPVS page edits (:class:ssvep.ui.fpvs_editor.FpvsEditor) through this same
function; scripts build one directly (scripts/build_fpvs_reading_demo.py is the worked
example) and the saved file is added to a protocol on Build Protocol as usual.
Source code in src/ssvep/stim/builder.py
Image stimuli¶
ssvep.stim.images ¶
Image stimuli — decode, pin, fit, composite and describe a picture target (pure; no GL).
A target can be filled with a picture (manifest pattern: "image" plus a per-stimulus
image block, v1.4). This module is the headless half of that feature, in the same division of
labour :mod:ssvep.stim.patterns has against :mod:ssvep.stim.renderer: everything that can be
decided without a display — reading the file, checking it is the file the manifest pinned, fitting
it to the target's exact pixel size, flattening its transparency onto the run's background, and
measuring what it emits — lives here as plain functions of arrays, so it is unit-testable. The
renderer only uploads the finished array.
It is also the first time a manifest refers to a file outside itself. A tone is synthesised and a
checkerboard is generated from numbers; a picture is an asset, so its identity has to be
declared in the manifest (sha256) for the run's fingerprint to cover what was shown, and its
light has to be described there (mean_luminance / rms_contrast) for the stimulus to be
describable when the file is not present (AGENTS.md §2.2). :func:describe produces those numbers.
Image files are stimulus assets, never participant data.
A freshly bundled picture is also re-encoded, not stored as its original bytes
(:func:clean_png_bytes, #355): a photograph a phone or camera wrote almost always carries EXIF
and often an embedded colour profile, both of which :func:ssvep.io.export.picture_metadata refuses
outright, so bundling the original bytes would pin a picture export could never ship. Re-encoding to
a clean PNG — same pixels, mode and alpha, EXIF orientation applied first, no pnginfo, no
icc_profile, no EXIF — and pinning the clean bytes' own hash is what :mod:ssvep.ui.design does
at the moment it stages a NEW pick into a design's asset bundle; an already-bundled picture's pin is
a durable contract and is never rewritten.
Conventions
- Arrays are
(H, W, C)uint8 with row 0 at the top of the picture — the orientation :mod:~ssvep.stim.patternsuses andrenderer._image_to_textureexpects. :func:load_imageand :func:resample_toalways returnC = 4(RGBA, straight — not premultiplied — alpha); :func:composite_overreturnsC = 3. - Sizes are width-first
(w, h), as everywhere in the manifest (resolution_px,size,source_px); array shapes are height-first, as numpy's are. - Pixel values are display-encoded. Linear light is
(v / 255) ** gammawith the run'sdisplay.gamma— the same power law :func:ssvep.stim.flicker.gamma_encodeinverts. Embedded ICC profiles are not applied: the toolbox is not colour-managed, and the manifest's gamma is its whole display model. - Luminance is Rec. 709 relative luminance of the linearised channels
(:data:
REC709_LUMA), so a white pixel is 1.0 and the numbers compare directly with a solid target's. That is the standard observer on sRGB-primaried panels, not a photometer reading.
ImageAssetError ¶
Bases: ValueError
An image asset cannot be used as declared: an illegal path, a pin mismatch, or a file that is
not a single, decodable picture. A missing file raises :class:FileNotFoundError instead.
check_asset_path ¶
Why path is not a legal manifest image.path, or None if it is.
The contract: relative to the manifest's asset bundle, / separators on every platform, and
nothing that could resolve outside the bundle, mean two different files on two machines, or look
like one path while being another. So:
- no absolute path, no drive letter, no backslash, no
./..segment, no empty segment — an absolute path in a manifest is a path on one person's disk, and a protocol that embeds its runs so nothing outside the file can break them (#115) is broken again by exactly that; - no
:anywhere (a drive, or an NTFS alternate data stream such asa.png::$DATA); - no segment ending in
.or a space (Windows strips them, soa.png.isa.pngthere and is not anywhere else); - no Windows device name as a segment's base name, whatever its case or extension
(
CON,nul.png,Com1.tar.gz…); - no invisible character — controls, format characters, surrogates, private-use or unassigned code points, line/paragraph separators, or any space but U+0020;
- and the string must already be NFC-normalised, so one name has one spelling.
schemas/run_manifest.schema.json states the same rule as a pattern for the field, less
what a regular expression cannot say — Unicode normalisation, and character categories beyond
the Basic Multilingual Plane's known controls, format characters and spaces. So the schema is a
necessary condition and this is the whole rule; a test checks that the two agree everywhere
else, code point by code point.
What no path rule can refuse is two names that differ only in case (ICON.PNG / icon.png):
one file on Windows and macOS, two on Linux. The sha256 pin is what makes that harmless — if
the wrong one of two is found, it does not hash to the pin and is refused.
Source code in src/ssvep/stim/images.py
resolve_asset ¶
The file manifest image path names — the first of roots that has it — as the fully
resolved path that was checked.
The path is checked (:func:check_asset_path) before anything touches the disk, so a path
that would escape the bundle is refused rather than looked up. Raises :class:ImageAssetError
for an illegal path and :class:FileNotFoundError, naming every root tried, when none has the
file. There is deliberately no fallback to the current directory — and so every root must be
absolute (a relative root is the current directory, one step removed): a path resolved
against the working directory is how this GUI has repeatedly read from the conda env's
Scripts folder. A match that a symlink or junction carries outside its root is refused too,
so "inside the bundle" means inside it on disk, not just in spelling. Which roots to search,
and in what order, is the asset lifecycle's (#258); this only walks the list it is given.
Source code in src/ssvep/stim/images.py
bundle_dir ¶
The asset bundle beside a manifest file: <manifest-stem>.assets/, as an absolute path.
The layout the asset lifecycle states (#258): a run manifest's bundle, and a protocol's own
bundle for every run it embeds. Build run uses it to find a loaded design's pictures; copying
pictures into a bundle on save, and beside a recording, is :mod:ssvep.io.assets.
Source code in src/ssvep/stim/images.py
sha256_of ¶
Lower-case hex SHA-256 of the file's bytes — the identity the manifest pins (image.sha256).
Source code in src/ssvep/stim/images.py
load_image ¶
Decode path to an (H, W, 4) uint8 RGBA array, verifying its pin first.
sha256 is the manifest's image.sha256, in its form: 64 lower-case hex digits, refused
otherwise, as the schema refuses it. When given, the bytes are hashed and a mismatch
raises :class:ImageAssetError before anything is decoded. The hash is taken over the very bytes
that are then decoded — one read — so the file cannot change between being verified and being
shown. A missing file raises :class:FileNotFoundError.
Decoding normalises what a picture file can be into the one shape the rest of the toolbox uses:
- EXIF orientation is applied, so the picture is the way up every image viewer shows it, and
source_pxdescribes that orientation. - Palette, greyscale, CMYK and the rest are converted to RGBA; a palette's transparent entry becomes alpha 0.
- 16-bit greyscale is scaled to 8 bits (Pillow's own conversion clips it instead, turning every value above 255 white). 32-bit integer and float images have no defined display range and are refused.
- An image with more than one frame is refused (animated GIF/PNG/WebP): a target shows one picture for the whole run, and quietly showing an animation's first frame would be a stimulus the manifest could not describe.
Source code in src/ssvep/stim/images.py
clean_png_bytes ¶
data (any format Pillow decodes) re-encoded as a clean PNG — what a freshly picked
picture is turned into before it is bundled and its hash pinned (:mod:ssvep.ui.design, #355).
Strips exactly three things, because export's own checker
(:func:ssvep.io.export.picture_metadata) refuses all three outright and nearly every
photograph a phone or camera writes carries at least one: EXIF (dates, GPS, camera/lens make
and model, a thumbnail, ...), an embedded colour profile (icc_profile), and any pnginfo
text chunk. Nothing else in the source file's im.info is ever passed to the PNG encoder, so
nothing else can leak through by accident — except a palette or colour-keyed transparency
(im.info["transparency"]), which is carried across explicitly: it is pixel information
(which index or colour reads as "not there"), never metadata, and dropping it along with
everything else would silently turn a transparent picture opaque.
Orientation is applied first. EXIF can say a picture is stored sideways or upside down and
meant to be rotated on display (:func:~PIL.ImageOps.exif_transpose, the same step
:func:load_image applies when decoding) — dropping that tag before applying it would leave
the bundled picture showing rotated, with nothing left to say so.
The pixels, the mode and the alpha are kept — RGB stays RGB, a greyscale L stays L,
a palette image stays paletted — so :func:describe_bytes of the clean bytes records the same
mean_luminance/rms_contrast/has_alpha the original file would have, to the
decoder's own rounding. A mode PNG cannot encode at all (CMYK, YCbCr, ...) is converted to RGB
or RGBA (:func:_has_alpha_channel); a 32-bit integer/float mode has no defined display range
and is refused outright, the same refusal :func:load_image already gives it
(:func:_refuse_undisplayable_mode).
The toolbox is not colour-managed (module docstring): it never applies an embedded ICC profile, so dropping one changes nothing on screen. JPEG's lossy coding is usually smaller than PNG's lossless one, so a photograph typically grows once re-encoded — accepted, and the only way a picture that actually carries EXIF/ICC can ever pass the export checker at all.
An image with more than one frame (animated GIF/WebP/PNG) is refused outright, naming why: export cannot ship an animation, and the renderer draws only a target's first frame, so bundling one would silently cut every frame after that rather than failing where the operator building the design can see it.
Source code in src/ssvep/stim/images.py
fit_geometry ¶
Where a src_w × src_h picture lands in a w_px × h_px target under fit.
Returns (box, dest). box is the (left, top, right, bottom) region of the source
that is shown, in source pixels — floats, so cover crops by a fraction of a pixel instead of
rounding the aspect off. dest is the (left, top, width, height) rectangle of the
target it is drawn into, in whole pixels, row 0 at the top. Everything outside dest is
transparent, and only contain leaves any: its limiting axis spans the target exactly, and
the other is rounded to whole pixels and centred (an odd leftover pixel goes right/bottom).
Source code in src/ssvep/stim/images.py
aspect_distortion ¶
How much stretch distorts a picture: target aspect ÷ source aspect.
1.0 is none; 1.25 draws it 25% wider (relative to its height) than it is; 0.8 is the same
distortion the other way. Only stretch distorts; contain and cover never do.
Source code in src/ssvep/stim/images.py
resample_to ¶
Fit arr to exactly w_px × h_px → (h_px, w_px, 4) uint8 RGBA.
Resampling happens here, once, on the CPU, so the renderer draws the result 1:1 and GL is never
asked to filter it — the GL_NEAREST + whole-pixel placement fix for #123 stays a fix rather
than becoming a question again. fit places the picture (:func:fit_geometry); pixels outside
it are transparent (alpha 0), and :func:composite_over then fills them with the background.
The filter is Lanczos, applied in linear light (gamma, the run's display gamma) to
premultiplied alpha. Both matter for a stimulus. Filtering display-encoded values darkens
fine detail: a 1-pixel black/white checkerboard averaged in encoded space comes out at pixel
127–128 (0 and 255 average to 127.5), which at gamma 2.2 is 0.22 of white, where the
checkerboard emits 0.5 — so the patch on screen would emit well under half the light the
manifest's mean_luminance records for the picture. In linear light the average is kept.
Premultiplying keeps the colour of fully transparent pixels from bleeding into the edge.
Memory is one full-resolution float32 plane at a time (plus Pillow's copy of it), not four: the alpha plane, then each colour plane linearised and premultiplied in turn, each filtered down to the target size before the next is made.
A picture that already has the target's size and needs no cropping is returned unfiltered, so a pixel-exact asset stays pixel-exact.
Source code in src/ssvep/stim/images.py
composite_over ¶
Flatten arr's transparency onto background_rgb → (H, W, 3) uint8.
background_rgb is linear-light [r, g, b] in [0, 1] — display.background_rgb.
Compositing is done in linear light, as the PNG specification prescribes and as the panel itself
mixes light: out = a · picture + (1 − a) · background per channel, then encoded with
gamma. An opaque pixel comes back exactly as it went in, and a 3-channel array is already
opaque, so it is returned as a copy. Works in row chunks, so the float working set is bounded.
Source code in src/ssvep/stim/images.py
luminance_stats ¶
(mean_luminance, rms_contrast) of an opaque (H, W, 3) image, in linear light.
mean_luminance— mean Rec. 709 relative luminance, 0 (black) to 1 (white): the fraction of a white target's light the picture emits.rms_contrast— standard deviation ÷ mean of that luminance: 0 for a uniform patch, and 1.0 for an equal-area black/white checkerboard (the value its Michelson contrast has too). 0 when the mean is 0.
Transparency must be flattened first (:func:composite_over): alpha is not a luminance, and what
shows through it depends on the run's background, which is a manifest fact of its own. Measured
in row chunks (:class:_Moments), so the float working set is bounded whatever the size.
Source code in src/ssvep/stim/images.py
describe ¶
The design-time facts a manifest records about a picture (stimuli[].image, less path
and fit, which are the designer's choices rather than properties of the file).
Returns {"sha256", "source_px", "has_alpha", "mean_luminance", "rms_contrast"}:
sha256— the file's identity (:func:sha256_of); verified againstsha256if given.source_px—[width, height]as decoded (EXIF orientation applied).has_alpha— whether any pixel is less than fully opaque, i.e. whether the background shows through. A file with an alpha channel that is opaque everywhere isFalse: it is the same picture as its RGB twin.mean_luminance/rms_contrast— :func:luminance_statsof the whole picture at its own resolution, composited overbackground_rgbatgamma, rounded to :data:STATS_DECIMALS. They depend on the file, the run's background and its gamma — all three manifest facts — and on nothing about the target's size, shape or fit, which the manifest declares separately. So they describe the picture as it will be lit, and stay valid however it is framed. Linear-light resampling (:func:resample_to) keeps the on-screen mean of a stretched picture equal to this, to within filter ringing and 8-bit rounding;containadds background bands,covershows a centre crop, and a circle target shows an inscribed ellipse, each of which moves the on-screen average away from the picture's own by an amount the declared geometry bounds but does not fix.
Composited and measured a chunk of rows at a time, never as a whole composited copy, so the
cost beyond the decoded picture itself is bounded — the same numbers
luminance_stats(composite_over(...)) gives, to float64 rounding.
Source code in src/ssvep/stim/images.py
describe_bytes ¶
:func:describe, on bytes already in memory rather than a file on disk — no pin to verify,
since there is nothing yet to verify it against: the caller either has not written the file at
all (:func:clean_png_bytes's result, before it is bundled and so before it has a pin) or
already checked one to get these bytes (:func:describe, which is this function plus the
file read and the pin check). Returns the same {"sha256", "source_px", "has_alpha",
"mean_luminance", "rms_contrast"}, with sha256 the hash of data itself.
Source code in src/ssvep/stim/images.py
is_sha256 ¶
True if value is a lower-case hex SHA-256, the only form the manifest records.
ellipse_mask ¶
(h_px, w_px) bool: the pixels of a w_px × h_px target inside its inscribed ellipse.
A pixel is inside when its centre is on or inside the ellipse — a hard edge, as the solid
shape: "circle" target's own polygon is drawn, so a round picture target and a round solid one
cover the same pixels to within the polygon's facets. Symmetric about both axes, so which way up
the rows run does not matter.
Source code in src/ssvep/stim/images.py
target_texture ¶
The (h_px, w_px, 4) uint8 texture an image target is drawn with, 1:1, row 0 at the top.
Built once per target, before the frame loop. The picture is fitted to the target's exact pixel
size (:func:resample_to), masked to the inscribed ellipse for a circle target (ellipse, in
numpy — GL never masks or filters it), and then split into what the two halves of the draw need:
- RGB is the fitted picture composited over
background_rgbin linear light (:func:composite_over) — exactly what the target shows at full luminance. Partial transparency, the picture's own soft edges included, is flattened here, where it can be done in linear light, because GL would blend it in display-encoded values, which darkens a half-covered pixel at gamma 2.2 to about a fifth of its light instead of a half. - A is coverage, not transparency: 255 wherever any of the picture shows, 0 wherever none
does — the bands
containletterboxes, the corners outside a circle target's ellipse, and the picture's own fully transparent pixels. The renderer's image shader discards a texel with A = 0, so those pixels are the cleared background on every frame, whatever the flicker does — the letterbox and the mask never flash — and modulates every covered texel between the background and the picture in linear light (:func:presented_frameis the same maths in numpy). Dimming the RGB instead, as the checkerboard path does, would dim those background-coloured pixels too, and on a background that is not black the target's whole bounding box would flicker.
Source code in src/ssvep/stim/images.py
image_stimuli ¶
The stimuli of a manifest's stimuli list that are image targets (pattern: "image").
load_target_images ¶
Every image target's picture, decoded: {target id: (H, W, 4) uint8 RGBA}.
What the renderer calls before it opens a window. Each picture is found with
:func:resolve_asset under roots (the first root that has it, never the working directory)
and decoded by :func:load_image with the manifest's pin, always — a target whose block has
no pin is refused rather than shown unverified. The first problem raises, as
:class:FileNotFoundError for a missing file (naming every path looked in) or
:class:ImageAssetError for anything else, prefixed with the target's id; :func:asset_problems
lists them all instead. A picture several targets share is decoded once.
Source code in src/ssvep/stim/images.py
verify_target_images ¶
Raise on the first image target whose picture is missing or is not the pinned file.
Resolve and hash only — no decode — so it is cheap enough to run before anything else, and the
renderer does, before it touches the display: the loud failure comes first, then the (slow)
decoding. :func:load_image checks the pin again on the very bytes it decodes, so nothing can
be swapped in between. Raises like :func:load_target_images, prefixed with the target's id.
Source code in src/ssvep/stim/images.py
asset_problems ¶
One line per image target whose picture could not be shown — [] when every one can.
With decode (the default) exactly the check :func:load_target_images makes (resolve,
verify the pin, decode), so a run this passes is one the renderer will load; the Run tab's lock
asks this, while nobody is waiting. decode=False resolves and verifies the pin only — the
recorder's and Measure flicker's pre-flight, which run just before the renderer decodes
everything itself and must not double that cost. Either way it reports every target rather than
stopping at the first, keeps no pictures, and names the file. cache (optional) is shared
across calls, so a picture several runs share is checked once.
Source code in src/ssvep/stim/images.py
presented_frame ¶
(H, W, 3) uint8: what an image target shows at flicker luminance lum — the renderer's
image fragment shader (renderer._IMAGE_FRAGMENT_SOURCE), in numpy.
texture is :func:target_texture's output. A covered texel C shows, in linear light,
``B + lum · (C − B)``
with B the run's linear background_rgb — the law a solid target follows
(:func:ssvep.stim.flicker.target_rgb), so every level, sine and contrast < 1 included, is
the declared modulation on every background, to 8-bit rounding. At the endpoints it is exact by
construction: lum ≥ 1 is the texel, lum ≤ 0 is background_px (the pixel the window is
cleared to). An uncovered texel (A = 0) is background_px on every frame — the shader discards
it, so the cleared background is left untouched. The GL readback test holds the real shader to
this.
Source code in src/ssvep/stim/images.py
unrendered_targets ¶
Ids of the targets in a manifest's stimuli whose fill this build cannot draw.
manifest_image_stimuli ¶
Every picture manifest shows, as image-target-shaped dicts ({"id", "pattern", "image"}).
An SSVEP run's image targets, plus - for an FPVS run (#494) - every declared item
(:func:ssvep.stim.fpvs.item_image_stimuli). Everything that finds, pins, bundles, copies and
verifies pictures (:mod:ssvep.io.assets, :func:asset_problems) takes this list, so an FPVS
run's items are bundled with a protocol, checked at lock and copied beside the recording exactly
as an image target's picture is.
Source code in src/ssvep/stim/images.py
match_mean_luminance ¶
Equate the mean luminance of opaque pictures by scaling each one's contrast about the background.
arrays are opaque (H, W, 3) uint8 pictures shown on background_rgb (linear) at
gamma - text rendered on the background, or photographs already composited onto it
(:func:composite_over). Each picture i is rewritten, per pixel and in linear light, as
B + k_i (C - B), so its mean luminance moves from m_i to target while any pixel equal
to the background stays the background. That keeps an FPVS oddball response from being a
luminance artefact (#494) without changing a picture's layout, and it is the helper #262 stage
2's luminance-matched image pairs need.
target defaults to the mean of the picture closest to the background, so every factor
is at most 1 and nothing clips; an explicit target that would need k > 1 is refused. Pictures
on both sides of the background (some brighter, some darker) cannot be matched this way and are
refused: scaling about the background cannot change a picture's sign.
Returns (matched_arrays, record). record is what the manifest records about it (FPVS's
design.fpvs.luminance_matching): the method, the background luminance, the target, each
picture's mean before, its factor, and its mean after 8-bit rounding.
Source code in src/ssvep/stim/images.py
png_bytes ¶
arr ((H, W, 3|4) uint8) encoded as a PNG with no metadata - a generated picture's bytes.