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: