Skip to content

Worked example — three appearance contrasts over one grid

A complete, shipped session protocol you can load, run, and argue with: example-for-teaching-lab-cyton. Three appearance manipulations — shape, colour, pattern — each a pair of runs over one shared 9-target grid, on an OpenBCI Cyton with an 8-channel Electro-Cap.

It exists for two reasons. Every other shipped example manipulates frequency, so nothing demonstrated the appearance fields (color, pattern, pattern_reversal, spatial_freq_cpd) that have been in the run-manifest schema since the stimulus-expressiveness work. And a teaching lab needs a design a student can reason about: one that isolates something, and whose failures to isolate it are written down rather than discovered in the analysis.

⚠️ This protocol is not cleared for recording as shipped. It runs at 8–12 Hz, below the 30–50 Hz band this deployment's ethics approval covers. See §7 — flicker frequency is an ethics matter, not a preference.


1. The files

File What it is
scripts/build_teaching_lab_protocol.py The source. Generates everything below.
run-manifests/example_teaching_shape_square.json white square (shape group, A)
run-manifests/example_teaching_shape_circle.json white circle (shape group, B)
run-manifests/example_teaching_colour_amber.json amber square (colour group, A)
run-manifests/example_teaching_colour_cyan.json cyan square (colour group, B)
run-manifests/example_teaching_pattern_solid.json solid white square (pattern group, A)
run-manifests/example_teaching_pattern_checkerboard.json contrast-reversing checkerboard (pattern group, B)
session-protocols/example_for_teaching_lab_cyton.json the protocol that orders the six runs

The seven JSON files are generated, not hand-edited:

conda activate ssvep
python scripts/build_teaching_lab_protocol.py

Edit the script and re-run it; do not edit the JSON. The shape group's baseline and the pattern group's baseline have to be the same stimulus for either contrast to mean anything, and two JSON files kept identical by hand do not stay identical. (The protocol is protocol_version 1.2, so it embeds its six manifests — re-running the script is what updates the copies inside it. See Protocols and sessions.)

2. The design

