#!/usr/bin/env python """Interactive preprocessing / artifact review — toggle each step, see raw vs cleaned. Loads one recording and lets you flip every cleaning step on/off and immediately see the effect: the RAW trace (grey) and the CLEANED trace (blue) are overlaid per channel, so you can literally watch a blink disappear when ICA is on, or see the untouched data with all toggles off. A report panel says what each step removed, and "ERD impact" recomputes the C3/C4 mu-ERD with the current cleaning so you see how it changes the actual MI signal. Because autoreject and ICA operate on a whole recording (offline), this review UI is where they live; cap_gui carries only the live-capable toggles (CAR / interp / ICA de-blink). python src/analysis/clean_ui.py # newest recording python src/analysis/clean_ui.py recordings/cap32_xxx.npz python src/analysis/clean_ui.py --synth-blinks # demo data with blinks python src/analysis/clean_ui.py --screenshot results/clean_ui_preview.png """ from __future__ import annotations import argparse import os import sys from pathlib import Path import numpy as np HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE.parent)) # src/ sys.path.insert(0, str(HERE)) # src/analysis/ import load as loadmod # noqa: E402 import artifacts as A # noqa: E402 BG, CARD, LINE = "#eef1f5", "#ffffff", "#dfe3ea" TXT, SUB = "#1f2733", "#6b7480" ACC, RAWCOL = "#2b6cb0", "#b8bfc9" GOOD, WARN, BAD = "#2e9e5b", "#c58a00", "#d1495b" VIEW_CH = ["FP1", "C3", "C4", "PZ"] # blink-prone + MI channels WIN_S = 12.0 def newest_recording(): rec = sorted((HERE.parents[1] / "recordings").glob("cap32_*.npz")) return str(rec[-1]) if rec else None # ---------------------------------------------------------------- compute layer class Model: def __init__(self, path): self.path = path self.raw, self.events, self.event_id = loadmod.read_recording(path) self.raw.load_data() self.fs = self.raw.info["sfreq"] self.ch = self.raw.ch_names self.raw_uv = self.raw.get_data() * 1e6 self.cache = {} def clean(self, flags: A.CleanFlags): key = tuple(sorted(vars(flags).items())) if key not in self.cache: if not any([flags.notch, flags.l_freq, flags.h_freq, flags.interp, flags.car, flags.ica]): self.cache[key] = (self.raw_uv, {"interpolated": [], "ica": None}) else: clean, report = A.preprocess(self.raw, flags) self.cache[key] = (clean.get_data() * 1e6, report) return self.cache[key] def erd_impact(self, flags): """Recompute contra/ipsi C3/C4 mu-ERD with the current cleaning (text summary).""" import mne if not len(self.events): return "no events in recording" clean, report = self.clean(flags) info = self.raw.info rawc = mne.io.RawArray(clean * 1e-6, info.copy(), verbose="ERROR") ep = mne.Epochs(rawc, self.events, self.event_id, tmin=loadmod.DEFAULT_TMIN, tmax=loadmod.DEFAULT_TMAX, baseline=None, preload=True, verbose="ERROR") if flags.autoreject and len(ep) >= 4: try: ep, _ = A.clean_epochs(ep) except Exception: pass sys.path.insert(0, str(HERE)) from erd_ers import _tfr, _band_timecourse, MU, IMAGERY out = [] try: tfrs = _tfr(ep.copy().pick("eeg")) for lab in tfrs: contra = "C4" if lab == "left" else "C3" ipsi = "C3" if lab == "left" else "C4" tt = tfrs[lab].times; w = (tt >= IMAGERY[0]) & (tt <= IMAGERY[1]) _, yc = _band_timecourse(tfrs[lab], contra, MU) _, yi = _band_timecourse(tfrs[lab], ipsi, MU) out.append(f"{lab}: {contra} {yc[w].mean():+.0f}% {ipsi} {yi[w].mean():+.0f}%") except Exception as e: return f"ERD calc failed: {e}" return " · ".join(out) if out else "no conditions" # ---------------------------------------------------------------- UI def build(model): import pyqtgraph as pg from PyQt6 import QtWidgets, QtCore pg.setConfigOption("background", CARD); pg.setConfigOption("foreground", "#414852") pg.setConfigOptions(antialias=True) root = QtWidgets.QWidget() root.setStyleSheet(f"background:{BG};color:{TXT};font-family:'Helvetica Neue',Helvetica,Arial;") root.resize(1280, 760) outer = QtWidgets.QHBoxLayout(root); outer.setContentsMargins(12, 12, 12, 12); outer.setSpacing(10) # ---- left control card ---- def card(): f = QtWidgets.QFrame() f.setStyleSheet(f"QFrame{{background:{CARD};border:1px solid {LINE};border-radius:8px;}}") lay = QtWidgets.QVBoxLayout(f); lay.setContentsMargins(12, 10, 12, 12); lay.setSpacing(8) return f, lay lcard, lc = card(); lcard.setFixedWidth(288) ttl = QtWidgets.QLabel("Preprocessing"); ttl.setStyleSheet(f"font-size:16px;font-weight:700;color:{TXT};") fname = QtWidgets.QLabel(Path(model.path).name); fname.setStyleSheet(f"color:{SUB};font-size:11px;") lc.addWidget(ttl); lc.addWidget(fname) def chk(text, on=True): c = QtWidgets.QCheckBox(text); c.setChecked(on) c.setStyleSheet(f"color:{TXT};font-size:13px;padding:3px 0;") return c hint = QtWidgets.QLabel("勾选=开启处理 · 全部取消=看原始数据") hint.setStyleSheet(f"color:{SUB};font-size:11px;") lc.addWidget(hint) cb_notch = chk("50 Hz notch") cb_band = chk("Band-pass 1–40 Hz") cb_interp = chk("Bad-channel interpolate") cb_car = chk("Common average ref (CAR)") cb_ica = chk("ICA — remove eye / artifact") method = QtWidgets.QComboBox(); method.addItems(["eog (FP1/FP2 proxy)", "iclabel"]) method.setStyleSheet(f"background:#f7f9fc;border:1px solid {LINE};border-radius:5px;padding:3px;") cb_ar = chk("autoreject (epoch repair)", on=False) for w in (cb_notch, cb_band, cb_interp, cb_car, cb_ica): lc.addWidget(w) mrow = QtWidgets.QHBoxLayout(); ml = QtWidgets.QLabel(" method"); ml.setStyleSheet(f"color:{SUB};") mrow.addWidget(ml); mrow.addWidget(method, 1); lc.addLayout(mrow) lc.addWidget(cb_ar) note = QtWidgets.QLabel("autoreject / ERD 只影响下方“ERD impact”\n(是逐-epoch 的离线步骤)") note.setStyleSheet(f"color:{SUB};font-size:10px;"); lc.addWidget(note) sep = QtWidgets.QFrame(); sep.setFrameShape(QtWidgets.QFrame.Shape.HLine); sep.setStyleSheet(f"color:{LINE};") lc.addWidget(sep) report = QtWidgets.QLabel("—"); report.setWordWrap(True) report.setStyleSheet(f"color:{TXT};font-size:11px;"); report.setTextFormat(QtCore.Qt.TextFormat.RichText) lc.addWidget(report) lc.addStretch(1) btn_erd = QtWidgets.QPushButton("Compute ERD impact") btn_erd.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) btn_erd.setStyleSheet(f"QPushButton{{background:{ACC};color:#fff;border:none;padding:8px;" "border-radius:6px;font-weight:600;}") lc.addWidget(btn_erd) erd_lbl = QtWidgets.QLabel(""); erd_lbl.setWordWrap(True); erd_lbl.setStyleSheet(f"color:{TXT};font-size:11px;") lc.addWidget(erd_lbl) outer.addWidget(lcard) # ---- right: signal card ---- rcard, rc = card() hdr = QtWidgets.QLabel(f"Raw vs cleaned " f" raw " f" cleaned") hdr.setStyleSheet(f"font-size:13px;font-weight:600;color:{TXT};") rc.addWidget(hdr) nch = len(VIEW_CH); SP = 220.0 laxis = pg.AxisItem("left"); laxis.setWidth(46) laxis.setTicks([[((nch - 1 - i) * SP, VIEW_CH[i]) for i in range(nch)]]) plot = pg.PlotWidget(axisItems={"left": laxis}); plot.setMenuEnabled(False); plot.setBackground(CARD) plot.showGrid(x=True, y=False, alpha=0.12); plot.setLabel("bottom", "time", units="s") labels = [] raw_curves = [plot.plot([], [], pen=pg.mkPen(RAWCOL, width=1.4)) for _ in VIEW_CH] clean_curves = [plot.plot([], [], pen=pg.mkPen(ACC, width=1.0)) for _ in VIEW_CH] rc.addWidget(plot, 1) slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) dur = model.raw_uv.shape[1] / model.fs slider.setRange(0, max(0, int((dur - WIN_S) * 10))) slider.setStyleSheet("QSlider::handle:horizontal{background:%s;border-radius:6px;width:14px;}" % ACC) rc.addWidget(slider) status = QtWidgets.QLabel("ready"); status.setStyleSheet(f"color:{SUB};font-size:11px;") rc.addWidget(status) outer.addWidget(rcard, 1) ctx = dict(root=root, model=model, plot=plot, raw_curves=raw_curves, clean_curves=clean_curves, labels=labels, slider=slider, status=status, report=report, erd_lbl=erd_lbl, SP=SP, ctrls=dict(notch=cb_notch, band=cb_band, interp=cb_interp, car=cb_car, ica=cb_ica, method=method, ar=cb_ar, erd=btn_erd)) return ctx def flags_of(ctx): c = ctx["ctrls"] m = "iclabel" if c["method"].currentText().startswith("iclabel") else "eog" f = A.CleanFlags( notch=50.0 if c["notch"].isChecked() else 0.0, l_freq=1.0 if c["band"].isChecked() else 0.0, h_freq=40.0 if c["band"].isChecked() else 0.0, interp=c["interp"].isChecked(), car=c["car"].isChecked(), ica=c["ica"].isChecked(), ica_method=m) f.autoreject = c["ar"].isChecked() return f def recompute(ctx): from PyQt6 import QtWidgets ctx["status"].setText("⏳ computing…"); QtWidgets.QApplication.processEvents() flags = flags_of(ctx) clean, rep = ctx["model"].clean(flags) ctx["_clean"] = clean # report parts = [] interp = rep.get("interpolated") or [] parts.append(f"Interpolated: {', '.join(interp) if interp else '—'}") ica = rep.get("ica") if ica and "error" in ica: parts.append(f"ICA: {ica['error']}") elif ica: ex = ica.get("exclude", []) labs = ica.get("labels", []) exlabs = ", ".join(f"#{i}:{labs[i].split(' ')[0]}" for i in ex) if ex else "none" parts.append(f"ICA ({ica['method']}): removed {ica['removed']}/{ica['n_components']} " f"comps [{exlabs}]") else: parts.append("ICA: off") ctx["report"].setText("
".join(parts)) replot(ctx) ctx["status"].setText("updated") def replot(ctx): clean = ctx.get("_clean"); model = ctx["model"] if clean is None: return fs = model.fs; t0 = ctx["slider"].value() / 10.0 i0, i1 = int(t0 * fs), int((t0 + WIN_S) * fs) tt = np.arange(i0, i1) / fs for k, c in enumerate(VIEW_CH): if c not in model.ch: continue ci = model.ch.index(c) base = (len(VIEW_CH) - 1 - k) * ctx["SP"] rawseg = model.raw_uv[ci, i0:i1]; rawseg = rawseg - rawseg.mean() clnseg = clean[ci, i0:i1]; clnseg = clnseg - clnseg.mean() cl = ctx["SP"] * 0.48 ctx["raw_curves"][k].setData(tt, np.clip(rawseg, -cl, cl) + base) ctx["clean_curves"][k].setData(tt, np.clip(clnseg, -cl, cl) + base) ctx["plot"].setXRange(t0, t0 + WIN_S, padding=0) ctx["plot"].setYRange(-ctx["SP"] * 0.6, len(VIEW_CH) * ctx["SP"], padding=0) def run(model): from PyQt6 import QtWidgets, QtCore app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) ctx = build(model); ctx["root"].setWindowTitle("Cap32 preprocessing review") c = ctx["ctrls"] for w in (c["notch"], c["band"], c["interp"], c["car"], c["ica"]): w.stateChanged.connect(lambda _=None: recompute(ctx)) c["method"].currentIndexChanged.connect(lambda _=None: recompute(ctx)) ctx["slider"].valueChanged.connect(lambda _=None: replot(ctx)) def do_erd(): ctx["erd_lbl"].setText("⏳ computing ERD…"); QtWidgets.QApplication.processEvents() txt = ctx["model"].erd_impact(flags_of(ctx)) ctx["erd_lbl"].setText("ERD impact (contra should be ⟪more negative⟫):
" + txt) c["erd"].clicked.connect(do_erd) ctx["root"].show() QtCore.QTimer.singleShot(50, lambda: recompute(ctx)) app.exec() def screenshot(model, out): os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt6 import QtWidgets app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) ctx = build(model) ctx["ctrls"]["method"].setCurrentIndex(0) # eog proxy (reliable on synth) ctx["root"].show(); app.processEvents() recompute(ctx); app.processEvents() Path(out).parent.mkdir(parents=True, exist_ok=True) ctx["root"].grab().save(str(out)); print("saved", out) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("path", nargs="?") ap.add_argument("--synth-blinks", action="store_true") ap.add_argument("--screenshot", default=None) args = ap.parse_args() if args.synth_blinks: sys.path.insert(0, str(HERE)) from erd_ers import synth_mi_recording path = synth_mi_recording(reps=12, out=HERE.parents[1] / "recordings" / "synth_mi_blinks.npz", blinks=True) else: path = args.path or newest_recording() if not path: raise SystemExit("no recording found — pass a path or use --synth-blinks") model = Model(path) if args.screenshot: screenshot(model, args.screenshot) else: run(model) if __name__ == "__main__": main()