more metadata

This commit is contained in:
2026-08-03 00:46:18 -07:00
parent 7c456e31e7
commit 0b52d2b0bc
2 changed files with 174 additions and 102 deletions
+7 -1
View File
@@ -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
+167 -101
View File
@@ -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 <b>{len(self.unique_values)}</b> unique <i>{self.field_name}</i> value(s) "
f"across loaded files.<br>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 <b>{len(self.unique_values)}</b> unique <i>{field_name}</i> value(s) "
f"across loaded files.<br>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('<a href="#">Why are these useful?</a>')
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-<id>/sub-<id>_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: