diff --git a/packages/roi-builder-pack.zip b/packages/roi-builder-pack.zip
new file mode 100644
index 0000000..c8f1844
Binary files /dev/null and b/packages/roi-builder-pack.zip differ
diff --git a/plugins.json b/plugins.json
index db715b0..6af8d34 100644
--- a/plugins.json
+++ b/plugins.json
@@ -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"
}
]
\ No newline at end of file
diff --git a/roi-builder-pack/__init__.py b/roi-builder-pack/__init__.py
new file mode 100644
index 0000000..af358d6
--- /dev/null
+++ b/roi-builder-pack/__init__.py
@@ -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("Available Channels:", 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("Defined Regions of Interest:", 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
\ No newline at end of file
diff --git a/roi-builder-pack/plugin.json b/roi-builder-pack/plugin.json
new file mode 100644
index 0000000..776b385
--- /dev/null
+++ b/roi-builder-pack/plugin.json
@@ -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"
+}
\ No newline at end of file
diff --git a/test-plugin/plugin.json b/test-plugin/plugin.json
index 0e309e3..89fdd28 100644
--- a/test-plugin/plugin.json
+++ b/test-plugin/plugin.json
@@ -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"
}
\ No newline at end of file