more metadata
This commit is contained in:
+7
-1
@@ -1,12 +1,18 @@
|
|||||||
# Verison 1.6.0
|
# 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
|
- 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 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
|
- 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 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
|
- 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
|
- 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
|
# Version 1.5.2
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from PySide6.QtWidgets import (
|
|||||||
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
|
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QPoint
|
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 PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
||||||
|
|
||||||
from src.window.about import AboutWindow
|
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"}
|
{"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):
|
class GroupAssignmentDialog(QDialog):
|
||||||
"""Dialog allowing users to create groups and assign unique metadata values to them."""
|
"""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)
|
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.resize(520, 420)
|
||||||
|
|
||||||
self.unique_values = unique_values or []
|
self.file_metadata = file_metadata or {}
|
||||||
self.field_name = field_name
|
|
||||||
|
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.groups = ["Group A", "Group B"]
|
||||||
self.combos = []
|
self.combos = []
|
||||||
|
self.unique_values = []
|
||||||
|
|
||||||
self._init_ui()
|
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):
|
def _init_ui(self):
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# Header info
|
# Field selector
|
||||||
info_label = QLabel(
|
field_box_layout = QHBoxLayout()
|
||||||
f"Found <b>{len(self.unique_values)}</b> unique <i>{self.field_name}</i> value(s) "
|
field_box_layout.addWidget(QLabel("Group by:"))
|
||||||
f"across loaded files.<br>Create custom group names and assign each value below:"
|
self.field_selector = QComboBox()
|
||||||
)
|
self.field_selector.addItems(self.field_names)
|
||||||
info_label.setWordWrap(True)
|
self.field_selector.currentTextChanged.connect(self._on_field_changed)
|
||||||
layout.addWidget(info_label)
|
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 Creation Bar
|
||||||
group_box_layout = QHBoxLayout()
|
group_box_layout = QHBoxLayout()
|
||||||
@@ -419,26 +441,10 @@ class GroupAssignmentDialog(QDialog):
|
|||||||
layout.addLayout(group_box_layout)
|
layout.addLayout(group_box_layout)
|
||||||
|
|
||||||
# Mapping Table (Values -> Group Dropdown)
|
# Mapping Table (Values -> Group Dropdown)
|
||||||
self.table = QTableWidget(len(self.unique_values), 2)
|
self.table = QTableWidget(0, 2)
|
||||||
self.table.setHorizontalHeaderLabels(
|
|
||||||
[f"{self.field_name} Value", "Assigned Group"]
|
|
||||||
)
|
|
||||||
self.table.horizontalHeader().setSectionResizeMode(
|
self.table.horizontalHeader().setSectionResizeMode(
|
||||||
QHeaderView.ResizeMode.Stretch
|
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)
|
layout.addWidget(self.table)
|
||||||
|
|
||||||
# Dialog Buttons
|
# Dialog Buttons
|
||||||
@@ -450,35 +456,62 @@ class GroupAssignmentDialog(QDialog):
|
|||||||
buttons.rejected.connect(self.reject)
|
buttons.rejected.connect(self.reject)
|
||||||
layout.addWidget(buttons)
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
@classmethod
|
# Populate for whichever field is selected first
|
||||||
def run_for_field(cls, parent, file_metadata: dict, field_name: str = "AGE"):
|
if self.field_names:
|
||||||
"""Extracts values, checks for data, presents the dialog, and returns mappings."""
|
self._rebuild_table(self.field_names[0])
|
||||||
# A. Collect unique non-empty values
|
|
||||||
unique_values = sorted(
|
def _on_field_changed(self, field_name: str):
|
||||||
list(
|
if field_name:
|
||||||
{
|
self._rebuild_table(field_name)
|
||||||
str(meta.get(field_name, "")).strip()
|
|
||||||
for meta in file_metadata.values()
|
def _rebuild_table(self, field_name: str):
|
||||||
if str(meta.get(field_name, "")).strip()
|
"""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(
|
QMessageBox.information(
|
||||||
parent,
|
parent,
|
||||||
"No Groupable Metadata",
|
"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
|
return None
|
||||||
|
|
||||||
# B. Instantiate and show dialog
|
|
||||||
dialog = cls(parent, unique_values=unique_values, field_name=field_name)
|
|
||||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
return dialog.get_mappings()
|
return dialog.field_name, dialog.get_mappings()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _add_group(self):
|
def _add_group(self):
|
||||||
"""Adds a new group to the available options."""
|
"""Adds a new group to the available options."""
|
||||||
name = self.group_input.text().strip()
|
name = self.group_input.text().strip()
|
||||||
@@ -495,7 +528,6 @@ class GroupAssignmentDialog(QDialog):
|
|||||||
combo.addItem("-- Select Group --")
|
combo.addItem("-- Select Group --")
|
||||||
combo.addItems(self.groups)
|
combo.addItems(self.groups)
|
||||||
|
|
||||||
# Preserve previous selection if it still exists
|
|
||||||
if current_selection in self.groups:
|
if current_selection in self.groups:
|
||||||
combo.setCurrentText(current_selection)
|
combo.setCurrentText(current_selection)
|
||||||
|
|
||||||
@@ -684,7 +716,7 @@ class MainApplication(QMainWindow):
|
|||||||
for item in DATA_SCHEMA:
|
for item in DATA_SCHEMA:
|
||||||
setattr(self, item["key"], {})
|
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.metadata_cache = {} # Internal file/path information metadata cache
|
||||||
self.bubble_widgets = {} # References to the UI "Bubble" objects
|
self.bubble_widgets = {} # References to the UI "Bubble" objects
|
||||||
self.current_file = None # Tracks the currently selected absolute path
|
self.current_file = None # Tracks the currently selected absolute path
|
||||||
@@ -778,16 +810,25 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
self.right_column_widget = QWidget()
|
self.right_column_widget = QWidget()
|
||||||
right_column_layout = QVBoxLayout(self.right_column_widget)
|
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():
|
for key, field in self.meta_fields.items():
|
||||||
label = QLabel(key.capitalize())
|
row_layout = QHBoxLayout()
|
||||||
right_column_layout.addWidget(label)
|
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
right_column_layout.addWidget(field)
|
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)
|
field.textChanged.connect(self.sync_bubble_data)
|
||||||
|
|
||||||
label_desc = QLabel('<a href="#">Why are these useful?</a>')
|
label_desc = QLabel('<a href="#">Why are these useful?</a>')
|
||||||
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
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.addWidget(label_desc)
|
||||||
right_column_layout.addStretch()
|
right_column_layout.addStretch()
|
||||||
self.right_column_widget.hide()
|
self.right_column_widget.hide()
|
||||||
@@ -1336,11 +1377,12 @@ class MainApplication(QMainWindow):
|
|||||||
if self.regroup_metadata is None or not self.regroup_metadata.isVisible():
|
if self.regroup_metadata is None or not self.regroup_metadata.isVisible():
|
||||||
file_meta = getattr(self, "file_metadata", {})
|
file_meta = getattr(self, "file_metadata", {})
|
||||||
if any(bool(meta) for meta in file_meta.values()):
|
if any(bool(meta) for meta in file_meta.values()):
|
||||||
mappings = GroupAssignmentDialog.run_for_field(
|
result = GroupAssignmentDialog.run(
|
||||||
self, file_meta, field_name="AGE"
|
self, file_meta, field_names=list(BIDS_FIELD_MAP.values())
|
||||||
)
|
)
|
||||||
if mappings:
|
if result:
|
||||||
self._apply_group_mappings(mappings, field_name="AGE")
|
field_name, mappings = result
|
||||||
|
self._apply_group_mappings(mappings, field_name=field_name)
|
||||||
else:
|
else:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
None,
|
None,
|
||||||
@@ -1784,12 +1826,13 @@ class MainApplication(QMainWindow):
|
|||||||
old_cfg = self.config_dict[abs_path]
|
old_cfg = self.config_dict[abs_path]
|
||||||
self.file_metadata[abs_path] = {
|
self.file_metadata[abs_path] = {
|
||||||
"AGE": str(old_cfg.get("AGE", "")),
|
"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", ""))
|
"GROUP": str(old_cfg.get("GROUP", ""))
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Scenario C: Empty default
|
# 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)
|
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]
|
meta = self.file_metadata[file_path]
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
for key in ["AGE", "GENDER", "GROUP"]:
|
for key in ["AGE", "SEX", "HAND", "GROUP"]:
|
||||||
value = meta.get(key, "").strip()
|
value = meta.get(key, "").strip()
|
||||||
if value:
|
if value:
|
||||||
parts.append(f"{key}: {value}")
|
parts.append(f"{key}: {value}")
|
||||||
@@ -2027,7 +2070,7 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
self.last_clicked_bubble = bubble
|
self.last_clicked_bubble = bubble
|
||||||
|
|
||||||
# show age / gender / group
|
# show age / sex / hand / group
|
||||||
self.right_column_widget.show()
|
self.right_column_widget.show()
|
||||||
|
|
||||||
file_path = bubble.file_path
|
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
|
# If it's a successful extraction, it won't have 'status' set yet
|
||||||
|
|
||||||
elif 'status' not in result:
|
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:
|
if file_path not in self.file_metadata:
|
||||||
self.file_metadata[file_path] = {}
|
self.file_metadata[file_path] = {}
|
||||||
if extracted_age:
|
for display_field, internal_key in BIDS_FIELD_MAP.items():
|
||||||
self.file_metadata[file_path]['AGE'] = extracted_age
|
val = result.get(display_field, '')
|
||||||
|
if val:
|
||||||
|
self.file_metadata[file_path][internal_key] = val
|
||||||
|
|
||||||
result = {'status': 'success', 'data': result}
|
result = {'status': 'success', 'data': result}
|
||||||
|
|
||||||
@@ -2646,16 +2687,17 @@ class MainApplication(QMainWindow):
|
|||||||
norm_path = os.path.normpath(file_path)
|
norm_path = os.path.normpath(file_path)
|
||||||
self.metadata_cache[norm_path] = data
|
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'):
|
if not hasattr(self, 'file_metadata'):
|
||||||
self.file_metadata = {}
|
self.file_metadata = {}
|
||||||
if norm_path not in self.file_metadata:
|
if norm_path not in self.file_metadata:
|
||||||
self.file_metadata[norm_path] = {}
|
self.file_metadata[norm_path] = {}
|
||||||
|
|
||||||
# 1. Store extracted BIDS metadata into file_metadata map
|
# 1. Store extracted BIDS metadata into file_metadata map
|
||||||
extracted_age = data.get('age', '')
|
for display_field, internal_key in BIDS_FIELD_MAP.items():
|
||||||
if extracted_age:
|
val = data.get(display_field, '')
|
||||||
self.file_metadata[norm_path]['AGE'] = str(extracted_age)
|
if val:
|
||||||
|
self.file_metadata[norm_path][internal_key] = str(val)
|
||||||
|
|
||||||
# 4. Update ONLY the text suffix on the bubble (spinner stays active!)
|
# 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)
|
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, {})
|
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():
|
for key, line_edit in self.meta_fields.items():
|
||||||
val = str(meta.get(key, "")).strip()
|
val = str(meta.get(key, "")).strip()
|
||||||
|
|
||||||
@@ -2747,14 +2789,13 @@ class MainApplication(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
mappings = GroupAssignmentDialog.run_for_field(
|
result = GroupAssignmentDialog.run(self, file_meta, field_names=list(BIDS_FIELD_MAP.values()))
|
||||||
self, file_meta, field_name="AGE"
|
if result:
|
||||||
)
|
field_name, mappings = result
|
||||||
if mappings:
|
self._apply_group_mappings(mappings, field_name=field_name)
|
||||||
self._apply_group_mappings(mappings, field_name="AGE")
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Applies group mappings to metadata and updates all UI widgets."""
|
||||||
# C. Update 'GROUP' in self.file_metadata for matching files
|
# C. Update 'GROUP' in self.file_metadata for matching files
|
||||||
for path_key, meta in self.file_metadata.items():
|
for path_key, meta in self.file_metadata.items():
|
||||||
@@ -2784,17 +2825,35 @@ class MainApplication(QMainWindow):
|
|||||||
print("[System] Background worker dismissed. RAM reclaimed.")
|
print("[System] Background worker dismissed. RAM reclaimed.")
|
||||||
|
|
||||||
|
|
||||||
def _get_bids_age(snirf_path: str) -> str:
|
def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
||||||
"""Traverses the path of a SNIRF file to extract age from BIDS TSV files."""
|
"""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)
|
path = Path(snirf_path)
|
||||||
|
fields = ["age", "sex", "hand"]
|
||||||
|
|
||||||
# Extract sub-XX and ses-YY labels from the path
|
# Extract sub-XX and ses-YY labels from the path
|
||||||
sub_id = next((part for part in path.parts if part.startswith("sub-")), None)
|
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)
|
ses_id = next((part for part in path.parts if part.startswith("ses-")), None)
|
||||||
|
|
||||||
if not sub_id:
|
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
|
# 1. Look for sub-<id>/sub-<id>_sessions.tsv
|
||||||
sub_dir = next((p for p in path.parents if p.name == sub_id), None)
|
sub_dir = next((p for p in path.parents if p.name == sub_id), None)
|
||||||
if sub_dir and ses_id:
|
if sub_dir and ses_id:
|
||||||
@@ -2802,12 +2861,11 @@ def _get_bids_age(snirf_path: str) -> str:
|
|||||||
if sessions_tsv.exists():
|
if sessions_tsv.exists():
|
||||||
try:
|
try:
|
||||||
df = pd.read_csv(sessions_tsv, sep="\t")
|
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-", "")]
|
matching = df[df["session_id"].astype(str).str.replace("ses-", "") == ses_id.replace("ses-", "")]
|
||||||
if not matching.empty and "age" in matching.columns:
|
if not matching.empty:
|
||||||
val = matching.iloc[0]["age"]
|
result = _row_to_dict(matching.iloc[0])
|
||||||
if pd.notna(val):
|
if result:
|
||||||
return str(val)
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2819,14 +2877,14 @@ def _get_bids_age(snirf_path: str) -> str:
|
|||||||
try:
|
try:
|
||||||
df = pd.read_csv(participants_tsv, sep="\t")
|
df = pd.read_csv(participants_tsv, sep="\t")
|
||||||
matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")]
|
matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")]
|
||||||
if not matching.empty and "age" in matching.columns:
|
if not matching.empty:
|
||||||
val = matching.iloc[0]["age"]
|
result = _row_to_dict(matching.iloc[0])
|
||||||
if pd.notna(val):
|
if result:
|
||||||
return str(val)
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return ""
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _extract_metadata_worker(file_name):
|
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)
|
short_chans = get_short_channels(raw, max_dist=0.015)
|
||||||
names = list(short_chans.ch_names)
|
names = list(short_chans.ch_names)
|
||||||
snirf_info['Short Channels'] = f"Likely - {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!"
|
snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!"
|
||||||
except:
|
except:
|
||||||
snirf_info['Short Channels'] = "Unlikely"
|
snirf_info['Short Channels'] = "Unlikely"
|
||||||
@@ -2876,7 +2936,13 @@ def _extract_metadata_worker(file_name):
|
|||||||
else:
|
else:
|
||||||
snirf_info['Annotations'] = "No annotations found"
|
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
|
return snirf_info
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user