← projects

Thoughts in Motion

Team (contributor) · 2023–24

PythonTkinterLSL (pylsl)MNENumPy / SciPyscikit-learn / joblibTCP socketsThree.jsUnity

GitHub

Overview

Thoughts in Motion lets a user act on a virtual world without touching anything. It reads two different biosignals, EEG from the brain and EMG from the muscles, and within the EEG it decodes two different signatures: one driven by what you look at, one by what you imagine doing.

It is also, and mainly, a tool. The project became Hugo Devoille’s Master’s thesis (Redefining Immersion: Olfactory and Biosignals integration in Human-Computer Interaction, November 2024, advised by Dr. Xiao Xiao in the Human Learning group), and its stated goal is to lower the barrier for newcomers: an open-source platform that retrieves, processes and transmits biosignals across software, so a designer can use an EEG headset without first becoming a signal-processing engineer.

The 3D scene with the SSVEP interaction active: the target sphere is green, the two roof lights are lit, the HUD reads SSVEP is now Online
The SSVEP interaction firing. The target turns green, the two roof lights start flickering, and the HUD switches to SSVEP is now Online.

Where it came from

The first version was a single Python script, written for one specific VR experiment. It pulled sensor data over LSL, filtered it, analysed it, and sent the result to Unity so that looking at the right thing would open a door. It worked, and it was welded shut: no options, no interface, no way to reuse it for a different sensor or a different application.

Thoughts in Motion is the generalization of that script. Same job, but with the knobs exposed and the application-specific parts taken out.

The project splits across two repositories. A Python acquisition and inference app turns raw sensor streams into predictions, and a 3D front-end consumes them. Between the two sits a plain socket. That seam is the whole design: the neuroscience side and the game side can be rebuilt independently, as long as numbers keep arriving on port 12345.

The pipeline

EEG / EMG headset
   ↓  LSL stream (pylsl: resolve_stream → StreamInlet)
Python + Tkinter app          Main.py
   ↓  MNE: RawArray → notch + band-pass → fixed-length epochs
   ↓  scikit-learn model (loaded from a .joblib file) → predict()
   ↓  socket, localhost:12345, comma-separated values
Unity scene  /  Three.js scene
   ↓  SSVEP stimulus, doors, interactions

1 · Acquisition, the Python app

The entry point is a Tkinter config panel. You start by picking the kind of signal you want to work with, and everything after that adapts to your choice. Adding a GUI to a real-time acquisition loop is what forced multithreading into the project: starting, stopping and talking between threads is the part that costs, not the widgets.

The app's start screen: a two-item listbox, EEG or EMG
The whole app starts here: EEG or EMG. The rest of the interface is built from that choice.

The EEG path

The EEG configuration panel: headset list, Resolve/Preview Stream, Notch and Band Freq filters, model path, epoch duration, Launch Server
The EEG panel. Montage, filters, model, epoch length, all set before the server opens.
  • Headset and montage. Two lab headsets are supported, and picking one builds the matching MNE Info. The Muse 2 gives 5 channels at 256 Hz (TP9, AF7, AF8, TP10 plus an auxiliary). The Bitbrain Versatile 32 gives 32, snapped to the standard_1020 montage. Naming and positioning channels correctly is not cosmetic: an electrode’s location is what ties a measurement to a brain region, so a mislabelled montage quietly invalidates the analysis.
  • Resolve Stream finds the LSL stream on the network and opens an inlet. Preview Stream pulls one second, 256 samples, and dumps it as a dataframe. Fastest way to answer the only question that matters at that moment: is this real signal, or is an electrode floating?
  • Filters. A notch against mains hum, which sits in the 45–50 Hz range in Europe, and a band-pass. Both optional, both entered as plain values, both applied to the live epoch rather than to a stored recording.
  • From SciPy to MNE. The original script built its filters by hand with NumPy and SciPy. The rewrite moved to MNE, a library written specifically for EEG, which brings epoch segmentation and preprocessing as first-class operations instead of maths you re-derive each time.
  • Model. Any classifier serialized with joblib can be dropped in by path. The app does not own the model. You train it elsewhere, you point at the .joblib, it loads.
  • Epoch duration sets the window fed to the classifier, and with it the latency of the whole interface. MNE builds make_fixed_length_epochs of that length, so every prediction costs exactly that much delay.
  • Launch Server opens the socket and starts pushing predictions to whoever connects.

Accuracy against latency

EEG has a poor signal-to-noise ratio, which pushes you toward heavier models. Deep networks read complex patterns better, and they also compete for the same machine that is filtering, segmenting and streaming in real time. Lighter machine-learning models cost far less and see less.

Leaving that arbitration to the user, rather than hard-coding a model, is the most defensible design decision in the project. The person who knows whether their application can afford 400 ms of inference is the person building the application.

The EMG path

