From 0b52d2b0bcda9d53059904462c02873417dbb317 Mon Sep 17 00:00:00 2001 From: tyler Date: Mon, 3 Aug 2026 00:46:18 -0700 Subject: [PATCH] more metadata --- changelog.md | 8 +- main.py | 268 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 174 insertions(+), 102 deletions(-) diff --git a/changelog.md b/changelog.md index fd1a820..c0b6cbe 100644 --- a/changelog.md +++ b/changelog.md @@ -1,12 +1,18 @@ # Verison 1.6.0 -- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility +- This is potentially a save-changing release due to adding more data into the save file and renaming existing data. Please update your project files to ensure compatibility - It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary - Optimized calculations being performed when calculating the heart rate to speed up step 5 by up to ~35% on a per-file basis - Optimized calculations being performed when running the General Linear Model to speed up step 5 by ~35% on a per-file basis - Fixed an issue where participants could be skipped when processing multiple particants at one which could prevent overall processing from completing - Fixed the two Group Stats Viewer windows crashing the application once opened when JSON_LOCATION was not set - The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set +- Changed the warning message of lots of short channels to be percentage based (25%) instead of numerical based (6) +- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant +- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values +- Added a new metadata value of HAND to allow for grouping participants based on their handiness +- Updated the layout of the participant metadata area to better accommodate current and future metadata values +- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accomidate the new metadata values # Version 1.5.2 diff --git a/main.py b/main.py index 369f4a1..d4797c3 100644 --- a/main.py +++ b/main.py @@ -34,7 +34,7 @@ from PySide6.QtWidgets import ( QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem ) from PySide6.QtCore import QThread, Signal, Qt, QTimer, QPoint -from PySide6.QtGui import QAction, QKeySequence, QIcon +from PySide6.QtGui import QAction, QFontMetrics, QKeySequence, QIcon from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen from src.window.about import AboutWindow @@ -333,8 +333,11 @@ DATA_SCHEMA = [ {"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"} ] - - +BIDS_FIELD_MAP = { + "BIDS - Age": "AGE", + "BIDS - Sex": "SEX", + "BIDS - Hand": "HAND", +} @@ -378,30 +381,49 @@ class SavingOverlay(QDialog): class GroupAssignmentDialog(QDialog): """Dialog allowing users to create groups and assign unique metadata values to them.""" - def __init__(self, parent=None, unique_values=None, field_name="AGE"): + def __init__(self, parent=None, file_metadata: dict | None = None, field_names: list[str] | None = None): super().__init__(parent) - self.setWindowTitle(f"Assign Groups by {field_name}") + self.setWindowTitle(f"Assign Groups by Metadata - {APP_NAME.upper()}") self.resize(520, 420) - self.unique_values = unique_values or [] - self.field_name = field_name + self.file_metadata = file_metadata or {} + + self.field_names = [ + f for f in (field_names or []) + if self._unique_values_for(f) + ] - # Initial default groups self.groups = ["Group A", "Group B"] self.combos = [] + self.unique_values = [] self._init_ui() + def _unique_values_for(self, field_name: str) -> list[str]: + return sorted( + { + str(meta.get(field_name, "")).strip() + for meta in self.file_metadata.values() + if str(meta.get(field_name, "")).strip() + } + ) + def _init_ui(self): layout = QVBoxLayout(self) - # Header info - info_label = QLabel( - f"Found {len(self.unique_values)} unique {self.field_name} value(s) " - f"across loaded files.
Create custom group names and assign each value below:" - ) - info_label.setWordWrap(True) - layout.addWidget(info_label) + # Field selector + field_box_layout = QHBoxLayout() + field_box_layout.addWidget(QLabel("Group by:")) + self.field_selector = QComboBox() + self.field_selector.addItems(self.field_names) + self.field_selector.currentTextChanged.connect(self._on_field_changed) + field_box_layout.addWidget(self.field_selector) + layout.addLayout(field_box_layout) + + # Header info (updated dynamically in _on_field_changed) + self.info_label = QLabel() + self.info_label.setWordWrap(True) + layout.addWidget(self.info_label) # Group Creation Bar group_box_layout = QHBoxLayout() @@ -419,26 +441,10 @@ class GroupAssignmentDialog(QDialog): layout.addLayout(group_box_layout) # Mapping Table (Values -> Group Dropdown) - self.table = QTableWidget(len(self.unique_values), 2) - self.table.setHorizontalHeaderLabels( - [f"{self.field_name} Value", "Assigned Group"] - ) + self.table = QTableWidget(0, 2) self.table.horizontalHeader().setSectionResizeMode( QHeaderView.ResizeMode.Stretch ) - - for row, val in enumerate(self.unique_values): - # Metadata Value Column (Read-Only) - val_item = QTableWidgetItem(str(val)) - val_item.setFlags(val_item.flags() ^ Qt.ItemFlag.ItemIsEditable) - self.table.setItem(row, 0, val_item) - - # Assigned Group Column (ComboBox) - combo = QComboBox() - self.combos.append(combo) - self.table.setCellWidget(row, 1, combo) - - self._refresh_combos() layout.addWidget(self.table) # Dialog Buttons @@ -450,35 +456,62 @@ class GroupAssignmentDialog(QDialog): buttons.rejected.connect(self.reject) layout.addWidget(buttons) - @classmethod - def run_for_field(cls, parent, file_metadata: dict, field_name: str = "AGE"): - """Extracts values, checks for data, presents the dialog, and returns mappings.""" - # A. Collect unique non-empty values - unique_values = sorted( - list( - { - str(meta.get(field_name, "")).strip() - for meta in file_metadata.values() - if str(meta.get(field_name, "")).strip() - } - ) + # Populate for whichever field is selected first + if self.field_names: + self._rebuild_table(self.field_names[0]) + + def _on_field_changed(self, field_name: str): + if field_name: + self._rebuild_table(field_name) + + def _rebuild_table(self, field_name: str): + """Rebuilds the value table for the newly-selected field. Any group + names created so far (self.groups) are kept - only the per-value + rows and their combo selections reset, since a value from one + field has no meaningful mapping to a value from another.""" + self.field_name = field_name + self.unique_values = self._unique_values_for(field_name) + + self.info_label.setText( + f"Found {len(self.unique_values)} unique {field_name} value(s) " + f"across loaded files.
Create custom group names and assign each value below:" ) - if not unique_values: + self.table.setRowCount(len(self.unique_values)) + self.table.setHorizontalHeaderLabels([f"{field_name} Value", "Assigned Group"]) + self.combos = [] + + for row, val in enumerate(self.unique_values): + val_item = QTableWidgetItem(str(val)) + val_item.setFlags(val_item.flags() ^ Qt.ItemFlag.ItemIsEditable) + self.table.setItem(row, 0, val_item) + + combo = QComboBox() + self.combos.append(combo) + self.table.setCellWidget(row, 1, combo) + + self._refresh_combos() + + @classmethod + def run(cls, parent, file_metadata: dict, field_names: list[str] = ["AGE", "SEX", "HAND"]): + """Checks for groupable data across the given fields, presents the + dialog with a field-selector dropdown, and returns (field_name, mappings) + for whichever field the user grouped by.""" + dialog = cls(parent, file_metadata=file_metadata, field_names=field_names) + + if not dialog.field_names: QMessageBox.information( parent, "No Groupable Metadata", - f"No '{field_name}' values were found in the metadata to group.", + f"None of {field_names} had values found in the metadata to group.", ) return None - # B. Instantiate and show dialog - dialog = cls(parent, unique_values=unique_values, field_name=field_name) if dialog.exec() == QDialog.DialogCode.Accepted: - return dialog.get_mappings() + return dialog.field_name, dialog.get_mappings() return None - + def _add_group(self): """Adds a new group to the available options.""" name = self.group_input.text().strip() @@ -495,7 +528,6 @@ class GroupAssignmentDialog(QDialog): combo.addItem("-- Select Group --") combo.addItems(self.groups) - # Preserve previous selection if it still exists if current_selection in self.groups: combo.setCurrentText(current_selection) @@ -684,7 +716,7 @@ class MainApplication(QMainWindow): for item in DATA_SCHEMA: setattr(self, item["key"], {}) - self.file_metadata = {} # AGE, GENDER, GROUP + self.file_metadata = {} # AGE, SEX, HAND, GROUP self.metadata_cache = {} # Internal file/path information metadata cache self.bubble_widgets = {} # References to the UI "Bubble" objects self.current_file = None # Tracks the currently selected absolute path @@ -778,16 +810,25 @@ class MainApplication(QMainWindow): self.right_column_widget = QWidget() right_column_layout = QVBoxLayout(self.right_column_widget) - self.meta_fields = {"AGE": QLineEdit(), "GENDER": QLineEdit(), "GROUP": QLineEdit()} + self.meta_fields = {"AGE": QLineEdit(), "SEX": QLineEdit(), "HAND": QLineEdit(), "GROUP": QLineEdit()} + font_metrics = QFontMetrics(self.font()) + label_width = max(font_metrics.horizontalAdvance(key.capitalize()) for key in self.meta_fields) + 10 + for key, field in self.meta_fields.items(): - label = QLabel(key.capitalize()) - right_column_layout.addWidget(label) - right_column_layout.addWidget(field) + row_layout = QHBoxLayout() + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(0) + + label = QLabel(key.capitalize() + ":") + label.setFixedWidth(label_width) + row_layout.addWidget(label) + row_layout.addWidget(field) + right_column_layout.addLayout(row_layout) field.textChanged.connect(self.sync_bubble_data) label_desc = QLabel('Why are these useful?') label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF.\nGender: Not currently used or implemented.\nGroup: Used to split participants into groups for comparisons between them.")) + label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF. Also used to assist in creating groups.\nGender: Used to assist in creating groups.\nHand: Used to assist in creating groups.\nGroup: Used to split participants into groups for comparisons between them.")) right_column_layout.addWidget(label_desc) right_column_layout.addStretch() self.right_column_widget.hide() @@ -1336,11 +1377,12 @@ class MainApplication(QMainWindow): if self.regroup_metadata is None or not self.regroup_metadata.isVisible(): file_meta = getattr(self, "file_metadata", {}) if any(bool(meta) for meta in file_meta.values()): - mappings = GroupAssignmentDialog.run_for_field( - self, file_meta, field_name="AGE" + result = GroupAssignmentDialog.run( + self, file_meta, field_names=list(BIDS_FIELD_MAP.values()) ) - if mappings: - self._apply_group_mappings(mappings, field_name="AGE") + if result: + field_name, mappings = result + self._apply_group_mappings(mappings, field_name=field_name) else: QMessageBox.information( None, @@ -1784,12 +1826,13 @@ class MainApplication(QMainWindow): old_cfg = self.config_dict[abs_path] self.file_metadata[abs_path] = { "AGE": str(old_cfg.get("AGE", "")), - "GENDER": str(old_cfg.get("GENDER", "")), + "SEX": str(old_cfg.get("SEX", "")), + "HAND": str(old_cfg.get("HAND", "")), "GROUP": str(old_cfg.get("GROUP", "")) } else: # Scenario C: Empty default - self.file_metadata[abs_path] = {"AGE": "", "GENDER": "", "GROUP": ""} + self.file_metadata[abs_path] = {"AGE": "", "SEX": "", "HAND": "", "GROUP": ""} self.show_files_as_bubbles_from_list(file_list, progress_states, filename) @@ -1968,7 +2011,7 @@ class MainApplication(QMainWindow): meta = self.file_metadata[file_path] parts = [] - for key in ["AGE", "GENDER", "GROUP"]: + for key in ["AGE", "SEX", "HAND", "GROUP"]: value = meta.get(key, "").strip() if value: parts.append(f"{key}: {value}") @@ -2027,7 +2070,7 @@ class MainApplication(QMainWindow): self.last_clicked_bubble = bubble - # show age / gender / group + # show age / sex / hand / group self.right_column_widget.show() file_path = bubble.file_path @@ -2609,14 +2652,12 @@ class MainApplication(QMainWindow): # If it's a successful extraction, it won't have 'status' set yet elif 'status' not in result: - # Wrap the raw extraction dictionary into our unified UI format - extracted_age = result.get('age', '') - - # Save AGE directly into file_metadata tracking if file_path not in self.file_metadata: self.file_metadata[file_path] = {} - if extracted_age: - self.file_metadata[file_path]['AGE'] = extracted_age + for display_field, internal_key in BIDS_FIELD_MAP.items(): + val = result.get(display_field, '') + if val: + self.file_metadata[file_path][internal_key] = val result = {'status': 'success', 'data': result} @@ -2646,16 +2687,17 @@ class MainApplication(QMainWindow): norm_path = os.path.normpath(file_path) self.metadata_cache[norm_path] = data - # 3. Store extracted BIDS age into file_metadata store + # 3. Store extracted BIDS data into file_metadata store if not hasattr(self, 'file_metadata'): self.file_metadata = {} if norm_path not in self.file_metadata: self.file_metadata[norm_path] = {} # 1. Store extracted BIDS metadata into file_metadata map - extracted_age = data.get('age', '') - if extracted_age: - self.file_metadata[norm_path]['AGE'] = str(extracted_age) + for display_field, internal_key in BIDS_FIELD_MAP.items(): + val = data.get(display_field, '') + if val: + self.file_metadata[norm_path][internal_key] = str(val) # 4. Update ONLY the text suffix on the bubble (spinner stays active!) bubble = self.bubble_widgets.get(norm_path) or self.bubble_widgets.get(file_path) @@ -2687,7 +2729,7 @@ class MainApplication(QMainWindow): norm_path, self.file_metadata.get(file_path, {}) ) - # Loop through all dynamically created fields (AGE, GENDER, GROUP, etc.) + # Loop through all dynamically created fields (AGE, SEX, HAND, GROUP, etc.) for key, line_edit in self.meta_fields.items(): val = str(meta.get(key, "")).strip() @@ -2747,14 +2789,13 @@ class MainApplication(QMainWindow): ) if reply == QMessageBox.StandardButton.Yes: - mappings = GroupAssignmentDialog.run_for_field( - self, file_meta, field_name="AGE" - ) - if mappings: - self._apply_group_mappings(mappings, field_name="AGE") + result = GroupAssignmentDialog.run(self, file_meta, field_names=list(BIDS_FIELD_MAP.values())) + if result: + field_name, mappings = result + self._apply_group_mappings(mappings, field_name=field_name) - def _apply_group_mappings(self, mappings: dict, field_name: str = "AGE"): + def _apply_group_mappings(self, mappings: dict, field_name: str = ""): """Applies group mappings to metadata and updates all UI widgets.""" # C. Update 'GROUP' in self.file_metadata for matching files for path_key, meta in self.file_metadata.items(): @@ -2784,17 +2825,35 @@ class MainApplication(QMainWindow): print("[System] Background worker dismissed. RAM reclaimed.") -def _get_bids_age(snirf_path: str) -> str: - """Traverses the path of a SNIRF file to extract age from BIDS TSV files.""" +def _get_bids_demographics(snirf_path: str) -> dict[str, str]: + """Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files. + 'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) - + many datasets leave it unset/inapplicable, so surfacing 'n/a' explicitly just adds noise. + """ path = Path(snirf_path) - + fields = ["age", "sex", "hand"] + # Extract sub-XX and ses-YY labels from the path sub_id = next((part for part in path.parts if part.startswith("sub-")), None) ses_id = next((part for part in path.parts if part.startswith("ses-")), None) - + if not sub_id: - return "" - + return {} + + def _row_to_dict(row) -> dict[str, str]: + result = {} + for field in fields: + if field not in row: + continue + val = row[field] + if pd.isna(val): + continue + val_str = str(val).strip() + if field == "hand" and val_str.lower() in ("n/a", "na", ""): + continue + result[field] = val_str + return result + # 1. Look for sub-/sub-_sessions.tsv sub_dir = next((p for p in path.parents if p.name == sub_id), None) if sub_dir and ses_id: @@ -2802,12 +2861,11 @@ def _get_bids_age(snirf_path: str) -> str: if sessions_tsv.exists(): try: df = pd.read_csv(sessions_tsv, sep="\t") - # Handle session_id matching whether it includes 'ses-' or not matching = df[df["session_id"].astype(str).str.replace("ses-", "") == ses_id.replace("ses-", "")] - if not matching.empty and "age" in matching.columns: - val = matching.iloc[0]["age"] - if pd.notna(val): - return str(val) + if not matching.empty: + result = _row_to_dict(matching.iloc[0]) + if result: + return result except Exception: pass @@ -2819,14 +2877,14 @@ def _get_bids_age(snirf_path: str) -> str: try: df = pd.read_csv(participants_tsv, sep="\t") matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")] - if not matching.empty and "age" in matching.columns: - val = matching.iloc[0]["age"] - if pd.notna(val): - return str(val) + if not matching.empty: + result = _row_to_dict(matching.iloc[0]) + if result: + return result except Exception: pass - - return "" + + return {} def _extract_metadata_worker(file_name): @@ -2847,7 +2905,9 @@ def _extract_metadata_worker(file_name): short_chans = get_short_channels(raw, max_dist=0.015) names = list(short_chans.ch_names) snirf_info['Short Channels'] = f"Likely - {names}" - if len(names) > 6: + total_chans = len(raw.ch_names) + pct_short = (len(names) / total_chans * 100) if total_chans else 0 + if pct_short > 25: snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!" except: snirf_info['Short Channels'] = "Unlikely" @@ -2876,7 +2936,13 @@ def _extract_metadata_worker(file_name): else: snirf_info['Annotations'] = "No annotations found" - snirf_info["age"] = _get_bids_age(file_name) + demographics = _get_bids_demographics(file_name) + if "age" in demographics: + snirf_info["BIDS - Age"] = demographics["age"] + if "sex" in demographics: + snirf_info["BIDS - Sex"] = demographics["sex"] + if "hand" in demographics: + snirf_info["BIDS - Handedness"] = demographics["hand"] return snirf_info except Exception as e: