Track 1: retrieve actual viewed images from recorded EEG#

Estimated reading time:10 minutes

Load THINGS-EEG2 subject 08, session 02, task-test from nm000232. This is one whole recording (hundreds of MB) plus 60 original stimulus images. Observed tot_img_number values in the BIDS event sidecar identify the images; the release’s stimuli.tsv maps those IDs to the actual JPG files.

A frozen DINOv2-small encoder supplies 384-dimensional image targets. Ridge maps EEG to these real image features. Split image identities 60/20/20 for training, validation and testing. Each retrieval gallery contains only that split’s unseen images. The default uses nonofficial warm-up targets; EEGDASH_DINO_MODEL=giant opts into the costly official target encoder. The source calls this recording “test”, but our within-recording image split is an instructional experiment, not the official challenge train/test split.

Source: nemarDatasets/nm000232

Before you start#

Use EEGDash, NeuralSet, Transformers, TorchVision, MNE, NumPy, pandas, scikit-learn, Pillow and Matplotlib. The BDF is about 272 MB; first use also fetches 60 JPGs and a pretrained checkpoint (about 88 MB for DINOv2-small, about 4.5 GB for giant). CPU execution uses one image at a time. NeuralSet caches extracted targets, and Hugging Face retains the model checkpoint. EEGDASH_CACHE_DIR retains both the signal and things_test_images. Network failures are acquisition errors and are not replaced with substitute targets.

The official track retrieves unseen images using frozen DINOv2 features. The NeuralBench guide specifies giant features at relative depth 0.6667, mean token pooling and imsize=518, scored across the complete held-out gallery and then averaged across participants. Its within-batch validation metric is a different task.

The opt-in giant encoder uses the guide’s NeuralSet extraction settings. The default small model reduces download and CPU cost. Neither mode reproduces its contrastive EEG training, source-defined splits or hidden Alljoined cohort. This one-session ridge warm-up reports its own held-out gallery size alongside its measured score.

import os
from pathlib import Path
from urllib.request import urlopen

import matplotlib.pyplot as plt
import mne
import numpy as np
import pandas as pd
import torch
from neuralset.events.etypes import Image as ImageEvent
from neuralset.extractors import HuggingFaceImage
from PIL import Image
from sklearn.linear_model import Ridge
from sklearn.metrics import top_k_accuracy_score
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

from eegdash.paths import get_default_cache_dir

from eegdash import EEGDash, EEGDashDataset

Choose one converted recording and its observed image IDs#

The catalogue also contains original BrainVision source files. Selecting a path beginning with sub- chooses the converted BIDS BDF and its event sidecar rather than loading both representations of the same run. The exact path is printed so the acquisition remains reviewable.

tot_img_number in that sidecar records which stimulus was presented. Normal image trials with IDs 1–60 are kept, excluding target/catch trials. Subtracting one only changes these observed IDs into zero-based array indices. Numeric trigger order is never guessed to be an image identity.

records = EEGDash().find(
    {"dataset": "nm000232", "subject": "08", "session": "02", "task": "test"}
)
records = [record for record in records if record["bids_relpath"].startswith("sub-")]
cache = get_default_cache_dir()
dataset = EEGDashDataset(records=records, cache_dir=cache)
print(dataset.description.to_string(index=False))
print("Exact signal:", records[0]["bids_relpath"])
raw = dataset.datasets[0].raw.pick("eeg")
# Read the sidecar acquired together with this exact EEG recording.
event_path = Path(raw.filenames[0]).with_name(
    Path(raw.filenames[0]).name.replace("_eeg.bdf", "_events.tsv")
)
trials = pd.read_csv(event_path, sep="\t")
trials = trials[
    trials.trial_type.eq("image") & trials.tot_img_number.between(1, 60)
].copy()
labels = trials.tot_img_number.to_numpy(dtype=int) - 1
# Pin the metadata and JPG revision together; no inferred trigger-to-image mapping.
subject session task  age sex  n_sessions  run gender
     08      02 test 25.0   F         4.0 None   None
Exact signal: sub-08/ses-02/eeg/sub-08_ses-02_task-test_eeg.bdf
[10/15/26 13:13:48] INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/
                             "HTTP/1.1 200 OK"
