start of plugin support?

This commit is contained in:
2026-09-01 02:14:47 -07:00
parent 815f342ead
commit ca203fcb56
9 changed files with 476 additions and 243 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
# Version 1.7.1 # Version 1.7.1
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! - Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
- Fixed an issue where updating events in a snirf file would not release memory properly
# Version 1.7.0 # Version 1.7.0
+9 -47
View File
@@ -105,7 +105,7 @@ from mne_nirs.statistics._glm_level_first import RegressionResults # type: igno
from mne_connectivity.viz import plot_connectivity_circle # type: ignore from mne_connectivity.viz import plot_connectivity_circle # type: ignore
from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time # type: ignore from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time # type: ignore
from src.shared.shareddata import PLATFORM_NAME, resource_path from src.shared.shareddata import PLATFORM_NAME, resource_path, get_app_dir
@@ -162,42 +162,7 @@ QC_METRIC_LABELS = {
"total_processing_seconds": "Processing Time (s)", "total_processing_seconds": "Processing Time (s)",
} }
ROI_MAP = {} # TODO: Should be grabbed from the json file
ROI_MAP = {
'ROI_OccipitoParietal_BA18_19_7': [
'S1_D1', 'S1_D2', 'S4_D3'
],
'ROI_SuperiorParietal_BA7_5': [
'S2_D1', 'S3_D1', 'S2_D2', 'S2_D4', 'S4_D4'
],
'ROI_TPJ_AngularGyrus_BA39_19_40': [
'S4_D2', 'S5_D3', 'S4_D5', 'S5_D5'
],
# --- SPLIT BA40 REGIONS ---
'ROI_Posterior_BA40_Parietal': [
'S6_D4', 'S6_D5'
],
'ROI_Anterior_BA40_Sensorimotor': [
'S10_D5', 'S8_D5'
],
# --------------------------
'ROI_Supramarginal_Inferior_BA40_6': [
'S6_D6'
],
'ROI_VentralSomatosensory_BA1_2_3_43_48': [
'S8_D6', 'S9_D6'
],
'ROI_Sensorimotor_BA1_2_3_4_6': [
'S6_D8', 'S10_D8'
],
'ROI_DLPFC_FEF_BA6_8_9': [
'S7_D7', 'S7_D8'
],
'ROI_Broca_VLPFC_BA6_44_45_4': [
'S9_D7', 'S10_D7'
]
}
DOWNSAMPLE: bool DOWNSAMPLE: bool
DOWNSAMPLE_FREQUENCY: int DOWNSAMPLE_FREQUENCY: int
@@ -345,19 +310,15 @@ FEATURE_2: bool = False
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
os.chdir(script_dir) os.chdir(script_dir)
# Configure logging to file with timestamps and realtime flush # Configure logging to file with timestamps and realtime flush
if PLATFORM_NAME == 'darwin': if PLATFORM_NAME == 'darwin':
logging.basicConfig( log_path = os.path.abspath(os.path.join(os.path.dirname(sys.executable), "../../../fnirs_analysis.log"))
filename=os.path.join(os.path.dirname(sys.executable), "../../../fnirs_analysis.log"), # Needed to get out of the bundled application
level=logging.INFO,
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
filemode='a'
)
else: else:
log_path = os.path.join(get_app_dir(), "fnirs_analysis.log")
logging.basicConfig( logging.basicConfig(
filename='fnirs_analysis.log', filename=log_path,
level=logging.INFO, level=logging.INFO,
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s', format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S', datefmt='%Y-%m-%d %H:%M:%S',
@@ -514,7 +475,8 @@ def process_multiple_participants(file_paths, file_params, file_metadata,
success_count = {"value": 0} success_count = {"value": 0}
failed_stages = {"value": []} failed_stages = {"value": []}
qc_rows: list[dict[str, Any]] = [] qc_rows: list[dict[str, Any]] = []
qc_summary_path="qc_summary.xlsx" if qc_summary_path is None:
qc_summary_path = os.path.join(get_app_dir(), "qc_summary.xlsx")
def elapsed_heartbeat(): def elapsed_heartbeat():
# Ticks once a second so the GUI can show a live-updating timer, # Ticks once a second so the GUI can show a live-updating timer,
+21 -6
View File
@@ -36,6 +36,7 @@ from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is no
from file_ext_registration import register_file_association, ELEVATION_FLAG from file_ext_registration import register_file_association, ELEVATION_FLAG
from project_manager import ProjectManager from project_manager import ProjectManager
from src.window.about import AboutWindow from src.window.about import AboutWindow
from src.window.plugins import PluginsWindow
from src.window.terminal import TerminalWindow from src.window.terminal import TerminalWindow
from src.window.updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow from src.window.updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow
from src.window.updateoptodes import UpdateOptodesWindow from src.window.updateoptodes import UpdateOptodesWindow
@@ -43,7 +44,7 @@ from src.window.userguide import UserGuideWindow
from src.window.viewerlauncher import ViewerLauncherWidget from src.window.viewerlauncher import ViewerLauncherWidget
from src.window.welcome import WelcomeDialog from src.window.welcome import WelcomeDialog
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, get_app_dir
from startup_args import parse_startup_args from startup_args import parse_startup_args
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
@@ -556,6 +557,7 @@ class MainApplication(QMainWindow):
self.optodes = None self.optodes = None
self.events = None self.events = None
self.events_blazes = None self.events_blazes = None
self.plugins = None
self.terminal = None self.terminal = None
self.bubble_widgets = {} self.bubble_widgets = {}
self.param_sections = [] self.param_sections = []
@@ -910,6 +912,13 @@ class MainApplication(QMainWindow):
preferences_menu.addAction(action) preferences_menu.addAction(action)
self.pref_actions[config_key] = action self.pref_actions[config_key] = action
plugins_menu = menu_bar.addMenu("Plugins")
plugins_actions = [
("Plugin Manager", "Ctrl+Alt+P", self.plugins_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
]
for name, shortcut, slot, icon in plugins_actions:
plugins_menu.addAction(make_action(name, shortcut, slot, icon=icon))
terminal_menu = menu_bar.addMenu("Terminal") terminal_menu = menu_bar.addMenu("Terminal")
terminal_actions = [ terminal_actions = [
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")), ("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
@@ -1316,6 +1325,11 @@ class MainApplication(QMainWindow):
QMessageBox.Ok QMessageBox.Ok
) )
def plugins_gui(self):
if self.plugins is None or not self.plugins.isVisible():
self.plugins = PluginsWindow(self)
self.plugins.show()
def terminal_gui(self): def terminal_gui(self):
if self.terminal is None or not self.terminal.isVisible(): if self.terminal is None or not self.terminal.isVisible():
self.terminal = TerminalWindow(self) self.terminal = TerminalWindow(self)
@@ -2746,12 +2760,13 @@ if __name__ == "__main__":
log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log") log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log")
cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg") cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg")
else: else:
log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log") log_path = os.path.join(get_app_dir(), f"{APP_NAME}.log")
cfg_path = os.path.join(os.getcwd(), f"{APP_NAME}.cfg") cfg_path = os.path.join(get_app_dir(), f"{APP_NAME}.cfg")
try: try:
if os.path.exists(log_path):
os.remove(log_path) os.remove(log_path)
except: except Exception as e:
pass print(f"Warning: Could not remove old log file: {e}")
sys.stdout = open(log_path, "a", buffering=1) sys.stdout = open(log_path, "a", buffering=1)
sys.stderr = sys.stdout sys.stderr = sys.stdout
@@ -2778,4 +2793,4 @@ if __name__ == "__main__":
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
# Not 2600 lines yay! # Not 2800 lines yay!
+2 -1
View File
@@ -15,6 +15,7 @@ import copy
import pickle import pickle
import concurrent import concurrent
import configparser import configparser
import concurrent.futures
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any, List, Optional, Union from typing import TYPE_CHECKING, Any, List, Optional, Union
@@ -719,7 +720,7 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
return {} return {}
def _row_to_dict(row: pd.Series) -> dict[str, str]: def _row_to_dict(row: pd.Series) -> dict[str, str]:
result = {} result: dict[str, str] = {}
for field in fields: for field in fields:
if field not in row: if field not in row:
continue continue
+5 -6
View File
@@ -1,7 +1,6 @@
src\analysis\participantfoldchannels.py 158 src\analysis\participantfoldchannels.py 158
src\shared\flaresbasewidget.py 1001+ src\shared\flaresbasewidget.py 1155
src\window\updateevents.py 83 flares.py 2900
flares.py 1001+ main_unit_tests.py 152
main_unit_tests.py 153 main.py 732
main.py 691 project_manager.py 109
project_manager.py 113
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
import platform import platform
CURRENT_VERSION = "1.7.0" CURRENT_VERSION = "1.7.1"
APP_NAME = "flares" APP_NAME = "flares"
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite" APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
+224
View File
@@ -0,0 +1,224 @@
"""
Filename: plugins.py
Description: Plugins window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import json
import sys
import urllib.request
from pathlib import Path
from typing import Any, cast
# External library imports
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QTabWidget, QVBoxLayout, QWidget
from src.shared.shareddata import APP_NAME, PLATFORM_NAME, PLUGINS_URL
class PluginsWindow(QWidget):
"""
Plugins window containing two tabs: Installed Plugins and Plugin Browser.
Args:
parent (QWidget | None, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"{APP_NAME.upper()} - Plugins")
self.resize(650, 500)
self.plugins_dir: Path = self._resolve_plugins_dir()
self.repository_urls: list[str] = [PLUGINS_URL]
self.remote_plugins_data: list[dict[str, Any]] = []
self._has_fetched_remote: bool = False
main_layout = QVBoxLayout(self)
self.tab_widget = QTabWidget(self)
self.installed_tab = self._create_installed_tab()
self.browser_tab = self._create_browser_tab()
self.tab_widget.addTab(self.installed_tab, "Installed Plugins")
self.tab_widget.addTab(self.browser_tab, "Plugin Browser")
self.tab_widget.setCurrentIndex(0)
self.tab_widget.currentChanged.connect(self._on_tab_changed)
main_layout.addWidget(self.tab_widget)
self.setLayout(main_layout)
self.refresh_installed_plugins()
def _resolve_plugins_dir(self) -> Path:
"""Determines the local plugins directory based on execution context."""
if PLATFORM_NAME == "darwin":
base_dir = Path(sys.executable).parent / "../../.."
else:
base_dir = Path.cwd()
return (base_dir / "plugins").resolve()
def _on_tab_changed(self, index: int) -> None:
"""Triggers remote fetch only when switching specifically to the Plugin Browser tab."""
if index == 1 and not self._has_fetched_remote:
self.fetch_remote_plugins()
def refresh_installed_plugins(self) -> None:
"""Scans local plugins directory silently without UI popups."""
self.installed_list.clear()
self.plugins_dir.mkdir(parents=True, exist_ok=True)
installed_plugins: list[str] = []
for entry in self.plugins_dir.iterdir():
if entry.is_dir() and not entry.name.startswith((".", "__")):
installed_plugins.append(entry.name)
elif entry.is_file() and entry.suffix == ".py" and entry.stem != "__init__":
installed_plugins.append(entry.stem)
if installed_plugins:
self.installed_list.addItems(sorted(installed_plugins))
else:
self.installed_list.addItem("No plugins installed.")
def add_custom_repository(self) -> None:
"""Adds a custom repository URL from the input field and refreshes the browser list."""
url = self.repo_input.text().strip()
if not url:
return
if not (url.startswith("http://") or url.startswith("https://")):
self._show_browser_error("Invalid URL format. Must start with http:// or https://")
return
if url not in self.repository_urls:
self.repository_urls.append(url)
self.repo_input.clear()
self.fetch_remote_plugins()
def fetch_remote_plugins(self) -> None:
"""
Fetches plugins.json from all configured repository URLs directly into memory.
Aggregates results and handles network failures gracefully in-line.
"""
self.browser_list.clear()
self.browser_list.addItem("Fetching remote repositories...")
aggregated_plugins: list[dict[str, Any]] = []
failed_count: int = 0
for url in self.repository_urls:
try:
req = urllib.request.Request(
url,
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
)
with urllib.request.urlopen(req, timeout=5) as response:
if response.status == 200:
raw_data = response.read().decode("utf-8")
plugins = json.loads(raw_data)
if isinstance(plugins, list):
aggregated_plugins.extend(cast(list[Any], plugins))
else:
failed_count += 1
except Exception:
failed_count += 1
self.remote_plugins_data = aggregated_plugins
self._has_fetched_remote = True
if aggregated_plugins:
self._populate_browser_list()
elif failed_count == len(self.repository_urls):
self._show_browser_error(
"Unable to load plugins from any repository. Check your connection or URLs."
)
else:
self._show_browser_error("No plugins found across configured repositories.")
def _show_browser_error(self, message: str) -> None:
"""Renders error text directly in the list widget."""
self.browser_list.clear()
self.browser_list.addItem(message)
def _populate_browser_list(self) -> None:
"""Populates list widget with parsed remote plugin data."""
self.browser_list.clear()
for plugin in self.remote_plugins_data:
name = plugin.get("name", "Unknown Plugin")
version = plugin.get("version", "v0.0")
desc = plugin.get("description", "No description provided.")
display_text = f"{name} ({version}) - {desc}"
item = QListWidgetItem(display_text, self.browser_list)
item.setData(Qt.ItemDataRole.UserRole, plugin)
def _create_installed_tab(self) -> QWidget:
"""Creates 'Installed Plugins' tab UI."""
tab = QWidget()
layout = QVBoxLayout(tab)
path_label = QLabel(f"Directory: {self.plugins_dir}", tab)
self.installed_list = QListWidget(tab)
button_layout = QHBoxLayout()
self.btn_refresh_local = QPushButton("Refresh List", tab)
self.btn_enable_disable = QPushButton("Enable/Disable", tab)
self.btn_uninstall = QPushButton("Uninstall", tab)
self.btn_refresh_local.clicked.connect(self.refresh_installed_plugins)
button_layout.addWidget(self.btn_refresh_local)
button_layout.addStretch()
button_layout.addWidget(self.btn_enable_disable)
button_layout.addWidget(self.btn_uninstall)
layout.addWidget(path_label)
layout.addWidget(self.installed_list)
layout.addLayout(button_layout)
return tab
def _create_browser_tab(self) -> QWidget:
"""Creates 'Plugin Browser' tab UI."""
tab = QWidget()
layout = QVBoxLayout(tab)
# Custom Repository Input Controls
repo_layout = QHBoxLayout()
self.repo_input = QLineEdit(tab)
self.repo_input.setPlaceholderText("Enter custom plugins.json URL...")
self.btn_add_repo = QPushButton("Add Repo", tab)
self.btn_add_repo.clicked.connect(self.add_custom_repository)
repo_layout.addWidget(self.repo_input)
repo_layout.addWidget(self.btn_add_repo)
label = QLabel("Available Plugins from Repositories:", tab)
self.browser_list = QListWidget(tab)
button_layout = QHBoxLayout()
self.btn_fetch_remote = QPushButton("Fetch Remote Lists", tab)
self.btn_install = QPushButton("Install Plugin", tab)
self.btn_fetch_remote.clicked.connect(self.fetch_remote_plugins)
button_layout.addWidget(self.btn_fetch_remote)
button_layout.addStretch()
button_layout.addWidget(self.btn_install)
layout.addLayout(repo_layout)
layout.addWidget(label)
layout.addWidget(self.browser_list)
layout.addLayout(button_layout)
return tab
+198 -167
View File
@@ -1,6 +1,7 @@
""" """
Filename: updateevents.py Filename: updateevents.py
Description: Methods to update snirf events for FLARES Description: Methods to update snirf events
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw Author: Tyler de Zeeuw
License: GPL-3.0 License: GPL-3.0
@@ -9,8 +10,9 @@ License: GPL-3.0
# Built-in imports # Built-in imports
import os import os
import json import json
import concurrent.futures
from enum import Enum, auto from enum import Enum, auto
from typing import Any, List, Optional, cast from typing import Any, List, Optional, Sequence, Union, cast
# External library imports # External library imports
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
@@ -20,16 +22,172 @@ from mne import Annotations
from mne.io import read_raw_snirf #type: ignore from mne.io import read_raw_snirf #type: ignore
from mne_nirs.io import write_raw_snirf #type: ignore from mne_nirs.io import write_raw_snirf #type: ignore
from mne.io.base import BaseRaw
from src.shared.shareddata import APP_NAME from src.shared.shareddata import APP_NAME
def _load_annotations_worker(file_path: str) -> Annotations:
"""Isolated worker process to load SNIRF annotations without leaving HDF5 memory leaks in main process."""
raw: BaseRaw = read_raw_snirf(file_path, preload=False, verbose=False)
return cast(Annotations, getattr(raw, "annotations"))
def load_snirf_annotations_isolated(file_path: str) -> Annotations:
"""Spawns a short-lived process to extract annotations and cleanly releases C-memory allocations."""
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
future = executor.submit(_load_annotations_worker, file_path)
return future.result()
def _write_snirf_worker(
file_path: str,
save_path: str,
onsets: list[float],
durations: list[float],
descriptions: list[str]
) -> None:
"""Isolated process worker to read, annotate, write, and immediately free memory/file handles."""
raw = read_raw_snirf(file_path, preload=True, verbose=False)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
raw.set_annotations(new_annotations) #type: ignore
write_raw_snirf(raw, save_path)
def write_snirf_with_annotations_isolated(
file_path: str,
save_path: str,
onsets: list[float],
durations: list[float],
descriptions: list[str]
) -> None:
"""Executes SNIRF writing in a dedicated worker process to ensure 100% memory/handle cleanup."""
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
future = executor.submit(
_write_snirf_worker,
file_path,
save_path,
onsets,
durations,
descriptions
)
future.result()
def _align_boris_and_write_snirf_worker(
file_path: str,
save_path: str,
boris_events: list[Any],
time_shift: float
) -> int:
"""
Isolated process worker to read SNIRF, align BORIS events, write updated file,
and cleanly exit releasing all HDF5 C-memory allocations and file locks.
"""
raw = read_raw_snirf(file_path, preload=True, verbose=False)
# Type-safe extraction of sampling frequency from raw.info
info_dict = cast(dict[str, Any], raw.info)
sfreq = float(info_dict.get("sfreq", 10.0))
min_shift = 1.0 / sfreq if sfreq > 0 else 0.1
max_attempts = 10
onsets: list[float] = []
durations: list[float] = []
descriptions: list[str] = []
open_events: dict[str, list[float]] = {}
label_counts: dict[str, int] = {}
used_times: set[float] = set()
for raw_event in boris_events:
if not isinstance(raw_event, (list, tuple)):
continue
event = cast(Sequence[Any], raw_event)
if len(event) < 3:
continue
event_time = float(event[0])
label = str(event[2])
count = label_counts.get(label, 0) + 1
label_counts[label] = count
if label not in open_events:
open_events[label] = []
if count % 2 == 1:
open_events[label].append(event_time)
else:
if open_events[label]:
start_time = open_events[label].pop(0)
duration = event_time - start_time
if duration <= 0:
continue
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(round(duration, 6))
descriptions.append(label)
# Handle unmatched start markers
for label, starts in open_events.items():
for start_time in starts:
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(0.0)
descriptions.append(label)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
raw.set_annotations(new_annotations) #type: ignore
write_raw_snirf(raw, save_path)
return len(onsets)
def align_boris_and_write_snirf_isolated(
file_path: str,
save_path: str,
boris_events: list[Any],
time_shift: float
) -> int:
"""Executes BORIS alignment and SNIRF writing in a dedicated worker process."""
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
future = executor.submit(
_align_boris_and_write_snirf_worker,
file_path,
save_path,
boris_events,
time_shift
)
return future.result()
class EventUpdateMode(Enum): class EventUpdateMode(Enum):
WRITE_SNIRF = auto() # destructive WRITE_SNIRF = auto() # destructive
WRITE_JSON = auto() # non-destructive WRITE_JSON = auto() # non-destructive
class UpdateEventsWindow(QWidget): class UpdateEventsWindow(QWidget):
def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): def __init__(self, parent: Optional[QWidget]=None, mode: EventUpdateMode=EventUpdateMode.WRITE_SNIRF, caller: Optional[Union[str, object]] = None,):
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self.mode = mode self.mode = mode
@@ -210,12 +368,13 @@ class UpdateEventsWindow(QWidget):
if file_path: if file_path:
self.line_edit_file_a.setText(file_path) self.line_edit_file_a.setText(file_path)
try: try:
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed # Memory leak safe: Extracted in a separate process that terminates immediately
raw = read_raw_snirf(file_path, preload=False) annotations: Annotations = load_snirf_annotations_isolated(file_path)
annotations = raw.annotations
print(f"Loaded {len(annotations)} annotations from {file_path}")
# Build individual event entries # Build individual event entries
event_entries = [] event_entries: list[str] = []
for onset, description in zip(annotations.onset, annotations.description): for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s" event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str) event_entries.append(event_str)
@@ -252,6 +411,7 @@ class UpdateEventsWindow(QWidget):
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}") QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
def extract_boris_observation_keys(self, data: dict[str, Any]) -> List[str]: def extract_boris_observation_keys(self, data: dict[str, Any]) -> List[str]:
if "observations" not in data: if "observations" not in data:
raise KeyError("Missing 'observations' key in BORIS file.") raise KeyError("Missing 'observations' key in BORIS file.")
@@ -274,11 +434,16 @@ class UpdateEventsWindow(QWidget):
self.combo_events.setEnabled(False) self.combo_events.setEnabled(False)
return return
event_entries = [] event_entries: list[str] = []
for event in events: for raw_event in events:
if isinstance(event, list) and len(event) >= 3: if not isinstance(raw_event, (list, tuple)):
timestamp = event[0] continue
label = event[2]
event = cast(Sequence[Any], raw_event)
if len(event) >= 3:
timestamp = float(event[0])
label = str(event[2])
display = f"{label} @ {timestamp:.3f}" display = f"{label} @ {timestamp:.3f}"
event_entries.append(display) event_entries.append(display)
@@ -363,71 +528,15 @@ class UpdateEventsWindow(QWidget):
save_path += ".snirf" save_path += ".snirf"
try: try:
raw = read_raw_snirf(file_a, preload=True) # Memory leak safe: Worker handles alignment calculation, read, write, and process termination
event_count = align_boris_and_write_snirf_isolated(
file_a,
save_path,
boris_events,
time_shift
)
# --- Align BORIS events to SNIRF --- QMessageBox.information(self, "Success", f"SNIRF file updated with {event_count} aligned BORIS events.")
boris_events = boris_obs.get("events", [])
onsets, durations, descriptions = [], [], []
open_events = {} # label -> list of start times
label_counts = {}
used_times = set()
sfreq = raw.info['sfreq']
min_shift = 1.0 / sfreq
max_attempts = 10
for event in boris_events:
if not isinstance(event, list) or len(event) < 3:
continue
event_time = event[0]
label = event[2]
count = label_counts.get(label, 0) + 1
label_counts[label] = count
if label not in open_events:
open_events[label] = []
if count % 2 == 1:
open_events[label].append(event_time)
else:
if open_events[label]:
start_time = open_events[label].pop(0)
duration = event_time - start_time
if duration <= 0:
continue
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(duration)
descriptions.append(label)
# Handle unmatched starts
for label, starts in open_events.items():
for start_time in starts:
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(0.0)
descriptions.append(label)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
raw.set_annotations(new_annotations)
write_raw_snirf(raw, save_path)
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}") QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
@@ -466,79 +575,9 @@ class UpdateEventsWindow(QWidget):
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}") QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}")
# def update_optode_positions(self, file_a, file_b, save_path):
# fiducials = {}
# ch_positions = {}
# # Read the lines from the optode file
# with open(file_b, 'r') as f:
# for line in f:
# if line.strip():
# # Split by the semicolon and convert to meters
# ch_name, coords_str = line.split(":")
# coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
# # The key we have is a fiducial
# if ch_name.lower() in ['lpa', 'nz', 'rpa']:
# fiducials[ch_name.lower()] = coords
# # The key we have is a source or detector
# else:
# ch_positions[ch_name.upper()] = coords
# # Create montage with updated coords in head space
# initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
# # Read the SNIRF file, set the montage, and write it back
# # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
# raw = read_raw_snirf(file_a, preload=True)
# raw.set_montage(initial_montage)
# write_raw_snirf(raw, save_path)
# def _apply_events_to_snirf(self, raw, new_annotations, save_path):
# raw.set_annotations(new_annotations)
# write_raw_snirf(raw, save_path)
# def _write_event_mapping_json(
# self,
# file_a,
# file_b,
# selected_obs,
# snirf_anchor,
# boris_anchor,
# time_shift,
# mapped_events,
# save_path
# ):
# payload = {
# "source": {
# "called_from": self.caller,
# "snirf_file": os.path.basename(file_a),
# "boris_file": os.path.basename(file_b),
# "observation": selected_obs
# },
# "alignment": {
# "snirf_anchor": snirf_anchor,
# "boris_anchor": boris_anchor,
# "time_shift_seconds": time_shift
# },
# "events": mapped_events,
# "created_at": datetime.utcnow().isoformat() + "Z"
# }
# with open(save_path, "w", encoding="utf-8") as f:
# json.dump(payload, f, indent=2)
# return save_path
class UpdateEventsBlazesWindow(QWidget): class UpdateEventsBlazesWindow(QWidget):
def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): def __init__(self, parent: Optional[QWidget]=None, mode: EventUpdateMode=EventUpdateMode.WRITE_SNIRF, caller: Optional[Union[str, object]] = None,):
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self.mode = mode self.mode = mode
@@ -692,12 +731,13 @@ class UpdateEventsBlazesWindow(QWidget):
if file_path: if file_path:
self.line_edit_file_a.setText(file_path) self.line_edit_file_a.setText(file_path)
try: try:
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed # Memory leak safe: Extracted in a separate process that terminates immediately
raw = read_raw_snirf(file_path, preload=False) annotations: Annotations = load_snirf_annotations_isolated(file_path)
annotations = raw.annotations
print(f"Loaded {len(annotations)} annotations from {file_path}")
# Build individual event entries # Build individual event entries
event_entries = [] event_entries: list[str] = []
for onset, description in zip(annotations.onset, annotations.description): for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s" event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str) event_entries.append(event_str)
@@ -748,12 +788,11 @@ class UpdateEventsBlazesWindow(QWidget):
event_strings: List[str] = [] event_strings: List[str] = []
# The new format is a flat list chronologically ordered # Flat list chronologically ordered
for event in data["events"]: for event in data["events"]:
track_name = event.get("track_name", "Unknown") track_name = event.get("track_name", "Unknown")
onset = event.get("start_sec", 0.0) onset = event.get("start_sec", 0.0)
# Formatting to match your SNIRF style: "Event Name @ 0.000s"
display_str = f"{track_name} @ {onset:.3f}s" display_str = f"{track_name} @ {onset:.3f}s"
event_strings.append(display_str) event_strings.append(display_str)
@@ -792,7 +831,9 @@ class UpdateEventsBlazesWindow(QWidget):
time_shift = snirf_anchor_time - json_anchor_time time_shift = snirf_anchor_time - json_anchor_time
onsets, durations, descriptions = [], [], [] onsets: list[float] = []
durations: list[float] = []
descriptions: list[str] = []
skipped_count = 0 skipped_count = 0
try: try:
@@ -806,7 +847,7 @@ class UpdateEventsBlazesWindow(QWidget):
original_end = event.get("end_sec", original_start) original_end = event.get("end_sec", original_start)
duration = original_end - original_start duration = original_end - original_start
# FILTER: Minimum 0.1s duration # Minimum 0.1s duration
if duration < 0.1: if duration < 0.1:
skipped_count += 1 skipped_count += 1
continue continue
@@ -834,19 +875,9 @@ class UpdateEventsBlazesWindow(QWidget):
if not save_path.lower().endswith(".snirf"): save_path += ".snirf" if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
try: try:
raw = read_raw_snirf(file_a, preload=True) # Memory leak safe: Worker handles read, write, and complete memory reclamation upon exit
write_snirf_with_annotations_isolated(file_a, save_path, onsets, durations, descriptions)
# Create annotations
new_annotations = Annotations(
onset=onsets,
duration=durations,
description=descriptions
)
# Replace existing annotations with the new aligned JSON tracks
raw.set_annotations(new_annotations)
write_raw_snirf(raw, save_path)
QMessageBox.information(self, "Success", QMessageBox.information(self, "Success",
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)") f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
except Exception as e: except Exception as e:
+1 -2
View File
@@ -1,6 +1,6 @@
""" """
Filename: updateoptodes.py Filename: updateoptodes.py
Description: Methods to update optode locations for FLARES Description: Methods to update optode locations
Note: Compliant with pylance strict type checking Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw Author: Tyler de Zeeuw
@@ -8,7 +8,6 @@ License: GPL-3.0
""" """
# Built-in imports # Built-in imports
import os
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Union from typing import Dict, Optional, Union