SSVEP
A complete, hackable brain-computer interface that reads EEG from the Knight Board, figures out which flickering square you’re looking at, and turns that into a keyboard press. It uses SSVEP (steady-state visually evoked potentials) as the brain signal and TRCA (task-related component analysis) as the classifier.
Full source: github.com/NeuroPawn/ssvep
1. What Is SSVEP and Why Does It Work?
Section titled “1. What Is SSVEP and Why Does It Work?”When you stare at a light that flickers at a fixed frequency , neurons in your primary visual cortex (V1), at the back of your head in the occipital lobe, start firing in lockstep with that flicker. This is neural entrainment: large populations of neurons synchronise their activity to the rhythm of the external stimulus. The result is a periodic voltage oscillation on the scalp at and its harmonics (, , …) that EEG electrodes can pick up.
If four squares flicker at 6.67, 8.57, 10, and 12 Hz and you look at the 10 Hz square, your occipital EEG develops a clear peak at 10 Hz (and 20 Hz, 30 Hz, …). The BCI just has to answer: which flicker frequency is dominant in the EEG right now? That’s a remarkably robust question to ask of the brain, which is why SSVEP BCIs are fast and need almost no user training.
Key properties the pipeline exploits:
- The response is strongest over the occipital cortex, so electrode placement matters (see below).
- The response has a short latency (~120–150 ms) before the cortex “locks on”, so the classifier ignores the first ~150 ms of each window.
- The response is stereotyped and repeatable for a given person, so a personalised spatial filter (TRCA) can be learned from it.
2. Where to Place the Electrodes
Section titled “2. Where to Place the Electrodes”SSVEP is generated in the visual cortex, so every electrode goes over the occipital / parieto-occipital scalp. Placing electrodes anywhere else just adds noise.
Recommended 8-channel montage (matches ssvep/config.py → ELECTRODE_LABELS):
| Channel | Position | Notes |
|---|---|---|
| 1 | Oz | Midline occipital (strongest) |
| 2 | O1 | Left occipital |
| 3 | O2 | Right occipital |
| 4 | PO7 | Left parieto-occipital |
| 5 | PO8 | Right parieto-occipital |
| 6 | PO3 | Left parieto-occipital (medial) |
| 7 | PO4 | Right parieto-occipital (medial) |
| 8 | POz | Midline parieto-occipital |
Reference and ground: put the reference on one earlobe/mastoid (A1) and the ground/bias on the other (A2). Part the hair, use conductive gel, and keep impedances low — SSVEP is small (microvolts) and buried under mains hum.
3. Setting Up the Knight Board
Section titled “3. Setting Up the Knight Board”When BrainFlow first connects to the Knight Board, every EEG channel is powered down. If you start streaming right away you get flat lines. Each channel must be:
- Turned on with a gain, via
chon_{channel}_{gain}— this powers the channel’s amplifier at the given PGA gain (the repo uses12). - Added to the bias / right-leg-drive (RLD) loop with
rldadd_{channel}— this feeds an inverted common-mode signal back into the body to actively cancel mains hum and movement artifacts. It’s the single biggest factor in getting clean SSVEP.
ssvep/board.py sends these two commands for every channel, with short
pauses in between because the firmware needs a moment to apply each register
write:
board.prepare_session()board.start_stream(450000)time.sleep(2)
for ch in range(1, num_channels + 1): board.config_board(f"chon_{ch}_12") # power channel ON at gain 12 board.config_board(f"rldadd_{ch}") # add channel to bias/RLD loop4. Reading Data Without Draining the Buffer
Section titled “4. Reading Data Without Draining the Buffer”BrainFlow stores incoming samples in a fixed-size ring buffer. There are two ways to read it, and the difference matters:
| Method | Behaviour |
|---|---|
get_board_data() | Returns everything and empties the buffer (destructive). |
get_current_board_data(n) | Returns a copy of the latest n samples and leaves the buffer intact (non-destructive). |
The pipeline always uses get_current_board_data(188) (wrapped as
KnightBoard.get_latest). Because it’s non-destructive, the board keeps
filling the ring buffer continuously in the background — after each flicker
period the code simply peeks at the most recent 188 samples (~1.5 s at
125 Hz), the exact window the user was just staring at. Nothing has to
coordinate “who owns the buffer”; the stimulus process and the classifier
never fight over it, and no samples are ever lost.
5. Timing Is Everything
Section titled “5. Timing Is Everything”SSVEP classification lives and dies by timing. Two things must be exact.
The flicker itself must be frame-accurate. Each square is set white or black once per monitor refresh according to the sign of a sine wave:
colour = white if sin(2 * pi * f * t) >= 0 else black # updated every win.flip()On a 60 Hz monitor the chosen frequencies land on near-integer frame counts (6.67 Hz ≈ 9 frames, 8.57 Hz ≈ 7, 10 Hz = 6, 12 Hz = 5), so the flicker stays rock-steady. If the monitor drops frames, the real flicker frequency drifts away from the intended one and accuracy collapses.
The EEG window must line up with the flicker. The trial structure is:
|<-- 1.0 s cue -->|<----- 1.5 s flicker ----->| capture + 0.5 s rest | look here eyes on target grab last 188 samplesAt the end of the 1.5 s flicker the recording flag is raised, and the
classifier grabs the last 188 samples. TRCA then uses samples [19:144] —
i.e. it throws away the first ~150 ms (visual latency, before the cortex
entrains) and keeps a clean 1.0 s window.
6. Pre-Processing
Section titled “6. Pre-Processing”The exact same filter chain is applied when recording training data and when
classifying live data — otherwise the learned spatial filters no longer
match the incoming signal. It lives in one place, ssvep/preprocessing.py.
Per channel, in order:
- Detrend (constant) — remove the DC offset / slow baseline.
- Band-pass 3–48 Hz (2nd-order Butterworth, zero-phase) — keep the SSVEP fundamentals and their first harmonics; drop drift and muscle noise.
- Band-stop 49–51 Hz and 59–61 Hz (4th-order, zero-phase) — notch out mains hum. Keeping both notches means the same code works on 50 Hz and 60 Hz power grids.
All filters are zero-phase (forward-backward) so they don’t shift the timing of the response, which matters because the analysis window is only 1 s long.
7. How TRCA Classifies SSVEP
Section titled “7. How TRCA Classifies SSVEP”TRCA (Task-Related Component Analysis) learns, for each target frequency, a spatial filter — a set of weights that combines the 8 electrodes into a single channel that maximises the reproducibility of the SSVEP response across training trials. Intuitively: it finds the electrode mixture where “the part of the signal that repeats every time you look at 10 Hz” is strongest and the random noise cancels out.
The pipeline (ssvep/trca_model.py, built on
meegkit):
- Filter bank. The signal is split into 6 overlapping sub-bands (
config.FILTERBANK) so the classifier can exploit SSVEP harmonics, not just the fundamental. Higher sub-bands carry the 2nd/3rd harmonics. - Fit. For each target, TRCA computes a spatial filter and an averaged template from the training trials.
- Predict. A new 1 s window is filtered through every sub-band and every target’s spatial filter, correlated with that target’s template, and the sub-band correlations are combined. The target with the highest score wins.
The pipeline uses the ensemble variant, which shares information across all
targets’ filters and is noticeably more accurate. evaluate_trca.py runs
leave-one-block-out cross-validation and reports accuracy plus ITR
(information transfer rate, bits/min — the standard BCI speed metric).
8. Why the Order You Look at the Stimuli Matters
Section titled “8. Why the Order You Look at the Stimuli Matters”TRCA is a supervised, personalised method: it only knows what a “10 Hz response” looks like because it was told, by looking at the 10 Hz target when the software recorded a trial labelled 10 Hz. Two consequences follow:
- Labels must be correct. During collection you’re cued which square to look at, and the captured window is saved under that target’s label. Look at the wrong square and that trial is mislabelled, poisoning the model.
- Training data must reflect the real world. The classifier only generalises to conditions it has seen. Train sitting still in a dark room, then use the BCI in bright light while moving, and the live EEG won’t match the templates — accuracy drops. Collect data the same way you’ll use it: same electrode placement, posture, lighting, distance to screen, and gaze-shift rhythm.
The repo also randomises the order targets are cued within each block. Always looking at them in a fixed order (top-left, top-right, …) risks the model learning sequence/adaptation artifacts (eye fatigue, expectation) that correlate with the label but have nothing to do with the flicker. Randomising the order — while still recording several blocks so every target appears many times — breaks that confound.
9. Project Layout
Section titled “9. Project Layout”ssvep/ # the reusable, commented library├── config.py # all tunable parameters (port, freqs, timing, keys)├── board.py # KnightBoard: connect, configure channels, read buffer├── preprocessing.py # shared detrend + band-pass + notch filter chain├── trca_model.py # load data, fit TRCA, cross-validate, predict├── recording.py # background board-reader process (collect / predict)└── stimulus.py # PsychoPy window, flicker squares, single-trial routine
collect_training_data.py # SCRIPT 1: record your own labelled SSVEP blocksrealtime_control.py # SCRIPT 2: live TRCA control mapped to keyboard keysevaluate_trca.py # offline leave-one-block-out cross-validationtrca4stim.ipynb # notebook: visualise harmonics + walk through TRCA
docs/electrode_montage.svg # electrode diagramutils/freq-checker/ # Arduino sketch to validate flicker timing (LDR)training_data/ # your recorded block_{block}_{trial}.csv files10. Quick Start
Section titled “10. Quick Start”Install
Section titled “Install”git clone https://github.com/NeuroPawn/ssvep.gitcd ssveppip install -r requirements.txtConfigure
Section titled “Configure”Open ssvep/config.py and set at least:
SERIAL_PORT— your board’s COM port (Windows) or/dev/tty…(Linux/macOS).STIMULUS_SCREEN—0for your main monitor,1for a second monitor.KEY_MAP— which key each target should press.
Make sure the stimulus monitor runs at 60 Hz.
1. Collect training data
Section titled “1. Collect training data”python collect_training_data.pyLook only at the cued (red-outlined) square each trial and hold your gaze
steady during the flash. Do 6+ blocks. Files land in training_data/.
2. Check your model
Section titled “2. Check your model”python evaluate_trca.pyAim for high per-block accuracy. If it’s poor: check electrode contact, redo the board setup, confirm the monitor is a true 60 Hz, and collect more blocks.
3. Control something in real time
Section titled “3. Control something in real time”python realtime_control.pyGive focus to the app you want to control (a game, a robot teleop window, etc.). Look at a target for ~1.5 s and the mapped key is pressed. Press Escape to stop.
