EEG_SSVEP_Drone β SSVEP control on a 32-channel dry-electrode cap
Mirror of github.com/twu3202/EEG_SSVEP_Drone. Data also at Twu31/cap32-ssvep.
Steering a DJI Tello with SSVEP (steady-state visually evoked potentials) recorded on a low-cost 32-channel dry-electrode cap (TI ADS1299, 250 Hz, WiFi/UDP). The aim was a continuous control loop β a command every 300β400 ms β rather than one command every five seconds.
Companion project: EEG_MI (motor imagery on the same cap; this repo reuses its acquisition/protocol layer rather than forking it).
What actually happened
Decoding is barely above chance, and this repo is mostly the record of finding out why.
| Session | Targets | Best decoder | Accuracy | Chance | 95 % CI |
|---|---|---|---|---|---|
ssvep_20260725_150031 |
12 | FBCCA, 2.4 s | 0.150 | 0.083 | [0.10, 0.22] |
ssvep_20260725_171821 |
4 | TRCA-CCA, 1.8 s | 0.450 | 0.250 | [0.33, 0.58] |
Above chance, statistically β and nowhere near usable for flying anything. The interesting part is the diagnosis.
The misclassifications all fell to low frequencies, and it was not alpha
Errors piled up at 8 Hz regardless of the true target. The obvious suspect is the alpha rhythm, and this subject's IAF is exactly 10.00 Hz β but that turned out to be the second effect, not the cause.
CCA/FBCCA's score Ο is a variance ratio, so argmax over candidate frequencies is really
comparing absolute power at each. Under a S(f) β f^-Ξ± aperiodic background the noise
floor itself goes as Ο_noise(f) β f^(-Ξ±/2) β monotonically decreasing, so the lowest
candidate frequency wins by default. Measured slope: β0.95 to β1.11, against an
independently fitted Ξ± = 1.79β2.34 whose prediction matches to within 0.06. The 8 Hz noise
floor sits 1.7β1.9Γ higher than 15.7 Hz.
Alpha is real and does sit on top of it β in the one confound-free session the excess over the power law peaks at exactly 10.0 Hz, matching the independently measured IAF β but that excess is only +19.7 % against the +71 % from the 1/f floor.
Full derivation and the measurements: docs/lowfreq_bias_report.pdf (δΈζ).
Two results that did not survive scrutiny
- Channel selection. "Dropping unstable electrodes measurably helps" held until stimulus duration was put on an axis and paired tests were run β then it went away.
- The onset-skip hypothesis (that discarding the first N ms of each trial would help) was tested directly and failed.
Holm correction is applied throughout, which is why several early effects disappeared.
Data
Four calibration sessions, published with the code in calib/ and mirrored to
huggingface.co/datasets/Twu31/cap32-ssvep.
import numpy as np
d = np.load("calib/ssvep_20260725_171821.npz", allow_pickle=True)
d["X"] # (60, 10, 500) float32 Β΅V β trials Γ channels Γ samples
d["y"] # (60,) int32 β target index
d["freqs"] # (4,) Hz; d["phases"] (4,) rad; d["names"] (4,) target names
d["ch_names"] # ('P7','P3','PZ','P4','P8','PO3','PO4','O1','OZ','O2')
β These files contain 10 posterior channels, not all 32. Channel selection was applied at
record time, so these sessions cannot be re-analysed with a different montage.
meta_json.channels_full lists the full 32-channel montage the cap was wearing.
Layout
src/ssvep/ stimulus, decoders (CCA / FBCCA / eTRCA / TDCA / TRCA-CCA),
calibration, quality checks, the 1/f bias diagnostic, benchmarks
src/control/ Tello SDK driver, safety layer, latency test, MuJoCo simulator
src/acquisition/ SsvepStream β timing-exact capture with gap reconstruction
(imports EEG_MI's protocol layer; does not fork it)
glasses/ Rokid AR-glasses on-device SSVEP probe (Android/Kotlin)
calib/ four calibration sessions: .npz signals + .json paradigm sidecars
docs/ the 1/f bias report; Tello control-stack notes
results/ benchmarks, bias diagnostics, duration frontiers, simulator captures
Why timing matters more here than for MI
At 12 Hz one cycle is 83 ms, so a 4 ms epoch-boundary error is 14Β° of phase. eTRCA and TDCA are phase-locking decoders and collapse to chance when that drifts β which is exactly what happened before the phase-aware templates fix. The acquisition layer reconstructs dropped UDP frames rather than skipping them, for the same reason: skipping compresses the time axis.
Related: the cap's sample clock was later measured against an external generator at +3235 ppm β small for band power, but a 0.4 % clock error rotates a 12 Hz tone by a full cycle in ~2 s. See the crosstalk report in EEG_MI.
Running it
pip install -r requirements.txt
python src/ssvep/benchmark.py --demo # decoders on synthetic SSVEP, no hardware
python src/ssvep/stimulus.py --targets 4 # frame-accurate flicker stimulus
python src/ssvep/bias_diag.py calib/ssvep_20260725_150031.npz # the 1/f diagnostic
python src/control/sim_mujoco.py # Tello simulator, no drone needed
Live capture needs the cap on its ESPBCI access point and the Tello on its own β one WiFi
adapter cannot hold both, which is the first architectural problem in this project. Options
are written up in docs/tello_control_stack.md.
License
MIT for the code. The recordings are CC BY 4.0.
Recordings are from a single consenting adult subject (the author). No clinical or identifying information is included.