Group A B What varies
shape white square white circle outline geometry
colour amber square (#FFA800) cyan square (#00CAFF) hue, at matched luminance
pattern solid white square white/black checkerboard, 1.0 cyc/deg, contrast-reversing spatial structure

Everything else is held constant across all six runs:

  • Grid — 3×3, target centres at ±6°, targets 2×2° of visual angle (builder.GRID_SPREAD_DEG / TARGET_SIZE_DEG, the same grid the other 9-class presets use, so it cannot drift from what Generate grid produces in the Design tab).
  • Frequencies — 8.0, 8.5, … 12.0 Hz, one per target, sinusoidal, full contrast.
  • Timing — 1.5 s cue, 4.0 s of usable stimulation plus a 0.14 s onset lead-in that analysis discards, 0.75 s ITI. 9 trials/block × 3 blocks = 27 trials per run, ~2 min 53 s of run time, ~1 min 52 s of flicker. Six runs ≈ 17 min of stimulus presentation, before prep, breaks and impedance checks.
  • Hardware — OpenBCI Cyton over the USB dongle at 250 Hz, on the Electro-Cap 8-channel 10-20 carrier (Cz Pz P7 P3 P4 P8 O1 O2). Reference, ground and electrode type are derived from the carrier, not typed in, so they cannot disagree with MONTAGE_ELECTROCAP.md.
  • Display — 40 cm diagonal at 70 cm viewing distance, black background, gamma 2.2. See §6.

No resting run and no questionnaires — this is a one-hour class, not a study session. For what a resting run would add (individual peak alpha, a negative control, a montage check before the SSVEP runs are spent), see Resting state; at 8–12 Hz the PAF question is more than academic, because a target flickering at a participant's own alpha peak is measuring their alpha rhythm as much as a driven response.

Order. Group order is fixed — shape, then colour, then pattern. Within each group the two runs are counterbalanced across participants. §8 has the catch in that.

3. Running it

  1. Build Protocol tab → load session-protocols/example_for_teaching_lab_cyton.json.
  2. Run Session tab → enter the participant code (sub-XXX — a numeric code only, never a name, initials or student number; see Data governance) and the session. The resolver expands the protocol into six ordered runs for that participant.
  3. Run them in the order it gives you. The order is a property of the participant code, not of the operator's memory.

Before the first class on a new rig, verify that the flicker actually reaches the screen at the frequencies the manifest claims — a photodiode against the panel, measured rather than assumed. A 240 Hz display that silently drops to 60 Hz on battery power turns 12 Hz into something else, and nothing in the recording will say so. Measure refresh in the Design tab is the software half of the same check.

4. What each contrast isolates — and what it doesn't

This is the part worth teaching from. None of the three manipulations is clean, and the reasons are different in each case.

4.1 Shape — confounded with light, and it cannot not be

The circle is inscribed in the same 2×2° bounding box as the square. So it covers π/4 ≈ 78.5% of the square's area and emits about 21% less light. The shape contrast is therefore shape and luminous flux, and flux is a strong driver of SSVEP amplitude. If the circle run decodes worse, the honest first hypothesis is "less light", not "circles are harder".

The tempting fix does not work. Matching the illuminated pixel count means growing the circle to a 2.26° diameter — and then it subtends a larger retinal area, extends further into the periphery, and samples a different population of receptive fields at a different cortical magnification. Area is not a nuisance variable you can quietly compensate; it is a second manipulation.

There are three things you could hold constant and you can only pick one:

Match Circle size What is then equal What is then unequal
Bounding box (chosen) 2.00° diameter extent, centre eccentricity, inter-target spacing, the grid area (78.5%), flux, contour length
Area / flux 2.26° diameter illuminated pixels, total light extent, eccentricity reached, target–target gap
Maximum extent 2.83° diameter corner-to-corner reach area (157%), flux

The bounding box was chosen because it is what the builder produces, and because it keeps the grid identical — same centres, same eccentricities, same gaps between neighbouring targets. The alternative moves geometry the rest of the design depends on.

Two smaller ones while you are here. Contour length falls by exactly the same π/4 factor (square perimeter 8°, circle circumference 6.28°), and SSVEP is substantially an edge response, so "less light" and "less edge" are themselves confounded with each other in this pair — these two runs cannot tell them apart. And the circle is drawn as a polygon approximation (a pyglet.shapes.Ellipse), so its boundary is not antialiased the way a rectangle's axis-aligned edge is; at 184 px per target that is a sub-percent effect on total light, but it is not zero.

The teachable point: "same size" is not one thing. Ask which sense of same a paper means, every time.

4.2 Colour — matched luminance, for a standard observer, on an assumed panel

The pair is amber (linear-light RGB 1.00 / 0.40 / 0.00, sRGB #FFA800) and cyan (0.00 / 0.60 / 1.00, sRGB #00CAFF). Both sit at Rec.709 relative luminance 0.50 — half of white, and equal to each other to well within one display code value. That match is derived in the build script from the target luminance rather than typed in, and asserted at build time, so the two cannot drift apart when someone edits one of them. The hues are far apart on both the L–M and S-cone axes, so they also stay distinguishable to the common colour-vision deficiencies.

This replaced a full-saturation red/blue pair. By those same weights, full blue sits at luminance 0.0722 and full red at 0.2126 — blue is about a third the luminance of red, so that version manipulated colour and a three-to-one luminance difference, which is not a colour experiment.

Four limits on how far "isoluminant" goes here:

  1. It is photometric, not measured. The weights (0.2126 / 0.7152 / 0.0722) assume sRGB primaries and the gamma 2.2 declared in the manifest. A wide-gamut or uncalibrated laptop panel has different primaries and a different transfer curve, so the two patches will not be exactly equal on that screen. Measuring them with a photometer at the viewing position is a genuinely good undergraduate exercise, and is the only way to claim more than "nominally matched".
  2. It is a standard observer. Individual isoluminance varies with cone ratios and macular pigment; the real procedure is heterochromatic flicker photometry, per person, and the match moves noticeably between individuals.
  3. It is a foveal standard, used at 6° eccentricity. Relative spectral sensitivity is not constant across the visual field, and every target in this design is in the periphery. A pair matched for the fovea is only approximately matched where these targets actually are.
  4. Neither run is a purely chromatic stimulus. Each target flickers between its colour and the black background, so both runs modulate luminance from 0 to 0.5 — what the matching equalises is how much luminance modulation each run has, not that there is none. A chromatic-only stimulus would swap between two isoluminant colours at constant mean luminance, which the current flicker model (background ↔ colour) does not express. That is a limitation of the toolbox, not of the example.

Expect both colour runs to decode worse than white, because both carry half the luminance modulation of the white runs. The comparison that means something is amber vs cyan, not either against the shape group.

The teachable point: "isoluminant" is a claim about a measurement, an observer, and a location in the visual field. Ask which.

4.3 Pattern — a different response, at a different frequency

The checkerboard is 1.0 cycle/degree across a 2° target — 2 cycles, a 4×4 board of 0.5° checks, about 46 px each on the class laptop — and it contrast-reverses at the target's frequency instead of dimming.

That makes it the least like its baseline of the three contrasts:

  • The response lands at 2f, not f. One flicker cycle contains two reversals, and the visual system responds to each. A 10 Hz checkerboard target evokes its strongest response at 20 Hz. The decoder's candidate frequencies are still 8–12 Hz.
  • The decoder is not blind to it, but it is not aimed at it. CCA and FBCCA templates here carry three harmonics, so the f template does contain 2f and does match the response — through its harmonic term, with the fundamental contributing nothing. FBCCA helps further: its sub-bands are laid out at multiples of the candidate band, so the second one does cover the 2f range, down-weighted by the filter-bank weighting (k^-1.25 + 0.25). But that machinery exists to pick up harmonics of the signal, not to treat 2f as the signal, and the strongest-weighted sub-band is still the one where this stimulus has least to offer. Lower accuracy in the checkerboard run is therefore not by itself evidence about checkerboards.
  • One specific confusion is built in. With 2f spanning 16–24 Hz and the candidates' third harmonics spanning 24–36 Hz, the two sets touch at exactly one point: the 12 Hz target's 24 Hz response is also the 8 Hz candidate's third harmonic. If 12 Hz is misclassified as 8 Hz more often than chance explains, that is why.
  • Mean luminance is constant during stimulation — a reversing checkerboard swaps light and dark checks, so the patch's average output does not change, while the solid target swings from black to white. The two runs therefore differ in mean luminance, in luminance modulation depth, and in spatial structure, all at once. Pattern-reversal versus solid is a paradigm comparison, not a parameter comparison.

The teachable point: changing the stimulus can change which frequency you should be looking at. Check that the analysis followed.

5. What to expect in the data

Eight tin electrodes, a 4-second window, calibration-free decoding, nine classes. Chance is 11%. The alpha band is chosen precisely because it gives the largest SSVEP on this hardware, and because a student should be able to see the response in the live decoder rather than take it on faith.

Rough expectations, in descending order of confidence: the two white runs (shape A, pattern A) decode best; the circle a little worse than the square; the two colour runs clearly worse than white but comparable to each other; the checkerboard's accuracy says more about the decoder's frequency assumptions than about checkerboards. All of this is a prediction, not a result — the point of running it is that it might be wrong.

For analysis, run the dataset's own reproducible pipeline (BIDS/code/01_xdf_to_bids.py, then 02_run_offline_pipeline.py) or the Analyze tab, which is the same code path — not an ad-hoc script. See BIDS conversion.

6. Display, resolution and refresh

Does it matter what resolution the manifest was authored at? Almost never — and the reason is worth knowing.

The manifest's display.resolution_px is design-time metadata, not a render setting. It feeds the off-screen check, the area-weighted photosensitivity warning, the Design-tab preview, and the BIDS ScreenSize field. At run time the renderer sizes every target from the live window resolution on the monitor it actually opens, which the Build/Run tabs auto-detect (the picker defaults to the highest-refresh panel; you can pin a specific one).

Because this design is authored in deg units, the physical mapping is re-derived on the day, from the live resolution and the screen diagonal you declared:

px_per_cm = hypot(res_w, res_h) / screen_diagonal_cm

So a 2° target stays 2° of visual angle on any 40 cm panel at 70 cm, whatever its pixel count. Only the pixel count changes:

Resolution (40 cm diagonal, 70 cm distance) px/cm 2° target Visible field
1920×1080 55.1 135 px ±14.0° × ±8.0°
2560×1600 (the class laptop) 75.5 184 px ±13.6° × ±8.6°

What does move is the aspect ratio. Going 16:9 → 16:10 on the same diagonal trades about 0.4° of width for 0.6° of height. The ±6° grid plus a 1° target half-width reaches ±7.0°, so it fits either way — with 1.0° to spare at 16:9 and 1.6° at 16:10. This example declares 2560×1600 because that is the class laptop's native resolution; had it stayed at 1920×1080 nothing would have rendered differently, but the design-time checks and the BIDS screen-size metadata would have described a display nobody used.

Two corollaries:

  • A norm-units design is a different story. There, sizes are fractions of the screen, so the same manifest on a different aspect ratio subtends a different visual angle. This is why deg is the canonical unit.
  • refresh_hz is nominal in the same way. The renderer takes the panel's advertised mode rate, and Measure refresh in the Design tab records what the rig actually did. On a laptop, check it on battery as well as on mains — power management changes refresh rate silently.

Set the screen diagonal and viewing distance honestly; those two, not the resolution, are what make the degrees real. Fix the viewing distance physically — a mark on the desk — rather than trusting it.

7. Photosensitivity and ethics

The design runs at 8–12 Hz, and that is a deliberate, accepted choice rather than an oversight:

  • It is below 15–25 Hz, the peak seizure-provocative band, which this design avoids entirely.
  • The teaching protocol screens participants for seizure disorders before anyone sits down.
  • The band gives by far the largest SSVEP on eight tin electrodes, which is what makes the live decoder legible in a class.

It is nevertheless below the 30–50 Hz band ssvep.stim.flicker treats as this study's safe one, so every target raises an elevated design-time warning (never high — that classification needs the peak band). Ten warnings per manifest: nine targets, plus the nominal-refresh nag. Those warnings are correct and should not be suppressed.

The amplifier is a second, independent divergence. An 8-channel OpenBCI Cyton is not in this deployment's approved equipment either, so this example diverges from the approved protocol on two axes — frequency band and hardware — and both need covering before a participant is recorded. The frequency band is the one the software can see and warn about; the hardware is not, which is exactly why it is written down here.

⚠️ Screening is a mitigation, not coverage. Where a deployment's ethics approval covers high-frequency flicker only — as this one's does — recording a participant at 8–12 Hz — or on an amplifier the approval does not name — is a divergence from it and needs the approval extended first, on both counts. Raising that is the operator's, and it is not something this example asserts on their behalf. Build it, preview it, argue about it; do not record on it until the coverage exists in writing.

The abort path is always available at runtime, and the photosensitivity bibliography is the evidence behind the bands.

8. Things you might reasonably change

  • The counterbalancing moves in lockstep. All three groups use the same scheme, which over two runs assigns the arm by sub-XXX parity — so there are two distinct session orders, not 2³ = 8. sub-001 gets circle-first, cyan-first, checkerboard-first; sub-002 gets the exact mirror. Order is balanced within each contrast, which is what the design asks for, but it is perfectly correlated between contrasts, so any fatigue or drift effect lands on the same arm of all three. Switching two of the three groups to randomized decouples them (the resolver seeds each randomized group independently, from the protocol seed, the participant and the group label).
  • The frequency band, if the REB position changes — the design is otherwise agnostic to it. Moving to 30–50 Hz costs amplitude, and costs the class its visible response; it would want more trials or a longer window to compensate.
  • Blocks and trials. 3 blocks × 9 trials is about as short as nine classes can be while still giving each target three trials. More blocks buys a better accuracy estimate at the cost of class time.
  • Whether the two baselines stay separate. The shape group's baseline and the pattern group's baseline are the same stimulus, recorded twice, so that each contrast is self-contained and internally counterbalanced. They are separate runs with separate names so the two recordings are distinguishable without reading run_index. Merging them would save three minutes and cost each contrast its own baseline.