FPVS — fast periodic visual stimulation¶
A run of the fpvs paradigm presents a rapid stream of pictures at a base rate (default 6 Hz), one per cycle, each fading in and out with a sinusoidal contrast envelope (or, if you choose, a square one: full contrast while on). Every Nth item (default every 5th) comes from a second list, the oddball category. If the brain tells the two categories apart, a response appears at the oddball frequency (6 / 5 = 1.2 Hz) and its harmonics, on top of the general visual response at the base rate and its harmonics. No task is done on the items themselves: the participant watches a fixation mark (for letter strings, a box drawn around them; for pictures, a cross) and presses the spacebar when it changes colour.
The first use is reading: words among consonant strings (Lochy et al., 2015), where a word-discrimination response is expected over left occipito-temporal sites. A faces demo (neutral faces with a smiling-face oddball) ships as an example (session-protocols/example_fpvs_faces.json, §8): modified copies of a CC BY 4.0 face set, credited in its bundle and in NOTICE.
⚠️ Ethics approval. FPVS is a different visual method from a flickering-target SSVEP run, with its own stimuli (words, letter strings, and faces). If your deployment's approval covers only SSVEP flicker, check that it covers FPVS and its stimuli before recording anyone.
1. Building an FPVS run¶
In the GUI. On Build run, set Design type to FPVS. The page has three parts:
- Stream: base rate (the oddball frequency, base / N, is shown live; a base rate that does not divide the display's refresh rate is flagged in red with the rates that do), oddball every N items, sequence length, fade, number of sequences, gap, size jitter, item size (in the display's units), the seed, the item order (a new order for each session, the default for a new design, or the same order for everyone; §2), the task (fixation colour change, with its changes per sequence, or none), the item envelope (sine, the default, or square with a duty cycle), the fixation mark (automatic: a box for text items, a cross for pictures; or box, cross, none), the mark's base and target colours, and the instructions shown before the stream starts (empty means the automatic text). Every input has a tooltip saying what it is, its units and a typical value, and, for the flicker-related ones, why it matters for photosensitivity.
- Two item lists, Base and Oddball, each from a Folder of pictures (you supply the pictures; the toolbox ships none) or a Text list (type or paste one item per line, Load from file... for a .txt or .csv (UTF-8, UTF-16 with or without a BOM, or Windows-1252; a .csv's first column, skipping a first row whose first cell is a header such as item, word or string when the file has more than one column), or Fill from built-in list for the shipped consonant strings, pseudowords, real words or false-font strings). A text list has a font size and an optional Font file (.ttf or .otf; empty means the bundled DejaVu Sans Mono). A false font such as BACS is a font file you obtain yourself and name here; its name and sha256 are recorded in
design.fpvs.text, and the font is never copied into the run's bundle.design.fpvs.textalways has alistsrecord (which font drew which list); the flatfont/font_file/font_pxkeys are there as well when one font drew every string. A run loaded with a font file recorded keeps requiring it: once you edit a list, that list cannot be built until you choose a file with the recorded sha256 or press Default to drop the font on purpose. The two lists have their own font settings, so a false-font base list can sit beside a real-font oddball list. - Items: text is shown at full contrast. Every string is drawn in the same black ink and nothing scales it; the two lists are matched by selecting the strings, at least on length (a longer string carries more ink). Under the lists, the length check shows each list's string lengths (mean, SD, range, and how many strings of each length) and turns orange when the lists differ: when their mean lengths differ by more than 0.5 characters, or the share of strings of any one length differs by more than 10 percentage points. It warns and never refuses, and the same check is a builder warning and is recorded in the run as
design.fpvs.text.lengths. For pictures, Match mean luminance across both lists (pictures) (on by default) brings every item of both lists to one mean luminance (mean only: not contrast, histogram or spatial frequency); if some items sit on the light side of the background and some on the dark side it refuses, and says so. Per-string matching for text is an explicit opt-in (Also scale text strings to one mean luminance, off): it scales every string's contrast down to the lowest-ink string's, which leaves most strings faint. Build items renders or reads the items into the run's asset bundle and shows a few of each as a preview; Save builds them too if they are out of date. A new FPVS design starts on a mid-grey background, because the items are dark ink on the background.
The validation panel grades the stream's flicker exactly as for any run. Preview flicker, Preview session and Measure flicker are disabled for FPVS runs: they play and measure flickering targets, not a stream of pictures, and there is no stream preview on this page.
Editing a saved run. Load it (the page shows its parameters, and its items "as loaded"). Change nothing and Save writes the file back byte-for-byte. Change any parameter and the sequences are drawn again from the seed, which makes a different design, a new version of the run: Save then asks for a new name if another saved run has this one. Changing either item list rebuilds both (the loaded items were matched together), starting from the loaded strings when the run was made from text. Saving over the same file removes pictures the run no longer references from its bundle's text/ and pictures/. A folder's pictures are composited onto the run's background and bundled as opaque pictures whether or not luminance matching is on (matching then also scales them). Details the form has no control for (item markers, change duration) carry over from the loaded run. A run saved before the item-order choice existed loads as Same order for everyone, what it did. A run saved before the fixation mark and the instruction screen existed loads as the cross it drew, and saves back unchanged.
The same design can be built in Python, which is how the shipped demo is made:
from ssvep.stim import builder as B, fpvs, text_items
display = B.Display(refresh_hz=60, background_rgb=(0.5, 0.5, 0.5))
bundle = "run-manifests/my_fpvs.assets" # pictures live beside the manifest
base, odd, text_rec, lum_rec = text_items.render_item_lists(
["BCDF", "GHJK", ...], ["TREE", "BOOK", ...], bundle,
background_rgb=display.background_rgb, gamma=display.gamma)
block = fpvs.make_fpvs_block(base, odd, item_size=[6.0, 2.0], seed=1,
luminance_matching=lum_rec, text=text_rec) # lum_rec is None: full contrast
print(fpvs.describe_lengths(block["text"]["lengths"]), fpvs.length_warnings(block))
spec = B.fpvs_run(block, name="my-fpvs", display=display, asset_dir=bundle)
print(spec.validate())
spec.save("run-manifests/my_fpvs.json")
For pictures rather than text, describe each file with ssvep.stim.builder.describe_image and pass the resulting items (each needs an id) to make_fpvs_block; match their mean luminance first with ssvep.stim.images.match_mean_luminance if the two categories could differ in light. scripts/build_fpvs_reading_demo.py is the worked example.
Then, as for every run: Build Protocol → add the saved run, Set up acquisition hardware, save (its pictures are bundled with the protocol); Set up Session → lock; Run Session → prep and record.
What you can set¶
make_fpvs_block argument |
Default | Meaning |
|---|---|---|
base_hz |
6 | Items per second. Must divide the display refresh (60, 120, 144 and 240 Hz all work at 6 Hz); anything else is refused, because FPVS measures the stream's periodicity and every item must last the same whole number of frames. The refresh the design is built at is checked here; the panel it plays on may differ (§3). |
oddball_every |
5 | An oddball at positions N, 2N, … (1.2 Hz at 6 Hz). |
sequence_s |
60 | The analysed part of each sequence; a whole number of oddball cycles (refused otherwise). |
fade_s |
2 | Linear fade-in before and fade-out after; never analysed. |
n_sequences, gap_s |
2, 5 | Sequences per run, and fixation-only time before each and after the last. |
size_jitter |
0.10 | Each item is scaled by a factor in [0.9, 1.1], so low-level size cues cannot drive the oddball response. |
seed |
0 | The seed the design's sequences are realised from. With realise: "per_session" these are the design's sample order; each session plays its own (§2). |
realise |
"per_session" |
When the item order is drawn: "per_session", a new order for each participant and each visit, drawn when the session is resolved; "design", one order, drawn here, for everyone. A run saved before the field existed has none, which means "design". |
task, changes_per_sequence, change_s |
fixation colour, 6, 0.2 s | The fixation mark turns from its base to its target colour at random times, at least 2 s apart, never in a fade and never within 1.5 s (the response window's end) of stim_off, so a press to the last one still lands inside the analysed sequence; a sequence too short to fit them is refused with the reason. A design recorded before this rule still loads; the builder warns about its late changes. The participant presses space. |
fixation_colours |
blue, red | The mark's base and target colours (linear-light RGB 0-1). The builder warns when they, or either and the background, are closer than a CIE 1976 colour difference of 20 (about 2 is just noticeable side by side); with the task on, base and target closer than 2 (or the same) are refused, since nobody could see the change. |
fixation |
automatic | "box", "cross" or "none"; automatic is a box when every item is a string, else a cross (and none without the task). See §2 for the box's geometry. |
envelope, duty_cycle |
"sine_contrast", 0.5 |
Each item's contrast within its cycle: the literature's sinusoid (0 at the item's first frame, 100 % at mid-cycle, back to 0), or "square": full contrast for duty_cycle of the cycle, background for the rest (1.0 = no blank between items). duty_cycle × frames per item must be whole; the editor takes the duty you type to the nearest whole frame at the display's refresh and says how many (33.33 % at 6 Hz on 144 Hz is 8 of 24 frames). Square is the harder flicker (§4). |
instructions |
automatic | The instruction screen's text (§3); automatic names the mark and the task and ends "Press the spacebar to begin". |
item_markers |
false | Also mark every base item's onset (oddballs are always marked). |
Sequence rules. Each list is drawn as a shuffled cycle: every item of the list is shown once, in a seeded random order, before any item is shown again; then the list is reshuffled. The cycle continues across the run's sequences (sequence 2 picks up where sequence 1 left off), so a list with at least as many items as the run has positions for it is shown with no repetition at all. No item ever follows itself, including across a reshuffle, and no oddball repeats the previous oddball. Each list needs at least two items, and the two lists may not share a picture. The base and oddball lists are cycled separately, for text and pictures alike. How many positions each list needs is n_sequences x the items per sequence (fades included) at base positions (4 of every 5 at the default ratio) or at oddball positions (1 of every 5): for the defaults (2 x 60 s at 6 Hz with 2 s fades, 384 items per sequence) that is 616 base and 152 oddball items. A shorter list is not refused: the builder warns, names how many times its items repeat and how many items would avoid it.
2. What the manifest records (schema v1.9)¶
Everything is declared, nothing is inferred (the manifest spine): paradigm: "fpvs" and design.fpvs carry the rates, the ratio, the timing, the item lists — each picture pinned by its SHA-256 in the asset bundle and described by its measured mean luminance and RMS contrast, exactly as an image target's picture is — the seed, and the realised sequence: which item at every position, its size scale, and the fixation-change times. The sequence is drawn when the design is built, so the manifest's fingerprint covers it; the renderer plays it verbatim and analysis reads it. The loader refuses a hand-edited file that breaks any rule above.
A new order for each session (#530). design.fpvs.realise says when the order a participant sees is drawn. With "design" (and in every run saved before the field existed, which has none) it is the order drawn when the design was built, for everyone, and nothing below applies. With "per_session", the default for every new design (the Build run page, make_fpvs_block, and the reading and faces demo scripts), the order drawn at design time is a sample: the preview, the builder's checks and the flicker grade use it, but nobody is shown it. Instead, when a session is resolved (at Lock, and again on every relock or resume), each such run's order is drawn again from a seed derived from the protocol, the session and the run: the first 52 bits of SHA-256 of protocol seed|sub-XXX|ses-YYY|group label|run label (the recipe a randomized group's shuffle already uses, with the session and the run added; ssvep.runtime.session.fpvs_session_seed). The draw is the design-time one (the same shuffled cycle across the run's sequences, the same item, scale and fixation-change rules), only the seed differs, and the drawn order passes the same checks the loader applies to any FPVS run (the sequence rules, the cycle and sampling) before the session can lock. So:
- every participant and every visit gets its own order; the same participant, session and protocol always get the same one, so a relock or a resumed session plays the order it started with, and nothing is drawn or stored at lock;
- the recording holds exactly what played: its
*.session.jsonsidecar embeds the realised run manifest, whosedesign.fpvshas that session'sseed,sequencesandsampling, plusrealisation: {"scheme": "per_session", "design_seed": <the design's seed>};session_position.fpvs_seedrepeats the seed. Everything else in the block (lists, rates, timing, task, fixation, instructions) is the design's. The renderer and the analysis read the recording's manifest as they always have; - it is reproducible two ways: from the recording alone (
ssvep.stim.fpvs.rederivesdraws the sequences again from the recorded seed and compares them), and from the protocol plus thesub/sescodes. The loader does not refuse a recording whose sequences do not re-derive, because the recorded sequences, not the seed, are what was played, and a later toolbox may draw differently; - the protocol's pin (
source.fingerprint) and the protocol fingerprint are the design's; indesign.fpvs, a recording's own manifest differs from the protocol's embedded copy in exactlyseed,sequences,samplingandrealisation(outside it, the recording also carries the as-runacquisition, as every recording does); - a run label repeated in one group (the same design run twice on purpose) adds which repeat it is to the key, so the repeats differ too;
- a protocol with such a run is stamped protocol schema 1.8 (Session protocols, protocol versions), so a build that cannot draw a per-session order refuses to load it and asks for a newer toolbox instead of playing the sample order to everyone;
- the FPVS report says where the order came from (Item order: the design's order with its seed, or the per-session seed, the design seed and whether the order re-derives from it;
item_orderin its*_metrics.json). A recording whose design says"per_session"but that has norealisationrecord was made by a toolbox that played the design's sample order: the report, the derivatives summary line and the BIDS conversion's notes all say design order played (not per-session), and it is never read as a per-session order.
The seed reveals nothing beyond the codes. Its inputs are the protocol seed, the group and run labels (design facts) and the de-identified sub-/ses- codes that are already in every file name and in session_position; no date, time, operator or other identifier enters it. It is a one-way hash, but of a small space of codes, so it can be matched back to the code it was made from: it is exactly as de-identified as the codes are. The export keeps realise, realisation and the seed (never the sequences) and keeps the numeric sub- codes, so it links nothing new; an export that ever re-codes participants must drop the per-session seed, or the old code could be recovered from it.
Text items record the string each picture shows, the font (DejaVu Sans Mono, as matplotlib ships it, with its file's SHA-256) and size, and design.fpvs.text.lengths records both lists' string-length statistics (per list n, mean, sd, min, max and counts per length; between them mean_diff, max_share_diff, and the thresholds the builder warns above). A text design has no luminance_matching block: its strings are at full contrast. When matching was done (pictures, or a text design built with the opt-in, or one recorded before text went to full contrast), design.fpvs.luminance_matching records it: each picture's contrast scaled about the background, in linear light, to the mean of the lowest-contrast item. Such designs still load and play as recorded.
The fixation mark is design.fpvs.fixation. A box is a rectangular outline centred on the screen whose inner edge clears the largest thing an item can draw by a margin of a quarter of that thing's height on every side, with a line 0.08 of that height wide, drawn outward (at least 2 px on screen). For text, "the largest thing" is the strings' ink: design.fpvs.text.max_ink_px records the widest and tallest ink over every string, measured from the rendered pictures and made symmetric about the centre, and it is scaled into item_size as the pictures are, at the largest size jitter, so no string can touch the box. For pictures it is the item box at the largest jitter. The block records the inner size, line_width, margin, extent and what the extent was measured from; the loader refuses a box smaller than the extent, compared in pixels at the declared display.resolution_px, where the renderer draws it. In deg the geometry is computed in degrees directly. In norm units, whose x unit is half the screen's width and y unit half its height, the box is computed in pixels for the run's display and converted back per axis (so the clearance is the same in pixels on every side; line_width is then in x units and margin in y units), and a norm box needs display.resolution_px. Shown at other proportions than declared (a windowed preview, another panel), the renderer never draws the box smaller than the items' extent there plus the clearance. On the shipped demo it is 5.43 × 1.94 deg inside, around ink of at most 4.79 × 1.29 deg. A design with no fixation block (made before it existed) draws what it always did: a cross with the colour task, nothing without. The colour task recolours whichever mark is drawn.
design.fpvs.instructions is the instruction screen's text (§3). design.fpvs.sampling records how the sequences drew from the lists and what that gave: method (shuffled_cycle_per_run), and per list (base, oddball) its size n_items, the run's positions for it, counts (how many times each item is shown, over all sequences and fades), max_shown and repeated (whether any item is shown more than once). It is computed from the realised sequences, and the loader refuses a file where it does not match them or where an item repeats before its whole list has been shown. A design realised before the record existed has none; its sequences each started a fresh cycle, and it still loads and plays as recorded.
design.timing is derived from the block (stim_s = sequence_s, the fade-in as the lead-in analysis discards), and the run has exactly one stimulus, stream, which exists for photosensitivity grading (§4).
Markers¶
| Segment | When |
|---|---|
block_start / block_end |
the first frame of a sequence's fade-in / the end of its fade-out |
stim_on / stim_off |
the start / end of the analysed window (fades excluded) |
oddball |
every oddball item's onset; item names it |
item |
every base item's onset, only with item_markers |
instructions |
the instruction screen appeared (before the first sequence) |
run_start |
the participant pressed the spacebar on it; the stream starts |
fixation_change |
the fixation mark (cross or box) changes colour |
response |
the participant pressed the response key (space) during the stream; the start press is not one. source says where it arrived: stimulus (the stimulus window) or gui (the toolbox window, forwarded; §3) |
Every marker carries condition: "fpvs" and the base rate in target_freq_hz. In BIDS, an FPVS run's events.tsv gains an item column.
3. Running it¶
An FPVS run is prepared and started like any other run (Prep selected run, then Start recording), through the same pre-run checks: in particular, Start recording warns when no impedance reading was captured for the prepared run, with the same wording as an SSVEP or resting run, and Cancel stops the start. The one exception, for every paradigm alike, is the synthetic amp: it has no electrodes, so there is nothing to measure and no warning; the Prep run message says so.
The renderer uploads every item's picture as a texture before the first frame; a frame only sets the current item's contrast level and, once per item, its scale. The frame schedule follows the panel. design.fpvs declares the stream in Hz, seconds and items, never in frames, so the renderer derives the schedule at the stimulus screen's mode rate and re-checks every rule there: a design built at 60 Hz plays frame-exactly on a 120, 144 or 240 Hz panel (40 frames per item at 240 Hz), with every declared frequency, the realised sequence and the analysed window unchanged, and the lock's refresh check treats that as a match. The clock actually used is recorded with the run (provenance.frame_clock_hz), beside the rate the panel advertised (provenance.advertised_refresh_hz). It refuses to start on a panel the base rate does not divide (6 Hz on a 75 Hz panel: 12.5 frames per item), because no item could last a whole number of frames there; the lock flags that run as a refresh mismatch first. Esc stops the run at any time, including while the window is still measuring the panel before the first item.
The stimulus window must have the display before anything is recorded (#518). As soon as it is fullscreen the window is made topmost on Windows, so it covers the toolbox on a shared screen whether or not Windows gives it the foreground, and it asks for the foreground too, for the keyboard (the recorder grants it the right to; if that is lost, the toolbox keeps the keyboard and its own Esc still aborts the run). Then it measures the panel's refresh, for at most about 6 seconds (the first half second, the mode switch, is not counted), and only then publishes its marker stream. If the measured rate is under 80 % of the frame clock, the run is refused before the first item, within those seconds, and the Run tab shows why, with whether the window was topmost, whether it had the foreground, and whether the recorder's foreground grant was made. If the display did not report its refresh rate, the refusal says so instead: the frame clock was the design's rate, assumed. On a laptop or any rig with a single display, the toolbox and the stimulus share the screen, and a stimulus window that does not hold the foreground is drawn a frame at a time between the operator's window (it was measured at 1.75 Hz on a 240 Hz panel, the screen alternating black and the toolbox). If that refusal appears, keep every other window out of the foreground, the toolbox's own included, and start the run again; a second display for the stimulus avoids the question. Under the GUI the renderer's own output is kept in ssvep_renderer_<pid>.log in the system temp folder, overwritten by the next run. Its last lines are shown in the Run tab when the stimulus exits early or never publishes its marker stream in time. Esc stops the run throughout start-up, including while the stimulus waits for the recorder to subscribe, from whichever window has the keyboard; closing the stimulus window counts as Esc. On a shared screen the topmost stimulus covers the Run tab's Abort button, so Esc is the stop there; with a second display the button stays in reach. If Esc ever does nothing, Alt+Tab to the toolbox and press Esc there; the toolbox may stay hidden behind the stimulus, press Esc anyway, and it stops the run. The flicker and resting renderers follow the same start-up (#521).
An instruction screen comes first. After the refresh measurement and the marker stream (so its markers are in the recording), the stimulus window shows design.fpvs.instructions on the background and waits for the spacebar, which starts the stream (the first sequence's gap, then its fade-in); Esc aborts there as everywhere. Other keys do nothing. The wait is the participant's and open-ended: nothing times out on it. The recorder's only start-up timeout is for the marker stream to appear, which happens before the screen; the refresh measurement and its refusal are finished before it; and the recorder's EEG watchdog watches EEG, which keeps arriving while the participant reads. The screen's appearance and the start press are marked (instructions, run_start), so how long the participant took is in the recording. A spacebar press is recorded whichever toolbox window gets it. A key goes to the window that has the keyboard, and once the operator clicks the toolbox window during a run, the participant's presses go there instead of to the stimulus window. So while an FPVS run with the colour task records, the toolbox window forwards every spacebar press it receives into the run's marker stream as a response marker, timestamped on the LSL clock when the toolbox handles the key (which includes the toolbox's own event-loop delay, typically a few milliseconds), and takes the key (it does not also press a button or type a space). Each response marker says where the press arrived: source is stimulus (the stimulus window) or gui (forwarded). One physical press is one response: a key event goes to one window only, auto-repeat is ignored, and as a second guard a forwarded press within 0.1 s of another response is dropped. Only presses between run_start and the stimulus window closing count; Esc is never a response and still aborts the run from either window. The task score counts both sources alike. On a two-display rig this means a spacebar the operator presses in the toolbox window during the run is also counted as a response; it is tagged source: "gui", so it can be told apart and excluded when the recording is analysed. A press that is dropped is said. The run keeps the count of forwarded presses it did not record and, for each, why (before_run_start, after_stream_end or duplicate) and when (the LSL clock). They are written to the run log when the run ends, shown in the Run tab's live fixation-task readout only when the count is non-zero, and saved in the task record of the *.session.json sidecar as fpvs_task.dropped_responses (count and drops), so an operator can see why a press did nothing and an analyst how many were discarded. It does not change the score, and a run with no forwarding has no such key.
The stimulus window also tries to take the keyboard back in fullscreen at each sequence's first frame when it does not hold it. That is a best-effort attempt plus a diagnostic, not the fix: Windows refuses the foreground to a program that is not in front once the user has typed or clicked in another, which is exactly the case it targets, and the log line then says so (now NO). When it does succeed on a two-display rig, the operator's own keys go to the stimulus window until they click back in the toolbox (Esc there still aborts the run). A windowed preview never asks. The renderer's output (the renderer log, under the GUI) has a line for every key press the stimulus window receives during the stream (frame, time, and whether it counted as a response or was ignored) and for every time the window loses or regains the keyboard; the run's summary counts both.
The fixation task is scored live. An FPVS run has one stream and no targets to decode, so where an SSVEP run shows its live decoding accuracy, the Run tab's live panel shows the fixation task's running score: colour changes so far, hits, misses, false alarms, hit rate and mean reaction time, updated as the markers arrive (a miss counts once its response window has closed; until then the change is pending). It reads the markers the recorder already receives, on the recorder's side, so the stimulus process does nothing extra and there is no second key listener. Every spacebar press during the stream is a response marker with its LSL timestamp in the XDF, beside the fixation_change markers; Esc is an abort, not a response. When the run is saved, the same scorer scores the recording's markers into the sidecar's fpvs_task record (summary: n_changes, n_responses, hits, misses, false_alarms, hit_rate, mean_rt_s, median_rt_s, window_s), and the offline report uses it too, so the live panel's last numbers, the saved record and the report are the same numbers by construction. The response window is 0.15-1.5 s after a change: faster than 0.15 s is an anticipation, not a reaction to the change; 1.5 s leaves room for a slow responder well before the next change (at least 2 s later). It is this toolbox's choice, not a published standard, and it is recorded with every score. Changes are matched in time order, each to the earliest unused press in its window, so one press never answers two changes, and a press after stim_off still counts while its window is open. A press inside a change's window is always matched to it, so a second press in the same window is a false alarm at once. A run that is aborted is scored as finished: a change whose window was still open at Esc is saved as a miss. Reaction time is the response marker's LSL time minus the fixation_change marker's, both pushed from the stimulus frame loop, so it is quantised to the frame and display latency is not subtracted. Hit rate is hits / (hits + misses). The analysis of the brain response is the offline FPVS report (§5).
4. Photosensitivity¶
A 6 Hz contrast-modulated stream is flicker, and it is graded and acknowledged like any other. The run's one stimuli[] entry, stream, describes it to the existing flicker-risk grading as the worst case it can be in luminance and size: a full-contrast white patch the size of the largest jittered item, modulated at the base rate with the stream's own waveform (a sine for the sine envelope; a square for the square one, at duty 0.5, its strongest fundamental, whatever the design's duty cycle). No picture is brighter than white or larger than that, and the loader refuses a manifest whose stream entry claims anything lower or the gentler waveform. A square envelope is the harder flicker: each item switches on and off abruptly, so a 6 Hz square stream carries harmonics at 18, 30, 42 and 54 Hz (18 Hz in the 10–25 Hz peak band) that the grade, taken on the base rate, does not model; the builder says so in a warning of its own, and the sine stays the default. At duty cycle 1 there is no on/off square at all (each item replaces the last abruptly), so the steps are the differences between successive pictures and their harmonic content is not computed; the warning names the multiples of the base rate in the peak band as possible instead. What a solid patch does not describe is the pictures' spatial pattern (letter strokes, a face's edges): the grading has no model of it, so treat a patterned stream as at least as risky as its grade says. At 6 Hz with no measured display luminance the grade is high, so the lock asks the operator to acknowledge it, exactly as for a high-risk SSVEP design (#286 P5), and an approval envelope's flicker_hz and trial_stim_s limits apply (a sequence counts as its fade-in, sequence_s and fade-out together, one continuous period of stimulation). The fades are there for the participant too: the stream never starts or stops at full contrast.
5. Analysis and the report¶
BIDS/code/02_run_offline_pipeline.py and Analyze route an FPVS run to its own analysis (ssvep.analysis.fpvs) by its manifest's paradigm. An FPVS run is never decoded as SSVEP, and a between-condition contrast refuses one as a level.
- One FFT per sequence, over exactly
sequence_sfrom itsstim_onmarker (the fades excluded).sequence_sis a whole number of oddball cycles, so every harmonic falls on an FFT bin. Amplitude spectra are averaged over sequences. A sequence counts only if itsstim_offwas marked a wholesequence_slater: one stopped with Esc is left out and reported as cut short. The window must hold at least 12 oddball cycles (10 s at 1.2 Hz), so every harmonic has its full set of noise bins and none of them is the next harmonic; a shorter one is refused (the builder warns when you design one). - SNR = amplitude / the mean of the 20 surrounding bins (10 each side), skipping the adjacent bin on each side and dropping the local minimum and maximum of those 20 (an 18-bin mean; the Rossion-lab noise convention, Liu-Shuang et al. 2014); baseline-corrected amplitude (BCA) = amplitude − that mean. The per-bin z = (amplitude − mean) / SD uses all 20 neighbours untrimmed. Note this departs from Liu-Shuang et al. 2014 and Rossion et al. 2012, who trim the local minimum and maximum for the z as well: this toolbox deliberately keeps the untrimmed SD for z, because dropping the extremes shrinks the SD and inflates z. The trimmed mean is used only for SNR and BCA.
- Oddball harmonics that coincide with a base harmonic (6, 12, 18 Hz at the defaults) are excluded.
- Oddball significance is judged only on the channels expected to show it, as named regions of interest (ROIs): P7 + O1 (left occipito-temporal) is the primary analysis and the headline, and P8 + O2 (right) is the secondary, reported separately. The amplitude spectra of an ROI's channels are averaged first (Rossion; Lochy et al.).
- The verdict is one test per ROI: the z of the summed oddball harmonics (Retter, Rossion & Schiltz 2021's recommended test). The amplitude is summed over all non-coinciding oddball harmonics up to 20 Hz (a range fixed in advance, never picked from the data). The noise distribution is the same sum taken at each of the 20 neighbour offsets (±2 … ±11 bins), and
z = (sum − mean of noise sums) / SD of noise sums. It is significant when z > 1.64 (one-tailed, nominal α = .05 per ROI, per run). With only 20 noise sums the statistic is approximately t(19), not normal, so z > 1.64 gives about 5.9% per run, not 5% (an exact .05 would need 1.73); we keep 1.64, the field's convention (Retter et al. 2021). The realised false-positive rate on pure noise is slightly above nominal (about 6% per ROI in the repo's simulation: 11 of 200 pure-noise runs for the primary ROI). The two ROIs are separate tests: the secondary never rescues the primary, and a run in which either is significant by chance is correspondingly more common (about 11%). The report states the z, the number of harmonics and the α with the verdict. - The per-harmonic SNR, BCA and z are descriptive (where the response sits); they are not a test, because 13 harmonics (at the defaults) tested one by one at α = .05 give a false-positive rate near 15% per ROI.
- For quantification, the harmonics summed into the summed BCA are the first ones up to the last with z > 1.64 before two consecutive misses (Retter et al. 2021's selection rule). If the ROI's test is significant but that rule selects nothing, the whole tested range is summed instead, so a significant verdict never sits beside a summed BCA of 0. Each ROI records which in
quantified_over(selected_harmonics,tested_range, ordescriptive_only). When the test is not significant the summed BCA isdescriptive_only: it is kept in the file and the report labels it so, but it is not a response and is not interpreted (the literature reports BCA for significant responses). - The ROIs are the defaults of
ssvep.analysis.fpvs.DEFAULT_ROIS; passrois={"name": ["labels", ...]}tofpvs_response/fpvs_from_xdffor others (the first entry is the primary; an ROI with no channels, or one using a label the montage names twice, is refused). The ROI definitions are recorded in the result and in*_metrics.json(roi_definitions,rois, each with itssummed_testandsignificant). A montage that lacks an ROI channel does not substitute others: that ROI is reported as not computed, naming the missing channels, and the primary's headline is empty. A channel that was unusable counts as missing for the ROI it belongs to. - The base response has no expected site (it is the general visual response), so its summed BCA over the first four base harmonics is read on every usable channel; with the per-channel summed oddball BCA of the primary ROI it forms the topographies on the montage (the secondary ROI's topography is drawn as well).
A channel with a non-finite sample, or no variation at all (flat or railed), in any sequence is left out of every number and named in the report, rather than failing the run. The fixation task is scored as in §3 (hits, misses, false alarms, hit rate, mean and median RT, with the response window stated).
The reference is left as recorded unless the amplifier recorded reference-free, when a common average is applied (the same decision the SSVEP pipeline makes). The report shows, for each ROI, the SNR spectrum with the harmonics marked, the topographies (or, when the montage cannot place the recorded channels, a note saying so), a per-harmonic table and the fixation-task score; *_metrics.json carries the same numbers with paradigm: "fpvs".
6. The reading demo¶
run-manifests/example_fpvs_reading.json and session-protocols/example_fpvs_reading.json (with their .assets/ bundles) are a generic, ready-to-run reading design: 240 real words among 720 consonant strings (3-6 letters, uppercase, DejaVu Sans Mono; where they come from is below), 6 Hz base, a word every 5th item, two 60 s sequences, on the Electro-Cap 8-ch carrier (whose P7/P8 cover the occipito-temporal sites). It is built at 60 Hz and plays unchanged on any panel 6 Hz divides (60, 120, 144, 240 Hz; §3); python scripts/build_fpvs_reading_demo.py --refresh-hz 240 rebuilds it at another rate if you want the manifest's declared refresh to name your panel. Nothing repeats within the run: two 60 s sequences (with their 2 s fades, 384 items each) show 616 consonant strings and 152 words, and the lists are longer than that, so with the shuffled cycle (§1) every string shown is shown once; design.fpvs.sampling records it (max_shown 1, repeated false for both lists). The unused strings (104 consonant strings, 88 words) are bundled too, so a longer design built from the same lists can use them.
Contrast: why text is shown at full contrast¶
Measured on the shipped demo (#494 review). The envelope is right: at every panel rate the base rate divides (10, 20, 24 or 40 frames per item at 60, 120, 144, 240 Hz) each item starts at contrast 0 on its first frame, reaches exactly 100 % of its picture's contrast on its middle frame, and the item changes at a trough, so every item outside the fades reaches its peak. That is the literature's per-item sinusoid, and it is why the stream looks like periodic fading: an item spends only about 23 % of its cycle above 90 % of its peak (30 % at 60 Hz). The sequence-level fades are separate (2 s in, 2 s out, linear).
The pictures' own contrast was what was low, and it came from the per-string mean-luminance matching the demo used to apply. Matching scales every item's contrast about the background down to the mean of the lowest-ink string, and ink scales with string length (3 to 6 letters), so most strings lose a lot: the factors run from 0.38 to 1.0, median 0.58, and only the lowest-ink string keeps black ink. The median string's darkest ink is 58 % below the background (Weber; 41 % Michelson) at its peak, so averaged over its cycle it is 29 %. The modulation is in linear light, so to the eye the ink passes half its lightness difference from the background only above about 80 % of its peak. Nothing here is a rendering bug (the strings are drawn in black on the background, anti-aliased, and a few pixels at each glyph's edge are intermediate); it was the matching's cost. So text is now shown at full contrast and the lists are matched by selecting the items, at least on length, which the length check reports. The shipped demo's lists are matched exactly on length: the same share of each length in both (3, 4, 5 and 6 letters in the ratio 15 : 18 : 17 : 10, which is 60, 72, 68 and 40 words and 180, 216, 204 and 120 consonant strings; mean 4.37 letters, SD 1.03, in both), so its strings carry the same ink per length in both categories, and every string reaches black ink at its peak.
Where the demo's words come from: SCOWL. The 240 words are drawn from src/ssvep/stim/data/fpvs_words_scowl.txt, 1033 common English words derived from SCOWL (Spell Checker Oriented Word Lists, release 2020.12.07, Kevin Atkinson, http://wordlist.aspell.net/) by scripts/build_fpvs_word_list.py. The script reads SCOWL's smallest size, english-words.10 (its most common words: the 1000 most common English words of Moby Words II, the commonest frequency class of Kelk's UK English word list, and their inflections; far more than 240, so nothing less common is needed), and keeps words of 3-6 lowercase letters a-z (so no proper nouns, possessives, contractions or accented words). It drops inflected and derived forms of another candidate word (-s -es -d -ed -ing -ies -ied -er -est -ly -ness -ful, with a dropped e, a doubled consonant or y to i: books when book is kept, and likewise wider and widest beside wide, fitted beside fit, easily beside easy; the stem stays; a few words that only look derived, such as number and award, are listed as exceptions and stay, but the rule also drops some unrelated words that merely end like a derived form, such as thing, business and forest). Only regular suffixes are caught, so irregular pairs (speak/spoke, catch/caught, see/saw) can both appear. It also drops closed-class words (articles, pronouns, prepositions, auxiliaries), and, erring on excluding, every word on SCOWL's own taboo lists (misc/offensive.1, offensive.2, profane.1, profane.3, with their inflections) and a hand list made by reading every remaining candidate against one rule, applied to all of them: out when the word's usual sense is violence, weapons or the military; death, injury or illness; crime; fear, anger, distress or another negative feeling; failure, loss, fault or poor quality; an insult; sex or the body; religion; alcohol or drugs; a nationality; or a technical or unfamiliar form a participant may not read as a word (so fear, pain, war, army, angry, hung, suffer and fail are out; colours, ordinary verbs and neutral nouns stay). The 240 demo words contain no word beside its regular derived form. The script's docstring lists every filter. ssvep.stim.letter_strings.length_matched_words draws the demo's 240 from the list by length (seed 494). SCOWL's levels rank commonness coarsely; they are not frequency norms, so the words are not matched on frequency. To rebuild the list: python scripts/build_fpvs_word_list.py --fetch downloads the release archive from SCOWL's official SourceForge download into stimuli/scowl/ (git-ignored; the raw archive is never committed), checks its sha256 and rewrites the files; --tarball names a copy you already have.
SCOWL's licence. SCOWL may be used, copied, modified, distributed and sold for any purpose, provided its copyright and permission notice travels with every copy. That notice (SCOWL's Copyright file, verbatim, including the notices of the works SCOWL draws on) ships beside the list as src/ssvep/stim/data/SCOWL-Copyright.txt, and the toolbox's NOTICE file names it; keep both with any copy of the list. The permission notice is compatible with the toolbox's Apache-2.0 licence.
How the consonant strings are generated. ssvep.stim.letter_strings.consonant_strings draws every letter independently from the 20 consonants (A-Z without A, E, I, O, U and Y: Y is a vowel in gym, fly and myth, so it is left out, and a string can never be read as a syllable, as in the consonant strings of Lochy et al., 2015), each in proportion to how often it occurs in these words: its count over every letter of the 1033 shipped SCOWL words, renormalised over the consonants (table below). So the table comes from the same files the toolbox ships, and no other source enters. Lengths are set per length to give the words' exact length shares (180, 216, 204 and 120 strings of 3-6 letters), from a recorded seed (DEMO_CONSONANT_SEED, 4941), so the same strings come out on every build. A draw is discarded and drawn again when it repeats an earlier string, is a SCOWL entry or acronym (every vowel-less entry of 3-6 letters in any SCOWL list, any size), is on the list of vowel-less SCOWL entries (src/ssvep/stim/data/fpvs_consonant_exclusions.txt, 1883 strings, exact match), or starts with or contains an entry of the build script's hand list of 75 common or offensive acronyms and profane letter combinations (src/ssvep/stim/data/fpvs_consonant_blocklist.txt: WTF, FCK, SHT, BBC, NFL and so on, so WTFTFM is out as well as WTF; it is substring-matched only against this short list, since doing that against all 1883 entries would discard a third of the strings), so the realised letter counts follow the probabilities up to that rejection. These are type counts, not corpus token frequencies. The table counts letters once per word in this list of 1033 short content words, not per occurrence in running English text, and the list leaves out closed-class words (the, this, that, with, have), which is where much of English text's H and T comes from: H is 0.030 of the letters here against about 0.06 of the letters of running text. So H-heavy strings are rarer here than a corpus table would make them, and L, R and P commoner. Letters are i.i.d. apart from one rule: no letter three times in a row (a string such as RRR is drawn again; MAX_RUN = 2). The FPVS editor's Fill from built-in list offers both lists (240 SCOWL words, 720 generated consonant strings).
Letter frequencies over the 1033 shipped words (4961 letters), and the probability each consonant is drawn with:
| Letter | Count | Share of all letters | Draw probability |
|---|---|---|---|
| E | 670 | 0.1351 | never drawn (vowel) |
| A | 404 | 0.0814 | never drawn (vowel) |
| T | 401 | 0.0808 | 0.1327 |
| R | 357 | 0.0720 | 0.1181 |
| O | 330 | 0.0665 | never drawn (vowel) |
| L | 293 | 0.0591 | 0.0970 |
| I | 292 | 0.0589 | never drawn (vowel) |
| S | 279 | 0.0562 | 0.0923 |
| N | 270 | 0.0544 | 0.0893 |
| D | 196 | 0.0395 | 0.0649 |
| C | 186 | 0.0375 | 0.0615 |
| P | 176 | 0.0355 | 0.0582 |
| U | 160 | 0.0323 | never drawn (vowel) |
| H | 147 | 0.0296 | 0.0486 |
| M | 140 | 0.0282 | 0.0463 |
| F | 118 | 0.0238 | 0.0390 |
| G | 113 | 0.0228 | 0.0374 |
| B | 87 | 0.0175 | 0.0288 |
| W | 86 | 0.0173 | 0.0285 |
| Y | 83 | 0.0167 | never drawn (vowel) |
| K | 71 | 0.0143 | 0.0235 |
| V | 59 | 0.0119 | 0.0195 |
| X | 19 | 0.0038 | 0.0063 |
| J | 12 | 0.0024 | 0.0040 |
| Q | 9 | 0.0018 | 0.0030 |
| Z | 3 | 0.0006 | 0.0010 |
The matched lists in src/ssvep/stim/data/fpvs_reading_lists.csv: real words, pseudowords, consonant strings and false-font strings, five runs each, contributed by Aaron J. Newman; lists developed and matched for an earlier lexical-decision study. ssvep.stim.fpvs_demo.reading_list(name, runs) reads them, and reading_demo(lists="matched") builds the reading demo from them instead (60 of each, so a run repeats them: each consonant string up to 11 times and each word up to 3 times; the builder warns). reading_demo_pseudowords builds the variant with pseudowords as the base, for a lexical rather than orthographic contrast, from these matched lists (not shipped as a second manifest, to keep the bundle small). The lists are matched over the whole pool (60 unique words and 60 consonant strings with the same length distribution), which is why the default is all five runs; runs=(1, 2, ...) selects a subset (each string once, however many runs list it, since a word is in three of the five runs), and a subset is smaller and no longer length-matched.
The false-font strings are the pseudoword letters, meant to be drawn in a false font (BACS; Vidal, Content & Chetail, 2017). That font's licence is undeclared, so the toolbox does not ship it and the strings are not rendered in any demo yet; §7 has the fetch script for the font.
7. False fonts (BACS)¶
A false-font string is the usual control for letters: matched to real letters in visual complexity, but not letters. The BACS sets (Brussels Artificial Character Sets) are the validated ones, and the matched lists' falsefont_strings are meant to be drawn in them. The OSF project that hosts them declares no licence, so the toolbox does not ship or redistribute them. Fetch them yourself:
python scripts/fetch_bacs_fonts.py # lists what it would download and from where, then asks
python scripts/fetch_bacs_fonts.py --yes # no prompt
It lists the files from the OSF API (node vn66h), downloads BACS1.otf, BACS2sans.otf and BACS2serif.otf into stimuli/fonts/bacs/ (git-ignored; --dest for another folder), and writes the sha256 of each to fetch_record.json beside them. A second run leaves files that still match alone. The OSF listing is requested before you are asked (metadata only, to show sizes); no file is downloaded until you confirm. Each file is checked against OSF's md5 and deleted if it differs, and a file OSF gives no md5 for is deleted too unless you pass --allow-unverified. File names from the listing must be plain names (no path separators, drive letters or ..) or the script refuses. Nothing is downloaded without --yes or an interactive "y". If you use these fonts, cite the paper: Vidal, C., Content, A., & Chetail, F. (2017). BACS: The Brussels Artificial Character Sets for studies in cognitive psychology and neuroscience. Behavior Research Methods, 49, 2093–2112. https://doi.org/10.3758/s13428-016-0844-8
Both fetch scripts refuse, rather than rename, a file name from the server that is not a plain name: a path, a Windows device name (CON.otf, NUL, COM1), a trailing dot, a control, line-break, tab or bidirectional-override character, or one over 200 characters. A listing that is not JSON (a captive portal's page), a download that is cut short and a file-system error each end with a one-line message and exit code 1, never a traceback (#507).
8. Faces (London set)¶
The faces demo is an expression-change design: neutral faces at 6 Hz, a smiling face every 5th item, so a 1.2 Hz response and its harmonics says the visual system separates the expression from the general face response (Dzhelyova, Jacques & Rossion, 2017, used this base rate with an expression oddball every 5th item). The pictures are the Face Research Lab London Set (DeBruine & Jones, 2017): 102 adult models, front-facing, each neutral and smiling. It is CC BY 4.0: free to use and share with credit, which is why the toolbox fetches it rather than shipping it (modified copies need the credit kept with them). The only emotion it covers is happiness.
python scripts/fetch_london_faces.py # shows ~34 MB + ~54 MB, asks first (or --yes)
python scripts/build_fpvs_faces_demo.py # writes the run and a protocol, locally
From a copy you already downloaded. If you have the set's files from figshare (the zips and london_faces_info.csv, under figshare's own names), skip the download: python scripts/fetch_london_faces.py --from-dir path/to/folder, then the build command above. It makes no network request, reads only neutral_front.zip, smiling_front.zip and london_faces_info.csv (the other views are ignored), never modifies that folder (--dest must be a separate folder) and unpacks into stimuli/faces/london/ exactly as the download does, with the same ATTRIBUTION.txt and a fetch_record.json (sha256 of what it read; verified is false because there is no source checksum to compare). Only picture files are unpacked (the set's .tem landmark files and macOS junk are skipped). The demo's design is the one this section describes: 102 models in each list, 6 Hz base, a smiling face every 5th item (1.2 Hz), 2 sequences of 60 s with a 2 s fade, a fixation-colour task. The shipped example. session-protocols/example_fpvs_faces.json is this design built at 60 Hz with the default L* matching and a new order for each session, with its 204 pictures in session-protocols/example_fpvs_faces.assets/faces/ (about 10 MB; every picture is pinned by its sha256) and the credit in session-protocols/example_fpvs_faces.assets/ATTRIBUTION.txt. Unlike the reading demo it ships as a protocol only, with no separate run manifest, so the pictures exist once; load it in Build protocol, or open its run from there. Credit and citation. The pictures are modified copies of the Face Research Lab London Set: DeBruine, L. M. & Jones, B. C. (2017). Face Research Lab London Set. figshare. https://doi.org/10.6084/m9.figshare.5047666.v5, licensed CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). The authors ask that the set be cited as above; the bundle's ATTRIBUTION.txt carries that, the licence link and a statement of the changes made (crop, oval mask, greyscale, L* matching and roll-off), and must stay with the pictures wherever they are copied. To rebuild it from your own fetched copy: python scripts/fetch_london_faces.py (or --from-dir), then python scripts/build_fpvs_faces_demo.py --ship (it overwrites the shipped protocol and bundle; without --ship the build writes under the git-ignored stimuli/ instead, and a bundle built there is not to be committed).
Credit. CC BY 4.0 needs the credit kept with the pictures and any modified copies, so the build copies ATTRIBUTION.txt (the citation and the licence link) followed by a "Changes made" block generated from that run's own luminance_matching record (greyscale or colour, the method, the region it was measured on and its targets, how much the roll-off eased or how much was clipped), into the folder it writes beside the built pictures. Do not delete it.
The fetch script lists the files through the figshare API, checks each download against figshare's md5 and unpacks to stimuli/faces/london/neutral/ and .../smiling/ (git-ignored), with the set's london_faces_info.csv, an ATTRIBUTION.txt and fetch_record.json (sha256 of everything; the record is updated after each verified file, and a re-run restores a deleted neutral/, smiling/ or ATTRIBUTION.txt from the verified zips without downloading again). Unpacking refuses two zip entries with the same file name. The build script writes stimuli/faces/generated/fpvs_faces.json, its .assets/ pictures, fpvs_faces_protocol.json and a copy of the attribution. Nothing it writes is ever committed: stimuli/ and every *.assets/ folder are ignored and tests/test_compliance_data.py fails if one is tracked. Options: --refresh-hz, --base-hz (6), --every (5), --seed, --match, --grayscale (only with --match mean; the default mode is always greyscale and grayscale=False there is an error).
Each picture is centre-cropped to 3:4, resized to 300 x 400 px and composited through a fixed soft-edged oval onto the display's mid-grey (the raw photographs are head-and-shoulders on the lab's own backdrop, with hair and clothes; they are not masked faces, so the demo masks them). Everything outside the oval is then exactly the background, which stays fixed at the display's grey for every item. Inside the oval there is still some backdrop around the face: the oval is wide enough that on the London set a sixth to a quarter of its pixels are bright studio backdrop (L* above 85), most of it in its outer half. Every face of both lists is then converted to greyscale and matched together. A face whose mean luminance is below 0.01 (linear) is refused, as is a flat one.
The default: lightness and perceived contrast of the face, in CIELAB L* (match="lstar", build script --match lstar; fpvs_demo.match_faces_lstar). Each face is linearised with the display's gamma (as the renderer does) and converted to CIELAB lightness L*, a perceptual scale. Its mean L* and standard deviation of L* (perceived contrast) are measured on the face core (fpvs_demo.face_core_mask): the oval's fully opaque interior limited to radius 0.6 of the oval, with the same centre and axes. That covers the eyes, brows, nose, mouth and cheeks, cuts the hairline and chin, and is one fixed region for every picture; it holds almost no backdrop (about 1 % of its pixels on average, at most 6 %, against 14-51 % in each outer tenth of the oval's radius). The whole picture inside the oval is then transformed so that the core's mean and SD equal the set's medians (SHINE's mean-and-SD match, done in L* on the face). Where the transformed values would leave the displayable 0-100, a smooth roll-off eases them instead of clipping: L* is unchanged between 10 and 90 and eased towards 0 and 100 beyond (a tanh shoulder, monotonic, so no tonal order is lost and no detail in the face is flattened to black or white). The bright studio backdrop around the most-lightened faces does saturate to white: on the London set about 2% of oval pixels end at full white (up to about a third of the oval on the most-lightened face), all of it backdrop, not face.
Why the core and not the whole oval. An earlier version of this matching took the statistics over the whole oval. Against darker skin the bright backdrop inside the oval inflates its SD of L*, so equalising that SD made the faces themselves unequal: on the London set the darkest quarter of the faces came out 17 % flatter on the face than the lightest quarter, while the oval-level numbers looked level. A luminance threshold (dropping L* > 85) was rejected as the alternative: skin highlights, eye whites and teeth reach it on lighter faces and in smiles, which would make the region depend on skin tone and expression.
What it gives on the London set (204 pictures, measured on the face core of the written 8-bit pictures). The targets are mean L* 54.5 and SD 13.4. Face contrast (core SD of L*) of the darkest and lightest quarters of the faces (by raw face-core lightness) is 13.1 and 13.4 (2 % apart), and it varies by 2.4 % across all faces (coefficient of variation); the black and white models' faces end at 12.7 and 13.4. Mean lightness is 54 in both quarters. The roll-off reshapes a median 0.7 % of a face core's pixels (at most 2.9 %): eye whites, teeth and stray backdrop on the most-lightened darker faces. Counting the backdrop inside the oval it reshapes up to 37 % of a face's pixels, nearly all of them bright backdrop eased towards white. At the 1.2 Hz oddball rate neither the face core's mean L* nor its SD differs between smiling and neutral faces (+0.01 and +0.05 L*), and a simulation of the run's item sequences puts the whole-item luminance signal at 1.2 Hz at the level of the unmatched photographs.
What the photographs already had, and what this choice costs, stated plainly.
- The raw photographs' faces have about equal perceived contrast across skin tone: core SD of L* is 14.2 for the darkest quarter and 13.0 for the lightest, with no correlation with lightness. By group it is not equal: the black models' faces have about 13 % less core contrast than the white models' (12.3 against 14.1), a property of how the photographs were taken. Matching evens that out; keeping natural contrast would keep it.
- Every face is brought to one lightness: darker faces are lightened and lighter faces darkened. The darkest quarter's face core goes from mean L* 38 to 54, the lightest from 63 to 54. Every match of the mean across identities does this; it was chosen knowingly over keeping natural skin tones. The backdrop inside the oval follows: around the most-lightened darker faces it is pushed towards white.
- Smiling and neutral faces differ in the photographs: smiling faces' cores are darker (-1.5 L*) and higher in contrast (+1.4 L*). Matching on the core removes that. Matching on the whole oval does not, which is a second reason for the core.
- A residual linear-light contrast signal remains. Equal SD of L* is not equal linear RMS contrast when smiles' tonal distribution is shaped differently (teeth), so smiling faces' cores end slightly lower in linear RMS contrast than neutral ones (-0.015 in std/mean), and in simulations of the run that difference shows at 1.2 Hz in most runs. Which contrast metric drives early visual responses at this level is open; L* is the perceptual, defensible choice, but it is a trade.
The run's luminance_matching block records the method (lstar_mean_sd_face_core), core_radius (0.6), the targets (target_mean_lstar, target_sd_lstar), and per face, all on the core: mean and SD of L* before and after (after measured on the written 8-bit pictures), linear mean luminance and RMS contrast before and after, and the roll-off (rolloff_knee; rolloff_fraction, the share of core pixels it reshaped; rolloff_max_shift_lstar, the largest change it made to one core pixel; out_of_range_fraction, the share of core pixels a hard clip would have clipped; rolloff_fraction_oval, the share of all the oval's opaque pixels, backdrop included; their maxima and n_faces_out_of_range_over_1pct). It also records the fairness of face contrast across skin tone, with the raw face-core mean L* as the skin-tone proxy: fairness_sd_darkest_quartile, fairness_sd_lightest_quartile and their fairness_quartile_gap, fairness_sd_cv, and fairness_r, the correlation between skin tone and core SD of L* after matching. Read the gap and the CV first: when the SDs barely vary, a tiny consistent residue gives a large r (on the London set r is 0.63 with a 2 % gap). The build warns when the roll-off reshapes more than 5 % of any face core's pixels, and the built ATTRIBUTION.txt states the targets, the core, and how much the roll-off eased.
Why not linear-light RMS contrast (match="mean_rms", --match mean_rms, the earlier default, kept only as an explicit option; fpvs_demo.match_faces_mean_rms). It equalises mean luminance and RMS contrast (standard deviation divided by mean) in linear light, to the set's median mean and a quantile of its per-face RMS contrast (rms_target_quantile, --rms-quantile, default 0.10), clipping what leaves the range. It is biased against darker faces. Equal linear std/mean is not equal perceived contrast: a darker face's tonal detail sits low on the luminance curve, where small linear differences are large perceptual ones, so its linear std/mean is inflated, and equalising that number cuts darker faces' contrast hardest. On the London set, measured on the face core, it left the darkest quarter of the faces with 47 % less perceived contrast than the lightest (core SD of L* 6.1 against 11.4), where the photographs had them about equal. Use it only on purpose, for example to rebuild a design made with it. Its record carries method: "mean_rms_face_pixels", target_mean_luminance, target_rms_contrast, rms_target_quantile, the per-face values before and after, clipped_fraction and max_clipped_fraction; it warns when any face clips more than 1 % of its pixels and when low-contrast outliers set the quantile target (rms_target_outliers). Runs built with it, before or after the default changed, load unchanged and keep reporting their method; nothing relabels them.
match="mean" (--match mean) is the earliest, mean-only behaviour: colour faces (or grayscale=True, --grayscale), contrast scaled about the grey to the mean of the lowest-contrast face; it refuses a set whose face means fall on both sides of the grey, and a matching that cuts any face's contrast below half (min(factors) < 0.5) warns and sets strong_contrast_loss: true (with min_factor). Both lists draw on the same models: files are paired by the first number in their names, at least 10 models must be shared (fewer, a name with no number, or two files for one model is refused rather than pairing different people), and n_items keeps the same first N models in both lists. From Python: fpvs_demo.faces_demo(neutral_dir, smiling_dir, bundle_dir, ...). No picture ships, so CI checks the matching only on synthetic faces.
With --match mean, expect the build to warn on the real set. The 102 models span a wide range of skin tones, and scaling every picture's contrast about the grey to one mean flattens the darkest faces towards grey (a probe with realistic face means gave factors as low as 0.1). The L* default does not do this, and refuses a picture whose face core is nearly black (a mis-cropped photo or a dark backdrop inside the oval).
Cite: DeBruine, L. M. & Jones, B. C. (2017). Face Research Lab London Set. figshare. https://doi.org/10.6084/m9.figshare.5047666.v5 (CC BY 4.0). Dzhelyova, M., Jacques, C., & Rossion, B. (2017). At a single glance: Fast periodic visual stimulation uncovers the spatio-temporal dynamics of brief facial expression changes in the human brain. Cerebral Cortex, 27(8), 4106–4123.
9. One engine for FPVS and the later image stages¶
FPVS runs on ssvep.stim.item_stream, a frame-exact engine for any sequence of pictures with a per-item envelope and optional fades. #262's later image stages are meant to become configurations of it rather than new renderers: stage 2's alternation is a two-item stream alternating every half-cycle with a constant envelope, and stage 4's per-trial images are a stream whose items last one trial each. The luminance-matching helper (ssvep.stim.images.match_mean_luminance) is the one stage 2 needs for luminance-matched pairs.
Pointers¶
src/ssvep/stim/item_stream.py— the engine (pure).src/ssvep/stim/fpvs.py— the design block, its rules, the schedule, markers and per-frame driver (pure).src/ssvep/stim/fpvs_renderer.py— the pyglet shell.src/ssvep/stim/text_items.py,src/ssvep/stim/fpvs_demo.py— text items and the reading demo.src/ssvep/analysis/fpvs.py— the analysis (array API).- Lochy, A., Van Belle, G., & Rossion, B. (2015). A robust index of lexical representation in the left occipito-temporal cortex as evidenced by EEG responses to fast periodic visual stimulation. Neuropsychologia, 66, 18–31.
- Retter, T. L., Rossion, B., & Schiltz, C. (2021). Harmonic amplitude summation for frequency-tagging analysis. Journal of Cognitive Neuroscience, 33(11), 2372–2393.
- DeBruine, L. M. & Jones, B. C. (2017). Face Research Lab London Set. figshare. https://doi.org/10.6084/m9.figshare.5047666.v5 (CC BY 4.0).
- Dzhelyova, M., Jacques, C., & Rossion, B. (2017). At a single glance: Fast periodic visual stimulation uncovers the spatio-temporal dynamics of brief facial expression changes in the human brain. Cerebral Cortex, 27(8), 4106–4123.
- Vidal, C., Content, A., & Chetail, F. (2017). BACS: The Brussels Artificial Character Sets for studies in cognitive psychology and neuroscience. Behavior Research Methods, 49, 2093–2112. https://doi.org/10.3758/s13428-016-0844-8
- Liu-Shuang, J., Norcia, A. M., & Rossion, B. (2014). An objective index of individual face discrimination in the right occipito-temporal cortex by means of fast periodic oddball stimulation. Neuropsychologia, 52, 57–72.