start of plugin support?
This commit is contained in:
1 parent
815f342ead
commit
ca203fcb56
9 files changed
+476
-243
No files matched your search
+201
-170
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
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
|
||||
License: GPL-3.0
|
||||
@@ -9,27 +10,184 @@ License: GPL-3.0
|
||||
# Built-in imports
|
||||
import os
|
||||
import json
|
||||
import concurrent.futures
|
||||
from enum import Enum, auto
|
||||
from typing import Any, List, Optional, cast
|
||||
from typing import Any, List, Optional, Sequence, Union, cast
|
||||
|
||||
# External library imports
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from mne import Annotations
|
||||
from mne.io import read_raw_snirf #type: ignore
|
||||
from mne_nirs.io import write_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.io.base import BaseRaw
|
||||
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):
|
||||
WRITE_SNIRF = auto() # destructive
|
||||
WRITE_JSON = auto() # non-destructive
|
||||
|
||||
|
||||
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)
|
||||
|
||||
self.mode = mode
|
||||
@@ -210,12 +368,13 @@ class UpdateEventsWindow(QWidget):
|
||||
if file_path:
|
||||
self.line_edit_file_a.setText(file_path)
|
||||
try:
|
||||
# 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_path, preload=False)
|
||||
annotations = raw.annotations
|
||||
# Memory leak safe: Extracted in a separate process that terminates immediately
|
||||
annotations: Annotations = load_snirf_annotations_isolated(file_path)
|
||||
|
||||
print(f"Loaded {len(annotations)} annotations from {file_path}")
|
||||
|
||||
# Build individual event entries
|
||||
event_entries = []
|
||||
event_entries: list[str] = []
|
||||
for onset, description in zip(annotations.onset, annotations.description):
|
||||
event_str = f"{description} @ {onset:.3f}s"
|
||||
event_entries.append(event_str)
|
||||
@@ -251,6 +410,7 @@ class UpdateEventsWindow(QWidget):
|
||||
|
||||
except (json.JSONDecodeError, FileNotFoundError, KeyError) as 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]:
|
||||
if "observations" not in data:
|
||||
@@ -274,11 +434,16 @@ class UpdateEventsWindow(QWidget):
|
||||
self.combo_events.setEnabled(False)
|
||||
return
|
||||
|
||||
event_entries = []
|
||||
for event in events:
|
||||
if isinstance(event, list) and len(event) >= 3:
|
||||
timestamp = event[0]
|
||||
label = event[2]
|
||||
event_entries: list[str] = []
|
||||
for raw_event in events:
|
||||
if not isinstance(raw_event, (list, tuple)):
|
||||
continue
|
||||
|
||||
event = cast(Sequence[Any], raw_event)
|
||||
|
||||
if len(event) >= 3:
|
||||
timestamp = float(event[0])
|
||||
label = str(event[2])
|
||||
display = f"{label} @ {timestamp:.3f}"
|
||||
event_entries.append(display)
|
||||
|
||||
@@ -363,71 +528,15 @@ class UpdateEventsWindow(QWidget):
|
||||
save_path += ".snirf"
|
||||
|
||||
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 ---
|
||||
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.")
|
||||
QMessageBox.information(self, "Success", f"SNIRF file updated with {event_count} aligned BORIS events.")
|
||||
|
||||
except Exception as 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}")
|
||||
|
||||
|
||||
# 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):
|
||||
|
||||
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)
|
||||
|
||||
self.mode = mode
|
||||
@@ -692,12 +731,13 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
if file_path:
|
||||
self.line_edit_file_a.setText(file_path)
|
||||
try:
|
||||
# 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_path, preload=False)
|
||||
annotations = raw.annotations
|
||||
# Memory leak safe: Extracted in a separate process that terminates immediately
|
||||
annotations: Annotations = load_snirf_annotations_isolated(file_path)
|
||||
|
||||
print(f"Loaded {len(annotations)} annotations from {file_path}")
|
||||
|
||||
# Build individual event entries
|
||||
event_entries = []
|
||||
event_entries: list[str] = []
|
||||
for onset, description in zip(annotations.onset, annotations.description):
|
||||
event_str = f"{description} @ {onset:.3f}s"
|
||||
event_entries.append(event_str)
|
||||
@@ -748,12 +788,11 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
|
||||
event_strings: List[str] = []
|
||||
|
||||
# The new format is a flat list chronologically ordered
|
||||
# Flat list chronologically ordered
|
||||
for event in data["events"]:
|
||||
track_name = event.get("track_name", "Unknown")
|
||||
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"
|
||||
event_strings.append(display_str)
|
||||
|
||||
@@ -792,7 +831,9 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
|
||||
time_shift = snirf_anchor_time - json_anchor_time
|
||||
|
||||
onsets, durations, descriptions = [], [], []
|
||||
onsets: list[float] = []
|
||||
durations: list[float] = []
|
||||
descriptions: list[str] = []
|
||||
skipped_count = 0
|
||||
|
||||
try:
|
||||
@@ -806,7 +847,7 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
original_end = event.get("end_sec", original_start)
|
||||
duration = original_end - original_start
|
||||
|
||||
# FILTER: Minimum 0.1s duration
|
||||
# Minimum 0.1s duration
|
||||
if duration < 0.1:
|
||||
skipped_count += 1
|
||||
continue
|
||||
@@ -834,19 +875,9 @@ class UpdateEventsBlazesWindow(QWidget):
|
||||
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
|
||||
|
||||
try:
|
||||
raw = read_raw_snirf(file_a, preload=True)
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
QMessageBox.information(self, "Success",
|
||||
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
|
||||
except Exception as e:
|
||||
|
||||
Reference in new issue
Block a user