start of plugin support?
This commit is contained in:
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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,24 +310,20 @@ 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:
|
||||||
logging.basicConfig(
|
log_path = os.path.join(get_app_dir(), "fnirs_analysis.log")
|
||||||
filename='fnirs_analysis.log',
|
|
||||||
level=logging.INFO,
|
logging.basicConfig(
|
||||||
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
|
filename=log_path,
|
||||||
datefmt='%Y-%m-%d %H:%M:%S',
|
level=logging.INFO,
|
||||||
filemode='a'
|
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
|
||||||
)
|
datefmt='%Y-%m-%d %H:%M:%S',
|
||||||
|
filemode='a'
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
os.remove(log_path)
|
if os.path.exists(log_path):
|
||||||
except:
|
os.remove(log_path)
|
||||||
pass
|
except Exception as e:
|
||||||
|
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
@@ -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
@@ -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
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
+200
-169
@@ -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,27 +10,184 @@ 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
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
from mne import Annotations
|
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,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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user