new plugin
This commit is contained in:
Binary file not shown.
+15
-5
@@ -5,9 +5,9 @@
|
||||
"version": "0.1.0",
|
||||
"author": "Tyler de Zeeuw",
|
||||
"description": "Machine Learning on Processed fNIRS Data.",
|
||||
"platforms": ["win_x64"],
|
||||
"min_app_version": "1.7.0",
|
||||
"download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/machine_learning_pack_win_x64.zip"
|
||||
"platforms": ["win_x64"],
|
||||
"download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/machine-learning-pack-win-x64.zip"
|
||||
},
|
||||
{
|
||||
"id": "theme-pack",
|
||||
@@ -17,16 +17,26 @@
|
||||
"description": "Pure Python themes (cross-platform).",
|
||||
"min_app_version": "1.7.0",
|
||||
"platforms": ["darwin_arm64", "darwin_x64", "linux_x64"],
|
||||
"download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/theme_pack_multi.zip"
|
||||
"download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/theme-pack-multi.zip"
|
||||
},
|
||||
{
|
||||
"id": "test-plugin",
|
||||
"name": "Test Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "A basic test plugin demonstrating menu registration and custom widgets.",
|
||||
"author": "Tyler de Zeeuw",
|
||||
"description": "Test Plugin.",
|
||||
"min_app_version": "1.7.0",
|
||||
"platforms": ["win_x64"],
|
||||
"platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"],
|
||||
"download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/test-plugin.zip"
|
||||
},
|
||||
{
|
||||
"id": "roi-builder-pack",
|
||||
"name": "ROI Channel Builder Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "Extracts channel labels from SNIRF files and generates custom ROI grouping JSON files.",
|
||||
"author": "Tyler de Zeeuw",
|
||||
"min_app_version": "1.7.0",
|
||||
"platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"],
|
||||
"download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/roi-builder-pack.zip"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,608 @@
|
||||
"""
|
||||
Filename: plugins.py
|
||||
Description: roi-builder-pack plugin
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import os
|
||||
import json
|
||||
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."""
|
||||
QMessageBox.about(
|
||||
self.main_window,
|
||||
"About ROI Channel Builder",
|
||||
"This plugin loads SNIRF binary files using h5py, extracts source-detector channel pairs, "
|
||||
"and exports custom ROI channel group JSON files.",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "roi-builder-pack",
|
||||
"name": "ROI Channel Builder Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "Extracts channel labels from SNIRF files and generates custom ROI grouping JSON files.",
|
||||
"author": "Tyler de Zeeuw",
|
||||
"min_app_version": "1.7.0",
|
||||
"platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"],
|
||||
"download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/roi-builder-pack.zip"
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"id": "test_plugin",
|
||||
"name": "Test Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "A basic test plugin demonstrating menu registration and custom widgets.",
|
||||
"author": "Your Name",
|
||||
"min_app_version": "1.0.0",
|
||||
"platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"]
|
||||
"id": "test_plugin",
|
||||
"name": "Test Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "A basic test plugin demonstrating menu registration and custom widgets.",
|
||||
"author": "Tyler de Zeeuw",
|
||||
"min_app_version": "1.7.0",
|
||||
"platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"],
|
||||
"download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/test-plugin.zip"
|
||||
}
|
||||
Reference in New Issue
Block a user