Build a Gesture-Controlled EEG Explorer
Stream EEG, estimate cortical activity, and control a 3D brain with gestures.
Introduction
30 Second Summary
Brain activity can feel impossible to inspect without a laboratory full of specialist equipment. Open research data gives you a practical way to explore the workflow on your own computer.
In this project, you will replay an open motor-imagery EEG recording as live data inside a browser-based 3D brain explorer. You will steer the brain with one hand while its colors track relative activity across central cortical regions.
What You'll Build
You'll move one hand in front of your webcam to steer a glowing cortex while live regional scores rise and fall beside it in Google Chrome.
By the end of this project, you'll have:
- A repeatable EEG replay that sends open motor-imagery samples through LSL as if they were arriving from live hardware.
- A template-based cortical view built from fsaverage that maps one-second windows to central-region scores. An on-screen warning keeps the approximate nature of the result clear.
- A gesture-controlled 3D dashboard that responds to hand position and apparent hand size. Its status panel displays stream state, gesture category, and regional scores.
- Secret Mission: A repeatability audit across three 30-second periods plus a three-sentence interpretation that separates measured EEG, fsaverage estimates, and normalized browser colors.
Are there any prerequisites?
You should be comfortable with Python and machine learning. You need Windows, Google Chrome, a webcam, Python 3.11 or newer, and Node.js 20.19+ or 22.12+. No EEG headset or paid service is required.
Before We Start
Before any hands-on work, commit to building a research-only visualization of relative template-based cortical source activity from replayed EEG. Its fsaverage output is an approximation because it does not use the recorded participant's anatomy or a validated subject-specific inverse model.
Set Up the Windows Project
The Python analysis layer depends on a different package ecosystem from the Node.js browser layer. Version drift here can surface later as errors that look unrelated to setup.
This step gives both layers a clean Windows workspace in Visual Studio Code. You will finish with pinned packages plus version-matched browser assets.
In this step, get ready to:
- Confirm that Python plus Node.js meet the project requirements.
- Prepare the Visual Studio Code workspace with its virtual environment plus pinned manifests.
- Install every dependency with version-matched MediaPipe WASM assets.
Prepare the compatible workspace
The pinned release of Vite requires Node.js 20.19+ or 22.12+. The pinned release of MNE-Python requires Python 3.11 or newer.
Checking both runtimes first keeps incompatible packages out of your new workspace.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into Windows search. Press Enter to open it.
- Select Open Folder from the Visual Studio Code welcome screen.
- Choose your Desktop in the folder picker.
- Use the folder picker's new-folder control to create gesture-eeg-source-explorer on your Desktop.
- Select the gesture-eeg-source-explorer folder as your Visual Studio Code workspace.
The Explorer sidebar now shows the empty gesture-eeg-source-explorer workspace.
- Select Terminal from the Visual Studio Code top menu.
- Select New Terminal to create an integrated PowerShell terminal.
- Check the installed Python version by running this command:
python --version
What does this check show?
The command reports the Python interpreter that PowerShell resolves. This project needs Python 3.11 or newer.
✔️ I see a supported Python version
Python meets the project's minimum version requirement.
ⓧ I see an older Python version
The installed interpreter is too old for the pinned MNE packages.
- Visit the official Python downloads page for Windows.
- Download a Windows installer for Python 3.11 or newer.
- Configure the installer so the python command is available in PowerShell.
- Close the current integrated terminal after installation.
- Create a new integrated PowerShell terminal.
- Repeat the Python version check shown above.
ⓧ Python is not found
PowerShell cannot currently resolve a Python installation.
- Visit the official Python downloads page for Windows.
- Download a Windows installer for Python 3.11 or newer.
- Configure the installer so the python command is available in PowerShell.
- Close the current integrated terminal after installation.
- Create a new integrated PowerShell terminal.
- Repeat the Python version check shown above.
- Check the installed Node.js version by running this command:
node --version
What does this check show?
The command reports the Node.js runtime available to the Vite toolchain. Vite 8 supports Node.js 20.19+ or 22.12+.
✔️ I see a supported Node.js version
Node.js meets the version requirement for the pinned Vite release.
ⓧ I see an unsupported Node.js version
The installed Node.js release falls outside Vite's supported versions.
- Visit the official Node.js download page.
- Install a Node.js release that satisfies 20.19+ or 22.12+.
- Close the current integrated terminal after installation.
- Create a new integrated PowerShell terminal.
- Repeat the Node.js version check shown above.
ⓧ Node.js is not found
PowerShell cannot currently resolve a Node.js installation.
- Visit the official Node.js download page.
- Install a Node.js release that satisfies 20.19+ or 22.12+.
- Close the current integrated terminal after installation.
- Create a new integrated PowerShell terminal.
- Repeat the Node.js version check shown above.
Still blocked by a version check?
Restart Visual Studio Code after installing a runtime so its integrated terminal receives the updated system path.
If the old version still appears, use this troubleshooting prompt: Help me diagnose why Visual Studio Code PowerShell still resolves my previous Python or Node.js installation on Windows.
The dependency manifests give both package managers an exact installation plan. Their pins keep every learner on the same library releases.
- Select the new-file control in the Explorer sidebar for gesture-eeg-source-explorer.
- Name the new file requirements.txt.
- Add the pinned Python dependencies by pasting this content:
mne==1.13.2
mne-lsl==1.14.0
fastapi==0.142.2
uvicorn[standard]==0.54.0
What does this manifest include?
- MNE-Python provides the EEG processing plus source-modeling tools used later.
- MNE-LSL provides the live-stream replay plus buffer workflow.
- FastAPI provides the backend application plus WebSocket endpoint.
- Uvicorn runs that backend as an ASGI application.
- Save requirements.txt.
- Check the Explorer sidebar to confirm that requirements.txt appears inside gesture-eeg-source-explorer.
File missing from the Explorer?
Confirm that the file is named requirements.txt with no extra file extension. Check that it sits directly inside gesture-eeg-source-explorer.
Use this prompt if the file appears elsewhere: Help me move requirements.txt into my gesture-eeg-source-explorer folder in Visual Studio Code.
- Select the new-file control in the Explorer sidebar for gesture-eeg-source-explorer.
- Name the new file package.json.
- Add the pinned browser dependencies by pasting this content:
{
"name": "gesture-eeg-source-explorer",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"@mediapipe/tasks-vision": "1.1.0",
"three": "0.186.1"
},
"devDependencies": {
"vite": "8.3.3"
}
}
What does this manifest include?
- MediaPipe Gesture Recognizer supplies the webcam hand landmarks plus gesture categories used later.
- three.js renders the point-cloud brain inside Chrome.
- The dev script starts the local Vite development server.
- Save package.json.
- Check the Explorer sidebar to confirm that package.json appears beside requirements.txt.
Seeing JSON syntax warnings?
Compare every quote plus comma with the reference below. A missing comma prevents npm from reading package.json.
Use this prompt if Visual Studio Code still highlights the file: Help me find the JSON syntax error in my package.json file.
✔️ Awesome, I've got everything!
Your two saved manifests now match the project's pinned dependency plan.
ⓧ I'd like to double check the full code
The complete pinned manifests are shown below for comparison.
mne==1.13.2
mne-lsl==1.14.0
fastapi==0.142.2
uvicorn[standard]==0.54.0
{
"name": "gesture-eeg-source-explorer",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"@mediapipe/tasks-vision": "1.1.0",
"three": "0.186.1"
},
"devDependencies": {
"vite": "8.3.3"
}
}
A virtual environment keeps this project's Python packages inside .venv. Activating it makes the integrated terminal use that isolated environment.
- Create the virtual environment inside gesture-eeg-source-explorer by running these commands:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
What do these commands do?
- The first command creates an isolated Python environment in .venv.
- The second command activates that environment in the current PowerShell terminal.
- Check the start of the PowerShell prompt for .venv.
Your terminal now points at the project's isolated Python environment.
PowerShell blocking activation?
PowerShell can block local activation scripts under a restrictive execution policy. Review the Windows PowerShell note in the official Python virtual environment documentation before retrying the activation command.
Use this prompt for help with your current policy: Help me activate a Python .venv in Visual Studio Code PowerShell without weakening my Windows security settings.
Install the pinned dependencies
Each package manager now has a manifest to read. Installing from those files reproduces the exact dependency set used by the project.
The first Python installation can take a few minutes while packages download. A quiet stretch during that window is normal.
- Install the pinned Python dependencies into the active .venv by running:
python -m pip install -r requirements.txt
What does this command do?
The command asks pip to read requirements.txt. Each exact version is installed inside the active virtual environment.
Before you inspect the environment, which four package names do you expect it to report?
- Confirm the four Python packages by running:
python -m pip show mne mne-lsl fastapi uvicorn
What does this check prove?
The command prints installed metadata for every named package. Its version fields confirm whether the virtual environment followed requirements.txt.
You should see package entries reporting mne==1.13.2, mne-lsl==1.14.0, fastapi==0.142.2, plus uvicorn[standard]==0.54.0.
Python installation failed?
Confirm that the terminal prompt still starts with .venv. Confirm that requirements.txt matches the full-code reference.
MNE-LSL on Windows also depends on the supported Microsoft Visual C++ Redistributable when its streaming library loads.
Use this prompt with the final lines from your terminal: Help me troubleshoot this Windows pip installation for the pinned EEG project packages.
Give npm a few minutes on its first pass through the browser dependency tree.
- Install the pinned browser dependencies by running:
npm install
What does this command create?
npm reads package.json before downloading the browser packages. Their installed files are placed in node_modules.
Before you inspect the dependency tree, which three pinned packages do you expect npm to list?
- Confirm the top-level npm packages by running:
npm list --depth=0
What does this check prove?
The depth limit shows the packages declared directly by this project. It keeps transitive dependencies out of the verification output.
You should see @mediapipe/tasks-vision@1.1.0, three@0.186.1, plus vite@8.3.3.
npm installation failed?
Confirm that the integrated terminal is inside gesture-eeg-source-explorer. Confirm that package.json contains valid JSON.
Use this prompt with the final non-sensitive error lines: Help me troubleshoot npm install for this Windows Vite project.
Stage the MediaPipe WASM runtime
MediaPipe loads WebAssembly files when gesture recognition starts in the browser. Copying them from the installed package keeps the runtime matched to version 1.1.0.
- Create public\wasm before copying the installed MediaPipe runtime by running these commands:
New-Item -Path public\wasm -ItemType Directory -Force
Copy-Item -Path node_modules\@mediapipe\tasks-vision\wasm\* -Destination public\wasm -Recurse -Force
What do these commands do?
- New-Item creates the destination folder expected by the browser code.
- Copy-Item copies every version-matched runtime file from the installed MediaPipe package.
- Recurse preserves any nested content inside the runtime directory.
- Expand public in the Visual Studio Code Explorer sidebar.
- Select wasm to inspect its copied contents.
You should see version-matched MediaPipe runtime files inside public\wasm.
WASM folder still empty?
Confirm that npm install completed successfully. The source path only exists after npm creates node_modules.
Use this prompt if the copy still fails: Help me copy the installed MediaPipe WASM files into public/wasm with PowerShell.
Before the final check, do you expect every command to report only the pinned environment you just created?
- Verify both runtimes plus both dependency sets from gesture-eeg-source-explorer by running:
python --version
node --version
python -m pip show mne mne-lsl fastapi uvicorn
npm list --depth=0
What should each check confirm?
- Python reports version 3.11 or newer.
- Node.js reports a version supported by Vite 8.
- The Python metadata reports the four versions pinned in requirements.txt.
- The npm tree reports the three versions pinned in package.json.
The populated public\wasm folder confirms that the browser runtime is staged too.
That setup hurdle is cleared. Both package ecosystems now use the pinned versions required by the project.
Next, you will turn this workspace into an interactive cyan point-cloud brain.
Render the First Holographic Brain
Your development environment is ready, but the project still has no visual surface for the EEG activity. A static brain gives you an immediate rendering checkpoint before the slower stream and source-model work begins.
In this step, you will build a browser dashboard with three.js. You will render a cyan point-cloud cortex that responds to mouse movement and browser resizing.
In this step, get ready to:
- Build the browser interface for the brain canvas and research status panel.
- Style the interface as a full-screen holographic dashboard.
- Render a procedural point-cloud brain with mouse rotation and zoom controls.
Build the page shell
The page shell gives the renderer a dedicated canvas. It also reserves space for the stream status and webcam controls used later in the project.
- In the Visual Studio Code Explorer sidebar, create a folder named src inside your existing project folder.
- Create index.html at the top level of the same project folder.
You should now see src beside index.html in the Explorer sidebar.
- Add the document structure and brain canvas to index.html by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EEG Source Explorer</title>
</head>
<body>
<main class="app-shell">
<canvas id="brain-canvas"></canvas>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this structure provide?
- The brain-canvas element gives three.js a surface for drawing the point cloud.
- The module script loads /src/main.js as the browser application.
- The viewport setting lets the dashboard use the available Chrome window size.
- In index.html, locate the closing </main> tag.
- Insert the status panel immediately above that tag by pasting this code:
<section class="panel">
<p class="eyebrow">RESEARCH PROTOTYPE</p>
<h1>EEG Source Explorer</h1>
<p id="status">Backend offline. Showing placeholder cortex.</p>
<p class="warning">
Template-based relative source estimate. Not subject-specific or diagnostic.
</p>
<div class="camera-row">
<video id="webcam" autoplay playsinline muted></video>
<div>
<button id="enable-camera">Enable webcam</button>
<p id="gesture">Gesture: unavailable</p>
</div>
</div>
<h2>Central-region activity</h2>
<div id="regions"></div>
<p class="hint">
Move your hand to rotate. Move it closer or farther to zoom. Mouse controls remain active.
</p>
</section>
What does the panel contain?
- The status text makes the missing backend explicit while this placeholder view is running.
- The warning keeps the future template-based estimate framed as research-only output.
- The webcam elements reserve the controls needed for hand tracking later.
- The regions container provides a destination for live cortical activity rows.
- Save index.html.
✔️ Awesome, I've got everything!
Your page shell now contains the canvas and the complete research status panel.
ⓧ I'd like to double check the full code
Compare your saved index.html with this complete file.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EEG Source Explorer</title>
</head>
<body>
<main class="app-shell">
<canvas id="brain-canvas"></canvas>
<section class="panel">
<p class="eyebrow">RESEARCH PROTOTYPE</p>
<h1>EEG Source Explorer</h1>
<p id="status">Backend offline. Showing placeholder cortex.</p>
<p class="warning">
Template-based relative source estimate. Not subject-specific or diagnostic.
</p>
<div class="camera-row">
<video id="webcam" autoplay playsinline muted></video>
<div>
<button id="enable-camera">Enable webcam</button>
<p id="gesture">Gesture: unavailable</p>
</div>
</div>
<h2>Central-region activity</h2>
<div id="regions"></div>
<p class="hint">
Move your hand to rotate. Move it closer or farther to zoom. Mouse controls remain active.
</p>
</section>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>
- Create style.css inside the src folder.
- Create main.js inside the src folder.
You should now see main.js and style.css beneath src in the Explorer sidebar.
- Connect main.js to the stylesheet by adding this import:
import "./style.css";
What does this import do?
Vite follows this import and includes style.css in the browser bundle. Saving the stylesheet then triggers an automatic browser update.
- Save main.js.
- Before you start the development server, predict which interface text Chrome will show from your page shell.
- Start the Vite development server from the existing project terminal by running this command:
npm run dev
- Press the Windows key to open Windows search.
- Type Google Chrome and press Enter.
- Enter http://localhost:5173 in the Chrome address bar.
You should see the EEG Source Explorer text with the offline backend message. That visible page confirms that Vite can load your HTML and JavaScript module.
Page not loading?
- Confirm that the terminal running Vite remains open.
- Check that index.html sits beside package.json in the project folder.
- Check that main.js sits inside the src folder.
- Ask for help with the page structure if the browser remains blank.
Style the holographic dashboard
The stylesheet turns the plain document into a full-screen interface. Each group below has a visible job, so you can check the design as it takes shape.
- Add the global colors and full-screen canvas layout to style.css by pasting this code:
:root {
color: #d9fbff;
background: #02070d;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
overflow: hidden;
background:
radial-gradient(circle at 50% 45%, rgba(0, 222, 255, 0.14), transparent 36%),
#02070d;
}
.app-shell {
width: 100vw;
height: 100vh;
}
#brain-canvas {
display: block;
width: 100%;
height: 100%;
}
What does this CSS control?
- The root rules establish the cyan text and dark background used across the dashboard.
- The body gradient creates a glow behind the future point cloud.
- The app shell and canvas fill the entire browser viewport.
- Save style.css.
- Return to Chrome to inspect the updated page.
You should see a dark full-screen background with a cyan glow near the center. The interface text remains visible on top.
Still seeing a white background?
- Confirm that main.js still imports ./style.css.
- Confirm that style.css is saved inside src.
- Ask for help if Vite does not apply the stylesheet.
- Add the floating panel and title styles below the existing canvas rules by pasting this code:
.panel {
position: fixed;
top: 24px;
left: 24px;
width: min(360px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
padding: 20px;
border: 1px solid rgba(95, 225, 255, 0.35);
border-radius: 18px;
background: rgba(3, 14, 25, 0.82);
box-shadow: 0 0 34px rgba(0, 218, 255, 0.12);
backdrop-filter: blur(16px);
}
.eyebrow {
margin: 0;
color: #68e8ff;
font-size: 0.72rem;
letter-spacing: 0.18em;
}
h1 {
margin: 8px 0 10px;
font-size: 1.6rem;
}
How does the panel work?
- Fixed positioning keeps the panel in the top-left corner while the canvas fills the viewport.
- The translucent background lets the central glow remain faintly visible.
- The maximum height keeps the panel scrollable on shorter screens.
- Save style.css.
- Return to Chrome to inspect the updated panel.
You should see the status content inside a translucent rounded panel in the top-left corner.
Panel covering the whole page?
- Check that the selector begins with .panel.
- Check that the panel width uses the supplied min() value.
- Ask for help comparing the panel selector with the HTML class.
- Add the supporting text and camera-row styles below the heading rules by pasting this code:
h2 {
margin-top: 20px;
font-size: 1rem;
}
.warning,
.hint,
#status,
#gesture {
color: #9bb6c2;
font-size: 0.86rem;
line-height: 1.45;
}
.camera-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 12px;
align-items: center;
margin-top: 16px;
}
What changes here?
- The muted text color separates supporting information from the main heading.
- The camera row reserves a fixed preview column beside the webcam controls.
- Save style.css.
- Return to Chrome to inspect the updated spacing.
You should see the webcam area and its controls aligned in two columns.
Camera controls not aligned?
- Confirm that the HTML wrapper uses class="camera-row".
- Check that the selector in style.css begins with .camera-row.
- Ask for help if the grid remains stacked.
- Style the webcam preview and camera button below the camera-row rules by pasting this code:
#webcam {
width: 110px;
height: 82px;
border-radius: 10px;
background: #07131d;
object-fit: cover;
transform: scaleX(-1);
}
button {
border: 1px solid #54e5ff;
border-radius: 999px;
padding: 9px 13px;
color: #dffcff;
background: rgba(0, 190, 230, 0.16);
cursor: pointer;
}
Why mirror the webcam preview?
The horizontal scale mirrors the future webcam image. Your movement then feels like looking into a mirror while controlling the brain.
- Save style.css.
- Return to Chrome to inspect the webcam area.
You should see a dark rounded webcam placeholder beside a cyan outlined button.
Webcam placeholder missing?
- Confirm that the video element uses id="webcam".
- Check that the webcam selector begins with #webcam.
- Ask for help if the video element has no visible dimensions.
- Finish the region-row styles at the bottom of style.css by pasting this code:
.region {
margin: 10px 0;
}
.region-label {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
font-size: 0.8rem;
}
.track {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #102534;
}
.fill {
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #00d7ff, #ff4dcb, #fff36c);
transition: width 180ms linear;
}
What are the region styles for?
- Each region row pairs a cortical label with its current normalized score.
- The dark track provides a scale for the future activity fill.
- The fill transition smooths rapid width updates from live EEG windows.
- Save style.css.
The empty region container remains ready for activity rows when the backend starts sending labels.
Stylesheet showing an error?
- Check that every selector has one opening brace and one closing brace.
- Confirm that the region rules appear after the button rules.
- Ask for help locating a CSS syntax problem.
✔️ Awesome, I've got everything!
Your complete stylesheet now turns the page shell into a holographic dashboard.
ⓧ I'd like to double check the full code
Compare your saved style.css with this complete file.
:root {
color: #d9fbff;
background: #02070d;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
overflow: hidden;
background:
radial-gradient(circle at 50% 45%, rgba(0, 222, 255, 0.14), transparent 36%),
#02070d;
}
.app-shell {
width: 100vw;
height: 100vh;
}
#brain-canvas {
display: block;
width: 100%;
height: 100%;
}
.panel {
position: fixed;
top: 24px;
left: 24px;
width: min(360px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
padding: 20px;
border: 1px solid rgba(95, 225, 255, 0.35);
border-radius: 18px;
background: rgba(3, 14, 25, 0.82);
box-shadow: 0 0 34px rgba(0, 218, 255, 0.12);
backdrop-filter: blur(16px);
}
.eyebrow {
margin: 0;
color: #68e8ff;
font-size: 0.72rem;
letter-spacing: 0.18em;
}
h1 {
margin: 8px 0 10px;
font-size: 1.6rem;
}
h2 {
margin-top: 20px;
font-size: 1rem;
}
.warning,
.hint,
#status,
#gesture {
color: #9bb6c2;
font-size: 0.86rem;
line-height: 1.45;
}
.camera-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 12px;
align-items: center;
margin-top: 16px;
}
#webcam {
width: 110px;
height: 82px;
border-radius: 10px;
background: #07131d;
object-fit: cover;
transform: scaleX(-1);
}
button {
border: 1px solid #54e5ff;
border-radius: 999px;
padding: 9px 13px;
color: #dffcff;
background: rgba(0, 190, 230, 0.16);
cursor: pointer;
}
.region {
margin: 10px 0;
}
.region-label {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
font-size: 0.8rem;
}
.track {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #102534;
}
.fill {
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #00d7ff, #ff4dcb, #fff36c);
transition: width 180ms linear;
}
Render and test the point cloud
A three.js scene combines a camera and renderer with a collection of 3D objects. OrbitControls connects mouse input to the camera so the placeholder can be inspected from different angles.
Why three.js in the browser?
A desktop renderer would separate the 3D view from the future webcam interface. Three.js keeps the renderer and browser controls in one page.
The renderer setup spans two connected snippets. Replace the current contents of main.js with both snippets before saving.
- Replace the current stylesheet-only module with the imports and scene setup below:
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import "./style.css";
const canvas = document.querySelector("#brain-canvas");
const statusElement = document.querySelector("#status");
const regionsElement = document.querySelector("#regions");
const video = document.querySelector("#webcam");
const cameraButton = document.querySelector("#enable-camera");
const gestureElement = document.querySelector("#gesture");
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 20);
camera.position.set(0, 0, 3.2);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
What does the scene setup do?
- The element queries connect JavaScript to the canvas and status-panel elements.
- The scene acts as the container for the future brain object.
- The perspective camera provides depth while sitting far enough back to frame the cortex.
- The WebGL renderer draws into the existing canvas with smooth edges and transparency.
- Continue the same replacement by adding the controls and animation loop directly below the renderer:
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
controls.minDistance = 1.6;
controls.maxDistance = 4.5;
function resizeRenderer() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate() {
resizeRenderer();
controls.update();
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
animate();
How does the render loop work?
- OrbitControls adds damped mouse rotation and limits camera zoom.
- The resize function reads the canvas dimensions before updating the camera projection.
- The animation function redraws the scene on every browser frame.
- Save main.js.
- Return to Chrome to confirm that the styled dashboard remains responsive.
You should still see the complete status panel over the dark full-screen canvas. The renderer is now active and ready for a 3D object.
Dashboard disappeared after the JavaScript update?
- Check that both import paths match the supplied code exactly.
- Confirm that brain-canvas matches the canvas ID in index.html.
- Ask for help checking the three.js scene setup.
The placeholder function creates thousands of points on a rippled ellipsoid. Its two snippets form one function, so paste the second immediately after the first before saving.
- In main.js, locate the line that sets controls.maxDistance.
- Insert the brain declaration and the first part of createPlaceholderBrain() directly below that line:
let brain = createPlaceholderBrain();
scene.add(brain);
function createPlaceholderBrain() {
const positions = [];
const colors = [];
for (let index = 0; index < 4200; index += 1) {
const longitude = Math.random() * Math.PI * 2;
const latitude = Math.acos(2 * Math.random() - 1);
const ripple = 1 + 0.05 * Math.sin(longitude * 9) * Math.sin(latitude * 7);
positions.push(
0.86 * ripple * Math.sin(latitude) * Math.cos(longitude),
1.04 * ripple * Math.cos(latitude),
0.74 * ripple * Math.sin(latitude) * Math.sin(longitude),
);
colors.push(0.05, 0.62, 0.78);
}
How are the points positioned?
- The loop generates 4,200 positions around a sphere using longitude and latitude.
- Different axis scales stretch the sphere into a brain-like ellipsoid.
- The ripple calculation adds small surface folds.
- Each point receives the same cyan color.
The function is temporarily incomplete after this first snippet. The next snippet closes it before the browser checks the updated module.
- Complete createPlaceholderBrain() by pasting this code immediately below the loop:
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
const material = new THREE.PointsMaterial({
size: 0.018,
transparent: true,
opacity: 0.78,
vertexColors: true,
sizeAttenuation: true,
});
return new THREE.Points(geometry, material);
}
How does the point cloud reach the scene?
- BufferGeometry stores the generated coordinates and colors in attributes.
- PointsMaterial controls the size and transparency of every glowing point.
- The returned Points object becomes brain before the scene adds it for rendering.
- Save main.js.
- Before you check Chrome, predict whether the point cloud will keep its shape when the browser becomes wider.
- Return to Chrome to inspect the completed placeholder cortex.
You should see a glowing cyan point-cloud brain behind the status panel. That is the first complete visual layer of your EEG explorer.
- Drag across the canvas with your mouse to rotate the brain.
- Use the mouse wheel over the canvas to change the camera distance.
- Resize the Chrome window to test the responsive renderer.
The brain should rotate smoothly and stay proportioned as the window changes size. Zoom should stop at the configured minimum and maximum distances.
Brain not visible or interactive?
- Confirm that scene.add(brain) appears before createPlaceholderBrain().
- Check that the completed function ends before resizeRenderer() begins.
- Confirm that the Vite terminal remains running while you test Chrome.
- Ask for help with the point-cloud renderer.
✔️ Awesome, I've got everything!
Your browser now renders the complete interactive placeholder cortex.
ⓧ I'd like to double check the full code
Compare your saved main.js with this complete file for the current step.
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import "./style.css";
const canvas = document.querySelector("#brain-canvas");
const statusElement = document.querySelector("#status");
const regionsElement = document.querySelector("#regions");
const video = document.querySelector("#webcam");
const cameraButton = document.querySelector("#enable-camera");
const gestureElement = document.querySelector("#gesture");
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 20);
camera.position.set(0, 0, 3.2);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
controls.minDistance = 1.6;
controls.maxDistance = 4.5;
let brain = createPlaceholderBrain();
scene.add(brain);
function createPlaceholderBrain() {
const positions = [];
const colors = [];
for (let index = 0; index < 4200; index += 1) {
const longitude = Math.random() * Math.PI * 2;
const latitude = Math.acos(2 * Math.random() - 1);
const ripple = 1 + 0.05 * Math.sin(longitude * 9) * Math.sin(latitude * 7);
positions.push(
0.86 * ripple * Math.sin(latitude) * Math.cos(longitude),
1.04 * ripple * Math.cos(latitude),
0.74 * ripple * Math.sin(latitude) * Math.sin(longitude),
);
colors.push(0.05, 0.62, 0.78);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
const material = new THREE.PointsMaterial({
size: 0.018,
transparent: true,
opacity: 0.78,
vertexColors: true,
sizeAttenuation: true,
});
return new THREE.Points(geometry, material);
}
function resizeRenderer() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate() {
resizeRenderer();
controls.update();
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
animate();
Your holographic interface is running with a responsive 3D placeholder. Next, you will replace the silent mock surface with EEG samples arriving through a live-stream workflow.
Replay EEG as a Live Stream
Your cyan cortex already responds to mouse rotation and zoom. It currently has no live EEG samples behind it.
MNE-LSL replays a PhysioNet EEGBCI motor-imagery recording as a stream. Once those samples arrive, you will check whether channels and timestamps can identify cortical regions.
In this step, get ready to:
- Download subject 1 run 6 from EEGBCI.
- Replay the recording through a filtered two-second LSL buffer.
- Confirm that one-second sensor windows are reaching the backend.
Load and prepare the EEG recording
MNE-Python needs consistent channel names before it can attach standard sensor positions. An average-reference projection prepares the recording for the source-localization workflow that follows.
- Switch back to Visual Studio Code.
- Create backend.py beside requirements.txt using the new-file control in the Explorer sidebar.
- Prepare the EEG recording by pasting this code into backend.py:
import mne
from fastapi import FastAPI
from mne.datasets import eegbci
from mne_lsl.player import PlayerLSL
from mne_lsl.stream import StreamLSL
app = FastAPI()
STREAM_NAME = "EEGBCI-Motor-Imagery"
SOURCE_ID = "EEGBCI-Subject-1-Run-6"
(raw_path,) = eegbci.load_data(subjects=1, runs=[6], update_path=True)
raw = mne.io.read_raw_edf(raw_path, preload=True, verbose=False)
eegbci.standardize(raw)
raw.pick("eeg")
montage = mne.channels.make_standard_montage("fsaverage_1005")
raw.set_montage(montage)
raw.set_eeg_reference(projection=True)
@app.get("/health")
async def health():
return {"status": "ok"}
What does this code prepare?
- The EEGBCI loader downloads subject 1 run 6 when the recording is missing locally.
- The EDF reader loads the recording into memory.
- The standardization step cleans the channel names before keeping EEG channels.
- The fsaverage_1005 montage attaches template sensor positions.
- FastAPI exposes a health endpoint that confirms the backend application loaded.
- Save backend.py.
- Confirm that backend.py appears beside requirements.txt in the Explorer sidebar.
Seeing unresolved imports?
- Confirm that Visual Studio Code is using the interpreter inside .venv.
- Check that the pinned packages from requirements.txt remain installed in that environment.
- Help me resolve the Python imports in backend.py.
Replay and filter the stream
PlayerLSL turns the loaded recording into a repeating Lab Streaming Layer source. StreamLSL collects those samples in real time before applying a causal filter.
Why MNE-LSL?
A manual timer could reveal samples at regular intervals. MNE-LSL preserves the stream interface used by compatible neurophysiology hardware.
This replay can later be replaced with a live device while the stream-processing pattern stays familiar.
- In backend.py find this line: @app.get("/health").
- Insert the stream setup directly above that line by pasting this code:
player = PlayerLSL(
raw,
chunk_size=16,
name=STREAM_NAME,
source_id=SOURCE_ID,
annotations=False,
).start()
stream = StreamLSL(
2.0,
name=STREAM_NAME,
source_id=SOURCE_ID,
).connect(timeout=5)
stream.filter(8.0, 30.0, picks="eeg")
data, timestamps = stream.get_data(winsize=1.0, picks="eeg")
print(
f"LSL connected: {len(stream.ch_names)} channels at "
f"{stream.info['sfreq']} Hz"
)
How does the replay work?
- PlayerLSL publishes the loaded recording under EEGBCI-Motor-Imagery.
- The source ID EEGBCI-Subject-1-Run-6 distinguishes this recording from other LSL sources.
- StreamLSL connects a two-second buffer to the matching stream.
- The filter keeps EEG activity between 8.0 Hz and 30.0 Hz.
- The data request reads the latest one-second window. It also stores the corresponding timestamps.
- Save backend.py.
Run the live replay
Uvicorn imports the backend application. Importing the file now prepares the EEG recording before starting the local server.
- Create a second PowerShell terminal from the terminal panel in Visual Studio Code.
- Activate the existing virtual environment by running this command:
.\.venv\Scripts\Activate.ps1
What does this command do?
The activation script points this PowerShell terminal at the project-specific Python environment. The backend then uses the pinned packages you installed earlier.
Your terminal prompt should now display the virtual environment name.
Virtual environment not activating?
- Confirm that the terminal is in the folder containing .venv.
- Use the same PowerShell activation method that worked during project setup if script execution is blocked.
- Help me activate this project's .venv in PowerShell.
The first run can take several minutes while the EEGBCI recording downloads. Download progress in the terminal means the preparation is still moving.
Before you start the backend, do you think the terminal will report individual sensor channels or named cortical regions?
- Start the backend from the activated terminal by running this command:
uvicorn backend:app
What does this command start?
The uvicorn command loads the app object from backend.py. The process stays active so the local backend can keep replaying data.
After preparation finishes, you will see LSL connected: followed by a channel count and sampling rate. That is the replay working: your backend is receiving timed EEG sensor samples.
No LSL connection message?
- Confirm that the second terminal still shows the active virtual environment.
- Install the latest supported Microsoft Visual C++ Redistributable if the terminal reports that the LSL library cannot load.
- Check your internet connection if the EEGBCI download does not progress.
- Help me diagnose why the MNE-LSL replay does not connect.
Before you return to the browser, do you think a stream of channel samples can color anatomical regions on its own?
- Return to the existing Chrome tab at http://localhost:5173.
- Rotate the cyan cortex with the mouse.
- Inspect the status panel for regional activity.
You will still see the cyan placeholder cortex with no changing anatomical region rows. The panel still reports that the backend is offline because the browser has no stream connection yet.
What did the obstacle reveal?
The backend now provides voltage samples organized by channel and time. Those samples do not contain cortical labels.
The unchanged browser makes that limitation visible. A live sensor stream alone cannot justify coloring anatomical regions.
✔️ Awesome, I've got everything!
Your backend now replays filtered EEG samples through LSL. Keep the Uvicorn process running for the next step.
ⓧ I'd like to double check the full code
- Compare your complete backend.py file with this reference:
import mne
from fastapi import FastAPI
from mne.datasets import eegbci
from mne_lsl.player import PlayerLSL
from mne_lsl.stream import StreamLSL
app = FastAPI()
STREAM_NAME = "EEGBCI-Motor-Imagery"
SOURCE_ID = "EEGBCI-Subject-1-Run-6"
(raw_path,) = eegbci.load_data(subjects=1, runs=[6], update_path=True)
raw = mne.io.read_raw_edf(raw_path, preload=True, verbose=False)
eegbci.standardize(raw)
raw.pick("eeg")
montage = mne.channels.make_standard_montage("fsaverage_1005")
raw.set_montage(montage)
raw.set_eeg_reference(projection=True)
player = PlayerLSL(
raw,
chunk_size=16,
name=STREAM_NAME,
source_id=SOURCE_ID,
annotations=False,
).start()
stream = StreamLSL(
2.0,
name=STREAM_NAME,
source_id=SOURCE_ID,
).connect(timeout=5)
stream.filter(8.0, 30.0, picks="eeg")
data, timestamps = stream.get_data(winsize=1.0, picks="eeg")
print(
f"LSL connected: {len(stream.ch_names)} channels at "
f"{stream.info['sfreq']} Hz"
)
@app.get("/health")
async def health():
return {"status": "ok"}
Your EEG replay now supplies filtered sensor windows in real time. Next, you will connect those samples to the browser's anatomical view.
Turn Sensor Samples into Cortical Activity
Your replayed EEG now arrives as a filtered stream of sensor measurements. Those measurements still cannot identify which cortical regions are active.
This step uses MNE-Python with the fsaverage template to estimate source activity. A FastAPI WebSocket then carries the estimated geometry and regional scores into the three.js scene.
In this step, get ready to:
- Build an fsaverage forward model and minimum-norm inverse operator.
- Convert one-second EEG windows into normalized central-region activity.
- Stream cortical geometry and activation values into the browser.
Build the fsaverage source pipeline
A forward model describes how cortical currents could produce voltages at the EEG sensors. The inverse operator uses that relationship to estimate likely source activity from each incoming window.
Why use an inverse model?
Assigning electrode power directly to cortical labels would attach anatomical meaning without modeling how electrical activity reaches the scalp. The fsaverage forward and inverse workflow gives that mapping an explicit mathematical basis.
The anatomy still comes from a template. Treat every regional value as a research-only approximation.
- Switch back to backend.py in Visual Studio Code.
- Select the existing contents of backend.py.
- Use the complete file in the second tab below to replace the selection.
✔️ Awesome, I've got everything!
Your backend now contains the complete source-estimation and WebSocket pipeline.
ⓧ I'd like to double check the full code
import asyncio
import mne
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from mne.datasets import eegbci, fetch_fsaverage
from mne.minimum_norm import apply_inverse_raw, make_inverse_operator
from mne_lsl.player import PlayerLSL
from mne_lsl.stream import StreamLSL
app = FastAPI()
STREAM_NAME = "EEGBCI-Motor-Imagery"
SOURCE_ID = "EEGBCI-Subject-1-Run-6"
def build_geometry(src, labels):
region_names = [label.name for label in labels]
vertex_regions = [{}, {}]
for region_index, label in enumerate(labels):
hemisphere_index = 0 if label.hemi == "lh" else 1
for vertex in label.vertices:
vertex_regions[hemisphere_index][int(vertex)] = region_index
raw_points = []
for hemisphere_index, hemisphere in enumerate(src[:2]):
for vertex in hemisphere["vertno"][::6]:
coordinate = hemisphere["rr"][vertex]
region_index = vertex_regions[hemisphere_index].get(int(vertex), -1)
raw_points.append(
[
float(coordinate[0]),
float(coordinate[1]),
float(coordinate[2]),
region_index,
]
)
scale = max(abs(value) for point in raw_points for value in point[:3]) or 1.0
points = [
[point[0] / scale, point[1] / scale, point[2] / scale, point[3]]
for point in raw_points
]
return {"type": "geometry", "regions": region_names, "points": points}
def build_pipeline():
(raw_path,) = eegbci.load_data(subjects=1, runs=[6], update_path=True)
raw = mne.io.read_raw_edf(raw_path, preload=True, verbose=False)
eegbci.standardize(raw)
raw.pick("eeg")
montage = mne.channels.make_standard_montage("fsaverage_1005")
raw.set_montage(montage)
raw.set_eeg_reference(projection=True)
fs_dir = fetch_fsaverage(verbose=True)
subjects_dir = fs_dir.parent
src = fs_dir / "bem" / "fsaverage-ico-5-src.fif"
bem = fs_dir / "bem" / "fsaverage-5120-5120-5120-bem-sol.fif"
forward = mne.make_forward_solution(
raw.info,
trans="fsaverage",
src=src,
bem=bem,
meg=False,
eeg=True,
mindist=5.0,
n_jobs=None,
verbose=False,
)
noise_covariance = mne.make_ad_hoc_cov(raw.info)
inverse = make_inverse_operator(
raw.info,
forward,
noise_covariance,
loose=0.2,
depth=2.0,
verbose=False,
)
labels = mne.read_labels_from_annot(
"fsaverage",
parc="aparc",
regexp="central",
subjects_dir=subjects_dir,
verbose=False,
)
if not labels:
raise RuntimeError("No central cortical labels were found in fsaverage aparc.")
player = PlayerLSL(
raw,
chunk_size=16,
name=STREAM_NAME,
source_id=SOURCE_ID,
annotations=False,
).start()
stream = StreamLSL(
2.0,
name=STREAM_NAME,
source_id=SOURCE_ID,
).connect(timeout=5)
stream.filter(8.0, 30.0, picks="eeg")
print(
f"LSL connected: {len(stream.ch_names)} channels at "
f"{stream.info['sfreq']} Hz"
)
return {
"player": player,
"stream": stream,
"inverse": inverse,
"labels": labels,
"geometry": build_geometry(inverse["src"], labels),
}
def compute_activation(pipeline):
stream = pipeline["stream"]
sample_rate = float(stream.info["sfreq"])
if stream.n_new_samples < int(sample_rate * 0.25):
return None
data, timestamps = stream.get_data(winsize=1.0, picks="eeg")
if data.shape[1] < int(sample_rate * 0.9):
return None
window = mne.io.RawArray(data, stream.info.copy(), verbose=False)
source_estimate = apply_inverse_raw(
window,
pipeline["inverse"],
lambda2=1.0,
method="dSPM",
pick_ori="normal",
verbose=False,
)
time_courses = mne.extract_label_time_course(
source_estimate,
pipeline["labels"],
pipeline["inverse"]["src"],
mode="mean_flip",
verbose=False,
)
energies = [float((course * course).mean()) for course in time_courses]
peak = max(energies) if energies else 1.0
if peak == 0.0:
peak = 1.0
return {
"type": "activation",
"timestamp": float(timestamps[-1]),
"values": [energy / peak for energy in energies],
}
@app.get("/health")
async def health():
return {"status": "ok"}
@app.websocket("/ws")
async def eeg_websocket(websocket: WebSocket):
await websocket.accept()
pipeline = None
try:
await websocket.send_json(
{"type": "status", "message": "Preparing EEGBCI and fsaverage..."}
)
pipeline = await asyncio.to_thread(build_pipeline)
await websocket.send_json(pipeline["geometry"])
await websocket.send_json(
{"type": "status", "message": "Live template source estimate"}
)
while True:
update = await asyncio.to_thread(compute_activation, pipeline)
if update is not None:
await websocket.send_json(update)
await asyncio.sleep(0.2)
except WebSocketDisconnect:
pass
finally:
if pipeline is not None:
pipeline["stream"].disconnect()
pipeline["player"].stop()
What does this backend do?
- The forward solution combines the EEG sensor information with the fsaverage source space and boundary model.
- The inverse operator uses an ad hoc covariance model with loose cortical orientation constraints and EEG depth weighting.
- The label lookup selects cortical regions whose aparc names match central.
- The WebSocket sends preparation status, normalized geometry, live status, and activation messages.
- Save backend.py.
- Return to the PowerShell terminal running Uvicorn.
- End the running Uvicorn process using the terminal interrupt shortcut.
- Restart the updated backend by running this command:
uvicorn backend:app
What does this command start?
Uvicorn loads the FastAPI application from backend.py. The source pipeline starts when the browser connects to /ws.
- Switch to Chrome.
- Create a new browser tab.
- Load http://localhost:8000/health to confirm the updated backend responds.
You should see {"status":"ok"}. This confirms that Uvicorn imported the updated application successfully.
Backend not responding?
- Confirm the PowerShell prompt still shows the active .venv environment.
- Check that the previous Uvicorn process ended before starting the updated process.
- Compare the import section in backend.py with the complete file.
- Ask for help with the backend startup error.
Connect cortical activity to the browser
Each one-second LSL window becomes a normal-orientation dSPM estimate. Squared label time courses are divided by the largest energy in that window to produce relative values from zero to one.
What do the normalized values mean?
A value near one marks the strongest selected central label in the current window. The scale resets against each window's peak.
These values support visual comparison inside the prototype. They do not provide absolute neural power or validated anatomical localization.
- Switch back to src/main.js in Visual Studio Code.
- Select the existing contents of src/main.js.
- Use the complete file in the second tab below to replace the selection.
✔️ Awesome, I've got everything!
Your browser code now supports source geometry, regional rows, activation colors, and WebSocket messages.
ⓧ I'd like to double check the full code
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import "./style.css";
const canvas = document.querySelector("#brain-canvas");
const statusElement = document.querySelector("#status");
const regionsElement = document.querySelector("#regions");
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 20);
camera.position.set(0, 0, 3.2);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
controls.minDistance = 1.6;
controls.maxDistance = 4.5;
let brain = createPlaceholderBrain();
let pointRegions = [];
let regionNames = [];
let paused = false;
scene.add(brain);
function createPlaceholderBrain() {
const positions = [];
const colors = [];
for (let index = 0; index < 4200; index += 1) {
const longitude = Math.random() * Math.PI * 2;
const latitude = Math.acos(2 * Math.random() - 1);
const ripple = 1 + 0.05 * Math.sin(longitude * 9) * Math.sin(latitude * 7);
positions.push(
0.86 * ripple * Math.sin(latitude) * Math.cos(longitude),
1.04 * ripple * Math.cos(latitude),
0.74 * ripple * Math.sin(latitude) * Math.sin(longitude),
);
colors.push(0.05, 0.62, 0.78);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
const material = new THREE.PointsMaterial({
size: 0.018,
transparent: true,
opacity: 0.78,
vertexColors: true,
sizeAttenuation: true,
});
return new THREE.Points(geometry, material);
}
function installSourceGeometry(message) {
scene.remove(brain);
regionNames = message.regions;
pointRegions = message.points.map((point) => point[3]);
const positions = [];
const colors = [];
for (const [x, y, z] of message.points) {
positions.push(x, z, -y);
colors.push(0.05, 0.45, 0.58);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
const material = new THREE.PointsMaterial({
size: 0.025,
transparent: true,
opacity: 0.86,
vertexColors: true,
sizeAttenuation: true,
});
brain = new THREE.Points(geometry, material);
scene.add(brain);
renderRegionRows();
}
function renderRegionRows() {
regionsElement.replaceChildren();
regionNames.forEach((name, index) => {
const row = document.createElement("div");
row.className = "region";
row.innerHTML = `
<div class="region-label">
<span>${name}</span>
<span id="value-${index}">0.00</span>
</div>
<div class="track"><div class="fill" id="fill-${index}"></div></div>
`;
regionsElement.appendChild(row);
});
}
function updateActivation(values) {
if (paused || !brain.geometry.getAttribute("color")) {
return;
}
const colorAttribute = brain.geometry.getAttribute("color");
pointRegions.forEach((regionIndex, pointIndex) => {
const intensity = regionIndex >= 0 ? values[regionIndex] ?? 0 : 0;
const red = 0.04 + intensity * 0.96;
const green = 0.34 + intensity * 0.48;
const blue = 0.55 + (1 - intensity) * 0.35;
colorAttribute.setXYZ(pointIndex, red, green, blue);
});
colorAttribute.needsUpdate = true;
values.forEach((value, index) => {
const valueElement = document.querySelector(`#value-${index}`);
const fillElement = document.querySelector(`#fill-${index}`);
if (valueElement && fillElement) {
valueElement.textContent = value.toFixed(2);
fillElement.style.width = `${Math.round(value * 100)}%`;
}
});
}
const socket = new WebSocket("ws://localhost:8000/ws");
socket.addEventListener("open", () => {
statusElement.textContent = "Connected. Preparing EEG data and source model...";
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "status") {
statusElement.textContent = message.message;
}
if (message.type === "geometry") {
installSourceGeometry(message);
}
if (message.type === "activation") {
updateActivation(message.values);
}
});
socket.addEventListener("close", () => {
statusElement.textContent = "Backend disconnected. Showing the last frame.";
});
socket.addEventListener("error", () => {
statusElement.textContent = "Backend offline. Showing placeholder cortex.";
});
function resizeRenderer() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate() {
resizeRenderer();
controls.update();
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
window.addEventListener("keydown", (event) => {
if (event.code === "Space") {
paused = !paused;
statusElement.textContent = paused
? "Visualization paused"
: "Live template source estimate";
}
});
animate();
What does this browser code do?
- The WebSocket listens for status, geometry, and activation message types.
- The geometry handler replaces the procedural ellipsoid with normalized fsaverage source-space points.
- The region index stored beside each point connects that point to a cortical activity value.
- The activation handler updates the point-color buffer and the matching regional activity bar.
- Save src/main.js.
- Return to the Chrome tab showing http://localhost:5173.
This first WebSocket connection can feel slow because MNE prepares EEGBCI and fsaverage before constructing the forward model. The changing status message confirms that the backend is still working.
- Watch the PowerShell terminal until it prints a line beginning with LSL connected:.
- Watch the browser status until it reads Live template source estimate.
- Observe the regional values to confirm that they update continuously.
Still seeing the placeholder cortex?
- Confirm Uvicorn remains running in the activated .venv terminal.
- Confirm the Vite terminal still serves the page at http://localhost:5173.
- Check the backend terminal for a dependency-loading or source-model error.
- Ask for help tracing the WebSocket connection and geometry message.
Verify the live cortical bridge
The final check proves that sensor samples now pass through the inverse model. It also proves that regional activity reaches the renderer.
Before you refresh, which cortex do you expect to remain after the source model finishes preparing?
- Refresh the Chrome tab at http://localhost:5173.
You should first see the preparation status. The placeholder is then replaced by an fsaverage point cloud with continuously updating central-region rows.
- Drag across the canvas to rotate the fsaverage point cloud with the mouse.
- Scroll over the canvas to zoom the point cloud.
- Confirm that the regional scores continue updating during mouse movement.
- Confirm that the point colors continue changing with the regional scores.
That is the core neurotechnology bridge working. Your live LSL samples now drive an interactive template-based cortical visualization.
Your browser now turns filtered EEG windows into animated cortical activity. Next, you will control the live brain with your webcam hand movements.
Control the Brain with Your Hand
Your browser now receives live fsaverage geometry and normalized central-region activity from the EEG source pipeline. Mouse orbit controls already let you inspect that changing point cloud.
This step adds hands-free control through MediaPipe Gesture Recognizer. Your webcam landmarks will rotate the brain and control its zoom while the mouse remains available as a fallback.
In this step, get ready to:
- Load the local MediaPipe WASM runtime and published gesture model after a button click.
- Read normalized landmarks from one detected hand.
- Map hand position and apparent size to brain rotation and camera zoom.
Load the gesture model on demand
Gesture recognition needs two pieces in the browser. The local WASM runtime performs the computation while the gesture model interprets the webcam frames.
The model and camera load only after you choose to enable them. This keeps camera access under your control.
- Return to src/main.js in Visual Studio Code.
- Find the import group at the top of the file:
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import "./style.css";
This import group currently loads the 3D renderer and mouse controls. The stylesheet keeps the existing dashboard presentation attached to the module.
- Replace that group with the expanded import group below:
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import {
FilesetResolver,
GestureRecognizer,
} from "@mediapipe/tasks-vision";
import "./style.css";
What Do These Imports Add?
- FilesetResolver locates the version-matched vision runtime inside /wasm.
- GestureRecognizer creates the model-backed recognizer that processes video frames.
- The existing three.js and OrbitControls imports remain unchanged, so mouse interaction stays available.
- Save src/main.js.
- Refresh the existing Google Chrome tab.
You should still see the live fsaverage point cloud and changing regional activity rows. This confirms the browser can resolve the new package imports without disrupting the EEG display.
Did the Brain Disappear?
- Check that the MediaPipe package remains listed in package.json with the supplied version.
- Check the import spelling against the expanded group above.
- Confirm the Vite terminal remains running from the earlier step.
- Help me diagnose why the MediaPipe import stops my Vite page from rendering.
The camera button now needs a click handler that prepares the recognizer before requesting webcam access. The recognizer uses video mode because each prediction receives a frame from an ongoing stream.
- Add the model state and camera button handler below the existing WebSocket event listeners by pasting this code:
let gestureRecognizer = null;
let lastVideoTime = -1;
let lastGestureFrame = 0;
cameraButton.addEventListener("click", async () => {
cameraButton.disabled = true;
gestureElement.textContent = "Gesture: loading model...";
try {
const vision = await FilesetResolver.forVisionTasks("/wasm");
gestureRecognizer = await GestureRecognizer.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
"https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task",
},
runningMode: "VIDEO",
numHands: 1,
});
video.srcObject = await navigator.mediaDevices.getUserMedia({ video: true });
await video.play();
gestureElement.textContent = "Gesture: show one hand";
window.requestAnimationFrame(readGestureFrame);
} catch (error) {
cameraButton.disabled = false;
gestureElement.textContent = `Gesture error: ${error.message}`;
}
});
What Does This Handler Do?
- FilesetResolver.forVisionTasks("/wasm") loads the local runtime copied during project setup.
- GestureRecognizer.createFromOptions configures the published model for VIDEO input from one hand.
- navigator.mediaDevices.getUserMedia requests the camera only after the button click.
- requestAnimationFrame starts the frame-reading loop once the video is playing.
The frame loop needs a safe starting point before the camera handler can call it. This first version keeps scheduling frames while the next substep adds recognition.
- Add the initial readGestureFrame(now) function directly below the camera button handler by pasting this code:
function readGestureFrame(now) {
if (!gestureRecognizer) {
return;
}
window.requestAnimationFrame(readGestureFrame);
}
Why Schedule Another Frame?
The early return protects the loop until the recognizer exists. The final line schedules the function again on the browser's next animation frame, which keeps webcam processing connected to the page lifecycle.
Camera permission can feel intrusive. Chrome asks before sharing the feed, and this project keeps the video inside your local browser session.
- Save src/main.js.
- Refresh the existing Chrome tab.
- Click Enable webcam in the status panel.
- Approve Chrome's camera request.
The webcam preview should show your camera feed. The panel should change to Gesture: show one hand.
Camera Preview Still Empty?
- Confirm that no other video application is holding exclusive access to the webcam.
- Confirm that Chrome has permission to use the camera for the local page.
- Check that public/wasm still contains the copied MediaPipe runtime files.
- Help me troubleshoot why the webcam or MediaPipe model does not start in my local Vite app.
Read one hand on new video frames
Webcam frames can repeat while the browser waits for new video data. The loop compares each video timestamp and throttles recognition so the same frame is not processed repeatedly.
- Inside readGestureFrame(now), add this frame gate immediately above window.requestAnimationFrame(readGestureFrame);:
if (
video.readyState >= 2 &&
video.currentTime !== lastVideoTime &&
now - lastGestureFrame > 80
) {
lastVideoTime = video.currentTime;
lastGestureFrame = now;
const result = gestureRecognizer.recognizeForVideo(video, now);
}
How Does the Frame Gate Work?
- video.readyState >= 2 waits until the video has current frame data.
- video.currentTime !== lastVideoTime prevents duplicate processing of the same frame.
- now - lastGestureFrame > 80 spaces recognition calls apart to reduce work on the browser thread.
- recognizeForVideo(video, now) returns the gestures and normalized landmarks detected in the current frame.
Each detected hand contains normalized landmark coordinates. Averaging the horizontal and vertical coordinates gives the hand's center while its horizontal range estimates apparent hand size.
- Add the landmark calculations and gesture display directly after the recognizeForVideo line inside the frame gate by pasting this code:
if (result.landmarks.length > 0) {
const hand = result.landmarks[0];
const centerX = hand.reduce((sum, point) => sum + point.x, 0) / hand.length;
const centerY = hand.reduce((sum, point) => sum + point.y, 0) / hand.length;
const xValues = hand.map((point) => point.x);
const handSpan = Math.max(...xValues) - Math.min(...xValues);
const category = result.gestures[0]?.[0];
gestureElement.textContent = category
? `Gesture: ${category.categoryName} (${category.score.toFixed(2)})`
: "Gesture: hand detected";
} else {
gestureElement.textContent = "Gesture: show one hand";
}
What Do the Landmark Values Represent?
- centerX holds the average horizontal position of the first hand's landmarks.
- centerY holds the average vertical position of those landmarks.
- handSpan measures the horizontal landmark bounding width.
- category provides the recognized gesture name and confidence score when a category is available.
- Save src/main.js.
- Refresh the existing Chrome tab.
- Click Enable webcam.
- Approve the camera request if Chrome asks again.
- Hold one hand inside the webcam frame.
The panel should replace the prompt with a detected gesture category and score. That visible label confirms each new webcam frame reaches the recognizer.
Gesture Category Not Updating?
- Move your hand into the center of the webcam preview so the full hand is visible.
- Increase the light facing your hand if the preview is dark.
- Check that the frame gate sits inside readGestureFrame(now) above its final animation-frame request.
- Help me find why recognizeForVideo returns no landmarks for my webcam frames.
Map hand movement to the camera
The hand center provides two rotation controls. The landmark span acts as a depth cue because a nearby hand covers more of the frame than a distant hand.
- Inside the detected-hand branch, find this section around the handSpan calculation:
const xValues = hand.map((point) => point.x);
const handSpan = Math.max(...xValues) - Math.min(...xValues);
const category = result.gestures[0]?.[0];
The gesture category currently follows the size calculation immediately. The control mapping belongs between those two parts so it can reuse all three landmark measurements.
- Insert the rotation and zoom calculations after handSpan so that section looks like this:
const xValues = hand.map((point) => point.x);
const handSpan = Math.max(...xValues) - Math.min(...xValues);
brain.rotation.y += (0.5 - centerX) * 0.045;
brain.rotation.x += (centerY - 0.5) * 0.025;
camera.position.z = Math.max(1.7, Math.min(4.3, 4.2 - handSpan * 7));
const category = result.gestures[0]?.[0];
How Does the Control Mapping Work?
- centerX changes the brain's horizontal rotation around its vertical axis.
- centerY changes the brain's vertical tilt.
- handSpan moves the camera closer as your hand appears larger.
- Math.max and Math.min keep the camera between the supplied near and far distances.
A pause key gives you time to inspect one color frame without stopping the camera or renderer. The animation loop and mouse controls remain active while activation recoloring pauses.
- Add the keyboard listener directly below the existing animate(); call by pasting this code:
window.addEventListener("keydown", (event) => {
if (event.code === "Space") {
paused = !paused;
statusElement.textContent = paused
? "Visualization paused"
: "Live template source estimate";
}
});
What Does the Pause Key Change?
Pressing Space toggles paused. The existing updateActivation(values) guard then holds the current color frame while WebSocket messages and rendering continue.
Before you test, consider whether moving your hand right will rotate the brain in the same direction as moving the mouse.
- Save src/main.js.
- Return to the existing Chrome tab.
- Refresh the page.
- Click Enable webcam.
- Approve the camera request if Chrome asks again.
- Move one hand horizontally across the webcam frame.
You should see the cortex rotate horizontally while the gesture category continues updating.
- Move the same hand vertically across the webcam frame.
You should see the cortex tilt vertically.
- Move your hand closer to the webcam.
You should see the camera move closer to the cortex.
- Move your hand farther from the webcam.
You should see the camera pull back while remaining inside the clamped distance range.
- Drag the cortex with the mouse.
- Scroll the mouse wheel over the canvas.
The existing orbit and zoom behavior should still respond. Your hand controls now complement the mouse fallback.
- Press Space.
The status should change to Visualization paused. The regional color frame should hold while the brain remains interactive.
- Press Space again.
The status should return to Live template source estimate. The live regional colors should resume updating.
Hand Controls Feel Unresponsive?
- Keep your complete hand inside the webcam preview so the landmark center remains stable.
- Check that the three control lines sit inside the branch where landmarks are present.
- Confirm that brain still points to the current fsaverage point cloud after source geometry arrives.
- Help me troubleshoot why MediaPipe landmarks update but my three.js brain does not rotate or zoom.
✔️ Awesome, I've got everything!
Great work. Save src/main.js and keep both local servers running for your final demonstration.
ⓧ I'd like to double check the full code
Compare your complete src/main.js with the reference below.
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import {
FilesetResolver,
GestureRecognizer,
} from "@mediapipe/tasks-vision";
import "./style.css";
const canvas = document.querySelector("#brain-canvas");
const statusElement = document.querySelector("#status");
const regionsElement = document.querySelector("#regions");
const video = document.querySelector("#webcam");
const cameraButton = document.querySelector("#enable-camera");
const gestureElement = document.querySelector("#gesture");
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 20);
camera.position.set(0, 0, 3.2);
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
controls.minDistance = 1.6;
controls.maxDistance = 4.5;
let brain = createPlaceholderBrain();
let pointRegions = [];
let regionNames = [];
let paused = false;
scene.add(brain);
function createPlaceholderBrain() {
const positions = [];
const colors = [];
for (let index = 0; index < 4200; index += 1) {
const longitude = Math.random() * Math.PI * 2;
const latitude = Math.acos(2 * Math.random() - 1);
const ripple = 1 + 0.05 * Math.sin(longitude * 9) * Math.sin(latitude * 7);
positions.push(
0.86 * ripple * Math.sin(latitude) * Math.cos(longitude),
1.04 * ripple * Math.cos(latitude),
0.74 * ripple * Math.sin(latitude) * Math.sin(longitude),
);
colors.push(0.05, 0.62, 0.78);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
const material = new THREE.PointsMaterial({
size: 0.018,
transparent: true,
opacity: 0.78,
vertexColors: true,
sizeAttenuation: true,
});
return new THREE.Points(geometry, material);
}
function installSourceGeometry(message) {
scene.remove(brain);
regionNames = message.regions;
pointRegions = message.points.map((point) => point[3]);
const positions = [];
const colors = [];
for (const [x, y, z] of message.points) {
positions.push(x, z, -y);
colors.push(0.05, 0.45, 0.58);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
const material = new THREE.PointsMaterial({
size: 0.025,
transparent: true,
opacity: 0.86,
vertexColors: true,
sizeAttenuation: true,
});
brain = new THREE.Points(geometry, material);
scene.add(brain);
renderRegionRows();
}
function renderRegionRows() {
regionsElement.replaceChildren();
regionNames.forEach((name, index) => {
const row = document.createElement("div");
row.className = "region";
row.innerHTML = `
<div class="region-label">
<span>${name}</span>
<span id="value-${index}">0.00</span>
</div>
<div class="track"><div class="fill" id="fill-${index}"></div></div>
`;
regionsElement.appendChild(row);
});
}
function updateActivation(values) {
if (paused || !brain.geometry.getAttribute("color")) {
return;
}
const colorAttribute = brain.geometry.getAttribute("color");
pointRegions.forEach((regionIndex, pointIndex) => {
const intensity = regionIndex >= 0 ? values[regionIndex] ?? 0 : 0;
const red = 0.04 + intensity * 0.96;
const green = 0.34 + intensity * 0.48;
const blue = 0.55 + (1 - intensity) * 0.35;
colorAttribute.setXYZ(pointIndex, red, green, blue);
});
colorAttribute.needsUpdate = true;
values.forEach((value, index) => {
const valueElement = document.querySelector(`#value-${index}`);
const fillElement = document.querySelector(`#fill-${index}`);
if (valueElement && fillElement) {
valueElement.textContent = value.toFixed(2);
fillElement.style.width = `${Math.round(value * 100)}%`;
}
});
}
const socket = new WebSocket("ws://localhost:8000/ws");
socket.addEventListener("open", () => {
statusElement.textContent = "Connected. Preparing EEG data and source model...";
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "status") {
statusElement.textContent = message.message;
}
if (message.type === "geometry") {
installSourceGeometry(message);
}
if (message.type === "activation") {
updateActivation(message.values);
}
});
socket.addEventListener("close", () => {
statusElement.textContent = "Backend disconnected. Showing the last frame.";
});
socket.addEventListener("error", () => {
statusElement.textContent = "Backend offline. Showing placeholder cortex.";
});
let gestureRecognizer = null;
let lastVideoTime = -1;
let lastGestureFrame = 0;
cameraButton.addEventListener("click", async () => {
cameraButton.disabled = true;
gestureElement.textContent = "Gesture: loading model...";
try {
const vision = await FilesetResolver.forVisionTasks("/wasm");
gestureRecognizer = await GestureRecognizer.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
"https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task",
},
runningMode: "VIDEO",
numHands: 1,
});
video.srcObject = await navigator.mediaDevices.getUserMedia({ video: true });
await video.play();
gestureElement.textContent = "Gesture: show one hand";
window.requestAnimationFrame(readGestureFrame);
} catch (error) {
cameraButton.disabled = false;
gestureElement.textContent = `Gesture error: ${error.message}`;
}
});
function readGestureFrame(now) {
if (!gestureRecognizer) {
return;
}
if (
video.readyState >= 2 &&
video.currentTime !== lastVideoTime &&
now - lastGestureFrame > 80
) {
lastVideoTime = video.currentTime;
lastGestureFrame = now;
const result = gestureRecognizer.recognizeForVideo(video, now);
if (result.landmarks.length > 0) {
const hand = result.landmarks[0];
const centerX = hand.reduce((sum, point) => sum + point.x, 0) / hand.length;
const centerY = hand.reduce((sum, point) => sum + point.y, 0) / hand.length;
const xValues = hand.map((point) => point.x);
const handSpan = Math.max(...xValues) - Math.min(...xValues);
brain.rotation.y += (0.5 - centerX) * 0.045;
brain.rotation.x += (centerY - 0.5) * 0.025;
camera.position.z = Math.max(1.7, Math.min(4.3, 4.2 - handSpan * 7));
const category = result.gestures[0]?.[0];
gestureElement.textContent = category
? `Gesture: ${category.categoryName} (${category.score.toFixed(2)})`
: "Gesture: hand detected";
} else {
gestureElement.textContent = "Gesture: show one hand";
}
}
window.requestAnimationFrame(readGestureFrame);
}
function resizeRenderer() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate() {
resizeRenderer();
controls.update();
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
window.addEventListener("keydown", (event) => {
if (event.code === "Space") {
paused = !paused;
statusElement.textContent = paused
? "Visualization paused"
: "Live template source estimate";
}
});
animate();
You made the complete interface work. Live source activity now remains explorable through both mouse input and webcam hand landmarks.
Secret mission
Audit the Visualization Against False Certainty
Test whether the leading central label repeats across three timed periods. Then write a three-sentence interpretation that separates measured EEG, fsaverage estimates, and normalized browser colors.
Clean Up Your Resources
Clean Up Your Resources
Your prototype runs entirely on your Windows PC with no ongoing cloud costs. Choose whether to keep the project ready, pause its processes, or delete its local files and caches.
Resources you used:
- The running Uvicorn backend process.
- The running Vite development server.
- The Google Chrome browser session with webcam access.
- The project folder containing audit.md, backend.py, index.html, package.json, requirements.txt, .venv, node_modules, public/wasm, src/main.js, and src/style.css. The folder includes the version-matched MediaPipe WASM assets.
- Cached PhysioNet EEGBCI data and fsaverage data in the MNE-Python local data directory.
Keep everything running
No teardown is needed. Choose this if you are still testing the prototype or want to revisit your audit later.
- Keep the project folder with audit.md so your findings and source files remain together.
- Keep the cached EEGBCI and fsaverage data so later runs can reuse the downloads.
- Press Ctrl+C in the terminal running Uvicorn when you finish your session.
- Press Ctrl+C in the terminal running Vite when you finish your session.
- Close the Chrome tab showing the EEG Source Explorer when you want to release webcam access.
Your local files and cached datasets remain available without creating a background cloud service.
Pause - I'll come back to this later
Shut down the running processes to free up memory while keeping every local file for your next session.
- Press Ctrl+C in the terminal running Uvicorn.
- Press Ctrl+C in the terminal running Vite.
- Close Chrome to end webcam access.
- Keep the project folder with its .venv environment.
- Keep the cached EEGBCI and fsaverage data for a faster return.
You should see both terminal prompts return. Your webcam indicator should turn off after Chrome closes.
Delete - I don't want to use this again
Remove all local project resources when you are finished with the prototype.
Before You Delete
Deleting the project folder also removes your repeatability audit. Windows keeps the folder recoverable in the Recycle Bin until you empty it.
- Press Ctrl+C in the terminal running Uvicorn.
- Press Ctrl+C in the terminal running Vite.
- Close Chrome to release webcam access.
- Locate the project folder that contains backend.py in the Windows file browser.
- Press the Delete key to move the project folder to the Recycle Bin.
The project folder now sits in the Recycle Bin. Its .venv environment, node_modules dependencies, copied WASM assets, source files, and audit move with it.
- Open MNE-Python's local data directory in the Windows file browser.
- Delete the downloaded EEGBCI folder.
- Delete the downloaded fsaverage folder.
- Empty the Recycle Bin to permanently remove the deleted resources.
You should no longer see the project folder in its previous location. The MNE-Python local data directory should no longer contain the downloaded EEGBCI or fsaverage folders.
Nice Work!
Nice Work!
You did it. Your local research prototype now turns replayed EEG into a gesture-controlled 3D source explorer with scientifically honest limits.
You've learned how to:
- Replay a PhysioNet EEGBCI motor-imagery recording through MNE-LSL as a live Lab Streaming Layer feed. Apply a causal 8 to 30 Hz filter to one-second windows before source estimation.
- Build an fsaverage EEG forward model. Produce a minimum-norm dSPM inverse estimate. Summarize relative source energy through central cortical parcellations selected at runtime.
- Stream geometry plus activation updates through FastAPI WebSockets into a three.js point cloud. Use MediaPipe Gesture Recognizer landmarks to rotate the brain. Map hand span to camera zoom. Keep mouse orbit available as a fallback.
- Complete a Secret Mission that compares the top-ranked central label across three 30-second periods. Assess whether the ranking repeats. Write a defensible interpretation that separates measured scalp EEG from the fsaverage estimate. Explain what the normalized browser colors cannot establish.
Ready to quiz yourself?