<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://timnaher.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://timnaher.github.io/" rel="alternate" type="text/html" /><updated>2026-03-29T13:36:54+02:00</updated><id>https://timnaher.github.io/feed.xml</id><title type="html">Tim Näher</title><subtitle>PhD Candidate at the Max Planck Institute for Biological Cybernetics</subtitle><author><name>Tim Näher</name><email>tim.naeher@tuebingen.mpg.de</email></author><entry><title type="html">GazeAnnot: a desktop app for labeling eye movements and training U’n’Eye</title><link href="https://timnaher.github.io/posts/2026/03/gazeannot/" rel="alternate" type="text/html" title="GazeAnnot: a desktop app for labeling eye movements and training U’n’Eye" /><published>2026-03-29T00:00:00+01:00</published><updated>2026-03-29T00:00:00+01:00</updated><id>https://timnaher.github.io/posts/2026/03/gazeannot</id><content type="html" xml:base="https://timnaher.github.io/posts/2026/03/gazeannot/"><![CDATA[<p>I built a desktop application for labeling eye movement events and training the <a href="https://github.com/berenslab/uneye">U’n’Eye</a> deep learning model for automatic saccade detection. It’s called <strong>GazeAnnot</strong>, and it wraps the full workflow, from raw eye position data to trained model, into a single interactive tool.</p>

<figure>
  <img src="/images/blog-gazeannot/demo.gif" alt="GazeAnnot demo" style="max-width: 600px; width: 100%;" />
  <figcaption>GazeAnnot in action: labeling saccades on eye position and velocity traces, then training U'n'Eye with live loss curves and per-class metrics.</figcaption>
</figure>

<h2 id="why-this-exists">Why this exists</h2>

<p>Detecting saccades, microsaccades, and post-saccadic oscillations (PSO) in eye tracking data is a common task in vision research. The classical approach is velocity-threshold algorithms, but these require careful parameter tuning and struggle with noisy data or unusual saccade profiles. <a href="https://doi.org/10.1152/jn.00612.2018">U’n’Eye</a> (Bellet et al., 2019) showed that a deep neural network can match human-level saccade detection performance, but using it in practice means you need labeled training data, and labeling eye movement events by hand is tedious without a proper tool.</p>

<p>Existing labeling tools are either too general-purpose (not optimized for time series), require MATLAB, or don’t integrate with the training loop. I wanted something where you can load your data, label events interactively, train a model, evaluate it, and iterate, all without leaving the application.</p>

<h2 id="the-three-views">The three views</h2>

<p>GazeAnnot has three views, accessible from a sidebar: <strong>Label</strong>, <strong>Train</strong>, and <strong>Evaluate</strong>.</p>

<h3 id="label">Label</h3>

<p>The labeling interface shows two synchronized plots: eye position (X and Y overlaid, mean-subtracted) on top, and Euclidean velocity in deg/s on the bottom. You label by dragging on the plot to select a time region and pressing Space to apply the active event class.</p>

<p>There are three default event classes, Saccade, Microsaccade, and PSO, each with its own color and keyboard shortcut (1, 2, 3). You can add custom classes with their own names, colors, and shortcuts through the class manager.</p>

<p>A few things that make labeling less painful:</p>

<ul>
  <li><strong>Undo/redo</strong> for all label edits (Ctrl+Z / Ctrl+Shift+Z)</li>
  <li><strong>Right-click</strong> on a labeled segment to change its class</li>
  <li><strong>Double-click</strong> to erase a segment</li>
  <li><strong>Auto-save</strong> whenever you navigate between trials</li>
  <li><strong>Gaussian smoothing</strong> (G) and <strong>median filter</strong> (M) toggles for noisy data</li>
  <li>Auto-detection of Y files and existing label files when you load data</li>
</ul>

<h3 id="train">Train</h3>

<p>Once you have labeled data, you switch to the Train view. Two training modes are available:</p>

<ul>
  <li><strong>Full training</strong>: train on all labeled data and save the weights</li>
  <li><strong>K-fold cross-validation</strong> (2-20 folds): get honest generalization metrics before committing to a final model</li>
</ul>

<p>The training runs in a background thread with live loss curve visualization. In CV mode, each fold’s loss curve is overlaid so you can spot folds that behave differently. After training, you get per-class metrics (F1, precision, recall, support) and can optionally retrain on all data for the final deployment model.</p>

<p>You can configure learning rate, max epochs, data augmentation, and post-processing thresholds (minimum saccade duration, minimum distance between saccades). There’s also an option to collapse multi-class labels to binary (saccade vs. fixation) if your downstream task only needs that.</p>

<h3 id="evaluate">Evaluate</h3>

<p>The evaluation view lets you run predictions with any trained weights file and inspect the results:</p>

<ul>
  <li>Overall metrics: Cohen’s kappa, accuracy, weighted F1</li>
  <li>Per-class metrics table</li>
  <li>Onset/offset error histograms showing how well predicted event boundaries match ground truth</li>
  <li>A confusion browser that lets you navigate directly to trials where the model made errors</li>
</ul>

<h2 id="data-format">Data format</h2>

<p>GazeAnnot reads eye position data from CSV, MATLAB v7.3, or NumPy files. The expected structure is rows = trials, columns = timepoints. When you load an X file, the app tries to auto-detect the corresponding Y file using common naming conventions (<code class="language-plaintext highlighter-rouge">eye_x</code> / <code class="language-plaintext highlighter-rouge">eye_y</code>, <code class="language-plaintext highlighter-rouge">_x</code> / <code class="language-plaintext highlighter-rouge">_y</code>, etc.). Labels are stored as integers per timepoint (0 = fixation, 1 = saccade, 2 = microsaccade, …) and auto-saved as CSV next to your data files.</p>

<h2 id="technical-details">Technical details</h2>

<p>The app is built with <strong>PyQt6</strong> for the GUI and <strong>pyqtgraph</strong> for the interactive plots. The U’n’Eye model runs on <strong>PyTorch</strong>. The dark theme uses Catppuccin colors. The whole thing is pure Python, no compilation step, no Electron, no web stack.</p>

<p>All label edits go through a QUndoStack, so undo/redo is robust even for complex sequences of edits. Training workers run in separate QThreads with per-epoch callbacks, so the UI stays responsive during training.</p>

<h2 id="get-it">Get it</h2>

<p>The code is on GitHub: <a href="https://github.com/timnaher/gazeannot">timnaher/gazeannot</a></p>

<p>Install is straightforward:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>conda create <span class="nt">-n</span> gazeannot <span class="nv">python</span><span class="o">=</span>3.11 <span class="nt">-y</span>
conda activate gazeannot
pip <span class="nb">install </span>PyQt6 pyqtgraph numpy scipy pandas mat73 torch scikit-learn scikit-image matplotlib
git clone https://github.com/timnaher/gazeannot.git
<span class="nb">cd </span>gazeannot
python main.py
</code></pre></div></div>

