Experiment example (Psychtoolbox): Flipped squares, photosensitivity-safe
Description
A passive visual pattern-reversal experiment for the KIT MEG system. A low-contrast checkerboard on a mean-luminance grey background fades in, then reverses polarity continuously (light and dark checks swap) for the whole block, then fades out. The participant fixates a static red dot. There is no task and no response box.
The task was designed for a participant with epilepsy and is epilepsy-safe by default (small central square, every limit below). For a participant without epilepsy the launcher also offers a full-screen checkerboard (see Participant profile).
Pattern A: top-left check light.
Pattern B: the same checkerboard with light and dark swapped.
Both patterns contain exactly the same luminances in equal areas, so a reversal changes only the spatial arrangement of light and dark, never the mean luminance. Each reversal evokes a pattern-reversal visual evoked field (the MEG counterpart of the clinical pattern-reversal VEP, P100m), with minimal photic stimulation.
Code:
experiments/psychtoolbox/flipped_squares
(start with main.m; see Files).
The two polarities with default settings: 4 x 4 checks, Michelson contrast 0.40, 10 deg (2.5 deg checks), on grey 128. The red fixation dot is visible throughout the run.
Presentation sequence
After the operator presses SPACE, one continuous block runs:
run start trigger (KIT 228), then 2 s of grey with the fixation dot;
pattern A fades in over 1 s (raised cosine; trigger KIT 227 on the first fade frame);
half a second later the first reversal to B, then a reversal every 0.5 s (2 reversals per second), strictly alternating A and B. Every reversal is abrupt (one frame) and carries the channel of the new polarity: KIT 225 for a reversal to B, KIT 224 for a reversal to A;
half a second after the last reversal the pattern fades out over 1 s (trigger KIT 226 on the first fade frame);
2 s of grey, then the run end trigger (KIT 229, completed runs only).
A completed run therefore contains nReversals + 4 pulses (244 by
default), each with exactly one row in events.tsv.
Polarity of the top-left check (A up, B down) and the trigger pulses for a short example block of 8 reversals. Each trigger is a single 120 Hz frame.
Frame-level view. Left: the 1 s fade-in (120 frames); the pattern reaches full contrast 1 s after the fade-in trigger. Right: a reversal swaps the polarity from one frame to the next; the trigger is on the first frame of the new polarity and cleared on the next frame.
The fades scale only the check contrast around the background, so the mean
luminance stays constant during them as well. Reversals are abrupt, as in
the clinical pattern-reversal VEP, so the evoked response is sharply
time-locked to each reversal trigger. Reversals are scheduled on a fixed grid
relative to the run-start frame (2 s + fade + k x 0.5 s), so timing
errors never accumulate across a run. Between reversals nothing is redrawn.
Photosensitivity safety
Important
This protocol is intended for a participant with epilepsy. Obtain the responsible clinician’s approval of the stimulus (use *Preview stimuli*) and of the safety limits before the session, and record that approval with the study protocol. The limits are a conservative reading of published guidance; they are not a clinical guarantee.
Published guidance on visually induced seizures (Epilepsy Foundation expert consensus, Harding et al. 2005; updated review, Fisher et al. 2022, Epilepsia; ITU-R BT.1702 and UK broadcast guidance) identifies the main risk factors as luminance changes at 3 or more per second, oscillating patterns with 5 or more light-dark stripe pairs, flashing areas above roughly 10 to 25 % of the visual field, high luminance, and saturated red flashes. The experiment stays far from each of them:
Property |
Default / hard limit |
How it is kept safe |
|---|---|---|
Mean luminance |
constant |
Light and dark checks are symmetric around the grey background and an even number of checks gives equal areas. Pattern A, pattern B and the grey screen therefore have the same mean luminance: there is no full-field flash at a reversal or a fade. |
Contrast (Michelson) |
0.40 / 0.05 to 0.60 |
Never black/white. Pixel values 77 / 128 / 179 at the default. |
Reversal rate |
2/s / 0.5 to 2 per second |
Each reversal changes the luminance of every check once, so at most 2 luminance changes per second at any point (below 3/s). Rates must give a whole number of 120 Hz frames (2, 1.5, 1.2, 1 … per second). |
Pattern |
4 x 4 / at most 8 x 8; each check at least 1 deg |
At most 4 light-dark pairs per side (below 5); at most 0.5 cycles/deg. |
Size |
10 deg (about 5 % of the screen) / at most 12 deg (about 7 %) |
Central field, well below the 10-25 % area at which risk rises. Full screen is refused for this profile. |
Fade in / fade out |
1 s / 0 or 1 frame to 3 s |
The pattern appears and disappears gradually at the start and end of the block. |
Fixation dot |
static red, 0.2 deg |
Never changes, so it is not a red flash. |
Run length |
at most 15 min |
Split longer sessions into runs with breaks. |
Window open/close |
2 s fade to black |
Psychtoolbox splash/test screens are disabled ( |
The limits are defined once in cfg.safety in
flipped_squares_config.m.
They are not editable from the launcher. flipped_squares_check_params.m
enforces them in the launcher (Start stays disabled) and again at the start of
every run, so scripted calls cannot bypass them. Non-numeric, non-double,
fractional or out-of-range values are refused, and so is a ramp shorter than
one frame, a reversal rate above 2 per second and a rate that is not a whole
number of frames. The Preview stimuli button only renders stimuli that pass
the limits.
Note
Compared with the earlier onset/offset design (pattern shown 0.5 s every 2 s), the pattern is now on screen for the whole block. The luminance change rate per check is the same (at most 2 per second) and contrast, size and spatial frequency are unchanged, but the exposure to the pattern is longer: include this in the clinician’s review.
ESC or Q stops the run at once: the pattern is replaced by grey on the next frame, the trigger pixel is cleared, and the screen fades to black.
Participant profile
The launcher’s Participant box sets two run parameters that are saved with
every run (TaskParameters in events.json, Parameters and
ParticipantProfile in beh.json):
Setting |
Effect |
|---|---|
Participant has (or may have) photosensitive epilepsy
( |
Every limit in the safety table applies; the pattern is a central square of at most 12 deg. Full screen cannot be selected. |
Unticked ( |
The operator must confirm in a dialog that the participant does not have epilepsy or a history of photosensitivity. Full screen can then be ticked. |
Full-screen checkerboard ( |
The board fills the whole screen (about 57 x 34 deg on the PROPixx), centred on the fixation dot with a check corner at the centre. The check size is set directly (default 2.5 deg, 1 to 20 deg); square size and checks per side are ignored. Because the board is mirror-symmetric with opposite polarity about the vertical centre line, light and dark areas are exactly equal on the 1920-pixel-wide projector and the mean luminance still never changes. Contrast (<= 0.6), reversal rate (<= 2/s) and the 1 deg minimum check size still apply. |
flipped_squares_check_params.m refuses fullScreen = 1 together with
epilepsySafe = 1, so a scripted run cannot select full screen for the
epilepsy profile either. The production confirmation dialog repeats the
profile and pattern before the run starts.
Warning
A full-field reversing checkerboard is a strong pattern stimulus. Use the full-screen setting only for participants without epilepsy or photosensitivity.
Conditions
There is no randomization: reversals strictly alternate A -> B -> A, and the
number of reversals must be even, so there are exactly nReversals / 2
reversals to each polarity. Reversal 1 is to B (the block starts on A), so
odd reversals are to B and even ones to A.
MEG setup
Trigger protocol
Single-channel DataPixx pixel mode, with the same RGB codes as trigger_template.m and the Arabic language localizer. The mapping is defined once in flipped_squares_trigger_map.m.
Event |
KIT channel |
MNE channel |
Pixel RGB |
When |
|---|---|---|---|---|
|
224 |
|
|
First frame of pattern A after a reversal |
|
225 |
|
|
First frame of pattern B after a reversal |
|
226 |
|
|
First frame of the fade-out |
|
227 |
|
|
First frame of the fade-in |
|
228 |
|
|
First frame of the baseline |
|
229 |
|
|
End of the final grey of a completed run |
off |
none |
none |
|
Every other frame |
A completed run contains nReversals reversal pulses (224 + 225, half
each), one pattern_fade_in, one pattern_fade_out, one run_start
and one run_end: 244 pulses for the default 240 reversals. An aborted run
has no fade-out and no run_end; events.tsv always lists exactly the
pulses that were sent. Each pulse lasts one refresh (8.33 ms at 120 Hz); the
script checks every pulse width from the flip timestamps; a pulse off by
more than half a frame is logged and printed as a warning.
Channels 224 to 229 must be wired and checked on the KIT
before the first session (test_all_meg_channels_triggers.m pulses all of 224-231). The trigger marks the start of frame
scan-out, not the moment the participant sees the image: see
Display and trigger timing
for the measured projector lag (+8.36 ms). Reversals are abrupt, so the new
polarity is fully on screen on the trigger frame.
DataPixx and display safety
Warning
The background of this task is grey (128), not black. In pixel mode a grey top-left pixel would raise trigger lines. The code therefore draws the trigger rectangle explicitly black on every frame that is not an intended trigger, including text screens and recovery frames.
DataPixx is opened, set to DLP sequence program 0, with pixel mode disabled while the instruction screen is visible.
Pixel mode is enabled only after the operator presses SPACE, while the trigger pixel on screen is black.
Pixel mode is disabled before the end screen is drawn.
On abort or crash, the stimulus is cleared to grey with a black trigger pixel and pixel mode is disabled before any file is written.
The mouse cursor is hidden on the stimulus screen and parked in its centre, so it can never cover the trigger pixel.
30 trigger-free warm-up frames (0.25 s,
cfg.warmupFrames) follow SPACE before the run-start pulse. The first flips after the start screen can return a bad timestamp on the stimulus PC (PTB “beamposition timestamping computed an impossible stimulus onset”); the warm-up keeps that away from every trigger frame.Production requires a measured refresh of 120 +/- 0.5 Hz. Every reversal interval or trigger pulse outside one frame of its target is logged in the
*_okcolumns and printed as a warning. Timing never stops a run.A pulse whose flip timestamps give less than half a frame (impossible for a 1-frame pulse) is a timestamp error, not a trigger error: it is counted in
RunInfo.n_timestamp_anomaliesinbeh.jsonand never stops the run. The hardware pulse is still there forfill_events_onsets.py.
Geometry
Degrees are converted to pixels with the KIT projector geometry measured on
2026-09-06 (60 cm viewing distance, 65 x 37 cm projected image), using the
full screen width so windowed debug runs show the same sizes. The realized
square size in pixels and degrees is saved with every run. Update
viewingDistanceCm / screenWidthCm in the config if the setup changes.
The PROPixx is specified with a linear gamma (displayGamma = 1); if a
photometer measurement shows otherwise, enter the measured exponent and the
pixel values are recomputed in luminance units.
Running the experiment
Launcher
cd experiments/psychtoolbox/flipped_squares
main % opens the launcher (production preset selected)
The planned run length is recomputed whenever a value changes (press Enter or Tab after typing), together with the number of reversals to A and B, the fade and baseline times, the luminance changes per second, check size and square size. The Checks box lists every reason the run cannot start.
Subject / Session / Run: BIDS labels (letters and digits only; a
sub-orses-prefix is accepted). The next free run number for the chosen subject, session and preset is suggested automatically; an existing run is never overwritten.Participant: epilepsy-safe (default) or, after confirmation, without epilepsy, which enables the full-screen checkbox (see Participant profile).
Run parameters: number of reversals, reversals per second, contrast, square size, checks per side (square only), check size (full screen only) and fade in/out. The allowed range is shown next to each field.
Preview stimuli shows both polarities at the projector geometry (the operator monitor’s size and luminance differ from the PROPixx).
Start is disabled while anything is invalid. A production start asks for confirmation that KIT recording is running.
Preset |
DataPixx |
Display and checks |
Output folder |
|---|---|---|---|
Production |
ON |
Full screen, 120 Hz required, sync tests on, timing logged only |
|
Full rehearsal |
OFF |
Full screen, refresh not enforced |
|
Debug |
OFF |
Windowed, sync tests skipped, visible 50 x 50 px trigger patch |
|
Operator keys: SPACE starts the run, ESC or Q stops it at any time and saves the partial data. The participant never presses anything.
The console prints a progress line every 20 reversals (Reversal 40/240,
about 1 min 44 s to go). A run stopped early keeps the standard file names
with RunStatus aborted in beh.json; events.tsv has exactly one
row per trigger actually sent, so its onsets can be filled from the MEG
recording and the reversals shown can be analysed.
Run length
run length = 2 s grey + fade in + (nReversals + 1) / rate + fade out + 2 s grey
Reversals |
Rate |
Run length |
Note |
|---|---|---|---|
240 |
2 /s |
2 min 07 s |
Default (1 s fades) |
400 |
2 /s |
3 min 27 s |
|
600 |
2 /s |
5 min 07 s |
|
1200 |
2 /s |
10 min 07 s |
Maximum number of reversals |
Scripted launch
Passing a struct bypasses the launcher; the safety limits still apply.
spec = struct('subject', 'P01', 'session', '01', 'mode', 'debug');
spec.params = struct('nReversals', 20); % optional overrides, plain doubles
main(spec) % run = next free run number
% participant without epilepsy, full-screen board
spec.params = struct('epilepsySafe', 0, 'fullScreen', 1, 'checkSizeDeg', 2.5);
main(spec)
main('validate') % headless self-test (no PTB needed)
Psychtoolbox on Windows
As in the Arabic localizer, PsychStartup is called before Screen so
the GStreamer and libusb DLLs are found even when startup.m omits it.
Before the session
Clinician approval of the stimulus and the safety limits.
Set the stimulus computer’s desktop background on the projector screen to black.
Run
main('validate')after any change to the code or configuration.Run a Debug or Full rehearsal run on the stimulus computer.
Verify KIT channels 224 to 229 with test_all_meg_channels_triggers.m if the setup or wiring changed, then record a short production test run and check its pulse counts (see Preprocessing).
Start KIT acquisition, confirm the participant is fixating, press SPACE.
Saved data (BIDS)
Output is written below the experiment folder in a BIDS tree (data/ for
production, data_rehearsal/ for the other presets; both Git-ignored):
data/
dataset_description.json
README
sub-P01/ses-01/meg/sub-P01_ses-01_task-flippedsquares_run-01_events.tsv
sub-P01/ses-01/meg/sub-P01_ses-01_task-flippedsquares_run-01_events.json
sub-P01/ses-01/beh/sub-P01_ses-01_task-flippedsquares_run-01_beh.tsv
sub-P01/ses-01/beh/sub-P01_ses-01_task-flippedsquares_run-01_beh.json
stimuli/task-flippedsquares/checks-4_contrast-398_px-156_gamma-100_pattern-a.png
stimuli/task-flippedsquares/checks-4_contrast-398_px-156_gamma-100_pattern-b.png
sourcedata/sub-P01/ses-01/beh/sub-P01_ses-01_task-flippedsquares_run-01_desc-snapshot_beh.mat
sourcedata/sub-P01/ses-01/beh/sub-P01_ses-01_task-flippedsquares_run-01_desc-matlabconsole_log.txt
All files are written when the run is initialized and at completion, abort
or crash (not during the block, so disk writes can never delay a reversal). RunStatus in both JSON sidecars is initialized, running,
completed, aborted or crashed.
events.tsv (meg/)
One row per trigger pulse, in time order: run_start,
pattern_fade_in, one row per reversal (reversal_to_b,
reversal_to_a, …), pattern_fade_out, run_end.
Column |
Content |
|---|---|
|
|
|
Reversal rows: measured time to the next reversal (to the fade-out for
the last one); |
|
|
|
Integer event id = KIT channel (224 to 229); unique per |
|
|
|
KIT trigger channel, read by the lab’s single-channel sanity check. |
|
Polarity on screen from this event on ( |
|
Image path relative to the dataset |
|
Flip time of the frame carrying the pulse minus that of the run-start frame (stimulus-computer clock), used to verify the recorded triggers. |
|
Reversal number (0 for fades and run markers). |
|
Configured reversal period or fade length; realized fade (whole frames, 0 on reversal rows). |
|
Realized Michelson contrast after 8-bit rounding. |
|
1 if DataPixx pixel mode was active for this pulse (production), 0 in rehearsals. |
events.json documents every column and carries everything needed to
process the run without the MATLAB files:
Key |
Content |
|---|---|
|
|
|
For each KIT channel: MNE channel name, |
|
Number of pulses each channel must contain for this run. |
|
Which frame each trigger marks (fade in, reversal, fade out, run start, run end). |
|
Pulse length in frames and the measured display refresh. |
|
0.00836 s, the measured trigger-to-projector delay (logged, never applied to any time in the table). |
|
|
|
The run parameters, including |
|
Operating system, Psychtoolbox version and code version. |
As in the Arabic localizer, onset and sample are written as n/a
because only the recording knows them; the file becomes validator-ready once
they are filled (next section).
beh.tsv (beh/)
One row per planned reversal, including ones not reached in an aborted
run: new polarity, trigger code, status, planned and measured time (relative
to run start and as absolute Psychtoolbox VBL times), error in ms, measured
interval to the next reversal, trigger-pulse duration, the two timing checks
and missed flips. beh.json describes each column and stores the full
parameter set, derived timing, safety limits, realized stimulus values (pixel
values, contrast, size in px and deg), display information, hardware state,
trigger map and the fade-in/fade-out times (RunInfo).
sourcedata/
The .mat snapshot contains everything above plus the RNG states; the
console log is a per-run MATLAB diary that is never appended to.
Preprocessing
Filling onset and sample
fill_events_onsets.py
detects the pulses on MISC 001-MISC 008 with the lab’s
detect_pulses_on_channel (the detector used by the single-channel sanity
check), checks that the recorded sequence of channels is identical to the
logged sequence, and writes the events table with onset and sample
filled:
python experiments/psychtoolbox/flipped_squares/fill_events_onsets.py \
--events data/sub-P01/ses-01/meg/sub-P01_ses-01_task-flippedsquares_run-01_events.tsv \
--raw <path>/sub-P01_ses-01_task-flippedsquares_run-01_meg.con \
--out <BIDS root>/sub-P01/ses-01/meg/sub-P01_ses-01_task-flippedsquares_run-01_events.tsv
Every row with
hardware_emitted = 1is matched one-to-one, in order, to a recorded pulse on its channel (all five channels). If any pulse is missing or extra, nothing is written and the message names the first mismatch and the per-channel counts.ExpectedTriggerCountsinevents.jsongives the same counts for the lab’s1-kit_count_sanity_check_single_channel.py; a missingrun_end(RunComplete: false) means the run was not completed.The recorded intervals are compared with
onset_from_run_start; a difference above 2 ms is reported as a warning.Rows with
hardware_emitted = 0(rehearsals) stayn/a.MNE-BIDS
write_raw_bidswrites its own_events.tsvto the same path, so run this after conversion and write to the BIDSmeg/folder.
Epoching
sample counts from the first sample of the file. MNE event arrays include
first_samp, and the projector lag should be added when millisecond timing
matters:
import mne
import numpy as np
import pandas as pd
raw = mne.io.read_raw_kit(con_path, preload=True)
ev = pd.read_csv(filled_events_tsv, sep="\t")
ev = ev[ev.trial_type.isin(["reversal_to_a", "reversal_to_b"])]
lag = round(0.00836 * raw.info["sfreq"]) # trigger -> projector
events = np.c_[ev["sample"].astype(int) + raw.first_samp + lag,
np.zeros(len(ev), int), ev["value"].astype(int)]
epochs = mne.Epochs(raw, events,
event_id={"reversal_to_a": 224, "reversal_to_b": 225},
tmin=-0.1, tmax=0.4, baseline=(None, 0), preload=True)
evoked = epochs.average() # pattern-reversal response, both polarities
With 2 reversals per second the next reversal follows 0.5 s later, so keep
epochs within -0.1 to 0.4 s. Both polarities give the same reversal response
and are usually averaged together; comparing reversal_to_a with
reversal_to_b is a check for polarity-specific effects. The fade-in and
fade-out rows (227, 226) mark the start and end of the block and are not
used for averaging. Apply the projector lag to every trigger type.
Validation
main('validate') runs
self_test.m
without Psychtoolbox. It checks:
the trigger map (codes, unique channels, run start separate);
that the presets and safety limits respect the guidance above, and that the default run length is 2 min 07 s;
that 20 unsafe or invalid parameter sets are refused (excessive contrast, reversal rate, size or check count, 4 reversals/s, rates that are not a whole number of frames, odd checks, odd or fractional reversal counts, integer or logical types, sub-frame or over-long fades, runs over 15 min);
the participant profile: full screen refused with
epilepsySafe = 1, allowed with 0, square settings ignored in full screen, contrast and minimum check size still enforced;the images: exact polarity flip, identical mean luminance for A, B and the background, realized contrast, check count (also with gamma 2.2), and for the full-screen board at several screen and check sizes, with a check corner at the screen centre;
schedules: strict alternation starting with a reversal to B, balance, fixed reversal grid, planned run end equal to the launcher run length;
BIDS labels, file names and the content of every output file for a completed and an aborted run: one row per pulse, time order, alternating reversals, fade-out after the last reversal,
run_endonly when complete,ExpectedTriggerCountsequal to the table.
The run loop was also exercised end to end against a simulated 120 Hz Psychtoolbox clock, with and without fades: every pulse lasted one frame and carried the correct code (228, 227, then 225/224 alternating, 226, 229), reversals fell exactly 60 frames apart, the fade-in and fade-out each took 120 frames with monotonic contrast, nothing was redrawn between reversals, and an aborted run saved exactly the pulses that were sent.
Confirm on the real rig with a recorded rehearsal before the first participant.
Files
File |
Role |
|---|---|
|
Launcher, run loop, drawing, DataPixx and cleanup. |
|
Presets, default parameters, safety limits, geometry. |
|
Parameter and safety validation, planned run length. |
|
Event names, KIT channels and RGB codes. |
|
Builds the two checkerboards in luminance units. |
|
Alternating reversal sequence and planned block times. |
|
|
|
Writes events, beh and snapshot for each save. |
|
BIDS labels, paths, dataset files, next free run. |
|
TSV/JSON writers ( |
|
Headless test suite. |
|
Fills |
The figures on this page are generated by make_figures.py (the launcher image is a screenshot).
Open decisions before collection
Warning
Clinician sign-off on stimulus, limits, run length and number of runs.
Photometer measurement of the grey, light and dark luminance on the PROPixx (cd/m2) and of the display gamma.
The number of reversals needed per run and the number of runs.
The passive design has no behavioural attention check; consider eye tracking or a fixation check if attention must be documented.
References
Harding G, Wilkins AJ, Erba G, Barkley GL, Fisher RS (2005). Photic- and pattern-induced seizures: expert consensus of the Epilepsy Foundation of America Working Group. Epilepsia 46(9):1423-1425. https://doi.org/10.1111/j.1528-1167.2005.31305.x
Fisher RS, Acharya JN, Baumer FM, et al. (2022). Visually sensitive seizures: an updated review by the Epilepsy Foundation. Epilepsia 63(4):739-768. https://doi.org/10.1111/epi.17175