Same acquisition skeleton, much simpler maths, because EMG has a far better signal-to-noise ratio than EEG. Rectification is a numpy.abs away, and there is no model at all: a calibration instead. You press Calibration, contract the muscle as hard as you can for five seconds, and the app keeps the maximum value it saw. From then on you give it a ratio of that max, and anything below the resulting threshold is sent as 0.

That single number is why the EMG path works on a different body on a different day. The interface is calibrated in percent of your own maximum, never in volts.

2 · Rendering, the Three.js scene

The interactive world exists twice: a Unity project, and a Three.js mirror of it that runs in a browser. The web version is the one you can show someone on the spot, with no build and no headset. It ships three cameras and a Cycle camera button: an exterior orbit view, and two viewpoints inside the ship.

The exterior camera: two capital ships drifting in front of a planet
Camera 1, the exterior orbit view. The scene is a glTF export loaded with GLTFLoader.

Inside the first room, two red spheres stand in for the two BCI events. In the browser demo a raycast from the pointer triggers them, which is the honest way to demo a signal you cannot produce on command. Hovering a sphere plays the exact reaction that the classifier’s output plays in the real build.

Sphere 1, SSVEP. Steady-state visually evoked potentials: stare at something flickering at a fixed frequency and your visual cortex starts oscillating at that same frequency, which a decoder can pick out of the spectrum. Trigger it and the room’s two ceiling lights begin blinking on two different periods, 67 ms and 100 ms. Two distinguishable stimuli, so the classifier can tell which one you were looking at rather than only that you were looking.

Sphere 2, ERD/ERS. Event-related desynchronization and synchronization: imagining a movement, without performing it, drops the power of the sensorimotor µ and β rhythms. No stimulus needed, since the intent comes from you and not from the screen. Trigger it and the bulkhead door slides open.

The interaction console room, the second sphere active, HUD reading ERD/ERS are detected, doors are opening
The second interaction: ERD/ERS are detected, doors are opening. Two HUD corners, one per decoder, so you can always see why nothing moved.

Running both signatures side by side is what the word “hybrid” is doing here. SSVEP is fast and reliable but needs you to be looking at the stimulus. Motor imagery is slower and noisier but works with your eyes anywhere. Alone, neither makes a comfortable interface.

3 · Hardware

The Bitbrain Versatile 32 EEG cap, with its 32 wired electrodes
The Bitbrain Versatile 32: 32 gel electrodes on the 10–20 layout, one wire per channel. The headset most of the sessions ran on.
Earlier 3D-printed EEG headset prototype with dry electrode mounts
The earlier 3D-printed rig: a rigid lattice with screw-in dry electrode mounts and on-board acquisition.

The lab cap is accurate and slow. Gel, impedance checks, twenty minutes before anything happens, and a subject who cannot move much. The printed frame that preceded it traded signal quality per electrode for setup time: positions stay reproducible between sessions and you are running in a couple of minutes. Both answers are defensible, and which one you want depends entirely on whether your bottleneck is precision or the number of sessions you can fit in a day.

The Muse 2 sits at the far end of that scale, with 5 channels and a consumer price. Supporting both it and the Bitbrain is the point of the tool: the same GUI, the same pipeline, whatever hardware the lab or the practitioner happens to own.

4 · What it was tested on

Two Unity applications validated the platform end to end.

The first is a 2D platformer driven by EMG, where the player contracts a bicep to make the character jump. It is my own project, and it served as one of the two validation cases in Hugo’s thesis: contractions were interpreted correctly and reached Unity with low enough latency for the gameplay to stay responsive.

The second is an EEG reflex game: the player shoots at targets by blinking one eye. Different signal, different decoder, same path from headset to game.

Limits, and what would come next

The thesis is clear-eyed about what is missing. Tkinter was the fastest way to get an interface, and it is also the reason the project carries so much threading complexity; PyQt or Kivy would cost less to maintain and look like software from this decade. Preprocessing stays basic, with spatial filtering the obvious next addition for EEG. Support for ECG and EDA sensors would widen the range of applications. And training models inside the GUI, rather than importing a .joblib from elsewhere, would close the loop.

My part in it

Small, and worth being precise about. Hugo Devoille built the core: turning raw EEG into a usable signal. That is the hard half of a brain-computer interface, and the half that took a researcher’s expertise. I worked on the other side, making the decoder’s output mean something inside the 3D scene, and I ran test sessions wearing the Bitbrain Versatile 32. Most of the time I was the subject rather than the author.

The one concrete thing I can point to is the EMG platformer: I built it on top of this platform, and it ended up as one of the two applications used to validate the project in Hugo’s thesis.

The project stays on this site anyway, because it is where the rest of my work starts. Hugo introduced me to neuroscience through it, and both the EMG game controller and my Master’s thesis grew out of that. I would rather credit him properly than inflate my share.

Team project. The repository belongs to Hugo Devoille (linked above), and the Three.js visualization lives in a companion repo. Technical details on this page are drawn from his Master’s thesis, Redefining Immersion: Olfactory and Biosignals integration in Human-Computer Interaction (2024).