<p>A sample dataset (54 trials at 1000 Hz) is included in the <code class="language-plaintext highlighter-rouge">data/</code> directory so you can try it out immediately.</p>]]></content><author><name>Tim Näher</name><email>tim.naeher@tuebingen.mpg.de</email></author><category term="eye tracking" /><category term="deep learning" /><category term="PyQt6" /><category term="Python" /><category term="saccade detection" /><summary type="html"><![CDATA[I built a desktop application for labeling eye movement events and training the U’n’Eye deep learning model for automatic saccade detection. It’s called GazeAnnot, and it wraps the full workflow, from raw eye position data to trained model, into a single interactive tool.]]></summary></entry><entry><title type="html">Does the Muse S Athena’s fNIRS actually improve brain decoding?</title><link href="https://timnaher.github.io/posts/2026/03/multimodal-bci-fnirs/" rel="alternate" type="text/html" title="Does the Muse S Athena’s fNIRS actually improve brain decoding?" /><published>2026-03-22T00:00:00+01:00</published><updated>2026-03-22T00:00:00+01:00</updated><id>https://timnaher.github.io/posts/2026/03/multimodal-bci-fnirs</id><content type="html" xml:base="https://timnaher.github.io/posts/2026/03/multimodal-bci-fnirs/"><![CDATA[<p>The new <a href="https://choosemuse.com/">Muse S Athena</a> wearable EEG headband is now multimodal! Besides EEG it also includes optical sensors to measure fNIRS. This is a pretty big deal, as we are entering an era where consumer wearables ship with truly multimodal brain sensing out of the box. And there’s good reason for excitement: EEG and fNIRS measure fundamentally complementary aspects of brain activity. EEG captures fast, synchronous neural oscillations on a millisecond timescale, while fNIRS tracks the slower hemodynamic response, which are changes in blood oxygenation that follow neural activation with a delay of several seconds. These two signals are linked through neurovascular coupling but sensitive to different physiology and different artifacts, making them natural candidates for fusion (<a href="https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2017.00503/full">Hong &amp; Khan, 2017</a>; <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC9371171/">Chiarelli et al., 2022</a>).</p>

<p>Having a Muse S Athena at home, I wanted to test if this new modality actually adds benefits for simple BCI applications.</p>

<h2 id="the-setup">The setup</h2>

<p>The Muse S Athena includes 4 EEG electrodes (TP9, AF7, AF8, TP10 at 256 Hz) and 8 optical channels (4 long-distance fNIRS + 4 short-distance PPG at 64 Hz). After preprocessing the optics through the modified Beer-Lambert law and short-channel regression, I get 2 channels of oxygenated hemoglobin (<strong>HbO</strong>) and 2 channels of deoxygenated hemoglobin (<strong>HbR</strong>).</p>

<p>Since the Muse S covers frontal cortex, I chose a task contrast that targets exactly that region: <strong>mental arithmetic vs. rest</strong>. This is a classic cognitive load paradigm that has been shown to produce robust prefrontal activation, something we’ve previously shown can be decoded well even in multi-class settings using full research-grade fNIRS setups and Riemannian geometry classifiers (<a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC12523035/">Näher et al., 2025</a>). The question is whether a consumer device with just 2 fNIRS channels can capture enough of that signal to actually help the decoding.</p>

<p>I chose to implement a simple data collection strategy: A blocked design, of randomly interleaved trials with either 10 seconds or REST or 10 seconds of CALCULATE with a large and jittered ITI to allow the fNIRS to return to baseline between trials. During the CALCULATE trials, a three-digit number appears on screen and I mentally subtract 7 repeatedly for 10 seconds (487, 480, 473, …). In REST trials, I stare at a fixation cross for the same duration. I recorded 80 trials across multiple sessions (balanced classes).</p>

<p>The question: <strong>does adding hemodynamic data from fNIRS improve classification beyond what EEG alone can do?</strong></p>

<h2 id="why-this-matters">Why this matters</h2>

<p>Hybrid EEG-fNIRS systems have been shown to improve classification accuracy by around 5% on average compared to either modality alone (<a href="https://www.sciencedirect.com/science/article/pii/S1053811911008792">Fazli et al., 2012</a>). However, this number typically comes from research-grade setups with dense optode arrays covering large cortical areas. A consumer headband with 2 fNIRS channels covering a small frontal patch is a very different story. More sensors don’t automatically mean better decoding, each additional modality adds dimensionality, noise, and estimation cost. If the fNIRS channels don’t carry information that EEG is missing, they make the Muse S Athena more of a gimmick than actually a true useful multimodal brain wearable.</p>

<h2 id="riemannian-geometry-classifiers">Riemannian geometry classifiers</h2>

<p>As a classifier, I used <a href="https://pyriemann.readthedocs.io/">pyriemann</a> to classify covariance and kernel matrices. The key insight is that covariance/kernel matrices live on a curved manifold, not in flat Euclidean space, and respecting this geometry gives better classification, especially with small datasets like mine.</p>

<p>The EEG signal is bandpass-filtered into <strong>theta</strong> (4-8 Hz), <strong>alpha</strong> (8-13 Hz), and <strong>beta</strong> (13-30 Hz). Each band produces a 4x4 covariance matrix. For fNIRS, I split HbO and HbR into separate covariance blocks (2 channels each) rather than combining them into one matrix. This follows the approach we introduced in <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC12523035/">Näher et al. (2025)</a>.</p>

<p>Both EEG and fNIRS blocks are searched over the same space of covariance estimators (SCM, Ledoit-Wolf, OAS) and kernel methods (RBF, linear), each with optional shrinkage regularization. All matrices are trace-normalized before fusion so that modalities with different signal magnitudes (EEG in µV, fNIRS in µM) contribute equally.</p>

<h2 id="three-fusion-strategies">Three fusion strategies</h2>

<p>How you combine modalities matters as much as <em>what</em> you combine. I tested three strategies:</p>

<p><strong>Strategy A: Block EEG + block fNIRS.</strong> Each EEG band (4x4) and each fNIRS component (2x2) gets its own covariance block, assembled into a 16x16 block-diagonal matrix. This assumes the modalities are independent, no cross-modal correlations.</p>

<p><strong>Strategy B: Full EEG + block fNIRS.</strong> All 12 EEG channels (3 bands × 4 channels) get one full 12x12 covariance matrix that captures cross-band relationships within EEG. The fNIRS components are added as separate 2x2 blocks. This is a hybrid: cross-band EEG correlations yes, cross-modal correlations no.</p>

<p><strong>Strategy C: Full covariance.</strong> All 16 channels (12 EEG + 2 HbO + 2 HbR) go into one 16x16 covariance matrix. This captures cross-modal correlations, e.g., whether alpha power and HbO concentration co-vary differently during arithmetic vs rest. If EEG-fNIRS coupling carries discriminative information, this strategy should win.</p>

<h2 id="results">Results</h2>

<figure>
  <img src="/images/blog-multimodal/multimodal_1_all_conditions.png" alt="All conditions ranked by accuracy" />
  <figcaption>All five conditions ranked by stratified 5-fold cross-validation accuracy. Block EEG (4+4+4) achieves the highest accuracy at 68%. Color indicates fusion strategy; hatched bars include fNIRS. None of the fNIRS strategies surpass EEG-only baselines.</figcaption>
</figure>

<p>Here’s the punchline: In this setup and paradigm <strong>fNIRS adds nothing.</strong> Despite the promising multimodal setup, not a single fusion strategy beats plain EEG.</p>

<ul>
  <li><strong>Block EEG (68%)</strong> wins overall, treating each frequency band as an independent covariance block works best</li>
  <li><strong>Strategy B (67%)</strong> is the best fNIRS approach, closely matching the full EEG baseline but not exceeding it. It uses RBF kernels for both EEG and fNIRS blocks</li>
  <li><strong>Strategy C (64%)</strong>, the full cross-modal covariance, actually <em>hurts</em>. Cross-modal EEG×fNIRS correlations likely add noise, not signal</li>
  <li><strong>Strategy A (62%)</strong>, all block-diagonal, is the worst fNIRS approach</li>
</ul>

<h2 id="statistical-testing">Statistical testing</h2>

<p>I used a permutation test (1,000 permutations) to assess whether the differences are significant. Under the null hypothesis, each fold’s accuracy difference is equally likely to be positive or negative, and we permute the signs to build a null distribution.</p>

<p>None of the three comparisons reach significance (all p &gt; 0.5). The direction is consistently negative or zero.</p>

<h2 id="why-doesnt-fnirs-help">Why doesn’t fNIRS help?</h2>

<p>A few factors likely explain this:</p>

<p><strong>Spatial resolution.</strong> The Muse S has only 2 long-distance fNIRS channels (left and right forehead). Research-grade fNIRS systems use 20-50+ channels with dense source-detector grids. With just 2 channels, there might not be enough spatial information to capture the distributed hemodynamic pattern of cognitive load.</p>

<p><strong>EEG already captures the signal.</strong> Mental arithmetic can modulate alpha and theta power. On a 4-channel consumer EEG, this spectral signature might be already sufficient, the fNIRS doesn’t carry complementary information that EEG is missing.</p>

<p><strong>Curse of dimensionality.</strong> Adding fNIRS channels increases the covariance matrix dimensionality without proportionally adding discriminative features. With only 80 trials, the larger matrices become harder to estimate reliably.</p>

<h2 id="what-i-learned">What I learned</h2>

<p><strong>More sensors are not necessarily better.</strong> The engineering intuition that more data means better classification breaks down when the additional modality doesn’t carry complementary information relative to its dimensionality cost.</p>

<p><strong>Per-modality kernel tuning matters.</strong> An interesting finding: EEG alpha performed best with RBF kernels (<code class="language-plaintext highlighter-rouge">ker:rbf+shr0.01</code>), not standard covariance estimators. fNIRS also benefited from kernel methods. Using the same estimator for both would miss this. I wrote about the per-block kernel tuning approach in a previous <a href="https://pyriemann.readthedocs.io/en/latest/auto_examples/fnirs/plot_classif_fnirs.html">pyriemann fNIRS example</a>.</p>

<p><strong>Trace normalization is essential</strong> for multimodal fusion. Without it, the modality with larger absolute values dominates the block-diagonal matrix.</p>

<p><strong>Full cross-modal covariance hurts.</strong> Strategy C, computing one big covariance over all channels, performed worst. The cross-modal EEG×fNIRS correlations are noisy on a consumer device and add dimensionality without adding signal. Block-diagonal fusion (keeping modalities separate) is more robust.</p>

<h2 id="the-honest-takeaway">The honest takeaway</h2>

<p>On a consumer-grade Muse S Athena with 4 EEG channels and 2 fNIRS channels, adding fNIRS does not improve mental arithmetic vs rest classification in this small and personalized at-home experiment. This doesn’t mean multimodal sensing is useless, or that the Muse S fNIRS sensors are useless. This conclusion is limited to my specific setup, task, paradigm and maybe also to my neural dynamics. With more fNIRS channels,  or tasks where EEG is weaker, the answer might be different. But for this combination of device, task, and data size, the 4-channel EEG seems to already capture all the discriminative strucutre. I still think its cool that companies like Muse start to think more in the multimodal sense for wearable. Ultimately I believe that each modality has certain advantages and in my opinion, an ideal BCI/HCI setup leverages all these different strengths to build the best possible application, without stressing too much about only using brain data.</p>]]></content><author><name>Tim Näher</name><email>tim.naeher@tuebingen.mpg.de</email></author><category term="BCI" /><category term="EEG" /><category term="fNIRS" /><category term="Riemannian geometry" /><category term="Python" /><summary type="html"><![CDATA[The new Muse S Athena wearable EEG headband is now multimodal! Besides EEG it also includes optical sensors to measure fNIRS. This is a pretty big deal, as we are entering an era where consumer wearables ship with truly multimodal brain sensing out of the box. And there’s good reason for excitement: EEG and fNIRS measure fundamentally complementary aspects of brain activity. EEG captures fast, synchronous neural oscillations on a millisecond timescale, while fNIRS tracks the slower hemodynamic response, which are changes in blood oxygenation that follow neural activation with a delay of several seconds. These two signals are linked through neurovascular coupling but sensitive to different physiology and different artifacts, making them natural candidates for fusion (Hong &amp; Khan, 2017; Chiarelli et al., 2022).]]></summary></entry><entry><title type="html">Back to VS Code in a blink</title><link href="https://timnaher.github.io/posts/2026/03/brain-controlled-shortcut/" rel="alternate" type="text/html" title="Back to VS Code in a blink" /><published>2026-03-15T00:00:00+01:00</published><updated>2026-03-15T00:00:00+01:00</updated><id>https://timnaher.github.io/posts/2026/03/brain-controlled-shortcut</id><content type="html" xml:base="https://timnaher.github.io/posts/2026/03/brain-controlled-shortcut/"><![CDATA[<p>I keep losing my VS Code window. My desktop and browser is always a mess with way too many tabs open because I want to quickly look something up, and I am also guilty of having way too many desktops on my macbook open. Alt-tabbing through everything is annoying. What if I could just… blink my way back?</p>

<p>Good thing I have a Muse S Athena headband sitting on my desk.</p>

<h2 id="why-blinks-show-up-in-eeg">Why blinks show up in EEG</h2>

<p>EEG headbands like the Muse S Athena are designed to measure brain activity, but they also pick up a lot of other electrical signals. In clinical and basic neuroscience research, these are actually consideres <strong>artifacts</strong> and researchers, including myself, spend considerable effort removing them. One of the strongest artifact sources are the eyes.</p>

<p>The eyes acts as electrical dipoles: the cornea is positively charged relative to the retina. Every time you blink, your eyelid sweeps across this dipole and briefly rotates it, producing a large voltage deflection that propagates across the scalp. Frontal electrodes (like AF7 and AF8 on the Muse) pick up the strongest signal, while temporal electrodes (TP9, TP10) see a weaker version.</p>

<figure>
  <img src="/images/blog-toybci/eog_dipole.png" alt="Blink artifact waveform" />
  <figcaption>Average blink artifact waveform recorded from the Muse S. The frontal electrode (AF7, blue) picks up a large ~125 uV deflection peaking around 100 ms after blink onset, while the temporal electrode (TP10, orange) shows a much weaker response (~35 uV).</figcaption>
</figure>

<p>This is normally a nuisance, but for a BCI, it’s a feature. In fact, many modern consumer BCI applications rely heavily on minimally processed EEG that is dominated by peripheral signals: blinks, jaw clenches, heartbeat artifacts, eye movements. From a pure engineering perspective, these signals are incredibly useful. Eye tracking paths alone can reconstruct visual scenes surprisingly well. The downside is that this leads to many claims of “decoding brain activity” when the system is really picking up a mix of peripheral signals. For this project, I’m being explicit about it: we’re detecting blinks, not reading minds. Blink artifacts are large (~100-200 uV, compared to ~10-50 uV for actual brain signals), consistent, and trivially voluntary. The trick is distinguishing <em>intentional</em> blink sequences from normal blinking.</p>

<h2 id="the-idea">The idea</h2>

<p>Instead of detecting single blinks (which would trigger constantly), I defined a <strong>blink sequence</strong>: 4 rapid blinks with a distinct temporal sequence in about 1 second. This is distinctive enough that it basically never happens during normal activity, but easy enough to do on purpose. The end-to-end system needs to:</p>

<ol>
  <li>Stream EEG from the Muse S Athena in real time</li>
  <li>Detect blink sequences with low latency</li>
  <li>Trigger a macOS command (bring VS Code to front)</li>
</ol>

<h2 id="connecting-the-muse-s-athena-on-macos">Connecting the Muse S Athena on macOS</h2>

<p>This turned out to be the hardest part. The Muse S Athena (the latest hardware revision) uses a different BLE GATT profile than older Muses. It multiplexes all sensor data through fewer characteristics (<code class="language-plaintext highlighter-rouge">273e0013</code> instead of the traditional <code class="language-plaintext highlighter-rouge">273e0003-0007</code>). Neither BrainFlow nor muselsl support this.</p>

<p>I ended up using <code class="language-plaintext highlighter-rouge">bleak</code> (a Python BLE library) directly, building on the protocol reverse-engineered by the <a href="https://github.com/Amused-EEG/amused-py">amused-py</a> project.</p>

