640 lines
24 KiB
Python
640 lines
24 KiB
Python
"""
|
|
Filename: __init__.py
|
|
Description: roi-builder-pack plugin
|
|
|
|
Author: Tyler de Zeeuw
|
|
License: GPL-3.0
|
|
"""
|
|
|
|
# Built-in imports
|
|
import os
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Optional, Any
|
|
|
|
# External library imports
|
|
import h5py
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtGui import QAction
|
|
from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget,
|
|
QMenu, QMessageBox, QPushButton, QSplitter, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,)
|
|
|
|
from mne.io import read_raw_snirf
|
|
from mne.preprocessing.nirs import optical_density, beer_lambert_law
|
|
from flares import fold_channels
|
|
|
|
|
|
class RoiBuilderWidget(QWidget):
|
|
"""UI Widget for opening SNIRF files, grouping channels into ROIs, and exporting JSON files."""
|
|
|
|
NEW_GROUP_OPTION = "+ Create New Group"
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self.setWindowTitle("ROI Channel Builder")
|
|
self.resize(850, 620)
|
|
|
|
self.extracted_channels: list[str] = []
|
|
self._setup_ui()
|
|
|
|
def _setup_ui(self) -> None:
|
|
main_layout = QVBoxLayout(self)
|
|
|
|
file_box = QGroupBox("SNIRF File Selection", self)
|
|
file_layout = QHBoxLayout(file_box)
|
|
|
|
self.txt_file_path = QLineEdit(self)
|
|
self.txt_file_path.setReadOnly(True)
|
|
self.txt_file_path.setPlaceholderText("Select a .snirf file to load channels...")
|
|
|
|
btn_browse = QPushButton("Browse...", self)
|
|
btn_browse.clicked.connect(self._on_browse_snirf)
|
|
|
|
file_layout.addWidget(self.txt_file_path)
|
|
file_layout.addWidget(btn_browse)
|
|
main_layout.addWidget(file_box)
|
|
|
|
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
|
|
|
# Left Column: Extracted Channels
|
|
left_widget = QWidget(self)
|
|
left_layout = QVBoxLayout(left_widget)
|
|
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
left_layout.addWidget(QLabel("<b>Available Channels:</b>", self))
|
|
self.list_channels = QListWidget(self)
|
|
self.list_channels.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
left_layout.addWidget(self.list_channels)
|
|
|
|
# Draft Auto-Grouping Button
|
|
self.btn_auto_group = QPushButton("Auto ROI Grouping", self)
|
|
self.btn_auto_group.setToolTip("Automatically splits available channels via their physical locations.")
|
|
self.btn_auto_group.clicked.connect(self._on_auto_group)
|
|
left_layout.addWidget(self.btn_auto_group)
|
|
|
|
splitter.addWidget(left_widget)
|
|
|
|
# Right Column: Defined ROIs & Inputs
|
|
right_widget = QWidget(self)
|
|
right_layout = QVBoxLayout(right_widget)
|
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Form for selecting or adding ROI
|
|
form_box = QGroupBox("Assign Channels to Group", self)
|
|
form_layout = QVBoxLayout(form_box)
|
|
|
|
self.combo_group_select = QComboBox(self)
|
|
self.combo_group_select.addItem(self.NEW_GROUP_OPTION)
|
|
self.combo_group_select.currentIndexChanged.connect(self._on_group_selection_changed)
|
|
|
|
self.txt_roi_name = QLineEdit(self)
|
|
self.txt_roi_name.setPlaceholderText("ROI Name (e.g., Left_PFC)")
|
|
|
|
self.txt_roi_desc = QLineEdit(self)
|
|
self.txt_roi_desc.setPlaceholderText("Description (e.g., Left prefrontal cortex)")
|
|
|
|
btn_add_roi = QPushButton("Assign Selected Channels to Group", self)
|
|
btn_add_roi.setStyleSheet("font-weight: bold;")
|
|
btn_add_roi.clicked.connect(self._on_assign_channels)
|
|
|
|
form_layout.addWidget(QLabel("Target Group:"))
|
|
form_layout.addWidget(self.combo_group_select)
|
|
form_layout.addWidget(self.txt_roi_name)
|
|
form_layout.addWidget(self.txt_roi_desc)
|
|
form_layout.addWidget(btn_add_roi)
|
|
|
|
right_layout.addWidget(form_box)
|
|
|
|
# ROI Tree View
|
|
right_layout.addWidget(QLabel("<b>Defined Regions of Interest:</b>", self))
|
|
self.tree_rois = QTreeWidget(self)
|
|
self.tree_rois.setHeaderLabels(["ROI / Channel", "Description"])
|
|
self.tree_rois.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
self.tree_rois.header().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
right_layout.addWidget(self.tree_rois)
|
|
|
|
# Unassign & Remove Buttons
|
|
btn_layout = QHBoxLayout()
|
|
btn_unassign_channel = QPushButton("Unassign Selected Channel(s)", self)
|
|
btn_unassign_channel.clicked.connect(self._on_unassign_channels)
|
|
|
|
btn_remove_roi = QPushButton("Remove Selected Group", self)
|
|
btn_remove_roi.clicked.connect(self._on_remove_roi)
|
|
|
|
btn_layout.addWidget(btn_unassign_channel)
|
|
btn_layout.addWidget(btn_remove_roi)
|
|
right_layout.addLayout(btn_layout)
|
|
|
|
splitter.addWidget(right_widget)
|
|
splitter.setSizes([280, 570])
|
|
|
|
main_layout.addWidget(splitter)
|
|
|
|
bottom_layout = QHBoxLayout()
|
|
self.lbl_status = QLabel("Ready.", self)
|
|
|
|
btn_export = QPushButton("Export ROI JSON...", self)
|
|
btn_export.setStyleSheet("font-weight: bold;")
|
|
btn_export.clicked.connect(self._on_export_json)
|
|
|
|
bottom_layout.addWidget(self.lbl_status)
|
|
bottom_layout.addStretch()
|
|
bottom_layout.addWidget(btn_export)
|
|
|
|
main_layout.addLayout(bottom_layout)
|
|
|
|
def _add_channels_to_available_list(self, channels: list[str]) -> None:
|
|
"""Restores channels to the available list and keeps them sorted."""
|
|
existing = [self.list_channels.item(i).text() for i in range(self.list_channels.count())]
|
|
combined = set(existing).union(channels)
|
|
sorted_channels = sorted(list(combined), key=parse_sd_key)
|
|
|
|
self.list_channels.clear()
|
|
for ch in sorted_channels:
|
|
self.list_channels.addItem(ch)
|
|
|
|
def _find_roi_item_by_name(self, name: str) -> QTreeWidgetItem | None:
|
|
"""Finds an existing top-level tree item matching the group name."""
|
|
for i in range(self.tree_rois.topLevelItemCount()):
|
|
item = self.tree_rois.topLevelItem(i)
|
|
if item:
|
|
data = item.data(0, Qt.ItemDataRole.UserRole)
|
|
if data and data.get("name", "").strip().lower() == name.strip().lower():
|
|
return item
|
|
return None
|
|
|
|
|
|
def _on_group_selection_changed(self, index: int) -> None:
|
|
selected_text = self.combo_group_select.currentText()
|
|
|
|
if selected_text == self.NEW_GROUP_OPTION:
|
|
self.txt_roi_name.clear()
|
|
self.txt_roi_desc.clear()
|
|
self.txt_roi_name.setReadOnly(False)
|
|
else:
|
|
roi_item = self._find_roi_item_by_name(selected_text)
|
|
if roi_item:
|
|
data = roi_item.data(0, Qt.ItemDataRole.UserRole)
|
|
self.txt_roi_name.setText(data.get("name", ""))
|
|
self.txt_roi_desc.setText(data.get("description", ""))
|
|
self.txt_roi_name.setReadOnly(True)
|
|
|
|
def _on_browse_snirf(self) -> None:
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
self, "Select SNIRF File", "", "SNIRF Files (*.snirf *.h5);;All Files (*)"
|
|
)
|
|
if not file_path:
|
|
return
|
|
|
|
self.txt_file_path.setText(file_path)
|
|
try:
|
|
self.extracted_channels = extract_snirf_channels(file_path)
|
|
self.tree_rois.clear()
|
|
self.list_channels.clear()
|
|
|
|
# Reset combo box
|
|
self.combo_group_select.blockSignals(True)
|
|
self.combo_group_select.clear()
|
|
self.combo_group_select.addItem(self.NEW_GROUP_OPTION)
|
|
self.combo_group_select.blockSignals(False)
|
|
|
|
for ch in self.extracted_channels:
|
|
self.list_channels.addItem(ch)
|
|
|
|
self.lbl_status.setText(f"Loaded {len(self.extracted_channels)} channels from file.")
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error Reading SNIRF", f"Failed to load channels:\n{e}")
|
|
self.lbl_status.setText("Failed to load SNIRF file.")
|
|
|
|
def _on_assign_channels(self) -> None:
|
|
name = self.txt_roi_name.text().strip()
|
|
desc = self.txt_roi_desc.text().strip()
|
|
selected_items = self.list_channels.selectedItems()
|
|
|
|
if not name:
|
|
QMessageBox.warning(self, "Input Required", "Please specify a Group / ROI Name.")
|
|
return
|
|
|
|
if not selected_items:
|
|
QMessageBox.warning(self, "Selection Required", "Please select at least one channel from the left panel.")
|
|
return
|
|
|
|
new_channels = [item.text() for item in selected_items]
|
|
existing_item = self._find_roi_item_by_name(name)
|
|
|
|
if existing_item:
|
|
# Merge channels into existing group
|
|
data = existing_item.data(0, Qt.ItemDataRole.UserRole)
|
|
combined_channels = set(data.get("channels", [])).union(new_channels)
|
|
sorted_channels = sorted(list(combined_channels), key=parse_sd_key)
|
|
|
|
data["description"] = desc
|
|
data["channels"] = sorted_channels
|
|
|
|
existing_item.setText(1, desc)
|
|
existing_item.setData(0, Qt.ItemDataRole.UserRole, data)
|
|
|
|
# Rebuild child channel items
|
|
existing_item.takeChildren()
|
|
for ch in sorted_channels:
|
|
child = QTreeWidgetItem(existing_item)
|
|
child.setText(0, ch)
|
|
|
|
self.lbl_status.setText(f"Updated group '{name}' with {len(new_channels)} additional channels.")
|
|
else:
|
|
# Create new group tree item
|
|
roi_item = QTreeWidgetItem(self.tree_rois)
|
|
roi_item.setText(0, name)
|
|
roi_item.setText(1, desc)
|
|
|
|
sorted_channels = sorted(new_channels, key=parse_sd_key)
|
|
roi_data = {"name": name, "description": desc, "channels": sorted_channels}
|
|
roi_item.setData(0, Qt.ItemDataRole.UserRole, roi_data)
|
|
|
|
for ch in sorted_channels:
|
|
child = QTreeWidgetItem(roi_item)
|
|
child.setText(0, ch)
|
|
|
|
self.tree_rois.expandItem(roi_item)
|
|
|
|
self.combo_group_select.addItem(name)
|
|
self.combo_group_select.setCurrentText(name)
|
|
|
|
self.lbl_status.setText(f"Created group '{name}' with {len(new_channels)} channels.")
|
|
|
|
# REMOVE assigned channels from available channels list
|
|
for item in selected_items:
|
|
self.list_channels.takeItem(self.list_channels.row(item))
|
|
|
|
def _on_unassign_channels(self) -> None:
|
|
"""Unassigns individually selected channels from tree items and returns them to available list."""
|
|
selected_tree_items = self.tree_rois.selectedItems()
|
|
if not selected_tree_items:
|
|
QMessageBox.information(
|
|
self, "Selection Required", "Please select channel item(s) inside an ROI group to unassign."
|
|
)
|
|
return
|
|
|
|
channels_to_restore: list[str] = []
|
|
|
|
for item in selected_tree_items:
|
|
parent = item.parent()
|
|
# Check if this item is a child channel node
|
|
if parent is not None:
|
|
ch_name = item.text(0)
|
|
channels_to_restore.append(ch_name)
|
|
|
|
# Remove child from tree
|
|
parent.removeChild(item)
|
|
|
|
# Update parent's channel dataset
|
|
data = parent.data(0, Qt.ItemDataRole.UserRole)
|
|
if data and "channels" in data and ch_name in data["channels"]:
|
|
data["channels"].remove(ch_name)
|
|
parent.setData(0, Qt.ItemDataRole.UserRole, data)
|
|
|
|
if channels_to_restore:
|
|
self._add_channels_to_available_list(channels_to_restore)
|
|
self.lbl_status.setText(f"Unassigned {len(channels_to_restore)} channels back to available list.")
|
|
else:
|
|
QMessageBox.information(
|
|
self, "Select Channels", "Please select specific channel items under a group (not the group header)."
|
|
)
|
|
|
|
def _on_remove_roi(self) -> None:
|
|
"""Removes an entire ROI group and returns all its channels back to available list."""
|
|
selected_item = self.tree_rois.currentItem()
|
|
if not selected_item:
|
|
return
|
|
|
|
root_item = selected_item.parent() if selected_item.parent() else selected_item
|
|
index = self.tree_rois.indexOfTopLevelItem(root_item)
|
|
|
|
if index != -1:
|
|
data = root_item.data(0, Qt.ItemDataRole.UserRole)
|
|
name = data.get("name", "") if data else ""
|
|
channels = data.get("channels", []) if data else []
|
|
|
|
# Return channels to available list
|
|
if channels:
|
|
self._add_channels_to_available_list(channels)
|
|
|
|
self.tree_rois.takeTopLevelItem(index)
|
|
|
|
# Remove from combo box
|
|
cb_idx = self.combo_group_select.findText(name)
|
|
if cb_idx != -1:
|
|
self.combo_group_select.removeItem(cb_idx)
|
|
|
|
self.combo_group_select.setCurrentIndex(0)
|
|
self.lbl_status.setText(f"Removed group '{name}' and restored {len(channels)} channels.")
|
|
|
|
def _on_export_json(self) -> None:
|
|
roi_list: list[dict[str, Any]] = []
|
|
|
|
for i in range(self.tree_rois.topLevelItemCount()):
|
|
item = self.tree_rois.topLevelItem(i)
|
|
if item:
|
|
data = item.data(0, Qt.ItemDataRole.UserRole)
|
|
if data and data.get("channels"):
|
|
roi_list.append(data)
|
|
|
|
if not roi_list:
|
|
QMessageBox.warning(self, "No ROIs Defined", "Please create at least one non-empty group before exporting.")
|
|
return
|
|
|
|
save_path, _ = QFileDialog.getSaveFileName(
|
|
self, "Save Regions of Interest JSON", "regions_of_interest.json", "JSON Files (*.json)"
|
|
)
|
|
if not save_path:
|
|
return
|
|
|
|
output_data = {"regions_of_interest": roi_list}
|
|
|
|
try:
|
|
with open(save_path, "w", encoding="utf-8") as f:
|
|
json.dump(output_data, f, indent=2)
|
|
|
|
QMessageBox.information(self, "Export Successful", f"Saved ROI definitions to:\n{save_path}")
|
|
self.lbl_status.setText(f"Successfully exported {len(roi_list)} groups.")
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Export Failed", f"Could not save JSON file:\n{e}")
|
|
|
|
|
|
def _on_auto_group(self) -> None:
|
|
"""Automatically parses fOLD results and populates the tree and combo UI components."""
|
|
file_path = self.txt_file_path.text().strip()
|
|
if not file_path:
|
|
QMessageBox.warning(self, "No File Selected", "Please select a .snirf file first.")
|
|
return
|
|
|
|
self.btn_auto_group.setEnabled(False)
|
|
|
|
try:
|
|
# 1. Fetch fold specificity data
|
|
channel_results = get_channel_fold_results(
|
|
file_path=file_path,
|
|
atlas="Brodmann"
|
|
)
|
|
|
|
# 2. Determine groups automatically
|
|
auto_groups = _auto_group_channels(channel_results)
|
|
|
|
if not auto_groups:
|
|
QMessageBox.information(self, "Auto Grouping", "No valid ROI groups could be determined.")
|
|
return
|
|
|
|
# Map available channel strings to QListWidgetItems
|
|
available_items = {
|
|
self.list_channels.item(i).text(): self.list_channels.item(i)
|
|
for i in range(self.list_channels.count())
|
|
}
|
|
|
|
assigned_count = 0
|
|
groups_created = 0
|
|
|
|
for group_name, ch_list in auto_groups.items():
|
|
clean_channels: list[str] = []
|
|
items_to_remove: list = []
|
|
|
|
# Clean channel names (e.g., 'S1_D1 hbo' -> 'S1_D1') and match against available list
|
|
for raw_ch in ch_list:
|
|
ch_id = raw_ch.split()[0]
|
|
if ch_id in available_items:
|
|
clean_channels.append(ch_id)
|
|
items_to_remove.append(available_items[ch_id])
|
|
|
|
if not clean_channels:
|
|
continue
|
|
|
|
sorted_channels = sorted(list(set(clean_channels)), key=parse_sd_key)
|
|
|
|
# Check if group tree item exists or create new
|
|
existing_item = self._find_roi_item_by_name(group_name)
|
|
if existing_item:
|
|
data = existing_item.data(0, Qt.ItemDataRole.UserRole) or {}
|
|
combined = set(data.get("channels", [])).union(sorted_channels)
|
|
final_channels = sorted(list(combined), key=parse_sd_key)
|
|
|
|
data["name"] = group_name
|
|
data["description"] = group_name
|
|
data["channels"] = final_channels
|
|
existing_item.setData(0, Qt.ItemDataRole.UserRole, data)
|
|
|
|
existing_item.takeChildren()
|
|
for ch in final_channels:
|
|
child = QTreeWidgetItem(existing_item)
|
|
child.setText(0, ch)
|
|
else:
|
|
roi_item = QTreeWidgetItem(self.tree_rois)
|
|
roi_item.setText(0, group_name)
|
|
roi_item.setText(1, group_name)
|
|
|
|
roi_data = {
|
|
"name": group_name,
|
|
"description": group_name,
|
|
"channels": sorted_channels
|
|
}
|
|
roi_item.setData(0, Qt.ItemDataRole.UserRole, roi_data)
|
|
|
|
for ch in sorted_channels:
|
|
child = QTreeWidgetItem(roi_item)
|
|
child.setText(0, ch)
|
|
|
|
self.tree_rois.expandItem(roi_item)
|
|
|
|
if self.combo_group_select.findText(group_name) == -1:
|
|
self.combo_group_select.addItem(group_name)
|
|
|
|
groups_created += 1
|
|
|
|
# Remove assigned channels from available QListWidget
|
|
for item in items_to_remove:
|
|
row = self.list_channels.row(item)
|
|
if row != -1:
|
|
self.list_channels.takeItem(row)
|
|
ch_key = item.text()
|
|
if ch_key in available_items:
|
|
del available_items[ch_key]
|
|
|
|
assigned_count += len(clean_channels)
|
|
|
|
self.lbl_status.setText(f"Auto-grouped {assigned_count} channels into {groups_created} ROI regions.")
|
|
QMessageBox.information(
|
|
self,
|
|
"Auto Grouping Complete",
|
|
f"Successfully created {groups_created} ROI groups and assigned {assigned_count} channels."
|
|
)
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Fold Error", f"Failed to calculate fold channels:\n{e}")
|
|
|
|
finally:
|
|
self.btn_auto_group.setEnabled(True)
|
|
|
|
|
|
class Plugin:
|
|
"""Plugin entry point contract loaded by PluginManager."""
|
|
|
|
def __init__(self, main_window: QWidget) -> None:
|
|
self.main_window = main_window
|
|
self.name = "ROI Channel Builder"
|
|
self.widget_instance: RoiBuilderWidget | None = None
|
|
|
|
def register_menu(self, plugin_menu: QMenu) -> None:
|
|
"""Registers plugin options into the application's top menubar."""
|
|
open_action = QAction("Open ROI Builder Tool", self.main_window)
|
|
open_action.triggered.connect(self.show_widget)
|
|
|
|
about_action = QAction("About ROI Builder", self.main_window)
|
|
about_action.triggered.connect(self.show_about)
|
|
|
|
plugin_menu.addAction(open_action)
|
|
plugin_menu.addAction(about_action)
|
|
|
|
def show_widget(self) -> None:
|
|
"""Instantiates or focuses the ROI Builder window."""
|
|
if self.widget_instance is None or not self.widget_instance.isVisible():
|
|
self.widget_instance = RoiBuilderWidget()
|
|
self.widget_instance.show()
|
|
else:
|
|
self.widget_instance.raise_()
|
|
self.widget_instance.activateWindow()
|
|
|
|
def show_about(self) -> None:
|
|
"""Displays plugin information dynamically from plugin.json if available."""
|
|
# Fallback default values
|
|
title = "ROI Channel Builder"
|
|
version = "Unknown"
|
|
author = "Unknown"
|
|
description = (
|
|
"This plugin loads SNIRF binary files using h5py, extracts source-detector "
|
|
"channel pairs, and exports custom ROI channel group JSON files."
|
|
)
|
|
|
|
# Look for plugin.json in the same directory as this file
|
|
json_path = Path(__file__).resolve().parent / "plugin.json"
|
|
|
|
if json_path.is_file():
|
|
try:
|
|
with open(json_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
title = data.get("name", title)
|
|
version = data.get("version", version)
|
|
author = data.get("author", author)
|
|
description = data.get("description", description)
|
|
except Exception:
|
|
# Silently catch file read/parse errors to prevent crashing
|
|
pass
|
|
|
|
# Build formatted display message
|
|
message = (
|
|
f"<b>{title}</b><br>"
|
|
f"<b>Version:</b> {version} | <b>Author:</b> {author}<br><br>"
|
|
f"{description}"
|
|
)
|
|
|
|
QMessageBox.about(
|
|
self.main_window,
|
|
f"About {title}",
|
|
message,
|
|
)
|
|
|
|
|
|
def parse_sd_key(ch: str) -> tuple[int, int]:
|
|
"""Helper for natural sorting of S{source}_D{detector} strings."""
|
|
parts = ch.split("_")
|
|
s_num = int(parts[0].lstrip("S")) if len(parts) > 0 and parts[0].lstrip("S").isdigit() else 0
|
|
d_num = int(parts[1].lstrip("D")) if len(parts) > 1 and parts[1].lstrip("D").isdigit() else 0
|
|
return s_num, d_num
|
|
|
|
|
|
def extract_snirf_channels(file_path: str) -> list[str]:
|
|
"""Reads a .snirf file using h5py and extracts unique channel names (S{source}_D{detector})."""
|
|
|
|
channels: set[str] = set()
|
|
|
|
with h5py.File(file_path, "r") as h5_file:
|
|
if "nirs" not in h5_file:
|
|
raise ValueError("Invalid SNIRF file: missing top-level '/nirs' HDF5 group.")
|
|
|
|
nirs_group = h5_file["nirs"]
|
|
data_keys = [k for k in nirs_group.keys() if k.startswith("data")]
|
|
|
|
for d_key in data_keys:
|
|
data_group = nirs_group[d_key]
|
|
ml_keys = [k for k in data_group.keys() if k.startswith("measurementList")]
|
|
|
|
for ml_key in ml_keys:
|
|
ml = data_group[ml_key]
|
|
if "sourceIndex" in ml and "detectorIndex" in ml:
|
|
src = ml["sourceIndex"][()]
|
|
det = ml["detectorIndex"][()]
|
|
|
|
if hasattr(src, "item"):
|
|
src = src.item()
|
|
if hasattr(det, "item"):
|
|
det = det.item()
|
|
|
|
channels.add(f"S{src}_D{det}")
|
|
|
|
return sorted(list(channels), key=parse_sd_key)
|
|
|
|
|
|
def get_channel_fold_results(
|
|
file_path: str,
|
|
atlas: str = 'Brodmann',
|
|
progress_queue: Optional[Any] = None
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
"""
|
|
Directly loads a .snirf file, performs a lightweight HbO conversion,
|
|
and runs fold_channels without executing any GLM, QC, or pipeline steps.
|
|
"""
|
|
p_name = os.path.basename(file_path)
|
|
|
|
raw = read_raw_snirf(file_path, preload=True, verbose=False)
|
|
raw_od = optical_density(raw)
|
|
raw_haemo = beer_lambert_law(raw_od)
|
|
|
|
channel_results = fold_channels(
|
|
raw=raw_haemo,
|
|
p_name=p_name,
|
|
atlas=atlas,
|
|
progress_queue=progress_queue
|
|
)
|
|
|
|
return channel_results
|
|
|
|
|
|
def _auto_group_channels(channel_results: dict) -> dict[str, list[str]]:
|
|
"""Clusters channels by their highest specificity landmark (excluding Brain_Outside)."""
|
|
roi_groups: dict[str, list[str]] = {}
|
|
|
|
for ch_name, landmarks in channel_results.items():
|
|
if not landmarks:
|
|
continue
|
|
|
|
# Filter out Brain_Outside unless it's the only option
|
|
valid_landmarks = [
|
|
lm for lm in landmarks
|
|
if lm.get("Landmark") != "Brain_Outside"
|
|
]
|
|
if not valid_landmarks:
|
|
valid_landmarks = landmarks
|
|
|
|
# Pick highest specificity landmark
|
|
top_landmark = max(valid_landmarks, key=lambda x: x.get("Specificity", 0))
|
|
raw_name = top_landmark.get("Landmark", "Unassigned")
|
|
|
|
# Format label (e.g. "7 - Somatosensory..." -> "BA 7: Somatosensory Association Cortex")
|
|
parts = raw_name.split(" - ", 1)
|
|
if len(parts) == 2 and parts[0].strip().isdigit():
|
|
group_name = f"BA {parts[0].strip()}: {parts[1].strip()}"
|
|
else:
|
|
group_name = raw_name
|
|
|
|
roi_groups.setdefault(group_name, []).append(ch_name)
|
|
|
|
return roi_groups |