more metadata
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user