<p>The EEG data comes as 14-bit packed samples (not 12-bit like older Muses) at 256 Hz across 4 channels.</p>

<h2 id="recording-training-data">Recording training data</h2>

<p>I wrote a quick prompter in <a href="https://www.psychopy.org/">PsychoPy</a> (nostalgic, since I programmed my first psychophysics experiment in PsychoPy during my bachelor’s lol) that cues the target blink sequence at jittered intervals. I recorded 6 sessions (~460 seconds of data total, ~90 blink sequences). The EEG streams directly into CSV files alongside event markers.</p>

<p>The challenge is that the prompt only tells you <em>approximately</em> when the blinks happened. The exact onset and offset times of each blink within the sequence are unknown, which is why I needed a post hoc labeling strategy.</p>

<h2 id="labeling">Labeling</h2>

<p>Using the prompt timestamps as a rough guide, I search for blink peaks in a window after each cue. The labeling algorithm:</p>

<ol>
  <li>Bandpass filter the frontal channels (1-15 Hz) to isolate the blink waveform</li>
  <li>Find peaks using the known inter-blink interval (IBI) distribution (~200-500ms)</li>
  <li>Score candidate peak combinations by how well they match the expected rhythm</li>
  <li>Mark the region from first blink onset to last blink offset as positive</li>
</ol>

<figure>
  <img src="/images/blog-toybci/blink_trial_example.png" alt="Example blink sequence trial" />
  <figcaption>Example trial showing a 4-blink sequence across all four Muse channels (TP9, AF7, AF8, TP10). The green shaded region marks the ground truth label. The bottom row shows the binary label (1 during the blink sequence, 0 elsewhere).</figcaption>
</figure>

<p>The result is a binary label for every EEG sample: 1 during a blink sequence, 0 everywhere else. About 8-10% of the data is positive.</p>

<h2 id="the-model">The model</h2>

<p>I wanted something tiny and streaming-capable. The model sees a 5-second buffer of EEG and outputs predictions at 16 Hz (one prediction every 62.5ms):</p>

<figure>
  <img src="/images/blog-toybci/architecture.png" alt="StreamingBlinkNet architecture" />
  <figcaption>StreamingBlinkNet architecture (68K parameters). Three Conv1D blocks progressively downsample the 4-channel, 5-second EEG input (4x1280) to 64x160 features, followed by a bidirectional GRU and a linear head outputting per-timestep blink probabilities at 16 Hz (1x80).</figcaption>
</figure>

<p><strong>StreamingBlinkNet</strong>, 68K parameters:</p>
<ul>
  <li><strong>3x Conv1D blocks</strong> (kernel sizes 7, 5, 3) with batch norm, ReLU, and max pooling. This downsamples 256 Hz input to 16 Hz features while extracting local temporal patterns.</li>
  <li><strong>Bidirectional GRU</strong> (64 hidden units) captures longer temporal context across the full 5-second window. Bidirectional because we’re okay with ~2 second latency, so the model can look slightly into the “future” relative to the detection window.</li>
  <li><strong>Linear head</strong> maps to per-timestep blink probability.</li>
</ul>

<p>The model is intentionally acausal with about 2 seconds of latency. This is a lot, and you could definitely build something much faster with a causal architecture or just a shorter look ahead. But for triggering a keyboard shortcut in this little example, 2 seconds is fine, and letting the model see the complete blink sequence before making a decision dramatically reduces false positives.</p>

<h2 id="training">Training</h2>

<p>With only 6 sessions of data, training takes about 30 seconds on an M4 Mac. I used a session-wise train/val split with one full session held out for validation.</p>

<figure>
  <img src="/images/blog-toybci/training_curves.png" alt="Training curves" />
  <figcaption>Training curves over 25 epochs. Left: BCE loss for train (blue) and validation (orange) sets. Center: validation F1 score reaching 0.986 (green dot marks best checkpoint). Right: validation AUROC saturating at 0.999.</figcaption>
</figure>

<p>The model converges fast and hits <strong>F1 = 0.986</strong> on the held-out session with <strong>AUROC = 0.999</strong>. Early stopping kicks in around epoch 25. The class imbalance (~10% positive) is handled with a pos_weight of ~12x in the BCE loss. I used Adam with a learning rate of 1e-3 and cosine annealing, batch size 32, and early stopping with patience 15. For augmentations I applied Gaussian noise (2-5 uV), amplitude scaling (0.8-1.2x), channel dropout (10%), and time shifts of +/-50ms.</p>

<h2 id="does-it-actually-work">Does it actually work?</h2>

<p>Here’s the model’s predictions on the held-out validation session, plotted against ground truth:</p>

<figure>
  <img src="/images/blog-toybci/predictions_overlay.png" alt="Model predictions on held-out session" />
  <figcaption>Model predictions on a 30-second segment from the held-out validation session. Top: raw 4-channel EEG with blink artifacts visible as large deflections. Middle: ground truth binary label. Bottom: model output probability (blue) with detection threshold (red dashed line). The model produces a sharp, well-localized peak with no false positives.</figcaption>
</figure>

<p>The model cleanly detects blink sequences with sharp probability peaks that closely match the ground truth labels. No false positives in this segment.</p>

<h2 id="real-time-inference">Real-time inference</h2>

<p>The real-time system:</p>
<ol>
  <li>Connects to the Muse S via BLE</li>
  <li>Fills a rolling 5-second buffer with incoming EEG</li>
  <li>Runs inference every 80ms (~12 Hz)</li>
  <li>Checks the detection window (1.5-3.0s from buffer end) for sustained high probability</li>
  <li>On detection: plays a sound + runs <code class="language-plaintext highlighter-rouge">osascript -e 'tell application "Visual Studio Code" to activate'</code></li>
</ol>

<p>There’s a 3-second cooldown between detections to prevent double-triggers.</p>

<p>The whole thing runs as a tiny always-on-top window: just a status dot, a probability bar, and “Idle” / “Detected”.</p>

<figure>
  <video autoplay="" loop="" muted="" playsinline="" style="width: 100%; border-radius: 6px;">
    <source src="/images/blog-toybci/blinkdemo.mp4" type="video/mp4" />
  </video>
  <figcaption>The system in action. Four rapid blinks trigger VS Code to come to the foreground.</figcaption>
</figure>

<h2 id="takeaways">Takeaways</h2>

<p>The entire project, from first BLE packet to working brain-controlled shortcut, took an afternoon. The model is 68K parameters and trains in 30 seconds. The inference runs comfortably at 12 Hz on CPU.</p>

<p>It’s worth noting that this is a personalized model, trained and tested on my own data. One of the hardest problems in deep learning for (neural/bio) signals is cross-participant generalization, which requires much more data and far more sophisticated modeling approaches and augmentation strategies. This is a really simple problem: the signal is strong, the task is binary, and the model only needs to work for one person. That’s why a tiny CNN + GRU with 15 minutes of training data gets you to 0.986 F1.</p>

<p>Still, it’s incredibly cool to me that you can build something like this in an afternoon. Connecting a consumer EEG headband to a real-time neural network that actually controls your computer is just so much fun to play around with!!</p>]]></content><author><name>Tim Näher</name><email>tim.naeher@tuebingen.mpg.de</email></author><category term="BCI" /><category term="EEG" /><category term="deep learning" /><category term="Python" /><summary type="html"><![CDATA[I keep losing my VS Code window. My desktop and browser is always a mess with way too many tabs open because I want to quickly look something up, and I am also guilty of having way too many desktops on my macbook open. Alt-tabbing through everything is annoying. What if I could just… blink my way back?]]></summary></entry></feed>