[10/15/26 13:13:49] INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/manifest.json "HTTP/1.1 200 OK"
                    INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/sub-08/ses-02/eeg/sub-08_ses-02
                             _task-test_channels.tsv "HTTP/1.1
                             200 OK"
                    INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/sub-08/ses-02/eeg/sub-08_ses-02
                             _task-test_events.tsv "HTTP/1.1 200
                             OK"
[10/15/26 13:13:50] INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/sub-08/ses-02/eeg/sub-08_ses-02
                             _task-test_events.json "HTTP/1.1
                             500 Internal Server Error"
[10/15/26 13:13:52] INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/sub-08/ses-02/eeg/sub-08_ses-02
                             _task-test_events.json "HTTP/1.1
                             200 OK"
                    INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/sub-08/ses-02/eeg/sub-08_ses-02
                             _task-test_eeg.json "HTTP/1.1 200
                             OK"

Downloading sub-08_ses-02_task-test_eeg.bdf:   0%|          | 0.00/259M [00:00<?, ?B/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:   0%|          | 0.00/259M [00:00<?, ?B/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:   0%|          | 1.00M/259M [00:00<03:01, 1.49MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:   1%|          | 3.00M/259M [00:00<01:16, 3.53MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:   3%|▎         | 9.00M/259M [00:01<00:28, 9.13MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:   9%|▉         | 23.0M/259M [00:01<00:11, 20.9MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  13%|█▎        | 34.0M/259M [00:01<00:09, 26.1MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  19%|█▉        | 49.0M/259M [00:01<00:06, 33.0MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  24%|██▍       | 62.0M/259M [00:02<00:05, 36.7MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  29%|██▉       | 75.0M/259M [00:02<00:04, 40.4MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  34%|███▍      | 88.0M/259M [00:02<00:04, 43.5MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  39%|███▉      | 101M/259M [00:02<00:03, 44.6MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  45%|████▍     | 116M/259M [00:03<00:03, 47.3MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  47%|████▋     | 121M/259M [00:03<00:03, 44.9MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  52%|█████▏    | 134M/259M [00:03<00:02, 46.7MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  58%|█████▊    | 149M/259M [00:03<00:02, 49.0MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  61%|██████    | 158M/259M [00:03<00:02, 48.1MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  67%|██████▋   | 173M/259M [00:04<00:01, 51.2MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  71%|███████   | 184M/259M [00:04<00:01, 50.1MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  77%|███████▋  | 199M/259M [00:04<00:01, 51.7MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  82%|████████▏ | 213M/259M [00:04<00:00, 53.2MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  86%|████████▌ | 222M/259M [00:05<00:00, 51.9MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  90%|█████████ | 234M/259M [00:05<00:00, 52.4MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf:  96%|█████████▌| 248M/259M [00:05<00:00, 54.4MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf: 100%|█████████▉| 258M/259M [00:05<00:00, 53.7MB/s]
Downloading sub-08_ses-02_task-test_eeg.bdf: 100%|██████████| 259M/259M [00:05<00:00, 47.0MB/s]
[10/15/26 13:13:58] INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/dataset_description.json
                             "HTTP/1.1 200 OK"
                    INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/participants.tsv "HTTP/1.1 200
                             OK"
[10/15/26 13:13:59] INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/participants.json "HTTP/1.1 200
                             OK"
                    INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/README.md "HTTP/1.1 500
                             Internal Server Error"
[10/15/26 13:14:00] INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/README.md "HTTP/1.1 200 OK"
[10/15/26 13:14:01] INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/CHANGES "HTTP/1.1 200 OK"
                    INFO     HTTP Request: GET                   _client.py:1025
                             https://data.nemar.org/nm000232/v1.
                             1.1/.bidsignore "HTTP/1.1 200 OK"

Build targets from the actual stimulus files#

The pinned stimulus manifest maps each observed ID to its original JPG. Downloaded bytes must decode as images before entering the cache. Image IDs come from the observed event sidecar, rather than a guessed trigger ordering. The frozen encoder sees only original images; all EEG-to-target fitting and hyperparameter selection happen after the image-identity split below.

NeuralSet performs the same relative-layer selection and token averaging in either model mode. The small model yields 384 coordinates; giant yields 1536. Its image processor can resize/crop after imsize: using the public extractor preserves the guide’s actual processing path rather than hand-implementing a superficially similar resize. Unit normalization gives cosine ranking a common scale. These are measured pretrained activations, never random image targets.

revision = "5732fd1a9b9c873c94b25c7e7e70e550dc64faa7"
root = f"https://raw.githubusercontent.com/nemarDatasets/nm000232/{revision}/stimuli/"
manifest = pd.read_csv(root + "stimuli.tsv", sep="\t").set_index("stimulus_id")
image_cache = cache / "things_test_images" / revision
image_cache.mkdir(parents=True, exist_ok=True)
image_events = []
for identity in range(1, 61):
    row = manifest.loc[f"stim-test{identity:03d}"]
    destination = image_cache / Path(row.filename).name
    if not destination.exists():
        with urlopen(root + row.filename, timeout=60) as response:
            payload = response.read()
        # Decode before committing to cache: failed acquisitions cannot masquerade as images.
        from io import BytesIO

        with Image.open(BytesIO(payload)) as image:
            image.verify()
        destination.write_bytes(payload)
    image_events.append(
        ImageEvent(filepath=destination, start=0, duration=1, timeline="stimulus")
    )

model_size = os.environ.get("EEGDASH_DINO_MODEL", "small")
model_revision = {
    "small": "ed25f3a31f01632728cabb09d1542f84ab7b00566",
    "giant": "611a9d42f2335e0f921f1e313ad3c1b7178d206d",
}[model_size]
torch.set_num_threads(2)
image_encoder = HuggingFaceImage(
    model_name=f"facebook/dinov2-{model_size}",
    pretrained=True,
    layers=0.6667,
    token_aggregation="mean",
    imsize=518,
    batch_size=1,
    device="cpu",
    hf_config={
        "model_kwargs": {"revision": model_revision},
        "processor_kwargs": {"revision": model_revision},
    },
    infra={"folder": cache / "track1_dinov2", "cluster": None},
)
image_encoder.prepare(image_events)
embeddings = np.stack(
    [image_encoder.get_static(event).numpy() for event in image_events]
)
embeddings /= np.linalg.norm(embeddings, axis=1, keepdims=True)
# Limit windows to 180 ms: stimuli occur approximately every 200 ms. Earlier
# responses can still overlap because this is a rapid serial presentation task.
/home/runner/work/EEGDash/EEGDash/.venv/lib/python3.12/site-packages/neuralset/utils.py:86: UserWarning: The effect of "imsize"=518 might be cancelled by the HuggingFace processor.
  warnings.warn(message)

Fetching 6 files:   0%|          | 0/6 [00:00<?, ?it/s]
Fetching 6 files:  17%|█▋        | 1/6 [00:00<00:00,  6.62it/s]
Fetching 6 files:  83%|████████▎ | 5/6 [00:01<00:00,  4.17it/s]
Fetching 6 files: 100%|██████████| 6/6 [00:02<00:00,  2.96it/s]

  0%|          | 0/60 [00:00<?, ?it/s][10/15/26 13:14:14] INFO     Computing 60 image latents             image.py:162


Computing image embeddings:   0%|          | 0/60 [00:00<?, ?it/s]Using a slow image processor as `use_fast` is unset and a slow processor was saved with this model. `use_fast=True` will be the default behavior in v4.52, even if the model was saved with a slow processor. This will result in minor differences in outputs. You'll still be able to use a slow processor with `use_fast=False`.
[10/15/26 13:14:15] WARNING  Using a slow image processor as      logging.py:328
                             `use_fast` is unset and a slow
                             processor was saved with this model.
                             `use_fast=True` will be the default
                             behavior in v4.52, even if the model
                             was saved with a slow processor.
                             This will result in minor
                             differences in outputs. You'll still
                             be able to use a slow processor with
                             `use_fast=False`.
It looks like you are trying to rescale already rescaled images. If the input images have pixel values between 0 and 1, set `do_rescale=False` to avoid rescaling them again.
                    WARNING  It looks like you are trying to      logging.py:328
                             rescale already rescaled images. If
                             the input images have pixel values
                             between 0 and 1, set
                             `do_rescale=False` to avoid
                             rescaling them again.

  2%|▏         | 1/60 [00:02<01:58,  2.02s/it]

Computing image embeddings:   2%|▏         | 1/60 [00:01<01:34,  1.61s/it]
  3%|▎         | 2/60 [00:02<00:51,  1.12it/s]

Computing image embeddings:   3%|▎         | 2/60 [00:01<00:42,  1.37it/s]
  5%|▌         | 3/60 [00:02<00:30,  1.86it/s]

Computing image embeddings:   5%|▌         | 3/60 [00:01<00:25,  2.24it/s]
  7%|▋         | 4/60 [00:02<00:20,  2.71it/s]

Computing image embeddings:   7%|▋         | 4/60 [00:01<00:17,  3.18it/s]
  8%|▊         | 5/60 [00:02<00:15,  3.60it/s]

Computing image embeddings:   8%|▊         | 5/60 [00:02<00:13,  4.13it/s]
 10%|█         | 6/60 [00:02<00:11,  4.50it/s]

Computing image embeddings:  10%|█         | 6/60 [00:02<00:10,  5.03it/s]
 12%|█▏        | 7/60 [00:02<00:09,  5.33it/s]

Computing image embeddings:  12%|█▏        | 7/60 [00:02<00:09,  5.82it/s]
 13%|█▎        | 8/60 [00:02<00:08,  6.11it/s]

Computing image embeddings:  13%|█▎        | 8/60 [00:02<00:07,  6.53it/s]
 15%|█▌        | 9/60 [00:02<00:07,  6.75it/s]

Computing image embeddings:  15%|█▌        | 9/60 [00:02<00:07,  7.11it/s]
 17%|█▋        | 10/60 [00:03<00:06,  7.29it/s]

Computing image embeddings:  17%|█▋        | 10/60 [00:02<00:06,  7.57it/s]
 18%|█▊        | 11/60 [00:03<00:06,  7.69it/s]

Computing image embeddings:  18%|█▊        | 11/60 [00:02<00:06,  7.90it/s]
 20%|██        | 12/60 [00:03<00:06,  7.99it/s]

Computing image embeddings:  20%|██        | 12/60 [00:02<00:05,  8.15it/s]
 22%|██▏       | 13/60 [00:03<00:05,  8.23it/s]

Computing image embeddings:  22%|██▏       | 13/60 [00:02<00:05,  8.35it/s]
 23%|██▎       | 14/60 [00:03<00:05,  8.32it/s]

Computing image embeddings:  23%|██▎       | 14/60 [00:03<00:05,  8.40it/s]
 25%|██▌       | 15/60 [00:03<00:05,  8.40it/s]

Computing image embeddings:  25%|██▌       | 15/60 [00:03<00:05,  8.46it/s]
 27%|██▋       | 16/60 [00:03<00:05,  8.53it/s]

Computing image embeddings:  27%|██▋       | 16/60 [00:03<00:05,  8.57it/s]
 28%|██▊       | 17/60 [00:03<00:05,  8.59it/s]

Computing image embeddings:  28%|██▊       | 17/60 [00:03<00:04,  8.62it/s]
 30%|███       | 18/60 [00:03<00:04,  8.55it/s]

Computing image embeddings:  30%|███       | 18/60 [00:03<00:04,  8.57it/s]
 32%|███▏      | 19/60 [00:04<00:04,  8.56it/s]

Computing image embeddings:  32%|███▏      | 19/60 [00:03<00:04,  8.58it/s]
 33%|███▎      | 20/60 [00:04<00:04,  8.65it/s]

Computing image embeddings:  33%|███▎      | 20/60 [00:03<00:04,  8.66it/s]
 35%|███▌      | 21/60 [00:04<00:04,  8.68it/s]

Computing image embeddings:  35%|███▌      | 21/60 [00:03<00:04,  8.69it/s]
 37%|███▋      | 22/60 [00:04<00:04,  8.72it/s]

Computing image embeddings:  37%|███▋      | 22/60 [00:04<00:04,  8.72it/s]
 38%|███▊      | 23/60 [00:04<00:04,  8.75it/s]

Computing image embeddings:  38%|███▊      | 23/60 [00:04<00:04,  8.75it/s]
 40%|████      | 24/60 [00:04<00:04,  8.71it/s]

Computing image embeddings:  40%|████      | 24/60 [00:04<00:04,  8.71it/s]
 42%|████▏     | 25/60 [00:04<00:04,  8.72it/s]

Computing image embeddings:  42%|████▏     | 25/60 [00:04<00:04,  8.72it/s]
 43%|████▎     | 26/60 [00:04<00:03,  8.75it/s]

Computing image embeddings:  43%|████▎     | 26/60 [00:04<00:03,  8.76it/s]
 45%|████▌     | 27/60 [00:04<00:03,  8.80it/s]

Computing image embeddings:  45%|████▌     | 27/60 [00:04<00:03,  8.80it/s]
 47%|████▋     | 28/60 [00:05<00:03,  8.82it/s]

Computing image embeddings:  47%|████▋     | 28/60 [00:04<00:03,  8.82it/s]
 48%|████▊     | 29/60 [00:05<00:03,  8.82it/s]

Computing image embeddings:  48%|████▊     | 29/60 [00:04<00:03,  8.82it/s]
 50%|█████     | 30/60 [00:05<00:03,  8.81it/s]

Computing image embeddings:  50%|█████     | 30/60 [00:04<00:03,  8.81it/s]
 52%|█████▏    | 31/60 [00:05<00:03,  8.82it/s]

Computing image embeddings:  52%|█████▏    | 31/60 [00:05<00:03,  8.82it/s]
 53%|█████▎    | 32/60 [00:05<00:03,  8.81it/s]

Computing image embeddings:  53%|█████▎    | 32/60 [00:05<00:03,  8.81it/s]
 55%|█████▌    | 33/60 [00:05<00:03,  8.83it/s]

Computing image embeddings:  55%|█████▌    | 33/60 [00:05<00:03,  8.83it/s]
 57%|█████▋    | 34/60 [00:05<00:02,  8.82it/s]

Computing image embeddings:  57%|█████▋    | 34/60 [00:05<00:02,  8.82it/s]
 58%|█████▊    | 35/60 [00:05<00:02,  8.83it/s]

Computing image embeddings:  58%|█████▊    | 35/60 [00:05<00:02,  8.83it/s]
 60%|██████    | 36/60 [00:06<00:02,  8.75it/s]

Computing image embeddings:  60%|██████    | 36/60 [00:05<00:02,  8.75it/s]
 62%|██████▏   | 37/60 [00:06<00:02,  8.70it/s]

Computing image embeddings:  62%|██████▏   | 37/60 [00:05<00:02,  8.70it/s]
 63%|██████▎   | 38/60 [00:06<00:02,  8.65it/s]

Computing image embeddings:  63%|██████▎   | 38/60 [00:05<00:02,  8.65it/s]
 65%|██████▌   | 39/60 [00:06<00:02,  8.70it/s]

Computing image embeddings:  65%|██████▌   | 39/60 [00:05<00:02,  8.70it/s]
 67%|██████▋   | 40/60 [00:06<00:02,  8.73it/s]

Computing image embeddings:  67%|██████▋   | 40/60 [00:06<00:02,  8.73it/s]
 68%|██████▊   | 41/60 [00:06<00:02,  8.77it/s]

Computing image embeddings:  68%|██████▊   | 41/60 [00:06<00:02,  8.77it/s]
 70%|███████   | 42/60 [00:06<00:02,  8.82it/s]

Computing image embeddings:  70%|███████   | 42/60 [00:06<00:02,  8.82it/s]
 72%|███████▏  | 43/60 [00:06<00:01,  8.84it/s]

Computing image embeddings:  72%|███████▏  | 43/60 [00:06<00:01,  8.84it/s]
 73%|███████▎  | 44/60 [00:06<00:01,  8.83it/s]

Computing image embeddings:  73%|███████▎  | 44/60 [00:06<00:01,  8.83it/s]
 75%|███████▌  | 45/60 [00:07<00:01,  8.82it/s]

Computing image embeddings:  75%|███████▌  | 45/60 [00:06<00:01,  8.82it/s]
 77%|███████▋  | 46/60 [00:07<00:01,  8.78it/s]

Computing image embeddings:  77%|███████▋  | 46/60 [00:06<00:01,  8.78it/s]
 78%|███████▊  | 47/60 [00:07<00:01,  8.73it/s]

Computing image embeddings:  78%|███████▊  | 47/60 [00:06<00:01,  8.73it/s]
 80%|████████  | 48/60 [00:07<00:01,  8.76it/s]

Computing image embeddings:  80%|████████  | 48/60 [00:06<00:01,  8.76it/s]
 82%|████████▏ | 49/60 [00:07<00:01,  8.84it/s]

Computing image embeddings:  82%|████████▏ | 49/60 [00:07<00:01,  8.84it/s]
 83%|████████▎ | 50/60 [00:07<00:01,  8.88it/s]

Computing image embeddings:  83%|████████▎ | 50/60 [00:07<00:01,  8.88it/s]
 85%|████████▌ | 51/60 [00:07<00:01,  8.81it/s]

Computing image embeddings:  85%|████████▌ | 51/60 [00:07<00:01,  8.81it/s]
 87%|████████▋ | 52/60 [00:07<00:00,  8.78it/s]

Computing image embeddings:  87%|████████▋ | 52/60 [00:07<00:00,  8.78it/s]
 88%|████████▊ | 53/60 [00:07<00:00,  8.81it/s]

Computing image embeddings:  88%|████████▊ | 53/60 [00:07<00:00,  8.81it/s]
 90%|█████████ | 54/60 [00:08<00:00,  8.84it/s]

Computing image embeddings:  90%|█████████ | 54/60 [00:07<00:00,  8.84it/s]
 92%|█████████▏| 55/60 [00:08<00:00,  8.73it/s]

Computing image embeddings:  92%|█████████▏| 55/60 [00:07<00:00,  8.73it/s]
 93%|█████████▎| 56/60 [00:08<00:00,  8.73it/s]

Computing image embeddings:  93%|█████████▎| 56/60 [00:07<00:00,  8.73it/s]
 95%|█████████▌| 57/60 [00:08<00:00,  8.75it/s]

Computing image embeddings:  95%|█████████▌| 57/60 [00:07<00:00,  8.75it/s]
 97%|█████████▋| 58/60 [00:08<00:00,  8.69it/s]

Computing image embeddings:  97%|█████████▋| 58/60 [00:08<00:00,  8.70it/s]
 98%|█████████▊| 59/60 [00:08<00:00,  8.71it/s]

Computing image embeddings:  98%|█████████▊| 59/60 [00:08<00:00,  8.71it/s]
100%|██████████| 60/60 [00:08<00:00,  8.73it/s]
100%|██████████| 60/60 [00:08<00:00,  6.86it/s]


Computing image embeddings: 100%|██████████| 60/60 [00:08<00:00,  8.73it/s]
Computing image embeddings: 100%|██████████| 60/60 [00:08<00:00,  7.20it/s]

Extract short visual-response windows#

Event sample indices come from the same BDF sidecar. Posterior channels O1, Oz and O2 keep the predictor small. Windows run from onset to 180 ms because images arrive at roughly 200 ms intervals. This limits overlap with the next presentation but cannot remove responses to preceding images in the rapid stream; it is not isolated-trial visual decoding.

MNE handles epoch rejection and resamples the retained waveforms to 100 Hz. Indexing labels by epochs.selection preserves their correspondence if an epoch is dropped. Flattening yields (retained trials, channels × samples) features in volts. Feature scaling is deferred until training rather than estimated from the full collection.

events = np.column_stack(
    [
        trials["sample"].to_numpy(dtype=int),
        np.zeros(len(trials), dtype=int),
        np.ones(len(trials), dtype=int),
    ]
)
epochs = mne.Epochs(
    raw,
    events,
    event_id={"image": 1},
    tmin=0,
    tmax=0.18,
    baseline=None,
    picks=["O1", "Oz", "O2"],
    preload=True,
    reject_by_annotation=True,
).resample(100)
labels = labels[epochs.selection]
X = epochs.get_data().reshape(len(epochs), -1)
print("Actual EEG features:", X.shape, "actual image features:", embeddings.shape)
Not setting metadata
1214 matching events found
No baseline correction applied
0 projection items activated
Loading data for 1214 events and 181 original time points ...
0 bad epochs dropped
Actual EEG features: (1214, 54) actual image features: (60, 384)

Split repeated presentations by image identity#

An image can occur many times in the recording. A random trial split would let the decoder see the same image during training and evaluation, answering an easier repeated-stimulus question. The seeded permutation instead assigns complete image identities: 36 train, 12 validate, and 12 test when all 60 are retained. It randomizes only assignments, not signals or labels.

These are within-participant, within-session results. They do not measure cross-person or cross-device generalization. Disjoint image identities do not erase physiological overlap between successive presentations.

unique = np.unique(labels)
np.random.default_rng(2026).shuffle(unique)
first, second = int(0.6 * len(unique)), int(0.8 * len(unique))
train_ids, valid_ids, test_ids = [
    np.sort(group) for group in np.split(unique, [first, second])
]
train, valid, test = [
    np.isin(labels, group) for group in [train_ids, valid_ids, test_ids]
]

Tune the decoder and rank held-out candidate images#

Ridge predicts the frozen image coordinates from EEG. Its pipeline estimates feature means and scales on training images’ responses only. Validation retrieval selects among the three prespecified penalties; the selected fitted model is then evaluated once on test responses without refitting on validation.

Validation responses rank against the 12 validation images; final test responses rank against all 12 test images. Training images are absent from both candidate pools. searchsorted maps the actual image IDs to positions in each sorted gallery; those positions match similarity-matrix columns. Top-5 counts whether the viewed image occurs among the five highest cosine similarities. Its uniform reference here is 5/12, not the official gallery’s reference. The complete test pool is used for every test trial, irrespective of batching. One participant means this is one participant-level proportion; a multi-participant evaluation would average participant scores equally.

best_score, best_model = -np.inf, None
for alpha in [1, 10, 100]:
    model = make_pipeline(StandardScaler(), Ridge(alpha=alpha))
    model.fit(X[train], embeddings[labels[train]])
    score = top_k_accuracy_score(
        np.searchsorted(valid_ids, labels[valid]),
        cosine_similarity(model.predict(X[valid]), embeddings[valid_ids]),
        k=5,
        labels=np.arange(len(valid_ids)),
    )
    if score > best_score:
        best_score, best_model = score, model
score = top_k_accuracy_score(
    np.searchsorted(test_ids, labels[test]),
    cosine_similarity(best_model.predict(X[test]), embeddings[test_ids]),
    k=5,
    labels=np.arange(len(test_ids)),
)
print("Encoder:", image_encoder.model_name)
print("Held-out image top-5 accuracy:", score, "test candidates:", len(test_ids))
fig, ax = plt.subplots(figsize=(5, 4))
ax.bar(["uniform retrieval", "ridge"], [5 / len(test_ids), score])
ax.set(ylabel="Test image top-5 accuracy", ylim=(0, 1))
plt.show()
tutorial track 1 eeg to image
Encoder: facebook/dinov2-small
Held-out image top-5 accuracy: 0.4666666666666667 test candidates: 12

Make the next comparison informative#

Inspect errors per held-out image before concluding that a representation captures visual content. Some images have similar embeddings, and different images may have different numbers of usable responses. Averaging predictions within image or reporting image-wise scores answers a different evaluation question and should be prespecified.

Opt into EEGDASH_DINO_MODEL=giant to compare the official target encoder on the same image split; budget its checkpoint and CPU cost. Larger target vectors alone do not establish competition performance. The next protocol change is to use the release’s original train/test image sets, train the EEG mapping on separate source recordings, and retain the complete held-out gallery. The official NeuralBench preparation step builds its target cache; its full-retrieval subject-aggregated metric is the relevant comparison.

Related split-selection example: Braindecode train, test and tune.

Total running time of the script: (0 minutes 35.803 seconds)