| |
| """Eyes-open / eyes-closed ALPHA test — the single best full-chain acceptance test for an |
| EEG cap, needing NO extra equipment. It uses the Berger effect as a biological reference: |
| |
| close your eyes -> posterior (O1/O2/Oz/PO/P) 8-12 Hz ALPHA power shoots up |
| open your eyes -> it drops again |
| |
| This is universal and reproducible, so it IS the standard. If this cap shows a strong, |
| POSTERIOR-dominant alpha increase on eye-closure, then the whole acquisition chain works: |
| electrodes make contact, the amplifier and referencing are sane, the montage/wiring maps |
| occipital electrodes to the back of the head, and the µV/Hz scaling is in the right ballpark. |
| |
| The script records alternating eyes-open / eyes-closed blocks (it prompts you), then: |
| * posterior mean spectrum, open vs closed (an alpha bump should appear when closed) |
| * scalp topomap of the closed/open alpha ratio (should be posterior-dominant) |
| * per-channel closed/open alpha ratio bar |
| and prints a PASS/FAIL verdict. |
| |
| python src/acquisition/alpha_check.py # real cap (prompts you) |
| python src/acquisition/alpha_check.py --blocks 3 --secs 12 |
| python src/acquisition/alpha_check.py --demo # no hardware: validate plots |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import socket |
| import sys |
| import time |
| from pathlib import Path |
|
|
| import numpy as np |
|
|
| HERE = Path(__file__).resolve().parent |
| sys.path.insert(0, str(HERE.parent)) |
| sys.path.insert(0, str(HERE)) |
| from common.montage import CAP32_CHANNELS as CH |
|
|
| NCH = len(CH) |
| RESULTS = HERE.parents[1] / "results" |
| ALPHA = (8.0, 12.0) |
| POSTERIOR = ["O1", "O2", "OZ", "PO3", "PO4", "PZ", "P3", "P4", "P7", "P8"] |
| FRONTAL = ["FP1", "FP2", "AF3", "AF4", "F3", "F4", "FZ"] |
|
|
|
|
| |
| def _grab(src, parse_packet, fs, secs): |
| buf, end = [], time.time() + secs |
| for pkt in src.frames(): |
| p = parse_packet(pkt) |
| if p is not None: |
| buf.append(p[0]) |
| if time.time() >= end: |
| break |
| return np.array(buf, dtype=np.float64).T if buf else None |
|
|
|
|
| def collect_blocks(host, port, fs, n_blocks, secs, settle=1.5): |
| """Alternate eyes-open / eyes-closed blocks, prompting the user. RAW µV (no CAR/filter).""" |
| from udp_lsl_bridge import UdpSource, parse_packet, board_init, EEG_MODE |
| src = UdpSource(host, port); board_init(src, fs); time.sleep(0.4) |
| src.sock.settimeout(2.0) |
| out = {"open": [], "closed": []} |
| schedule = [] |
| for _ in range(n_blocks): |
| schedule += [("open", secs), ("closed", secs)] |
| try: |
| for cond, dur in schedule: |
| word = "闭眼" if cond == "closed" else "睁眼(看屏幕十字)" |
| print(f"\n>>> 请【{word}】保持 {dur:.0f}s", flush=True) |
| for k in (3, 2, 1): |
| print(f" {k}…", end="", flush=True); time.sleep(0.6) |
| print(" 开始") |
| _grab(src, parse_packet, fs, settle) |
| x = _grab(src, parse_packet, fs, dur) |
| if x is not None: |
| out[cond].append(x) |
| except (KeyboardInterrupt, socket.timeout): |
| pass |
| finally: |
| try: |
| src.send(EEG_MODE) |
| except OSError: |
| pass |
| return out |
|
|
|
|
| |
| def band_power(x, fs, lo, hi): |
| from scipy.signal import welch |
| f, P = welch(x, fs=fs, nperseg=min(x.shape[1], int(fs * 2)), axis=1) |
| return f, P, P[:, (f >= lo) & (f <= hi)].mean(1) |
|
|
|
|
| def analyse(blocks, fs, out, demo=False): |
| import matplotlib; matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import mne |
| if not blocks["open"] or not blocks["closed"]: |
| print("缺少 open 或 closed 数据段"); return |
| xo = np.concatenate(blocks["open"], axis=1) |
| xc = np.concatenate(blocks["closed"], axis=1) |
| f, Po, ao = band_power(xo, fs, *ALPHA) |
| _, Pc, ac = band_power(xc, fs, *ALPHA) |
| ratio = ac / np.clip(ao, 1e-9, None) |
| post = [CH.index(c) for c in POSTERIOR if c in CH] |
| front = [CH.index(c) for c in FRONTAL if c in CH] |
| r_post = float(np.median(ratio[post])) |
| r_front = float(np.median(ratio[front])) |
|
|
| info = mne.create_info(list(CH), fs, "eeg") |
| info.set_montage(mne.channels.make_standard_montage("standard_1020"), |
| match_case=False, on_missing="ignore", verbose="ERROR") |
|
|
| fig = plt.figure(figsize=(14, 4.6)); fig.patch.set_facecolor("white") |
| gs = fig.add_gridspec(1, 3, width_ratios=[1.2, 1, 1.4]) |
|
|
| ax0 = fig.add_subplot(gs[0]) |
| m = (f >= 2) & (f <= 30) |
| ax0.plot(f[m], Po[post].mean(0)[m], color="#6b7480", lw=2, label="eyes OPEN") |
| ax0.plot(f[m], Pc[post].mean(0)[m], color="#2b6cb0", lw=2, label="eyes CLOSED") |
| ax0.axvspan(*ALPHA, color="#2e9e5b", alpha=0.12) |
| ax0.set_xlabel("Hz"); ax0.set_ylabel("PSD (µV²/Hz)") |
| ax0.set_title("Posterior spectrum — alpha bump on closing?"); ax0.legend(fontsize=9) |
|
|
| ax1 = fig.add_subplot(gs[1]) |
| rr = np.clip(ratio, 0.3, 5.0) |
| im, _ = mne.viz.plot_topomap(rr, info, axes=ax1, cmap="RdBu_r", |
| vlim=(0.3, 3.0), show=False, contours=4) |
| ax1.set_title("closed/open alpha ratio\n(posterior-dominant?)", fontsize=10) |
| fig.colorbar(im, ax=ax1, shrink=0.7) |
|
|
| ax2 = fig.add_subplot(gs[2]) |
| cols = ["#2b6cb0" if CH[i] in POSTERIOR else ("#c58a00" if CH[i] in FRONTAL else "#9aa3b2") |
| for i in range(NCH)] |
| ax2.bar(range(NCH), ratio, color=cols) |
| ax2.axhline(1, color="#333", lw=0.8); ax2.axhline(2, color="#2e9e5b", ls="--", lw=1) |
| ax2.set_xticks(range(NCH)); ax2.set_xticklabels(CH, rotation=90, fontsize=6) |
| ax2.set_ylabel("closed/open alpha"); ax2.set_title("Per-channel (blue=posterior, amber=frontal)") |
|
|
| fig.suptitle("Acceptance — EYES-OPEN/CLOSED ALPHA (Berger effect)" + (" [DEMO]" if demo else ""), |
| fontweight="bold") |
| fig.tight_layout(); RESULTS.mkdir(parents=True, exist_ok=True) |
| fig.savefig(out, dpi=130, bbox_inches="tight", facecolor="white"); plt.close(fig) |
| print("saved", out) |
|
|
| print(f"\n posterior alpha closed/open = {r_post:.2f}× frontal = {r_front:.2f}×") |
| if r_post >= 2.0 and r_post > 1.5 * max(r_front, 1e-6): |
| print(" ✅ PASS: 明显的枕区 α 反应且以后脑为主 —— 整条采集链工作正常。") |
| elif r_post >= 1.3: |
| print(" ⚠ 弱 α 反应 —— 接触/放松不足,或后部电极座不好。多测几组、闭眼放松再试。") |
| else: |
| print(" ❌ 无 α 反应 —— 后部电极没接触好 / 接线映射可疑 / 参考异常。先查通道映射与接触。") |
|
|
|
|
| |
| def demo_blocks(fs, n_blocks, secs): |
| rng = np.random.default_rng(2) |
| post = set(CH.index(c) for c in POSTERIOR if c in CH) |
|
|
| def block(closed): |
| n = int(secs * fs); t = np.arange(n) / fs |
| x = np.empty((NCH, n)) |
| for c in range(NCH): |
| pink = np.cumsum(rng.normal(0, 1, n)); pink -= pink.mean(); pink *= 6 / (pink.std() + 1e-9) |
| a = (18 if closed else 4) if c in post else 3 |
| x[c] = a * np.sin(2 * np.pi * 10 * t + rng.uniform(0, 6)) + rng.normal(0, 4, n) + pink |
| return x |
|
|
| out = {"open": [], "closed": []} |
| for _ in range(n_blocks): |
| out["open"].append(block(False)); out["closed"].append(block(True)) |
| return out |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser(description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| ap.add_argument("--host", default="192.168.4.1"); ap.add_argument("--port", type=int, default=8086) |
| ap.add_argument("--sfreq", type=int, default=250) |
| ap.add_argument("--blocks", type=int, default=3, help="open/closed pairs") |
| ap.add_argument("--secs", type=float, default=12.0, help="seconds per block") |
| ap.add_argument("--demo", action="store_true") |
| ap.add_argument("--out", default=str(RESULTS / "acceptance_alpha.png")) |
| args = ap.parse_args() |
|
|
| if args.demo: |
| blocks = demo_blocks(args.sfreq, args.blocks, args.secs) |
| else: |
| print("即将交替【睁眼/闭眼】各若干段。闭眼时请放松、别用力眯眼(会有肌电)。") |
| blocks = collect_blocks(args.host, args.port, args.sfreq, args.blocks, args.secs) |
| no = sum(b.shape[1] for b in blocks["open"]) if blocks["open"] else 0 |
| nc = sum(b.shape[1] for b in blocks["closed"]) if blocks["closed"] else 0 |
| if not no or not nc: |
| print("无数据 — 连到 ESPBCI 且 IP=192.168.4.2 了吗?"); return |
| print(f"\ncollected open {no} / closed {nc} samples @ {args.sfreq} Hz") |
| analyse(blocks, args.sfreq, args.out, demo=args.demo) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|