new plugin

This commit is contained in:
2026-09-01 17:24:46 -07:00
parent 7a787c4315
commit a173a20dc8
5 changed files with 641 additions and 12 deletions
Binary file not shown.
+15 -5
View File
@@ -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"
}
]
+608
View File
@@ -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
+10
View File
@@ -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"
}
+8 -7
View File
@@ -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"
}