From a083d461d19de2768cdf29b148dcc7667dde555f Mon Sep 17 00:00:00 2001 From: andrianj Date: Fri, 29 May 2026 10:13:06 +0200 Subject: [PATCH 1/3] Improve-preprocessing-layout --- pyBer/gui_preprocessing.py | 123 ++++++++++++++++++++++++++++--------- pyBer/main.py | 40 +++++++----- 2 files changed, 120 insertions(+), 43 deletions(-) diff --git a/pyBer/gui_preprocessing.py b/pyBer/gui_preprocessing.py index 8c89942..bf399e9 100644 --- a/pyBer/gui_preprocessing.py +++ b/pyBer/gui_preprocessing.py @@ -847,13 +847,41 @@ class FileQueuePanel(QtWidgets.QGroupBox): def __init__(self, parent=None) -> None: super().__init__("Data", parent) + self.setObjectName("fileQueuePanel") self._current_dir_hint: str = "" self._build_ui() def _build_ui(self) -> None: v = QtWidgets.QVBoxLayout(self) - v.setSpacing(8) - v.setContentsMargins(8, 8, 8, 8) + v.setSpacing(10) + v.setContentsMargins(10, 10, 10, 10) + self.setStyleSheet( + """ + #fileQueuePanel QLabel[class="fieldLabel"] { + color: #d7e2f2; + font-weight: 650; + padding: 0 0 2px 1px; + } + #fileQueuePanel QLabel[class="pathHint"] { + color: #a9b7cb; + padding: 2px 1px 0 1px; + } + #fileQueuePanel QGroupBox#fileQueueSelectionBox { + margin-top: 12px; + } + #fileQueuePanel QGroupBox#fileQueueSelectionBox::title { + left: 10px; + padding: 0 8px; + } + #fileQueuePanel QListWidget { + padding: 6px; + } + #fileQueuePanel QListWidget::item { + min-height: 22px; + padding: 4px 6px; + } + """ + ) # Top actions top_row = QtWidgets.QHBoxLayout() @@ -867,9 +895,10 @@ def _build_ui(self) -> None: top_row.addWidget(self.btn_folder) # File list fills available height - self.list_files = PlaceholderListWidget("Drop files here or click Open File") + self.list_files = PlaceholderListWidget("Drop Doric/HDF5/CSV files here\nor click Open File") self.list_files.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) - self.list_files.setMinimumHeight(180) + self.list_files.setMinimumHeight(210) + self.list_files.setUniformItemSizes(True) self.btn_remove_file = QtWidgets.QPushButton("Remove selected") self.btn_remove_file.setProperty("class", "blueSecondarySmall") @@ -879,46 +908,69 @@ def _build_ui(self) -> None: # Selection block self.grp_sel = QtWidgets.QGroupBox("Selection") - form = QtWidgets.QGridLayout(self.grp_sel) - form.setContentsMargins(8, 8, 8, 8) - form.setHorizontalSpacing(6) - form.setVerticalSpacing(6) + self.grp_sel.setObjectName("fileQueueSelectionBox") + form = QtWidgets.QVBoxLayout(self.grp_sel) + form.setContentsMargins(12, 14, 12, 12) + form.setSpacing(8) self.combo_channel = QtWidgets.QComboBox() - self.combo_channel.setMinimumWidth(60) - _compact_combo(self.combo_channel, min_chars=6) + self.combo_channel.setMinimumWidth(180) + _compact_combo(self.combo_channel, min_chars=18) self.combo_trigger = QtWidgets.QComboBox() - self.combo_trigger.setMinimumWidth(60) - _compact_combo(self.combo_trigger, min_chars=6) + self.combo_trigger.setMinimumWidth(180) + _compact_combo(self.combo_trigger, min_chars=18) self.combo_trigger.addItem("") self.edit_time_start = QtWidgets.QLineEdit() self.edit_time_end = QtWidgets.QLineEdit() for ed in (self.edit_time_start, self.edit_time_end): - ed.setPlaceholderText("Start (s)" if ed is self.edit_time_start else "End (s)") + ed.setPlaceholderText("Start" if ed is self.edit_time_start else "End") val = QtGui.QDoubleValidator(0.0, 1e9, 3, ed) val.setLocale(_system_locale()) ed.setValidator(val) + ed.setMinimumWidth(82) ed.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) - form.addWidget(QtWidgets.QLabel("Channel"), 0, 0) - form.addWidget(self.combo_channel, 0, 1, 1, 3) - form.addWidget(QtWidgets.QLabel("Analog/Digital channel"), 1, 0) - form.addWidget(self.combo_trigger, 1, 1, 1, 3) - form.addWidget(QtWidgets.QLabel("Time window"), 2, 0) - form.addWidget(self.edit_time_start, 2, 1) - form.addWidget(QtWidgets.QLabel("to"), 2, 2) - form.addWidget(self.edit_time_end, 2, 3) + self.combo_channel.setToolTip("Signal channel to preprocess.") + self.combo_trigger.setToolTip("Optional trigger channel used for overlay and export alignment.") + self.edit_time_start.setToolTip("Optional window start in seconds.") + self.edit_time_end.setToolTip("Optional window end in seconds.") + + def _field(label_text: str, field: QtWidgets.QWidget) -> QtWidgets.QWidget: + box = QtWidgets.QWidget() + lay = QtWidgets.QVBoxLayout(box) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(3) + lab = QtWidgets.QLabel(label_text) + lab.setProperty("class", "fieldLabel") + lay.addWidget(lab) + lay.addWidget(field) + return box + + time_row = QtWidgets.QWidget() + time_lay = QtWidgets.QHBoxLayout(time_row) + time_lay.setContentsMargins(0, 0, 0, 0) + time_lay.setSpacing(6) + to_label = QtWidgets.QLabel("to") + to_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + to_label.setMinimumWidth(20) + time_lay.addWidget(self.edit_time_start, 1) + time_lay.addWidget(to_label, 0) + time_lay.addWidget(self.edit_time_end, 1) + + form.addWidget(_field("Signal channel", self.combo_channel)) + form.addWidget(_field("Trigger channel", self.combo_trigger)) + form.addWidget(_field("Time window (s)", time_row)) self.btn_cutting = QtWidgets.QPushButton("Cutting / Sectioning") self.btn_cutting.setProperty("class", "blueSecondarySmall") self.btn_cutting.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) - form.addWidget(self.btn_cutting, 3, 0, 1, 4) - form.setColumnStretch(1, 1) - form.setColumnStretch(3, 1) + form.addWidget(self.btn_cutting) self.lbl_hint = QtWidgets.QLabel("") - self.lbl_hint.setProperty("class", "hint") + self.lbl_hint.setProperty("class", "pathHint") + self.lbl_hint.setWordWrap(True) + self.lbl_hint.setMaximumHeight(42) self.lbl_hint.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) v.addLayout(top_row) @@ -966,21 +1018,36 @@ def _build_ui(self) -> None: self.btn_qc_batch.clicked.connect(self.batchQcRequested.emit) def set_path_hint(self, text: str) -> None: - self.lbl_hint.setText(text) + self.lbl_hint.setText(self._format_path_hint(text)) + self.lbl_hint.setToolTip(text or "") if text and os.path.isdir(text): self._current_dir_hint = text def path_hint(self) -> str: - return self.lbl_hint.text() + return self.lbl_hint.toolTip() or self.lbl_hint.text() def set_current_dir_hint(self, dir_path: str) -> None: self._current_dir_hint = dir_path or "" if dir_path: - self.lbl_hint.setText(dir_path) + self.lbl_hint.setText(self._format_path_hint(dir_path)) + self.lbl_hint.setToolTip(dir_path) def current_dir_hint(self) -> str: return self._current_dir_hint + def _format_path_hint(self, path: str) -> str: + text = str(path or "").strip() + if not text: + return "" + try: + parent = os.path.basename(os.path.dirname(text)) + name = os.path.basename(text) + if name and parent: + return f"Folder: {parent}/{name}" + return f"Folder: {name or text}" + except Exception: + return text + def add_file(self, path: str) -> None: item = QtWidgets.QListWidgetItem(os.path.basename(path)) item.setToolTip(path) diff --git a/pyBer/main.py b/pyBer/main.py index 8528699..0e81c8a 100644 --- a/pyBer/main.py +++ b/pyBer/main.py @@ -1470,7 +1470,7 @@ def _build_ui(self) -> None: self.artifact_panel.installEventFilter(self) self.addDockWidget(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, self.art_dock) - # Left pane: data browser + # Data browser: mounted immediately to the right of the toolbar rail. self.file_panel.setMinimumWidth(260) self.file_panel.setMaximumWidth(340) self.file_panel.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) @@ -1669,24 +1669,25 @@ def _build_ui(self) -> None: self._pre_drawer_splitter.setStretchFactor(0, 0) self._pre_drawer_splitter.setStretchFactor(1, 1) self._pre_drawer_splitter.setSizes([0, 1400]) - center_h.addWidget(self._pre_drawer_splitter, stretch=1) + content_widget = self._pre_drawer_splitter else: - center_h.addWidget(center_panel, stretch=1) + content_widget = center_panel - # Main splitter: data panel + visuals. Parameter popups are floating by default. + # Main splitter: data browser + visuals, both to the right of the toolbar rail. self.pre_splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) self.pre_splitter.setObjectName("preprocessing_splitter") self.pre_splitter.addWidget(self.file_panel) - self.pre_splitter.addWidget(center_widget) + self.pre_splitter.addWidget(content_widget) self.pre_splitter.setChildrenCollapsible(False) self.pre_splitter.setStretchFactor(0, 0) self.pre_splitter.setStretchFactor(1, 1) self.pre_splitter.setSizes([350, 1350]) self.pre_splitter.splitterMoved.connect(self._save_splitter_sizes) + center_h.addWidget(self.pre_splitter, stretch=1) pre_layout = QtWidgets.QVBoxLayout(self.pre_tab) pre_layout.setContentsMargins(10, 10, 10, 10) - pre_layout.addWidget(self.pre_splitter) + pre_layout.addWidget(center_widget) # Postprocessing tab self.post_tab = PostProcessingPanel() @@ -3997,7 +3998,7 @@ def _restore_settings(self) -> None: if self._force_fixed_dock_layouts: # Fixed mode: always enforce deterministic defaults. try: - self.pre_splitter.setSizes([300, 1200]) + self._set_pre_splitter_sizes(data_width=300, center_width=1200) except Exception: pass try: @@ -4008,15 +4009,15 @@ def _restore_settings(self) -> None: vals = [int(x) for x in splitter_sizes] if self._use_pg_dockarea_pre_layout: if len(vals) >= 3: - self.pre_splitter.setSizes([vals[0], max(640, vals[1] + vals[2])]) + self._set_pre_splitter_sizes(vals[0], max(640, vals[1] + vals[2])) elif len(vals) == 2: - self.pre_splitter.setSizes(vals[:2]) + self._set_pre_splitter_sizes(vals[0], vals[1]) elif len(vals) >= 3: left = max(260, vals[0]) center = max(640, vals[1] + vals[2]) - self.pre_splitter.setSizes([left, center]) + self._set_pre_splitter_sizes(left, center) elif len(vals) == 2: - self.pre_splitter.setSizes(vals[:2]) + self._set_pre_splitter_sizes(vals[0], vals[1]) except Exception: pass try: @@ -4045,16 +4046,16 @@ def _restore_settings(self) -> None: vals = [int(x) for x in splitter_sizes] if self._use_pg_dockarea_pre_layout: if len(vals) >= 3: - self.pre_splitter.setSizes([vals[0], max(640, vals[1] + vals[2])]) + self._set_pre_splitter_sizes(vals[0], max(640, vals[1] + vals[2])) elif len(vals) == 2: - self.pre_splitter.setSizes(vals[:2]) + self._set_pre_splitter_sizes(vals[0], vals[1]) elif len(vals) >= 3: # Migrate old 3-pane [left, center, right] into [left, center+right]. left = max(260, vals[0]) center = max(640, vals[1] + vals[2]) - self.pre_splitter.setSizes([left, center]) + self._set_pre_splitter_sizes(left, center) elif len(vals) == 2: - self.pre_splitter.setSizes(vals[:2]) + self._set_pre_splitter_sizes(vals[0], vals[1]) except Exception: pass @@ -4136,6 +4137,15 @@ def _save_settings(self) -> None: except Exception: pass + def _set_pre_splitter_sizes(self, data_width: int, center_width: int) -> None: + """Apply logical [data, center] sizes to the preprocessing splitter.""" + try: + data = max(0, int(data_width)) + center = max(640, int(center_width)) + self.pre_splitter.setSizes([data, center]) + except Exception: + pass + def _save_splitter_sizes(self, *_args) -> None: """Save the current splitter sizes to settings.""" try: From 8760b0cae7bac51d08186d036830d55eb411af79 Mon Sep 17 00:00:00 2001 From: andrianj Date: Fri, 29 May 2026 10:44:40 +0200 Subject: [PATCH 2/3] Refine-artifact-controls --- pyBer/gui_preprocessing.py | 33 ++++++-- pyBer/main.py | 155 ++++++++++++++++++++++++++++--------- pyBer/onboarding.py | 6 +- 3 files changed, 144 insertions(+), 50 deletions(-) diff --git a/pyBer/gui_preprocessing.py b/pyBer/gui_preprocessing.py index bf399e9..6537033 100644 --- a/pyBer/gui_preprocessing.py +++ b/pyBer/gui_preprocessing.py @@ -549,10 +549,20 @@ def __init__(self, parent=None) -> None: def _build_ui(self) -> None: layout = QtWidgets.QVBoxLayout(self) + table_min_height = 260 auto_group = QtWidgets.QGroupBox("Auto-detected (threshold)") + auto_group.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding, + ) auto_layout = QtWidgets.QVBoxLayout(auto_group) self.table_auto = QtWidgets.QTableWidget(0, 6) + self.table_auto.setMinimumHeight(table_min_height) + self.table_auto.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding, + ) self.table_auto.setHorizontalHeaderLabels(["ID", "Remove", "Source", "Core (s)", "Cut start", "Cut end"]) self.table_auto.horizontalHeader().setStretchLastSection(True) self.table_auto.verticalHeader().setVisible(False) @@ -563,12 +573,21 @@ def _build_ui(self) -> None: self.table_auto.setColumnWidth(2, 66) self.table_auto.setColumnWidth(3, 132) self.table_auto.setColumnWidth(4, 82) - auto_layout.addWidget(self.table_auto) - layout.addWidget(auto_group) + auto_layout.addWidget(self.table_auto, 1) + layout.addWidget(auto_group, 1) manual_group = QtWidgets.QGroupBox("Manual artifacts") + manual_group.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding, + ) manual_layout = QtWidgets.QVBoxLayout(manual_group) self.table = QtWidgets.QTableWidget(0, 3) + self.table.setMinimumHeight(table_min_height) + self.table.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding, + ) self.table.setHorizontalHeaderLabels(["ID", "Start (s)", "End (s)"]) self.table.horizontalHeader().setStretchLastSection(True) self.table.verticalHeader().setVisible(False) @@ -608,7 +627,7 @@ def _build_ui(self) -> None: self.btn_close = QtWidgets.QPushButton("Close") btnrow.addWidget(self.btn_close) manual_layout.addLayout(btnrow) - layout.addWidget(manual_group) + layout.addWidget(manual_group, 1) self.btn_add.clicked.connect(self._on_add) self.btn_update.clicked.connect(self._on_update_selected) @@ -1933,7 +1952,6 @@ def mk_spin(minw=60) -> QtWidgets.QSpinBox: self.btn_load_config = QtWidgets.QPushButton("Load config") self.btn_reset_defaults = QtWidgets.QPushButton("Reset defaults") for b in ( - self.btn_artifacts_panel, self.btn_qc, self.btn_qc_batch, self.btn_export, @@ -1946,6 +1964,7 @@ def mk_spin(minw=60) -> QtWidgets.QSpinBox: b.setProperty("class", "compactSmall") b.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) self.btn_export.setProperty("class", "compactPrimarySmall") + self.btn_artifacts_panel.setVisible(False) self.btn_save_config.clicked.connect(self._save_config) self.btn_load_config.clicked.connect(self._load_config) self.btn_reset_defaults.clicked.connect(self._reset_defaults) @@ -2026,8 +2045,7 @@ def mk_spin(minw=60) -> QtWidgets.QSpinBox: qc_grid.setHorizontalSpacing(6) qc_grid.setVerticalSpacing(6) qc_grid.addWidget(self.btn_export, 0, 0, 1, 2) - qc_grid.addWidget(self.btn_artifacts_panel, 1, 0) - qc_grid.addWidget(self.btn_advanced, 1, 1) + qc_grid.addWidget(self.btn_advanced, 1, 0, 1, 2) qc_grid.addWidget(self.btn_qc, 2, 0) qc_grid.addWidget(self.btn_qc_batch, 2, 1) qc_grid.addWidget(self.btn_metadata, 3, 0) @@ -2869,6 +2887,7 @@ def _build_ui(self) -> None: self.btn_redo.setToolTip("Redo last undone preprocessing action (Ctrl+Y)") self.btn_redo.setFixedSize(34, 30) self.btn_artifacts = QtWidgets.QPushButton("Artifacts") + self.btn_artifacts.setVisible(False) self.btn_box_select = QtWidgets.QPushButton("Box select") self.btn_box_select.setCheckable(True) self.btn_thresholds = QtWidgets.QPushButton("Thresholds: ON") @@ -2878,7 +2897,6 @@ def _build_ui(self) -> None: for b in ( self.btn_add_region, self.btn_clear_regions, - self.btn_artifacts, self.btn_box_select, self.btn_thresholds, ): @@ -2887,7 +2905,6 @@ def _build_ui(self) -> None: tools.addWidget(self.btn_clear_regions) tools.addWidget(self.btn_undo) tools.addWidget(self.btn_redo) - tools.addWidget(self.btn_artifacts) tools.addWidget(self.btn_box_select) tools.addWidget(self.btn_thresholds) tools.addStretch(1) diff --git a/pyBer/main.py b/pyBer/main.py index 0e81c8a..401203d 100644 --- a/pyBer/main.py +++ b/pyBer/main.py @@ -130,7 +130,6 @@ def _is_user_site_path(path: str) -> bool: app_qss, _make_icon, _paint_database, - _paint_list, _paint_sliders, _paint_filter, _paint_wave, @@ -207,7 +206,7 @@ def _to_bool(value: object, default: bool = False) -> bool: _POST_DOCK_PREFIX = "post." _FORCE_FIXED_DOCK_LAYOUTS = False _USE_PG_DOCKAREA_PRE_LAYOUT = True -_PRE_DOCKAREA_PRIMARY_ORDER = ("artifacts_list", "artifacts", "filtering", "baseline", "output", "export") +_PRE_DOCKAREA_PRIMARY_ORDER = ("artifacts", "filtering", "baseline", "output", "export") _PRE_DOCKAREA_OPTIONAL_ORDER = ("qc", "config") _PRE_DOCKAREA_DEFAULT_VISIBLE = frozenset(_PRE_DOCKAREA_PRIMARY_ORDER) _CSV_NONE_LABEL = "(none)" @@ -967,7 +966,14 @@ def __init__(self, qc: Dict[str, object], parent=None) -> None: r = float(qc.get("r", np.nan)) r2 = r * r if np.isfinite(r) else np.nan if np.isfinite(r): - self._add_plot_text_topleft(self.plot_corr, f"r={r:.3g} r2={r2:.3g}") + self._add_plot_text_topleft( + self.plot_corr, + f"r={r:.3g} r2={r2:.3g}", + color=(255, 213, 95), + corner="topright", + fill=(12, 16, 24, 205), + border=(255, 213, 95, 150), + ) self.plot_corr.setLabel("left", "Signal dF/F (%)") self.plot_corr.setLabel("bottom", "Isobestic dF/F (%)") else: @@ -1114,19 +1120,42 @@ def _add_filled_band( plot.addItem(upper_curve) plot.addItem(lower_curve) - def _add_plot_text_topleft(self, plot: pg.PlotWidget, text: str) -> None: + def _add_plot_text_topleft( + self, + plot: pg.PlotWidget, + text: str, + *, + color: Tuple[int, int, int] = (220, 220, 220), + corner: str = "topleft", + fill: Optional[Tuple[int, int, int, int]] = None, + border: Optional[Tuple[int, int, int, int]] = None, + ) -> None: if not text: return vb = plot.getViewBox() if not vb: return (x0, x1), (y0, y1) = vb.viewRange() - if not np.isfinite(x0) or not np.isfinite(y1): - return - pad_x = (x1 - x0) * 0.02 - pad_y = (y1 - y0) * 0.05 - item = pg.TextItem(text, color=(220, 220, 220), anchor=(0, 1)) - item.setPos(x0 + pad_x, y1 - pad_y) + if not all(np.isfinite(v) for v in (x0, x1, y0, y1)): + return + pad_x = (x1 - x0) * 0.03 + pad_y = (y1 - y0) * 0.08 + corner_norm = str(corner or "topleft").strip().lower() + if corner_norm == "topright": + anchor = (1, 1) + pos = (x1 - pad_x, y1 - pad_y) + else: + anchor = (0, 1) + pos = (x0 + pad_x, y1 - pad_y) + item = pg.TextItem( + text, + color=color, + anchor=anchor, + fill=pg.mkBrush(fill) if fill is not None else None, + border=pg.mkPen(border, width=1.0) if border is not None else None, + ) + item.setZValue(50) + item.setPos(*pos) plot.addItem(item) def _save_images(self) -> None: @@ -1530,8 +1559,7 @@ def _build_ui(self) -> None: self.btn_plot_style.setMenu(self.menu_plot_style) # Inline parameter section buttons (same row as workflow actions). - self.btn_section_artifacts_list = QtWidgets.QToolButton(); self.btn_section_artifacts_list.setText("Artifact list") - self.btn_section_artifacts = QtWidgets.QToolButton(); self.btn_section_artifacts.setText("Artifact setup") + self.btn_section_artifacts = QtWidgets.QToolButton(); self.btn_section_artifacts.setText("Artifacts") self.btn_section_filtering = QtWidgets.QToolButton(); self.btn_section_filtering.setText("Filtering") self.btn_section_baseline = QtWidgets.QToolButton(); self.btn_section_baseline.setText("Baseline") self.btn_section_output = QtWidgets.QToolButton(); self.btn_section_output.setText("Output") @@ -1539,7 +1567,6 @@ def _build_ui(self) -> None: self.btn_section_export = QtWidgets.QToolButton(); self.btn_section_export.setText("Export") self.btn_section_config = QtWidgets.QToolButton(); self.btn_section_config.setText("Configuration") self._section_buttons: Dict[str, QtWidgets.QPushButton] = { - "artifacts_list": self.btn_section_artifacts_list, "artifacts": self.btn_section_artifacts, "filtering": self.btn_section_filtering, "baseline": self.btn_section_baseline, @@ -1557,8 +1584,7 @@ def _build_ui(self) -> None: # ----- Modern shell: vertical icon rail + thin transport bar ------ # Configure section buttons as icon-only rail buttons. _rail_section_meta = { - "artifacts_list": ("Artifacts", "Detected and manual artifacts list", _paint_list), - "artifacts": ("Artifact", "Artifact detection thresholds", _paint_sliders), + "artifacts": ("Artifacts", "Detection thresholds and artifact list", _paint_sliders), "filtering": ("Filtering", "Low-pass and smoothing options", _paint_filter), "baseline": ("Baseline", "Baseline estimation across recording", _paint_wave), "output": ("Output", "Choose dFF / dF / z-score formula", _paint_chart), @@ -1603,7 +1629,7 @@ def _build_ui(self) -> None: sep.setObjectName("railSeparator") sep.setFrameShape(QtWidgets.QFrame.Shape.HLine) rail_layout.addWidget(sep) - for key in ("artifacts_list", "artifacts", "filtering", "baseline", + for key in ("artifacts", "filtering", "baseline", "output", "qc", "export", "config"): rail_layout.addWidget(self._section_buttons[key], 0, QtCore.Qt.AlignmentFlag.AlignHCenter) @@ -1859,8 +1885,7 @@ def _setup_section_popups(self) -> None: self.param_panel.card_actions.setVisible(False) section_widgets: Dict[str, QtWidgets.QWidget] = { - "artifacts_list": self.artifact_panel, - "artifacts": self.param_panel.card_artifacts, + "artifacts": self._build_artifacts_section_widget(), "filtering": self.param_panel.card_filtering, "baseline": self.param_panel.card_baseline, "output": self.param_panel.card_output, @@ -1869,8 +1894,7 @@ def _setup_section_popups(self) -> None: "config": self._build_config_actions_widget(), } section_titles: Dict[str, str] = { - "artifacts_list": "Artifact list", - "artifacts": "Artifact setup", + "artifacts": "Artifacts", "filtering": "Filtering", "baseline": "Baseline", "output": "Output", @@ -1939,6 +1963,45 @@ def _setup_section_popups(self) -> None: widget.installEventFilter(self) self._section_docks[key] = dock + def _build_artifacts_section_widget(self) -> QtWidgets.QWidget: + panel = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(panel) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + + self.param_panel.card_artifacts.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + layout.addWidget(self.param_panel.card_artifacts) + + try: + self.artifact_panel.btn_close.setVisible(False) + except Exception: + pass + table_min_heights = { + "table_auto": 260, + "table": 260, + } + for table_name, min_height in table_min_heights.items(): + try: + table = getattr(self.artifact_panel, table_name) + table.setMinimumHeight(min_height) + table.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding, + ) + except Exception: + pass + self.artifact_panel.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding, + ) + self.artifact_panel.show() + layout.addWidget(self.artifact_panel, 1) + + return panel + def _pre_dockarea_dock(self, key: str) -> Optional[Dock]: return self._pre_dockarea_docks.get(key) @@ -2037,7 +2100,7 @@ def _arrange_pre_dockarea_default(self) -> None: if self._pre_dockarea is None: return ordered = self._pre_dockarea_ordered_keys() - root = self._pre_dockarea_dock("artifacts_list") + root = self._pre_dockarea_dock("artifacts") if root is None and ordered: root = self._pre_dockarea_dock(ordered[0]) if root is None: @@ -2063,6 +2126,8 @@ def _set_pre_dockarea_visible(self, key: str, visible: bool) -> None: if dock is None: return if visible: + if key == "artifacts": + self.artifact_panel.show() self._arrange_pre_dockarea_default() dock.show() try: @@ -2092,8 +2157,6 @@ def _save_pre_dockarea_layout_state(self) -> None: left_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, 1) for key, dock in self._pre_dockarea_docks.items(): - if key == "artifacts_list": - continue try: base = f"pre_section_docks/{key}" self.settings.setValue(f"{base}/visible", bool(dock.isVisible())) @@ -2103,7 +2166,7 @@ def _save_pre_dockarea_layout_state(self) -> None: continue try: art_base = "pre_artifact_dock_state" - art_vis = bool(visible.get("artifacts_list", False)) + art_vis = bool(visible.get("artifacts", False)) self.settings.setValue(f"{art_base}/visible", art_vis) self.settings.setValue(f"{art_base}/floating", False) self.settings.setValue(f"{art_base}/area", left_i) @@ -2128,20 +2191,25 @@ def _restore_pre_dockarea_layout_state(self) -> None: except Exception: visible_map = {} + legacy_artifacts_visible = visible_map.pop("artifacts_list", None) + if legacy_artifacts_visible is not None and "artifacts" in self._pre_dockarea_docks: + visible_map["artifacts"] = bool(visible_map.get("artifacts", False) or legacy_artifacts_visible) + if not visible_map: for key in self._pre_dockarea_docks.keys(): - if key == "artifacts_list": - raw = self.settings.value("pre_artifact_dock_state/visible", None) - if raw is not None: - visible_map[key] = _to_bool(raw, False) - continue raw = self.settings.value(f"pre_section_docks/{key}/visible", None) if raw is not None: visible_map[key] = _to_bool(raw, False) + if key == "artifacts": + legacy_raw = self.settings.value("pre_artifact_dock_state/visible", None) + if legacy_raw is not None: + visible_map[key] = bool(visible_map.get(key, False) or _to_bool(legacy_raw, False)) if not visible_map: visible_map = self._pre_dockarea_default_visible_map() - active = str(self.settings.value(_PRE_DOCKAREA_ACTIVE_KEY, "artifacts_list") or "artifacts_list") + active = str(self.settings.value(_PRE_DOCKAREA_ACTIVE_KEY, "artifacts") or "artifacts") + if active == "artifacts_list": + active = "artifacts" if not bool(visible_map.get(active, False)): active = next((key for key in self._pre_dockarea_ordered_keys() if bool(visible_map.get(key, False))), "") @@ -2213,10 +2281,8 @@ def _build_qc_actions_widget(self) -> QtWidgets.QWidget: v.setSpacing(6) self.param_panel.btn_qc.setProperty("class", "blueSecondarySmall") self.param_panel.btn_qc_batch.setProperty("class", "blueSecondarySmall") - self.param_panel.btn_artifacts_panel.setProperty("class", "blueSecondarySmall") v.addWidget(self.param_panel.btn_qc) v.addWidget(self.param_panel.btn_qc_batch) - v.addWidget(self.param_panel.btn_artifacts_panel) v.addWidget(self.param_panel.lbl_fs) v.addStretch(1) return panel @@ -2310,8 +2376,7 @@ def _force_hide_pre_drawer_initially(self) -> None: pass _PRE_SECTION_TITLES = { - "artifacts_list": "Artifact list", - "artifacts": "Artifact setup", + "artifacts": "Artifacts", "filtering": "Filtering", "baseline": "Baseline", "output": "Output", @@ -2385,6 +2450,8 @@ def _toggle_section_popup(self, key: str, checked: bool) -> None: except Exception: pass dock.show() + if key == "artifacts": + self.artifact_panel.show() try: dock.raiseDock() except Exception: @@ -3418,7 +3485,7 @@ def _read_section_settings(prefix: str, keys: List[str]) -> Dict[str, Dict[str, return out if self._use_pg_dockarea_pre_layout and self._pre_dockarea_docks: - pre_sections = [k for k in self._pre_dockarea_docks.keys() if k != "artifacts_list"] + pre_sections = list(self._pre_dockarea_docks.keys()) else: pre_sections = list(self._section_docks.keys()) post_sections = [] @@ -3907,7 +3974,7 @@ def _has_saved_pre_layout_state(self) -> bool: return True keys = list(self._section_docks.keys()) if self._use_pg_dockarea_pre_layout and self._pre_dockarea_docks: - keys = [k for k in self._pre_dockarea_docks.keys() if k != "artifacts_list"] + keys = list(self._pre_dockarea_docks.keys()) for key in keys: if self.settings.contains(f"pre_section_docks/{key}/visible"): return True @@ -6942,22 +7009,34 @@ def _contains(target: Tuple[float, float], arr: List[Tuple[float, float]]) -> bo def _toggle_artifacts_panel(self) -> None: if self._use_pg_dockarea_pre_layout: self._setup_section_popups() - dock = self._pre_dockarea_dock("artifacts_list") + dock = self._pre_dockarea_dock("artifacts") if dock is None: return if dock.isVisible(): dock.hide() else: + self.artifact_panel.show() dock.show() try: dock.raiseDock() except Exception: pass - self._last_opened_section = "artifacts_list" + self._last_opened_section = "artifacts" self._sync_section_button_states_from_docks() self._save_panel_layout_state() return + section_dock = self._section_docks.get("artifacts") + if isinstance(section_dock, QtWidgets.QDockWidget): + if section_dock.isVisible(): + section_dock.setVisible(False) + else: + self.artifact_panel.show() + section_dock.setVisible(True) + section_dock.raise_() + self._save_panel_layout_state() + return + if isinstance(self.art_dock, QtWidgets.QDockWidget): if self.art_dock.isVisible(): self.art_dock.setVisible(False) diff --git a/pyBer/onboarding.py b/pyBer/onboarding.py index 0d10b85..281e9c3 100644 --- a/pyBer/onboarding.py +++ b/pyBer/onboarding.py @@ -602,8 +602,7 @@ def _before(_w: QtWidgets.QWidget) -> None: ] for key, title, body in [ - ("artifacts_list", "Artifact list", "Inspect detected and manual artifact windows, jump to them, and remove entries."), - ("artifacts", "Artifact setup", "Choose artifact thresholds and how artifacts are handled: interpolate, cut, low-pass locally, or leave unchanged."), + ("artifacts", "Artifacts", "Choose artifact thresholds, set artifact handling, and inspect detected or manual artifact windows."), ("filtering", "Filtering", "Set low-pass and smoothing options for signal and reference traces."), ("baseline", "Baseline", "Configure baseline estimation before computing dF/F, dF, or z-score outputs."), ("output", "Output", "Choose the processed trace formula and preview the resulting channel."), @@ -960,8 +959,7 @@ def _keyboard_cheatsheet_html() -> str: } _PRE_SECTION_META: Dict[str, Tuple[str, str, str, str]] = { - "artifacts_list": ("L", "#f5c542", "Artifact list", "Inspect and edit detected / manual artifacts"), - "artifacts": ("A", "#ee6471", "Artifact setup", "Detection thresholds and manual selection"), + "artifacts": ("A", "#ee6471", "Artifacts", "Detection thresholds and detected / manual artifact list"), "filtering": ("F", "#7d4df2", "Filtering", "Low-pass + smoothing for the photometry trace"), "baseline": ("B", "#4b9df8", "Baseline", "Baseline estimation across the recording"), "output": ("O", "#5dd39e", "Output", "Choose dFF / dF / z-score formula"), From ca48b7ea373f268a90411ea3ee8f252eddf156df Mon Sep 17 00:00:00 2001 From: andrianj Date: Fri, 29 May 2026 19:22:30 +0200 Subject: [PATCH 3/3] Add-continuous-PSTH-alignment --- pyBer/gui_postprocessing.py | 584 +++++++++++++++++++++++++++++++++++- pyBer/gui_preprocessing.py | 94 +++++- pyBer/main.py | 136 ++++++--- 3 files changed, 750 insertions(+), 64 deletions(-) diff --git a/pyBer/gui_postprocessing.py b/pyBer/gui_postprocessing.py index 7350db6..ddc3297 100644 --- a/pyBer/gui_postprocessing.py +++ b/pyBer/gui_postprocessing.py @@ -428,6 +428,319 @@ def _load_behavior_ethovision( } +_RULE_NUMBER_RE = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?" + + +def _parse_rule_float(text: str) -> Optional[float]: + raw = str(text or "").strip() + if not re.fullmatch(_RULE_NUMBER_RE, raw): + return None + try: + return float(raw) + except Exception: + return None + + +def _compare_rule_values(values: np.ndarray, op: str, threshold: float) -> np.ndarray: + if op == ">": + return values > threshold + if op == ">=": + return values >= threshold + if op == "<": + return values < threshold + if op == "<=": + return values <= threshold + raise ValueError(f"Unsupported threshold operator: {op}") + + +def _invert_rule_operator(op: str) -> str: + return {">": "<", ">=": "<=", "<": ">", "<=": ">="}.get(op, op) + + +def _continuous_rule_mask(values: np.ndarray, variable_name: str, rule_text: str) -> np.ndarray: + """Return a boolean mask for a simple threshold rule without using eval.""" + values = np.asarray(values, float) + rule = str(rule_text or "").strip() + if not rule: + raise ValueError("Enter a threshold rule.") + + and_parts = re.split(r"\s+(?:and|&&)\s+", rule, flags=re.IGNORECASE) + if len(and_parts) > 1: + mask = np.ones(values.shape, dtype=bool) + for part in and_parts: + mask &= _continuous_rule_mask(values, variable_name, part) + return mask + + or_parts = re.split(r"\s+(?:or|\|\|)\s+", rule, flags=re.IGNORECASE) + if len(or_parts) > 1: + mask = np.zeros(values.shape, dtype=bool) + for part in or_parts: + mask |= _continuous_rule_mask(values, variable_name, part) + return mask + + ops = list(re.finditer(r"(>=|<=|>|<)", rule)) + numbers = [float(m.group(0)) for m in re.finditer(_RULE_NUMBER_RE, rule)] + + if len(ops) >= 2 and len(numbers) >= 2: + # Users often type band rules as either a < x < b or a > x > b. + low, high = sorted(numbers[:2]) + inclusive = any(m.group(1) in {">=", "<="} for m in ops[:2]) + if inclusive: + return (values >= low) & (values <= high) + return (values > low) & (values < high) + + if len(ops) == 1: + match = ops[0] + left = rule[:match.start()].strip() + right = rule[match.end():].strip() + left_num = _parse_rule_float(left) + right_num = _parse_rule_float(right) + op = match.group(1) + if left_num is None and right_num is not None: + return _compare_rule_values(values, op, right_num) + if left_num is not None and right_num is None: + return _compare_rule_values(values, _invert_rule_operator(op), left_num) + if left_num is not None and right_num is not None: + return np.full(values.shape, bool(_compare_rule_values(np.asarray([left_num]), op, right_num)[0]), dtype=bool) + + if len(numbers) >= 2: + low, high = sorted(numbers[:2]) + return (values > low) & (values < high) + if len(numbers) == 1: + return values > numbers[0] + + raise ValueError(f"Could not parse threshold rule for {variable_name}.") + + +def _continuous_threshold_events( + time: np.ndarray, + values: np.ndarray, + rule_text: str, + variable_name: str, +) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + values = np.asarray(values, float).reshape(-1) + time = np.asarray(time, float).reshape(-1) + if time.size == 0 and values.size: + time = np.arange(values.size, dtype=float) + n = min(time.size, values.size) + if n <= 0: + return np.array([], float), np.array([], float), np.array([], float), np.array([], bool) + time = time[:n] + values = values[:n] + finite = np.isfinite(time) & np.isfinite(values) + mask = np.zeros(n, dtype=bool) + if np.any(finite): + mask[finite] = _continuous_rule_mask(values[finite], variable_name, rule_text) + prev_high = np.r_[False, mask[:-1]] + next_high = np.r_[mask[1:], False] + on_idx = np.where(mask & ~prev_high)[0] + off_idx = np.where(mask & ~next_high)[0] + on = time[on_idx] + off = time[off_idx] + m = min(on.size, off.size) + dur = off[:m] - on[:m] if m else np.array([], float) + if on.size != off.size: + dur = np.full(on.shape, np.nan, dtype=float) + else: + dur = np.maximum(dur, 0.0) + return on, off, dur, mask + + +def _continuous_behavior_name(variable_name: str, rule_text: str, align_text: str) -> str: + suffix = "offset" if str(align_text).strip().lower().endswith("offset") else "onset" + compact_rule = re.sub(r"\s+", " ", str(rule_text or "").strip()) + if len(compact_rule) > 42: + compact_rule = compact_rule[:39] + "..." + base = f"{variable_name} [{compact_rule}] {suffix}".strip() + return base or f"continuous {suffix}" + + +class ContinuousAlignDialog(QtWidgets.QDialog): + def __init__( + self, + behavior_sources: Dict[str, Dict[str, Any]], + parent: Optional[QtWidgets.QWidget] = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle("Align to continuous") + self.resize(760, 560) + self._sources = { + str(k): v + for k, v in (behavior_sources or {}).items() + if isinstance(v, dict) and bool(v.get("trajectory") or {}) + } + self._rule_touched = False + self._name_touched = False + + root = QtWidgets.QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(10) + + form = QtWidgets.QFormLayout() + form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) + form.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) + + self.combo_source = QtWidgets.QComboBox() + self.combo_variable = QtWidgets.QComboBox() + self.edit_rule = QtWidgets.QLineEdit() + self.edit_rule.setPlaceholderText("velocity > 6 or 2.1 < velocity < 5.5") + self.combo_align = QtWidgets.QComboBox() + self.combo_align.addItems(["Align to onset", "Align to offset"]) + self.edit_name = QtWidgets.QLineEdit() + self.cb_apply_all = QtWidgets.QCheckBox("Apply to all loaded files with this variable") + self.cb_apply_all.setChecked(True) + self.lbl_status = QtWidgets.QLabel("") + self.lbl_status.setProperty("class", "hint") + self.lbl_status.setWordWrap(True) + + _compact_combo(self.combo_source, min_chars=18) + _compact_combo(self.combo_variable, min_chars=18) + _compact_combo(self.combo_align, min_chars=10) + + form.addRow("Behavior file", self.combo_source) + form.addRow("Continuous variable", self.combo_variable) + form.addRow("Threshold rule", self.edit_rule) + form.addRow("Align to", self.combo_align) + form.addRow("Behavior name", self.edit_name) + form.addRow("", self.cb_apply_all) + root.addLayout(form) + + self.plot = pg.PlotWidget(title="Continuous threshold preview") + _opt_plot(self.plot) + self.plot.setMinimumHeight(260) + self.plot.setLabel("bottom", "Time (s)") + self.plot.setLabel("left", "Value") + root.addWidget(self.plot, stretch=1) + root.addWidget(self.lbl_status) + + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + self.btn_ok = buttons.button(QtWidgets.QDialogButtonBox.StandardButton.Ok) + if self.btn_ok is not None: + self.btn_ok.setText("Create alignment") + self.btn_ok.setProperty("class", "compactPrimary") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + self._populate_sources() + self.combo_source.currentIndexChanged.connect(self._populate_variables) + self.combo_source.currentIndexChanged.connect(self._update_default_rule) + self.combo_variable.currentIndexChanged.connect(self._update_default_rule) + self.combo_variable.currentIndexChanged.connect(self._update_preview) + self.combo_align.currentIndexChanged.connect(self._update_default_name) + self.combo_align.currentIndexChanged.connect(self._update_preview) + self.edit_rule.textEdited.connect(self._on_rule_edited) + self.edit_name.textEdited.connect(self._on_name_edited) + self._populate_variables() + self._update_default_rule() + self._update_preview() + + def _populate_sources(self) -> None: + self.combo_source.clear() + for stem in sorted(self._sources.keys()): + self.combo_source.addItem(stem, stem) + + def _selected_source(self) -> Tuple[str, Dict[str, Any]]: + key = str(self.combo_source.currentData() or self.combo_source.currentText() or "") + return key, self._sources.get(key, {}) + + def _populate_variables(self) -> None: + current = self.combo_variable.currentText() + self.combo_variable.blockSignals(True) + try: + self.combo_variable.clear() + _key, info = self._selected_source() + for name in sorted(str(k) for k in (info.get("trajectory") or {}).keys()): + self.combo_variable.addItem(name, name) + idx = self.combo_variable.findText(current) + if idx >= 0: + self.combo_variable.setCurrentIndex(idx) + finally: + self.combo_variable.blockSignals(False) + self._update_preview() + + def _selected_variable_data(self) -> Tuple[str, np.ndarray, np.ndarray]: + _key, info = self._selected_source() + variable = str(self.combo_variable.currentData() or self.combo_variable.currentText() or "") + trajectory = info.get("trajectory") or {} + values = np.asarray(trajectory.get(variable, np.array([], float)), float) + time = np.asarray(info.get("trajectory_time", np.array([], float)), float) + if time.size == 0 and values.size: + time = np.arange(values.size, dtype=float) + return variable, time, values + + def _update_default_rule(self) -> None: + variable, _time, values = self._selected_variable_data() + if not variable: + return + if not self._rule_touched or not self.edit_rule.text().strip(): + finite = values[np.isfinite(values)] + threshold = float(np.nanmedian(finite)) if finite.size else 0.0 + self.edit_rule.setText(f"{variable} > {threshold:.4g}") + self._update_default_name() + self._update_preview() + + def _update_default_name(self) -> None: + variable = str(self.combo_variable.currentData() or self.combo_variable.currentText() or "continuous") + if not self.edit_name.text().strip() or not getattr(self, "_name_touched", False): + self.edit_name.setText(_continuous_behavior_name(variable, self.edit_rule.text(), self.combo_align.currentText())) + + def _on_rule_edited(self, _text: str) -> None: + self._rule_touched = True + self._name_touched = False + self._update_default_name() + self._update_preview() + + def _on_name_edited(self, _text: str) -> None: + self._name_touched = True + + def _update_preview(self) -> None: + variable, time, values = self._selected_variable_data() + rule = self.edit_rule.text().strip() + self.plot.clear() + if self.btn_ok is not None: + self.btn_ok.setEnabled(False) + if not variable: + self.lbl_status.setText("Load a CSV or EthoVision file with continuous numeric columns first.") + return + n = min(time.size, values.size) + if n <= 0: + self.lbl_status.setText("Selected variable has no numeric samples.") + return + time = time[:n] + values = values[:n] + try: + on, off, dur, mask = _continuous_threshold_events(time, values, rule, variable) + except Exception as exc: + self.plot.plot(time, values, pen=pg.mkPen((120, 170, 220), width=1)) + self.lbl_status.setText(str(exc)) + return + self.plot.plot(time, values, pen=pg.mkPen((100, 190, 255), width=1)) + if mask.size == values.size and np.any(mask): + masked = np.where(mask, values, np.nan) + self.plot.plot(time, masked, pen=pg.mkPen((255, 180, 70), width=2)) + event_count = int(on.size if self.combo_align.currentText().endswith("onset") else off.size) + sample_count = int(np.sum(mask)) if mask.size else 0 + self.lbl_status.setText( + f"{sample_count} sample(s) pass the rule. {event_count} event(s) will be created." + ) + if self.btn_ok is not None: + self.btn_ok.setEnabled(event_count > 0) + + def config(self) -> Dict[str, object]: + source_key, _info = self._selected_source() + return { + "source_key": source_key, + "variable": str(self.combo_variable.currentData() or self.combo_variable.currentText() or ""), + "rule": self.edit_rule.text().strip(), + "align": self.combo_align.currentText(), + "name": self.edit_name.text().strip(), + "apply_all": self.cb_apply_all.isChecked(), + } + + def _compute_psth_matrix( t: np.ndarray, y: np.ndarray, @@ -496,6 +809,7 @@ def __init__(self, parent=None) -> None: self._processed: List[ProcessedTrial] = [] self._dio_cache: Dict[Tuple[str, str], Tuple[np.ndarray, np.ndarray]] = {} # (path,dio)->(t,x) self._behavior_sources: Dict[str, Dict[str, Any]] = {} # stem->behavior data + self._continuous_align_rules: Dict[str, Dict[str, object]] = {} self._last_mat: Optional[np.ndarray] = None self._last_tvec: Optional[np.ndarray] = None self._last_events: Optional[np.ndarray] = None @@ -632,6 +946,7 @@ def _build_ui(self) -> None: self.btn_use_current = QtWidgets.QPushButton("Use current preprocessed selection") self.btn_use_current.setProperty("class", "compactPrimary") self.btn_use_current.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_use_current.setVisible(False) self.btn_load_processed_single = QtWidgets.QPushButton("Load processed file (CSV/H5)") self.btn_load_processed_single.setProperty("class", "compactSmall") self.btn_load_processed_single.setSizePolicy( @@ -639,7 +954,6 @@ def _build_ui(self) -> None: QtWidgets.QSizePolicy.Policy.Fixed, ) single_layout.addWidget(self.lbl_current) - single_layout.addWidget(self.btn_use_current) single_layout.addWidget(self.btn_load_processed_single) group_layout = QtWidgets.QVBoxLayout(tab_group) @@ -654,9 +968,9 @@ def _build_ui(self) -> None: self.btn_refresh_dio = QtWidgets.QPushButton("Refresh A/D channel list") self.btn_refresh_dio.setProperty("class", "compactSmall") self.btn_refresh_dio.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_refresh_dio.setVisible(False) vsrc.addWidget(self.tab_sources) - vsrc.addWidget(self.btn_refresh_dio) grp_align = QtWidgets.QGroupBox("Behavior / Events") grp_align.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Expanding) @@ -721,7 +1035,7 @@ def _build_ui(self) -> None: # Preprocessed files list self.list_preprocessed = FileDropList() - self.list_preprocessed.setMinimumHeight(180) + self.list_preprocessed.setMinimumHeight(260) self.list_preprocessed.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding, @@ -730,7 +1044,7 @@ def _build_ui(self) -> None: # Behaviors list self.list_behaviors = FileDropList() - self.list_behaviors.setMinimumHeight(180) + self.list_behaviors.setMinimumHeight(260) self.list_behaviors.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding, @@ -800,14 +1114,17 @@ def _build_ui(self) -> None: self.spin_transition_gap.setValue(1.0) self.spin_transition_gap.setDecimals(2) + self.lbl_behavior_name = QtWidgets.QLabel("Behavior name") + self.lbl_behavior_align = QtWidgets.QLabel("Behavior align") self.lbl_trans_from = QtWidgets.QLabel("Transition from") self.lbl_trans_to = QtWidgets.QLabel("Transition to") self.lbl_trans_gap = QtWidgets.QLabel("Transition gap (s)") - fal.addRow("Behavior name", self.combo_behavior_name) - fal.addRow("Behavior align", self.combo_behavior_align) - fal.addRow(self.lbl_trans_from, self.combo_behavior_from) - fal.addRow(self.lbl_trans_to, self.combo_behavior_to) - fal.addRow(self.lbl_trans_gap, self.spin_transition_gap) + self.lbl_continuous_align = QtWidgets.QLabel("Continuous") + self.btn_continuous_align = QtWidgets.QPushButton("Align to continuous") + self.btn_continuous_align.setProperty("class", "compactSmall") + self.lbl_continuous_align_status = QtWidgets.QLabel("No continuous rule") + self.lbl_continuous_align_status.setProperty("class", "hint") + self.lbl_continuous_align_status.setWordWrap(True) # ── Shared QSS for PSTH subsection headers ── @@ -900,6 +1217,29 @@ def _dual_row(lbl_a: str, w_a, lbl_b: str, w_b): metric_post_widget = _dual_row("Start:", self.spin_metric_post0, "End:", self.spin_metric_post1) global_widget = _dual_row("Start:", self.spin_global_start, "End:", self.spin_global_end) + # ═══════════════════════════════════════════════════════ + # Section 0: Alignment + # ═══════════════════════════════════════════════════════ + grp_align_psth = QtWidgets.QGroupBox("Alignment") + grp_align_psth.setStyleSheet(_psth_section_qss) + fa_psth = QtWidgets.QFormLayout(grp_align_psth) + fa_psth.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) + fa_psth.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) + fa_psth.addRow(self.lbl_behavior_name, self.combo_behavior_name) + fa_psth.addRow(self.lbl_behavior_align, self.combo_behavior_align) + fa_psth.addRow(self.lbl_trans_from, self.combo_behavior_from) + fa_psth.addRow(self.lbl_trans_to, self.combo_behavior_to) + fa_psth.addRow(self.lbl_trans_gap, self.spin_transition_gap) + continuous_row = QtWidgets.QHBoxLayout() + continuous_row.setContentsMargins(0, 0, 0, 0) + continuous_row.setSpacing(6) + continuous_row.addWidget(self.btn_continuous_align, 0) + continuous_row.addWidget(self.lbl_continuous_align_status, 1) + continuous_widget = QtWidgets.QWidget() + continuous_widget.setLayout(continuous_row) + fa_psth.addRow(self.lbl_continuous_align, continuous_widget) + self._continuous_align_widget = continuous_widget + # ═══════════════════════════════════════════════════════ # Section 1 — Window & Baseline # ═══════════════════════════════════════════════════════ @@ -992,6 +1332,7 @@ def _dual_row(lbl_a: str, w_a, lbl_b: str, w_b): _psth_vbox = QtWidgets.QVBoxLayout(grp_opt) _psth_vbox.setContentsMargins(0, 0, 0, 0) _psth_vbox.setSpacing(4) + _psth_vbox.addWidget(grp_align_psth) _psth_vbox.addWidget(grp_window) _psth_vbox.addWidget(grp_filt) _psth_vbox.addWidget(grp_include) @@ -1856,8 +2197,8 @@ def _set_text_with_banner(text: str) -> None: self.btn_setup_load.setProperty("class", "compactPrimarySmall") self.btn_setup_refresh = QtWidgets.QPushButton("Refresh A/D") self.btn_setup_refresh.setProperty("class", "compactSmall") + self.btn_setup_refresh.setVisible(False) setup_btn_row.addWidget(self.btn_setup_load) - setup_btn_row.addWidget(self.btn_setup_refresh) setup_btn_row.addStretch(1) setup_wrap = QtWidgets.QWidget() setup_wrap.setLayout(setup_btn_row) @@ -1954,6 +2295,8 @@ def _set_text_with_banner(text: str) -> None: self.menu_recent_projects = self.menu_action_recent.addMenu("Projects") self.menu_action_recent.aboutToShow.connect(self._refresh_recent_postprocessing_menus) self.act_refresh_dio = self.menu_action_load.addAction("Refresh A/D channel list") + self.act_load_current.setVisible(False) + self.act_refresh_dio.setVisible(False) self.menu_action_load.addSeparator() self.act_open_plot_style = self.menu_action_load.addAction("Plot style...") self.btn_action_load.setMenu(self.menu_action_load) @@ -2591,6 +2934,7 @@ def _set_text_with_banner(text: str) -> None: self.combo_align.currentIndexChanged.connect(self._update_align_ui) self.combo_behavior_file_type.currentIndexChanged.connect(self._update_align_ui) self.combo_behavior_align.currentIndexChanged.connect(self._update_align_ui) + self.btn_continuous_align.clicked.connect(self._open_continuous_align_dialog) self.combo_align.currentIndexChanged.connect(self._refresh_behavior_list) self.combo_align.currentIndexChanged.connect(self._compute_psth) for w in ( @@ -3817,12 +4161,30 @@ def _set_resample_from_processed(self) -> None: self.spin_resample.setValue(fs) self._update_status_strip() + def _refresh_dio_channels_from_processed(self) -> None: + names: List[str] = [] + seen: set[str] = set() + for proc in self._processed: + name = str(getattr(proc, "dio_name", "") or "").strip() + if not name or name in seen: + continue + dio = getattr(proc, "dio", None) + try: + has_data = dio is not None and np.asarray(dio).size > 0 + except Exception: + has_data = dio is not None + if has_data: + seen.add(name) + names.append(name) + self.receive_dio_list(names) + @QtCore.Slot(list) def receive_current_processed(self, processed_list: List[ProcessedTrial]) -> None: self._processed = processed_list or [] if not self._autosave_restoring: self._project_dirty = True - # update trace preview with first entry + self._update_file_lists() + self._refresh_dio_channels_from_processed() self._refresh_behavior_list() self._refresh_sync_sources() self._set_resample_from_processed() @@ -3841,6 +4203,8 @@ def append_processed(self, processed_list: List[ProcessedTrial]) -> None: self._processed.extend(processed_list) if not self._autosave_restoring: self._project_dirty = True + self._update_file_lists() + self._refresh_dio_channels_from_processed() self._refresh_behavior_list() self._refresh_sync_sources() self._set_resample_from_processed() @@ -4022,6 +4386,7 @@ def _load_behavior_paths(self, paths: List[str], replace: bool) -> None: "Behavior load warning", f"No behavior or trajectory numeric columns detected in {os.path.basename(p)} for the selected file type.", ) + info.setdefault("event_behaviors", {}) info["source_path"] = str(p) self._behavior_sources[stem] = info loaded_any = True @@ -4043,6 +4408,95 @@ def _load_behavior_paths(self, paths: List[str], replace: bool) -> None: self._sync_temporal_modeling_context() self._refresh_sync_sources() + def _open_continuous_align_dialog(self) -> None: + has_continuous = any(bool((info or {}).get("trajectory") or {}) for info in (self._behavior_sources or {}).values()) + if not has_continuous: + QtWidgets.QMessageBox.information( + self, + "Align to continuous", + "Load a behavior CSV or EthoVision file with continuous numeric columns first.", + ) + return + dlg = ContinuousAlignDialog(self._behavior_sources, self) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + try: + count = self._apply_continuous_alignment_rule(dlg.config()) + except Exception as exc: + QtWidgets.QMessageBox.warning(self, "Align to continuous", str(exc)) + return + self.statusUpdate.emit(f"Created continuous alignment for {count} behavior file(s).", 5000) + + def _apply_continuous_alignment_rule(self, config: Dict[str, object]) -> int: + source_key = str(config.get("source_key") or "").strip() + variable = str(config.get("variable") or "").strip() + rule = str(config.get("rule") or "").strip() + align = str(config.get("align") or "Align to onset").strip() + name = str(config.get("name") or "").strip() or _continuous_behavior_name(variable, rule, align) + apply_all = bool(config.get("apply_all", True)) + if not variable: + raise ValueError("Choose a continuous variable.") + if not rule: + raise ValueError("Enter a threshold rule.") + + targets: List[Tuple[str, Dict[str, Any]]] = [] + if apply_all: + for stem, info in (self._behavior_sources or {}).items(): + if variable in (info.get("trajectory") or {}): + targets.append((stem, info)) + elif source_key in self._behavior_sources: + targets.append((source_key, self._behavior_sources[source_key])) + if not targets: + raise ValueError(f"No loaded behavior file contains '{variable}'.") + + total_events = 0 + updated = 0 + first_align_index = self.combo_behavior_align.findText(align) + for stem, info in targets: + trajectory = info.get("trajectory") or {} + values = np.asarray(trajectory.get(variable, np.array([], float)), float) + time = np.asarray(info.get("trajectory_time", np.array([], float)), float) + on, off, dur, _mask = _continuous_threshold_events(time, values, rule, variable) + events = off if align.endswith("offset") else on + if events.size == 0: + continue + event_store = info.setdefault("event_behaviors", {}) + event_store[name] = { + "on": np.asarray(on, float), + "off": np.asarray(off, float), + "dur": np.asarray(dur, float), + "rule": rule, + "variable": variable, + "align": align, + "source": stem, + } + self._continuous_align_rules[name] = { + "source_key": stem, + "variable": variable, + "rule": rule, + "align": align, + "name": name, + "apply_all": apply_all, + } + total_events += int(events.size) + updated += 1 + + if updated == 0: + raise ValueError("The threshold rule did not create any onset or offset events.") + + self._refresh_behavior_list() + idx_name = self.combo_behavior_name.findText(name) + if idx_name >= 0: + self.combo_behavior_name.setCurrentIndex(idx_name) + if first_align_index >= 0: + self.combo_behavior_align.setCurrentIndex(first_align_index) + self.lbl_continuous_align_status.setText(f"{name}: {total_events} event(s)") + if not self._autosave_restoring: + self._project_dirty = True + self._compute_psth() + self._refresh_sync_sources() + return updated + def _sync_temporal_modeling_context(self) -> None: if not hasattr(self, "section_temporal"): return @@ -4094,6 +4548,7 @@ def _load_processed_paths(self, paths: List[str], replace: bool) -> None: self.lbl_group.setText(f"{len(self._processed)} file(s) loaded") self._push_recent_paths("postprocess_recent_processed_paths", paths) self._update_file_lists() + self._refresh_dio_channels_from_processed() self._refresh_sync_sources() self._set_resample_from_processed() self._compute_psth() @@ -4140,6 +4595,8 @@ def _update_align_ui(self) -> None: self.combo_behavior_file_type.setVisible(use_beh) self.btn_load_beh.setEnabled(use_beh) self.btn_load_beh.setVisible(use_beh) + self.lbl_behavior_name.setEnabled(use_beh) + self.lbl_behavior_name.setVisible(use_beh) self.combo_behavior_name.setEnabled(use_beh) self.combo_behavior_name.setVisible(use_beh) show_time_panel = bool(use_beh and self._behavior_sources_need_generated_time()) @@ -4147,8 +4604,20 @@ def _update_align_ui(self) -> None: self.grp_behavior_time.setVisible(show_time_panel) # Behavior align combo + transition settings + self.lbl_behavior_align.setEnabled(use_beh) + self.lbl_behavior_align.setVisible(use_beh) self.combo_behavior_align.setEnabled(use_beh) self.combo_behavior_align.setVisible(use_beh) + for w in ( + self.lbl_continuous_align, + self.btn_continuous_align, + self.lbl_continuous_align_status, + getattr(self, "_continuous_align_widget", None), + ): + if w is None: + continue + w.setEnabled(use_beh) + w.setVisible(use_beh) is_transition = use_beh and self.combo_behavior_align.currentText().startswith("Transition") for w in ( self.combo_behavior_from, @@ -4896,6 +5365,7 @@ def _project_dirty_fingerprint(self) -> str: "kind": str(data.get("kind", "") or ""), "row_count": int(data.get("row_count", 0) or 0), "behaviors": sorted(str(k) for k in (data.get("behaviors", {}) or {}).keys()), + "event_behaviors": sorted(str(k) for k in (data.get("event_behaviors", {}) or {}).keys()), "trajectory": sorted(str(k) for k in (data.get("trajectory", {}) or {}).keys()), } ) @@ -5549,6 +6019,10 @@ def _load_processed_h5(self, path: str) -> Optional[ProcessedTrial]: ) def _refresh_behavior_list(self) -> None: + prev_name = self.combo_behavior_name.currentText().strip() + prev_analysis = self.combo_behavior_analysis.currentText().strip() if hasattr(self, "combo_behavior_analysis") else "" + prev_from = self.combo_behavior_from.currentText().strip() + prev_to = self.combo_behavior_to.currentText().strip() self.combo_behavior_name.clear() if hasattr(self, "combo_behavior_analysis"): self.combo_behavior_analysis.clear() @@ -5560,6 +6034,8 @@ def _refresh_behavior_list(self) -> None: except Exception: pass if not self._behavior_sources: + self.combo_behavior_from.clear() + self.combo_behavior_to.clear() self._refresh_spatial_columns() self._compute_spatial_heatmap() self._update_data_availability() @@ -5569,6 +6045,8 @@ def _refresh_behavior_list(self) -> None: for info in self._behavior_sources.values(): behaviors = info.get("behaviors") or {} behavior_names.update(str(k) for k in behaviors.keys()) + event_behaviors = info.get("event_behaviors") or {} + behavior_names.update(str(k) for k in event_behaviors.keys()) behaviors = sorted(list(behavior_names)) for name in behaviors: self.combo_behavior_name.addItem(name) @@ -5579,6 +6057,18 @@ def _refresh_behavior_list(self) -> None: for name in behaviors: self.combo_behavior_from.addItem(name) self.combo_behavior_to.addItem(name) + for combo, previous in ( + (self.combo_behavior_name, prev_name), + (self.combo_behavior_from, prev_from), + (self.combo_behavior_to, prev_to), + ): + idx = combo.findText(previous) + if idx >= 0: + combo.setCurrentIndex(idx) + if hasattr(self, "combo_behavior_analysis"): + idx = self.combo_behavior_analysis.findText(prev_analysis) + if idx >= 0: + self.combo_behavior_analysis.setCurrentIndex(idx) # Update the lists with numbered items self._update_file_lists() @@ -7010,6 +7500,26 @@ def _export_sync_aligned_files(self) -> None: self.statusUpdate.emit(f"Aligned time export complete: {out_dir}", 5000) def _extract_behavior_events(self, info: Dict[str, Any], behavior_name: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + event_behaviors = info.get("event_behaviors") or {} + if behavior_name in event_behaviors: + event_info = event_behaviors.get(behavior_name) or {} + if isinstance(event_info, dict): + on = np.asarray(event_info.get("on", np.array([], float)), float) + off = np.asarray(event_info.get("off", on), float) + dur = np.asarray(event_info.get("dur", np.array([], float)), float) + else: + on = np.asarray(event_info, float) + off = on.copy() + dur = np.full(on.shape, np.nan, dtype=float) + on = on[np.isfinite(on)] + off = off[np.isfinite(off)] + on = np.sort(np.unique(on)) + off = np.sort(np.unique(off)) if off.size else on.copy() + if dur.size != on.size: + m = min(on.size, off.size) + dur = off[:m] - on[:m] if m else np.array([], float) + return on, off, np.asarray(dur, float) + behaviors = info.get("behaviors") or {} if behavior_name not in behaviors: return np.array([], float), np.array([], float), np.array([], float) @@ -9924,6 +10434,24 @@ def _save_project_h5(self, path: str) -> None: ds = behaviors_group.create_dataset(f"item_{b_idx:04d}", data=data, **kwargs) ds.attrs["name"] = str(name) + event_behaviors_group = entry.create_group("event_behaviors") + event_behaviors = source.get("event_behaviors") or {} + for e_idx, (name, event_info) in enumerate(event_behaviors.items()): + event_entry = event_behaviors_group.create_group(f"item_{e_idx:04d}") + event_entry.attrs["name"] = str(name) + if isinstance(event_info, dict): + for attr_name in ("rule", "variable", "align", "source"): + if event_info.get(attr_name) is not None: + event_entry.attrs[attr_name] = str(event_info.get(attr_name)) + self._write_h5_numeric(event_entry, "on", np.asarray(event_info.get("on", np.array([], float)), float)) + self._write_h5_numeric(event_entry, "off", np.asarray(event_info.get("off", np.array([], float)), float)) + self._write_h5_numeric(event_entry, "dur", np.asarray(event_info.get("dur", np.array([], float)), float)) + else: + values = np.asarray(event_info, float) + self._write_h5_numeric(event_entry, "on", values) + self._write_h5_numeric(event_entry, "off", values) + self._write_h5_numeric(event_entry, "dur", np.full(values.shape, np.nan, dtype=float)) + trajectory_group = entry.create_group("trajectory") trajectory = source.get("trajectory") or {} for t_idx, (name, values) in enumerate(trajectory.items()): @@ -10082,6 +10610,7 @@ def _aligned(values: Optional[np.ndarray], fill_nan: bool = True) -> np.ndarray: float, ), "behaviors": {}, + "event_behaviors": {}, "trajectory": {}, "trajectory_time": np.asarray( self._read_h5_numeric(entry, "trajectory_time") @@ -10105,6 +10634,26 @@ def _aligned(values: Optional[np.ndarray], fill_nan: bool = True) -> np.ndarray: name = self._h5_text(ds.attrs.get("name", b_key), b_key) info["behaviors"][name] = np.asarray(ds[()], float) + event_behaviors_group = entry.get("event_behaviors") + if isinstance(event_behaviors_group, h5py.Group): + for e_key in sorted(event_behaviors_group.keys()): + event_entry = event_behaviors_group.get(e_key) + if not isinstance(event_entry, h5py.Group): + continue + name = self._h5_text(event_entry.attrs.get("name", e_key), e_key) + on = self._read_h5_numeric(event_entry, "on") + off = self._read_h5_numeric(event_entry, "off") + dur = self._read_h5_numeric(event_entry, "dur") + info["event_behaviors"][name] = { + "on": np.asarray(on if on is not None else np.array([], float), float), + "off": np.asarray(off if off is not None else np.array([], float), float), + "dur": np.asarray(dur if dur is not None else np.array([], float), float), + "rule": self._h5_text(event_entry.attrs.get("rule", ""), ""), + "variable": self._h5_text(event_entry.attrs.get("variable", ""), ""), + "align": self._h5_text(event_entry.attrs.get("align", ""), ""), + "source": self._h5_text(event_entry.attrs.get("source", stem), stem), + } + trajectory_group = entry.get("trajectory") if isinstance(trajectory_group, h5py.Group): for t_key in sorted(trajectory_group.keys()): @@ -10273,12 +10822,16 @@ def _reset_project_state(self) -> None: self._clear_cached_analysis_outputs() self._processed = [] self._behavior_sources = {} + self._continuous_align_rules = {} self._sync_results_by_file = {} self._last_sync_preview = None self._pending_project_recompute_from_current = False self._dio_cache.clear() + self.receive_dio_list([]) self.lbl_group.setText("(none)") self.lbl_beh.setText("(none)") + if hasattr(self, "lbl_continuous_align_status"): + self.lbl_continuous_align_status.setText("No continuous rule") self.lbl_behavior_msg.setText("") self.lbl_signal_msg.setText("") if hasattr(self, "txt_sync_report"): @@ -10415,6 +10968,7 @@ def _load_project_from_path(self, path: str, from_autosave: bool = False) -> boo self.lbl_beh.setText(f"{len(self._behavior_sources)} file(s) loaded [{mode_label}]") self._update_file_lists() + self._refresh_dio_channels_from_processed() self._refresh_behavior_list() self._refresh_sync_sources() self._set_resample_from_processed() @@ -10665,6 +11219,7 @@ def _collect_settings(self) -> Dict[str, object]: "behavior_from": self.combo_behavior_from.currentText(), "behavior_to": self.combo_behavior_to.currentText(), "transition_gap": float(self.spin_transition_gap.value()), + "continuous_align_rules": copy.deepcopy(self._continuous_align_rules), "window_pre": float(self.spin_pre.value()), "window_post": float(self.spin_post.value()), "baseline_start": float(self.spin_b0.value()), @@ -10788,6 +11343,9 @@ def _set_combo_data(combo: QtWidgets.QComboBox, val: object) -> None: _set_combo(self.combo_behavior_to, data.get("behavior_to")) if "transition_gap" in data: self.spin_transition_gap.setValue(float(data["transition_gap"])) + rules = data.get("continuous_align_rules") + if isinstance(rules, dict): + self._continuous_align_rules = copy.deepcopy(rules) if "window_pre" in data: self.spin_pre.setValue(float(data["window_pre"])) if "window_post" in data: @@ -11411,6 +11969,10 @@ def _get_all_behavior_names(self) -> List[str]: for beh in behaviors: if beh not in names: names.append(beh) + event_behaviors = info.get("event_behaviors") or {} + for beh in event_behaviors: + if beh not in names: + names.append(beh) return names def _compute_psth_for_behavior(self, behavior_name: str) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], List[str]]: diff --git a/pyBer/gui_preprocessing.py b/pyBer/gui_preprocessing.py index 6537033..5770b94 100644 --- a/pyBer/gui_preprocessing.py +++ b/pyBer/gui_preprocessing.py @@ -4,6 +4,8 @@ from typing import Callable, Dict, List, Optional, Tuple import json import os +import subprocess +import sys import numpy as np from PySide6 import QtCore, QtWidgets, QtGui @@ -549,6 +551,8 @@ def __init__(self, parent=None) -> None: def _build_ui(self) -> None: layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(10) table_min_height = 260 auto_group = QtWidgets.QGroupBox("Auto-detected (threshold)") @@ -557,22 +561,31 @@ def _build_ui(self) -> None: QtWidgets.QSizePolicy.Policy.Expanding, ) auto_layout = QtWidgets.QVBoxLayout(auto_group) + auto_layout.setContentsMargins(8, 16, 8, 8) self.table_auto = QtWidgets.QTableWidget(0, 6) self.table_auto.setMinimumHeight(table_min_height) self.table_auto.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding, ) - self.table_auto.setHorizontalHeaderLabels(["ID", "Remove", "Source", "Core (s)", "Cut start", "Cut end"]) - self.table_auto.horizontalHeader().setStretchLastSection(True) + self.table_auto.setHorizontalHeaderLabels(["ID", "Use", "Src", "Core", "Start", "End"]) + auto_header = self.table_auto.horizontalHeader() + auto_header.setStretchLastSection(False) + auto_header.setHighlightSections(False) + auto_header.setMinimumSectionSize(28) self.table_auto.verticalHeader().setVisible(False) + self.table_auto.verticalHeader().setDefaultSectionSize(26) self.table_auto.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) self.table_auto.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) - self.table_auto.setColumnWidth(0, 42) - self.table_auto.setColumnWidth(1, 72) - self.table_auto.setColumnWidth(2, 66) - self.table_auto.setColumnWidth(3, 132) - self.table_auto.setColumnWidth(4, 82) + self.table_auto.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.table_auto.setAlternatingRowColors(True) + self.table_auto.setShowGrid(False) + self.table_auto.setWordWrap(False) + for col, width in ((0, 36), (1, 44), (2, 48)): + auto_header.setSectionResizeMode(col, QtWidgets.QHeaderView.ResizeMode.Fixed) + self.table_auto.setColumnWidth(col, width) + for col in (3, 4, 5): + auto_header.setSectionResizeMode(col, QtWidgets.QHeaderView.ResizeMode.Stretch) auto_layout.addWidget(self.table_auto, 1) layout.addWidget(auto_group, 1) @@ -582,17 +595,30 @@ def _build_ui(self) -> None: QtWidgets.QSizePolicy.Policy.Expanding, ) manual_layout = QtWidgets.QVBoxLayout(manual_group) + manual_layout.setContentsMargins(8, 16, 8, 8) self.table = QtWidgets.QTableWidget(0, 3) self.table.setMinimumHeight(table_min_height) self.table.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding, ) - self.table.setHorizontalHeaderLabels(["ID", "Start (s)", "End (s)"]) - self.table.horizontalHeader().setStretchLastSection(True) + self.table.setHorizontalHeaderLabels(["ID", "Start", "End"]) + manual_header = self.table.horizontalHeader() + manual_header.setStretchLastSection(False) + manual_header.setHighlightSections(False) + manual_header.setMinimumSectionSize(32) + manual_header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) + manual_header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch) + manual_header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Stretch) + self.table.setColumnWidth(0, 42) self.table.verticalHeader().setVisible(False) + self.table.verticalHeader().setDefaultSectionSize(26) self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) self.table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + self.table.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.table.setAlternatingRowColors(True) + self.table.setShowGrid(False) + self.table.setWordWrap(False) manual_layout.addWidget(self.table) @@ -603,7 +629,8 @@ def _build_ui(self) -> None: ed.setDecimals(3) ed.setRange(-1e9, 1e9) ed.setKeyboardTracking(False) - ed.setMinimumWidth(140) + ed.setMinimumWidth(96) + ed.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) self.btn_add = QtWidgets.QPushButton("Add") self.btn_update = QtWidgets.QPushButton("Update selected") @@ -851,6 +878,7 @@ class FileQueuePanel(QtWidgets.QGroupBox): openFileRequested = QtCore.Signal() openFolderRequested = QtCore.Signal() selectionChanged = QtCore.Signal() + sendToPostprocessingRequested = QtCore.Signal(list) channelChanged = QtCore.Signal(str) triggerChanged = QtCore.Signal(str) @@ -918,6 +946,8 @@ def _build_ui(self) -> None: self.list_files.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) self.list_files.setMinimumHeight(210) self.list_files.setUniformItemSizes(True) + self.list_files.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.list_files.customContextMenuRequested.connect(self._show_file_context_menu) self.btn_remove_file = QtWidgets.QPushButton("Remove selected") self.btn_remove_file.setProperty("class", "blueSecondarySmall") @@ -1148,6 +1178,50 @@ def _remove_selected_files(self) -> None: def _update_remove_button(self) -> None: self.btn_remove_file.setEnabled(len(self.list_files.selectedItems()) > 0) + def _show_file_context_menu(self, pos: QtCore.QPoint) -> None: + item = self.list_files.itemAt(pos) + if item is not None and not item.isSelected(): + self.list_files.clearSelection() + item.setSelected(True) + self.list_files.setCurrentItem(item) + paths = self.selected_paths() + if not paths: + return + + menu = QtWidgets.QMenu(self) + act_reveal = menu.addAction("Reveal in Explorer") + act_send = menu.addAction("Load in postprocessing") + menu.addSeparator() + act_remove = menu.addAction("Remove from list") + chosen = menu.exec(self.list_files.viewport().mapToGlobal(pos)) + if chosen is act_reveal: + self._reveal_path_in_file_manager(paths[0]) + elif chosen is act_send: + self.sendToPostprocessingRequested.emit(paths) + elif chosen is act_remove: + self._remove_selected_files() + + def _reveal_path_in_file_manager(self, path: str) -> None: + target = os.path.normpath(str(path or "")) + if not target: + return + folder = target if os.path.isdir(target) else os.path.dirname(target) + try: + if sys.platform.startswith("win"): + if os.path.exists(target): + subprocess.Popen(["explorer", "/select,", target]) + elif folder and os.path.isdir(folder): + subprocess.Popen(["explorer", folder]) + return + if sys.platform == "darwin" and os.path.exists(target): + subprocess.Popen(["open", "-R", target]) + return + if folder and os.path.isdir(folder): + QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(folder)) + except Exception: + if folder and os.path.isdir(folder): + QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(folder)) + class SectionParamsDialog(QtWidgets.QDialog): def __init__(self, params: ProcessingParams, parent=None) -> None: diff --git a/pyBer/main.py b/pyBer/main.py index 401203d..6fce0a9 100644 --- a/pyBer/main.py +++ b/pyBer/main.py @@ -1750,6 +1750,7 @@ def _build_ui(self) -> None: self.file_panel.advancedOptionsRequested.connect(self._open_advanced_options) self.file_panel.qcRequested.connect(self._run_qc_dialog) self.file_panel.batchQcRequested.connect(self._run_batch_qc) + self.file_panel.sendToPostprocessingRequested.connect(self._send_preprocessing_paths_to_postprocessing) # Parameters: changes and actions self.param_panel.paramsChanged.connect(self._on_params_changed) @@ -2411,11 +2412,16 @@ def _update_pre_drawer_visibility(self) -> None: sizes = splitter.sizes() if len(sizes) >= 2: if any_checked: - if sizes[0] < 60: - total = sum(sizes) or 1 - drawer_w = max(420, int(total * 0.28)) + total = sum(sizes) or 1 + if active_key == "artifacts": + target_w = max(520, int(total * 0.34)) + else: + target_w = max(420, int(total * 0.28)) + drawer_w = min(target_w, max(420, total - 640)) + if sizes[0] < 60 or (active_key == "artifacts" and sizes[0] < drawer_w - 20): + delta_w = max(0, drawer_w - max(0, sizes[0])) sizes[0] = drawer_w - sizes[1] = max(400, sizes[1] - drawer_w) + sizes[1] = max(400, sizes[1] - delta_w) splitter.setSizes(sizes) else: if sizes[0] > 0: @@ -5725,6 +5731,11 @@ def _on_main_tab_changed(self, index: int) -> None: _LOG.exception("Failed to handle main tab switch") finally: self._restore_window_state_after_tab_switch(was_fullscreen, was_maximized) + try: + if self.tabs.currentWidget() is self.post_tab: + QtCore.QTimer.singleShot(0, self._post_get_current_dio_list) + except Exception: + pass self._handling_main_tab_change = False if self._force_fixed_dock_layouts: QtCore.QTimer.singleShot(0, self._enforce_fixed_layout_for_active_tab) @@ -5962,6 +5973,7 @@ def _on_file_selection_changed(self) -> None: self._current_channel = None self._current_trigger = None self.plots.set_title("No file loaded") + self._post_get_current_dio_list() self._update_plot_status() return @@ -5998,6 +6010,7 @@ def _on_file_selection_changed(self) -> None: # update post tab selection context self.post_tab.set_current_source_label(os.path.basename(path), self._current_channel or "") + self._post_get_current_dio_list() self._update_plot_status() def _on_channel_changed(self, ch: str) -> None: @@ -7306,19 +7319,32 @@ def _export_one(proc: ProcessedTrial, suffix: str = "") -> None: # ---------------- Postprocessing bridge ---------------- - @QtCore.Slot() - def _post_get_current_processed(self): - # Determine selection context: if multiple selected, provide multiple processed outputs if available - paths = self._selected_paths() - if not paths: - paths = [self._current_path] if self._current_path else [] + def _postprocessing_bridge_paths(self, paths: Optional[List[str]] = None) -> List[str]: + raw_paths = [str(p or "") for p in (paths or []) if str(p or "")] + if not raw_paths: + raw_paths = self._selected_paths() + if not raw_paths: + raw_paths = [self._current_path] if self._current_path else [] + out: List[str] = [] + seen: set[str] = set() + for path in raw_paths: + if not path or path in seen: + continue + seen.add(path) + out.append(path) + return out + def _processed_trials_for_postprocessing_paths(self, paths: List[str]) -> List[ProcessedTrial]: out: List[ProcessedTrial] = [] + try: + params = self.param_panel.get_params() + except Exception: + params = ProcessingParams() + start_s, end_s = self._time_window_bounds() for p in paths: doric = self._loaded_files.get(p) if not doric: continue - # Use current channel when available for all selected files if self._current_channel and self._current_channel in doric.channels: ch = self._current_channel else: @@ -7326,45 +7352,69 @@ def _post_get_current_processed(self): key = (p, ch) if key in self._last_processed: out.append(self._last_processed[key]) - else: - # compute on-demand (fast due to decimation), using current params - try: - params = self.param_panel.get_params() - trial = doric.make_trial(ch, trigger_name=self._current_trigger) - trial = self._apply_time_window(trial) - start_s, end_s = self._time_window_bounds() - manual = self._clip_regions_to_window(self._manual_regions_by_key.get(key, []), start_s, end_s) - manual_exclude = self._clip_regions_to_window(self._manual_exclude_by_key.get(key, []), start_s, end_s) - proc = self.processor.process_trial( - trial, - params, - manual_regions_sec=manual, - manual_exclude_regions_sec=manual_exclude, - preview_mode=False, - ) - cutouts = self._cutout_regions_by_key.get(key, []) - proc = self._apply_cutouts_to_processed(proc, cutouts) - self._last_processed[key] = proc - out.append(proc) - except Exception: - pass - - self.post_tab.receive_current_processed(out) - - @QtCore.Slot() - def _post_get_current_dio_list(self): - # Analog/digital channel list for current/selected files: union. - paths = self._selected_paths() - if not paths: - paths = [self._current_path] if self._current_path else [] + continue + try: + trial = doric.make_trial(ch, trigger_name=self._current_trigger) + trial = self._apply_time_window(trial) + manual = self._clip_regions_to_window(self._manual_regions_by_key.get(key, []), start_s, end_s) + manual_exclude = self._clip_regions_to_window(self._manual_exclude_by_key.get(key, []), start_s, end_s) + proc = self.processor.process_trial( + trial, + params, + manual_regions_sec=manual, + manual_exclude_regions_sec=manual_exclude, + preview_mode=False, + ) + cutouts = self._cutout_regions_by_key.get(key, []) + proc = self._apply_cutouts_to_processed(proc, cutouts) + self._last_processed[key] = proc + out.append(proc) + except Exception: + pass + return out - dio = set() + def _send_dio_list_for_paths_to_postprocessing(self, paths: List[str]) -> None: + dio: set[str] = set() for p in paths: f = self._loaded_files.get(p) if f: dio |= set(f.trigger_by_name.keys()) self.post_tab.receive_dio_list(sorted(dio)) + @QtCore.Slot(list) + def _send_preprocessing_paths_to_postprocessing(self, paths: List[str]) -> None: + source_paths = self._postprocessing_bridge_paths(paths) + processed = self._processed_trials_for_postprocessing_paths(source_paths) + if not processed: + self._show_status_message("No selected preprocessing file could be loaded into postprocessing.", 6000) + return + self.post_tab.receive_current_processed(processed) + self._send_dio_list_for_paths_to_postprocessing(source_paths) + first = processed[0] + self.post_tab.set_current_source_label( + os.path.basename(getattr(first, "path", "") or ""), + str(getattr(first, "channel_id", "") or ""), + ) + try: + idx = self.tabs.indexOf(self.post_tab) + if idx >= 0: + self.tabs.setCurrentIndex(idx) + except Exception: + pass + self._show_status_message(f"Loaded {len(processed)} preprocessing file(s) into postprocessing.", 6000) + + @QtCore.Slot() + def _post_get_current_processed(self): + paths = self._postprocessing_bridge_paths() + out = self._processed_trials_for_postprocessing_paths(paths) + self.post_tab.receive_current_processed(out) + self._send_dio_list_for_paths_to_postprocessing(paths) + + @QtCore.Slot() + def _post_get_current_dio_list(self): + paths = self._postprocessing_bridge_paths() + self._send_dio_list_for_paths_to_postprocessing(paths) + @QtCore.Slot(str, str) def _post_get_dio_data_for_path(self, path: str, dio_name: str): """