File size: 12,821 Bytes
d37c079 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | #!/usr/bin/env python
"""Load a cap recording and cut it into labelled MI epochs.
This is the "录制 → 分析加载, 按 trigger 切 epoch" link. A recording saved by cap_gui is
either a `.npz` (raw µV + a per-sample label track) or an MNE `.fif` (with the labels as
annotations). Here we:
recording ──► MNE Raw (µV→V, montage) ──► filter (notch + band-pass)
──► bad-channel detect + interpolate (simple artifact handling)
──► events from the label track's rising edges
──► Epochs, one per imagery onset, baseline-corrected
The label track is the software `marker` (small MI codes 1–5, see common.mi_events) when
present, else the hardware `trigger` column. Heavier artifact handling (ICA / autoreject /
EOG-EMG regression) is deliberately NOT here yet — see analysis/artifacts.py / the plan.
CLI:
python src/analysis/load.py recordings/cap32_*.npz # summary + event counts
python src/analysis/load.py recordings/cap32_*.npz --epochs # build epochs, print shape
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # src/
from common.montage import CAP32_CHANNELS # noqa: E402
from common.mi_events import CODE_TO_LABEL # noqa: E402
DEFAULT_TMIN, DEFAULT_TMAX = -2.0, 4.0
DEFAULT_BASELINE = (-1.5, -0.5)
FULLSCALE_UV = (2 ** 23 - 1) * 0.02235175 # ADS1299 ±full scale ≈ 187500 µV
# --------------------------------------------------------------------- raw npz
def load_npz(path):
z = np.load(path, allow_pickle=True)
data = z["data"].astype(np.float64) # (n_ch, N) µV
fs = float(z["fs"])
ch = [str(c) for c in z["ch_names"]] if "ch_names" in z else list(CAP32_CHANNELS)
trigger = z["trigger"].astype(np.int64) if "trigger" in z else np.zeros(data.shape[1], int)
marker = z["marker"].astype(np.int64) if "marker" in z else None
gap = z["gap"].astype(np.int8) if "gap" in z else None # 1 = reconstructed sample
rec = dict(data=data, fs=fs, ch_names=ch, trigger=trigger, marker=marker, gap=gap)
# format v2: explicit trial table + metadata written by the paradigm
if "trial_onset" in z:
rec["trials"] = dict(onset=z["trial_onset"].astype(np.int64),
code=z["trial_code"].astype(np.int64),
name=[str(s) for s in z["trial_name"]],
cue=z["trial_cue_onset"].astype(np.int64)
if "trial_cue_onset" in z else None)
if "meta_json" in z:
import json
try:
rec["meta"] = json.loads(str(z["meta_json"]))
except Exception:
pass
return rec
def events_from_trials(trials):
"""Exact events from the paradigm's trial table (preferred over edge detection)."""
on = np.asarray(trials["onset"]); code = np.asarray(trials["code"])
keep = on >= 0
on, code = on[keep], code[keep]
events = np.column_stack([on, np.zeros_like(on), code]).astype(int)
event_id = {}
for c, n in zip(code.tolist(), np.asarray(trials["name"])[keep].tolist()):
event_id.setdefault(str(n), int(c))
return events, event_id
def label_track(rec):
"""Pick the label track: software `marker` if it carries any event, else hardware."""
m = rec.get("marker")
if m is not None and np.any(m != 0):
return m, "marker"
return rec["trigger"], "trigger"
# --------------------------------------------------------------- events / raw
def events_from_track(track, fs=None):
"""Rising edges of a per-sample integer label -> MNE events (n,3) + event_id.
An event starts where the label changes to a nonzero value."""
track = np.asarray(track)
onsets = np.where((track[1:] != track[:-1]) & (track[1:] != 0))[0] + 1
codes = track[onsets].astype(int)
events = np.column_stack([onsets, np.zeros_like(onsets), codes]).astype(int)
present = sorted(set(codes.tolist()))
event_id = {CODE_TO_LABEL.get(c, f"code{c}"): int(c) for c in present}
return events, event_id
def to_raw(rec, montage=True):
import mne
info = mne.create_info(list(rec["ch_names"]), rec["fs"], "eeg")
raw = mne.io.RawArray(rec["data"] * 1e-6, info, verbose="ERROR") # µV -> V
if montage:
raw.set_montage(mne.channels.make_standard_montage("standard_1020"),
match_case=False, on_missing="ignore", verbose="ERROR")
return raw
def read_recording(path):
"""Unified reader → (raw, events, event_id). Accepts .npz or .fif."""
import mne
path = str(path)
if path.endswith(".fif"):
raw = mne.io.read_raw_fif(path, preload=True, verbose="ERROR")
try:
events, ann_id = mne.events_from_annotations(raw, verbose="ERROR")
# map annotation descriptions ("left"/"right"/"T2"/…) back to MI codes
from common.mi_events import MI_TASK_CODES
event_id = {k: v for k, v in ann_id.items()}
return raw, events, event_id
except Exception:
return raw, np.empty((0, 3), int), {}
rec = load_npz(path)
raw = to_raw(rec)
if rec.get("trials") is not None and np.any(np.asarray(rec["trials"]["onset"]) >= 0):
events, event_id = events_from_trials(rec["trials"]) # v2: exact, from the paradigm
else:
track, _src = label_track(rec)
events, event_id = events_from_track(track, rec["fs"])
return raw, events, event_id
# ------------------------------------------------------- simple artifact steps
def detect_bad_channels(raw, flat_uv=0.5, noisy_uv=150.0, rail_uv=1.5e5, hf_z=6.0,
min_bad_frac=0.5):
"""Conservative bad-channel flags (no ICA) using ABSOLUTE thresholds so real signal is
never touched — a channel with more mu/beta is NOT bad. Flags only:
• flat / dead (std < flat_uv),
• railed (peak-to-peak > rail_uv ≈ 150 mV, ADS1299 saturated),
• grossly noisy (std > noisy_uv µV — non-physiological for scalp EEG),
• high-frequency junk (sample-to-sample jitter a strong outlier vs the array).
Returns a list of channel names. Deliberately misses the "slightly noisy posterior"
case — that is left to ICA/autoreject, which we add separately."""
# An INTERMITTENT channel must not be condemned for the whole recording: only call it
# globally bad when it is bad for at least `min_bad_frac` of the time. Segments where an
# otherwise-good channel rails are handled per-epoch instead (see channel_uptime).
frac, _ = channel_uptime(raw)
# Judge amplitude on a HIGH-PASSED copy: dry electrodes carry huge DC drift, so on
# unfiltered data every channel's std exceeds `noisy_uv` and the whole array would be
# condemned. This makes the function safe to call on raw or filtered input alike.
hp = raw.copy().filter(1.0, None, verbose="ERROR") if raw.info["highpass"] < 0.5 else raw
x = hp.get_data() * 1e6 # V -> µV
sd = x.std(axis=1)
ptp = np.ptp(x, axis=1)
hf = np.std(np.diff(x, axis=1), axis=1) # high-freq content proxy
del ptp
bad = set()
for i, name in enumerate(raw.ch_names):
if frac[i] >= min_bad_frac or sd[i] > noisy_uv:
bad.add(name)
# high-freq outlier: broken electrodes jitter far more than the median channel
keep = np.array([raw.ch_names[i] not in bad for i in range(len(hf))])
if keep.sum() >= 6:
med = np.median(hf[keep]); mad = np.median(np.abs(hf[keep] - med)) + 1e-9
for i, name in enumerate(raw.ch_names):
if name not in bad and (hf[i] - med) / (1.4826 * mad) > hf_z and hf[i] > 40.0:
bad.add(name)
return sorted(bad)
def channel_uptime(raw, win_s=1.0, rail_frac=0.5):
"""Time-RESOLVED channel health: (frac_bad per channel, mask (n_ch, n_win)).
The cap has at least one INTERMITTENT electrode (F7 sat at +full scale for four whole
sessions, yet reads normally in others — a loose connector, not a permanent break).
A single whole-recording verdict handles that badly: a channel that is fine for half a
session gets interpolated away entirely, and one that rails only briefly may not be
flagged at all while its rail artifact still poisons CAR and any epoch it touches.
So score every channel per window and let the caller act per segment."""
x = raw.get_data() * 1e6
fs = raw.info["sfreq"]
w = max(1, int(win_s * fs))
n = x.shape[1] // w
if n == 0:
return np.zeros(x.shape[0]), np.zeros((x.shape[0], 0), bool)
seg = x[:, :n * w].reshape(x.shape[0], n, w)
railed = (np.abs(seg) > 0.97 * FULLSCALE_UV).mean(-1) > rail_frac
flat = seg.std(-1) < 0.5
bad = railed | flat
return bad.mean(1), bad
def clean_raw(raw, l_freq=1.0, h_freq=40.0, notch=50.0, car=False,
interpolate=True, verbose=False):
"""Filter + (optional) notch + bad-channel interpolation. Returns a *copy*.
This is the light, dependency-free cleaning; ICA/autoreject live elsewhere."""
raw = raw.copy()
if notch:
raw.notch_filter(notch, verbose="ERROR")
raw.filter(l_freq, h_freq, verbose="ERROR")
bad = detect_bad_channels(raw)
raw.info["bads"] = bad
if verbose and bad:
print(f" bad channels: {bad}")
if interpolate and bad and raw.get_montage() is not None:
raw.interpolate_bads(reset_bads=True, verbose="ERROR")
if car:
raw.set_eeg_reference("average", verbose="ERROR")
return raw, bad
# --------------------------------------------------------------------- epochs
def make_epochs(path, tmin=DEFAULT_TMIN, tmax=DEFAULT_TMAX, baseline=DEFAULT_BASELINE,
picks=None, l_freq=1.0, h_freq=40.0, notch=50.0, car=False,
interpolate=True, reject_uv=None, verbose=True, drop_filled=False):
"""Recording path → cleaned, baseline-corrected MI Epochs (labelled by task name)."""
import mne
raw, events, event_id = read_recording(path)
if len(events) == 0:
raise SystemExit(f"no events/markers found in {path} — was a paradigm run recorded?")
if drop_filled and str(path).endswith(".npz"):
gap = load_npz(path).get("gap")
if gap is not None and gap.any(): # drop trials overlapping reconstructed data
fs0 = raw.info["sfreq"]
lo, hi = int(tmin * fs0), int(tmax * fs0)
keep = [not gap[max(0, o + lo): o + hi].any() for o in events[:, 0]]
n_drop = len(keep) - sum(keep)
events = events[np.array(keep, bool)]
if verbose and n_drop:
print(f" dropped {n_drop} epoch(s) containing UDP-gap-filled samples")
raw, bad = clean_raw(raw, l_freq, h_freq, notch, car, interpolate, verbose)
reject = dict(eeg=reject_uv * 1e-6) if reject_uv else None
ep = mne.Epochs(raw, events, event_id=event_id, tmin=tmin, tmax=tmax,
baseline=baseline, picks=picks, preload=True, reject=reject,
reject_by_annotation=False, verbose="ERROR")
if verbose:
counts = {k: int((ep.events[:, 2] == v).sum()) for k, v in ep.event_id.items()}
print(f"epochs: {len(ep)} × {len(ep.ch_names)}ch × {ep.times.size} samp "
f"[{tmin}, {tmax}]s @ {raw.info['sfreq']:.0f}Hz · {counts}"
+ (f" · interpolated {bad}" if bad else ""))
return ep
# --------------------------------------------------------------------- CLI
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path")
ap.add_argument("--epochs", action="store_true", help="build epochs (not just summarise)")
ap.add_argument("--tmin", type=float, default=DEFAULT_TMIN)
ap.add_argument("--tmax", type=float, default=DEFAULT_TMAX)
ap.add_argument("--no-interp", action="store_true")
args = ap.parse_args()
raw, events, event_id = read_recording(args.path)
print(f"{args.path}\n {len(raw.ch_names)} ch @ {raw.info['sfreq']:.0f} Hz, "
f"{raw.n_times} samples ({raw.times[-1]:.1f}s)")
print(f" events: {len(events)} event_id: {event_id}")
if events.size:
for name, code in event_id.items():
print(f" {name:8s} (code {code}): {(events[:,2]==code).sum()} trials")
if args.epochs:
make_epochs(args.path, tmin=args.tmin, tmax=args.tmax,
interpolate=not args.no_interp)
if __name__ == "__main__":
main()
|