"""
Filename: main.py
Description: FLARES main executable
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import sys
import json
import time
import pickle
import shutil
import platform
import traceback
import subprocess
import concurrent.futures
from queue import Empty
from enum import Enum, auto
from pathlib import Path, PurePosixPath
from datetime import datetime
from multiprocessing import Process, current_process, freeze_support, Manager, Queue
# External library imports
import numpy as np
import pandas as pd
import psutil
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
from mne.io import read_raw_snirf
from mne.preprocessing.nirs import source_detector_distances
from mne_nirs.io import write_raw_snirf
from mne.channels import make_dig_montage
from mne_nirs.channels import get_short_channels # type: ignore
from mne import Annotations
from PySide6.QtWidgets import (
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter,
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QListView, QMenu, QSpinBox, QProgressBar
)
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint
from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
CURRENT_VERSION = "1.4.3"
APP_NAME = "flares"
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
PLATFORM_NAME = platform.system().lower()
# Selectable parameters on the right side of the window
SECTIONS = [
{
"title": "Preprocessing",
"params": [
{"name": "DOWNSAMPLE", "default": True, "type": bool, "help": "Should the snirf files be downsampled? If this is set to True, DOWNSAMPLE_FREQUENCY will be used as the target frequency to downsample to."},
{"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "depends_on": "DOWNSAMPLE", "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated."},
]
},
{
"title": "Trimming",
"params": [
{"name": "TRIM", "default": True, "type": bool, "help": "Should the start of the files be trimmed?"},
{"name": "SECONDS_TO_KEEP", "default": 5, "type": float, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
]
},
{
"title": "Verify Optode Placement",
"params": [
{"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "help": "Should an image be generated for each participant outlining their optode placement on a head?"},
{"name": "SHOW_OPTODE_NAMES", "default": True, "type": bool, "depends_on": "OPTODE_PLACEMENT", "help": "Should the optode names be written next to their location in the image?"},
]
},
{
"title": "Short/Long Channels",
"params": [
{"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."},
{"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNEL", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
{"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
]
},
{
"title": "Heart Rate",
"params": [
{"name": "HEART_RATE", "default": True, "type": bool, "help": "Should an attempt be made to calculate the participants heart rate?"},
{"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "depends_on": "HEART_RATE", "help": "Will remove this many seconds from the start and end of the file. Useful if recording before cap is firmly placed, or participant removes cap while still recording."},
{"name": "MAX_LOW_HR", "default": 40, "type": int, "depends_on": "HEART_RATE", "help": "Any heart rate windows that average below this value will be rounded up to this value."},
{"name": "MAX_HIGH_HR", "default": 200, "type": int, "depends_on": "HEART_RATE", "help": "Any heart rate windows that average above this value will be rounded down to this value."},
{"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "depends_on": "HEART_RATE", "help": "How many individual data points to be used to create a single data point/window."},
{"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "depends_on": "HEART_RATE", "help": "Only used for visualization. Shows the 'range' of the calculated heart rate, which is just the average +- this value."},
]
},
{
"title": "Scalp Coupling Index",
"params": [
{"name": "SCI", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "help": "Independent SCI calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "help": "SCI threshold on a scale of 0-1. A value of 0 is bad coupling while a value of 1 is perfect coupling. Any channels lower than this value will be marked as bad."},
]
},
{
"title": "Signal to Noise Ratio",
"params": [
{"name": "SNR", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Signal to Noise Ratio. This metric calculates how much of the observed signal was noise versus how much of it was a useful signal."},
{"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "depends_on": "SNR", "help": "SNR threshold (dB). A typical scale would be 0-25, but it is possible for values to be both above and below this range. Higher values correspond to a better signal. If SNR is True, any channels lower than this value will be marked as bad."},
]
},
{
"title": "Peak Spectral Power",
"params": [
{"name": "PSP", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of a frequency component that is most prominent in a particular frequency range or spectrum."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "help": "Independent PSP calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."},
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "help": "PSP threshold. A typical scale would be 0-0.5, but it is possible for values to be above this range. Higher values correspond to a better signal. If PSP is True, any channels lower than this value will be marked as bad."},
]
},
{
"title": "Cross Validation",
"params": [
{"name": "CV", "default": True, "type": bool, "help": "Identifies bad channels using the Coefficient of Variation."},
{"name": "CV_THRESHOLD", "default": 20, "type": int, "depends_on": "CV", "help": "Noise threshold (%)."},
]
},
{
"title": "Median Absolute Deviation",
"params": [
{"name": "MAD", "default": True, "type": bool, "help": "Identifies bad channels using Mean Absolute Deviation."},
{"name": "MAD_THRESHOLD", "default": 4, "type": int, "depends_on": "MAD", "help": "Amount of deviations before the channel is flagged bad."},
]
},
{
"title": "Power Spectral Density Noise",
"params": [
{"name": "PSD_NOISE", "default": True, "type": bool, "help": "Identifies bad channels based on their excessive power at high frequencies."},
{"name": "TARGET_FREQ_DIV", "default": 4, "type": int, "depends_on": "PSD_NOISE", "help": "What frequency to check for excessive power. Will take the recording frequency and divide by this number. Has to be greater than 2."},
{"name": "DB_LIMIT", "default": -60, "type": int, "depends_on": "PSD_NOISE", "help": "What db level the power level needs to be below at the target frequency."},
]
},
{
"title": "Channel Variance",
"params": [
{"name": "CHANNEL_VAR", "default": True, "type": bool, "help": "Identifies bad channels based on comparing the variance of the first 25% of the data to the last 25%."},
{"name": "CHANNEL_THRESH", "default": 0.05, "type": float, "depends_on": "CHANNEL_VAR", "help": "If the end variance is less than this % of the start variance, the channel will be marked as bad."},
]
},
{
"title": "Bad Channels Handling",
"params": [
{"name": "BAD_CHANNELS_HANDLING", "default": ["Interpolate"], "type": list, "options": ["Interpolate", "Remove", "None"], "exclusive": True, "help": "How should we deal with the bad channels that occurred? Note: Some analysis options will only work when this is set to 'Interpolate'."},
{"name": "MAX_DIST", "default": 0.03, "type": float, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "help": "The maximum distance to look for neighbours when interpolating. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "MIN_NEIGHBORS", "default": 2, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "help": "The minimumn amount of neighbours needed within the MAX_DIST parameter. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "MAX_BAD_CHANNELS", "default": 12, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Remove", "help": "Maximum amount of bad channels before the participant as a whole is marked as bad (exclusive). If this occurs, the participant will be prevented from processing any further. Used only when BAD_CHANNELS_HANDLING is set to 'Remove'."},
]
},
{
"title": "Optical Density",
"params": [
# NOTE: Intentionally empty
]
},
{
"title": "Temporal Derivative Distribution Repair filtering",
"params": [
{"name": "TDDR", "default": True, "type": bool, "help": "Apply Temporal Derivitave Distribution Repair filtering - a method that removes baseline shift and spike artifacts from the data."},
]
},
{
"title": "Wavelet filtering",
"params": [
{"name": "WAVELET", "default": True, "type": bool, "help": "Apply Wavelet filtering. It is a method to filter involving decomposition, threholding, and reconstruction."},
{"name": "IQR", "default": 1.5, "type": float, "depends_on": "WAVELET", "help": "Scaling factor for the Inter-Quartile Range."},
{"name": "WAVELET_TYPE", "default": "db4", "type": str, "depends_on": "WAVELET", "help": "Wavelet type. Valid values are ['bior1.1', 'bior1.3', 'bior1.5', 'bior2.2', 'bior2.4', 'bior2.6', 'bior2.8', 'bior3.1', 'bior3.3', 'bior3.5', 'bior3.7', 'bior3.9', 'bior4.4', 'bior5.5', 'bior6.8', 'coif1', 'coif2', 'coif3', 'coif4', 'coif5', 'coif6', 'coif7', 'coif8', 'coif9', 'coif10', 'coif11', 'coif12', 'coif13', 'coif14', 'coif15', 'coif16', 'coif17', 'db1', 'db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8', 'db9', 'db10', 'db11', 'db12', 'db13', 'db14', 'db15', 'db16', 'db17', 'db18', 'db19', 'db20', 'db21', 'db22', 'db23', 'db24', 'db25', 'db26', 'db27', 'db28', 'db29', 'db30', 'db31', 'db32', 'db33', 'db34', 'db35', 'db36', 'db37', 'db38', 'dmey', 'haar', 'rbio1.1', 'rbio1.3', 'rbio1.5', 'rbio2.2', 'rbio2.4', 'rbio2.6', 'rbio2.8', 'rbio3.1', 'rbio3.3', 'rbio3.5', 'rbio3.7', 'rbio3.9', 'rbio4.4', 'rbio5.5', 'rbio6.8', 'sym2', 'sym3', 'sym4', 'sym5', 'sym6', 'sym7', 'sym8', 'sym9', 'sym10', 'sym11', 'sym12', 'sym13', 'sym14', 'sym15', 'sym16', 'sym17', 'sym18', 'sym19', 'sym20']"},
{"name": "WAVELET_LEVEL", "default": 3, "type": int, "depends_on": "WAVELET", "help": "Wavelet Decomposition level (must be >= 0)."},
]
},
{
"title": "Haemoglobin Concentration",
"params": [
# NOTE: Intentionally empty
]
},
{
"title": "Enhance Negative Correlation",
"params": [
{"name": "ENHANCE_NEGATIVE_CORRELATION", "default": False, "type": bool, "help": "Apply Enhance Negative Correlation."},
]
},
{
"title": "Filtering",
"params": [
{"name": "FILTER", "default": True, "type": bool, "help": "Should the data be bandpass filtered?"},
{"name": "L_FREQ", "default": 0.005, "type": float, "depends_on": "FILTER", "help": "Any frequencies lower than this value will be removed."},
{"name": "H_FREQ", "default": 0.3, "type": float, "depends_on": "FILTER", "help": "Any frequencies higher than this value will be removed."},
{"name": "L_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "help": "How wide the transitional period should be so the data doesn't just drop off on the lower bound."},
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "help": "How wide the transitional period should be so the data doesn't just drop off on the upper bound."},
]
},
{
"title": "Extracting Events*",
"params": [
#{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
]
},
{
"title": "Epoch Calculations",
"params": [
# TODO: implement drop
{"name": "EPOCH_HANDLING", "default": ["shift"], "type": list, "options": ["shift", "strict"], "help": "What to do if two unique events occur at the same time. Shift will automatically move one event to the first valid free index. Strict will raise an error processing the file. Drop will remove one of the events."},
{"name": "MAX_SHIFT", "default": 5, "type": int, "depends_on": "EPOCH_HANDLING", "depends_value": "shift", "help": "Amount of indexes to look ahead and see if there is a valid one to shift to. If none were found, will fall back to 'strict' behaviour."},
#{"name": "REJECT_BY_ANNOTATIONS", "default": True, "type": bool, "help": "Help."},
#{"name": "MAX_SHIFT", "default": 5, "type": int, "depends_on": "EPOCH_HANDLING", "depends_value": "shift", "help": "Amount of indexes to look ahead and see if there is a valid one to shift to. If none were found, will fall back to 'strict' behaviour."},
#{"name": "MAX_SHIFT", "default": 5, "type": int, "depends_on": "EPOCH_HANDLING", "depends_value": "shift", "help": "Amount of indexes to look ahead and see if there is a valid one to shift to. If none were found, will fall back to 'strict' behaviour."},
{"name": "T_MIN", "default": -5, "type": int, "help": "Seconds before the epoch to be used."},
{"name": "T_MAX", "default": 15, "type": int, "help": "Seconds after the epoch to be used."},
]
},
{
"title": "Design Matrix",
"params": [
{"name": "RESAMPLE", "default": True, "type": bool, "help": "Should the data be resampled before calculating the design matrix? Downsampling is useful for speeding up calculations without losing overall data shape."},
{"name": "RESAMPLE_FREQ", "default": 1, "type": int, "help": "The frequency the data should be resampled to."},
{"name": "HRF_MODEL", "default": ["fir"], "type": list, "options": ["fir", "glover", "spm", "spm + derivative", "spm + derivative + dispersion", "glover + derivative", "glover + derivative + dispersion"], "exclusive": True, "help": "Specifies the haemodynamic response function."},
{"name": "STIM_DUR", "default": 0.5, "type": float, "help": "The length of your stimulus. If your HRF_MODEL is fir, this dictates how wide a bin should be."},
{"name": "DRIFT_MODEL", "default": ["cosine"], "type": list, "options": ["cosine", "polynomial"], "help": "Specifies the desired drift model."},
{"name": "HIGH_PASS", "default": 0.01, "type": float, "help": "High-pass frequency in case of a cosine model (in Hz)."},
{"name": "DRIFT_ORDER", "default": 1, "type": int, "help": "Order of the drift model (in case it is polynomial)"},
{"name": "FIR_DELAYS", "default": 15, "type": range, "depends_on": "HRF_MODEL", "depends_value": "fir", "help": "In case of FIR design, yields the array of delays used in the FIR model (in scans)."},
{"name": "MIN_ONSET", "default": -24, "type": int, "help": "Minimal onset relative to frame times (in seconds)"},
{"name": "OVERSAMPLING", "default": 50, "type": int, "help": "Oversampling factor used in temporal convolutions."},
{"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNEL", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
]
},
{
"title": "General Linear Model",
"params": [
{"name": "NOISE_MODEL", "default": "ar1", "type": str, "help": "The temporal variance model. Defaults to first order auto regressive model 'ar1'. The AR model can be set to any integer value by modifying the value of N. E.g. use ar5 for a fifth order model. If the string auto is provided a model with order 4 times the sample rate will be used."},
{"name": "BINS", "default": 0, "type": int, "help": "Maximum number of discrete bins for the AR coef histogram/clustering. By default the value is 0, which will set the number of bins to the number of channels, effectively estimating the AR model for each channel."},
{"name": "N_JOBS", "default": 1, "type": int, "help": "The number of CPUs to use to do the GLM computation. -1 means 'all CPUs'."},
]
},
{
"title": "Finishing Touches",
"params": [
# Intentionally empty (TODO)
]
},
{
"title": "Other",
"params": [
{"name": "TIME_WINDOW_START", "default": 0, "type": int, "help": "Where to start averaging the fir model bins. Only affects the significance and contrast images."},
{"name": "TIME_WINDOW_END", "default": 15, "type": int, "help": "Where to end averaging the fir model bins. Only affects the significance and contrast images."},
{"name": "MAX_WORKERS", "default": 6, "type": int, "help": "Number of files to be processed at once. Setting this to a small integer value may help on underpowered systems. Remove the value to use an automatic amount."},
{"name": "VERBOSITY", "default": False, "type": bool, "help": "Setting this to True will log lots of debugging information to the log file. Setting this to False will log minimal data."},
]
},
]
class SaveProjectThread(QThread):
finished_signal = Signal(str)
error_signal = Signal(str)
def __init__(self, filename, project_data):
super().__init__()
self.filename = filename
self.project_data = project_data
def run(self):
try:
with open(self.filename, "wb") as f:
pickle.dump(self.project_data, f)
self.finished_signal.emit(self.filename)
except Exception as e:
self.error_signal.emit(str(e))
class SavingOverlay(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setModal(True)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
label = QLabel("Saving Project…")
label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;")
layout.addWidget(label)
self.setLayout(layout)
class TerminalWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
self.output_area = QTextEdit()
self.output_area.setReadOnly(True)
self.input_line = QLineEdit()
self.input_line.returnPressed.connect(self.handle_command)
layout = QVBoxLayout()
layout.addWidget(self.output_area)
layout.addWidget(self.input_line)
self.setLayout(layout)
self.commands = {
"hello": self.cmd_hello,
"help": self.cmd_help,
"version": self.cmd_version,
}
def handle_command(self):
command_text = self.input_line.text()
self.input_line.clear()
self.output_area.append(f"> {command_text}")
parts = command_text.strip().split()
if not parts:
return
command_name = parts[0]
args = parts[1:]
func = self.commands.get(command_name)
if func:
try:
result = func(*args)
if result:
self.output_area.append(str(result))
except Exception as e:
self.output_area.append(f"[Error] {e}")
else:
self.output_area.append(f"[Unknown command] '{command_name}'")
def cmd_hello(self, *args):
return "Hello from the terminal!"
def cmd_help(self, *args):
return f"Available commands: {', '.join(self.commands.keys())}"
def cmd_version(self, *args):
return f"{CURRENT_VERSION}"
class AboutWindow(QWidget):
"""
Simple About window displaying basic application information.
Args:
parent (QWidget, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"About {APP_NAME.upper()}")
self.resize(250, 100)
layout = QVBoxLayout()
label = QLabel(f"About {APP_NAME.upper()}", self)
label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", self)
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
label4 = QLabel(f"Version v{CURRENT_VERSION}")
layout.addWidget(label)
layout.addWidget(label2)
layout.addWidget(label3)
layout.addWidget(label4)
self.setLayout(layout)
class UserGuideWindow(QWidget):
"""
Simple User Guide window displaying basic information on how to use the software.
Args:
parent (QWidget, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"User Guide - {APP_NAME.upper()}")
self.resize(250, 100)
layout = QVBoxLayout()
label = QLabel("Progress Bar Stages:", self)
label2 = QLabel("Stage 1: Preprocessing\n"
"Stage 2: Trimming\n"
"Stage 3: Verify Optode Placement\n"
"Stage 4: Short/Long Cannels\n"
"Stage 5: Heart Rate\n"
"Stage 6: Scalp Coupling Index\n"
"Stage 7: Signal to Noise Ratio\n"
"Stage 8: Peak Spectral Power\n"
"Stage 9: Cross Validation\n"
"Stage 10: Median Absolute Deviation\n"
"Stage 11: Power Spectral Density Noise\n"
"Stage 12: Channel Variance\n"
"Stage 13: Bad Channels Handling\n"
"Stage 14: Optical Density\n"
"Stage 15: Temporal Derivative Distribution Repair Filtering\n"
"Stage 16: Wavelet Filtering\n"
"Stage 17: Haemoglobin Concentration\n"
"Stage 18: Enhance Negative Correlation\n"
"Stage 19: Filter\n"
"Stage 20: Extracting Events\n"
"Stage 21: Epoch Calculations\n"
"Stage 22: Design Matrix\n"
"Stage 23: General Linear Model\n"
"Stage 24: Generate GLM Results\n"
"Stage 25: Generate Channel Significance\n"
"Stage 26: Generate Channel, Region of Interest, and Contrast Results\n"
"Stage 27: Compute Contrast Results\n"
"Stage 28: Finishing Up\n", self)
label3 = QLabel(f"For more information, visit the Git wiki page here.", self)
label3.setTextFormat(Qt.TextFormat.RichText)
label3.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
label3.setOpenExternalLinks(True)
layout.addWidget(label)
layout.addWidget(label2)
layout.addWidget(label3)
self.setLayout(layout)
class UpdateOptodesWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("Update optode positions")
self.resize(760, 200)
self.label_file_a = QLabel("SNIRF file:")
self.line_edit_file_a = QLineEdit()
self.line_edit_file_a.setReadOnly(True)
self.btn_browse_a = QPushButton("Browse .snirf")
self.btn_browse_a.clicked.connect(self.browse_file_a)
self.label_file_b = QLabel("Text file:")
self.line_edit_file_b = QLineEdit()
self.line_edit_file_b.setReadOnly(True)
self.btn_browse_b = QPushButton("Browse .txt/.xlsx")
self.btn_browse_b.clicked.connect(self.browse_file_b)
self.label_suffix = QLabel("Suffix to append to filename:")
self.line_edit_suffix = QLineEdit()
self.line_edit_suffix.setText("flare")
self.btn_clear = QPushButton("Clear")
self.btn_go = QPushButton("Go")
self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout()
self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(False) # Handle the click internally
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.
"
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.
"
"Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.
"
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.
"
"An example format of what a digitization text file should look like can be found by clicking here. Currently only .xlsx files directly exported from a
"
"Polhemus system are supported.")
self.description.linkActivated.connect(self.handle_link_click)
layout.addWidget(self.description)
help_text_a = "Select the SNIRF (.snirf) file to update with new optode positions."
file_a_layout = QHBoxLayout()
# Help button on the left
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
file_a_container = QWidget()
file_a_container_layout = QHBoxLayout()
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
file_a_container_layout.addWidget(self.label_file_a)
file_a_container_layout.addWidget(self.line_edit_file_a)
file_a_container_layout.addWidget(self.btn_browse_a)
file_a_container.setLayout(file_a_container_layout)
file_a_container.setToolTip(help_text_a)
file_a_layout.addWidget(file_a_container)
layout.addLayout(file_a_layout)
help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates, or a .xlsx file from a Polhemius system."
file_b_layout = QHBoxLayout()
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
file_b_container_layout = QHBoxLayout()
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
file_b_container_layout.addWidget(self.label_file_b)
file_b_container_layout.addWidget(self.line_edit_file_b)
file_b_container_layout.addWidget(self.btn_browse_b)
file_b_container.setLayout(file_b_container_layout)
file_b_container.setToolTip(help_text_b)
file_b_layout.addWidget(file_b_container)
layout.addLayout(file_b_layout)
help_text_suffix = "This text will be appended to the original filename when saving. Default is 'flare'."
suffix_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget()
suffix_container_layout = QHBoxLayout()
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
suffix_container_layout.addWidget(self.label_suffix)
suffix_container_layout.addWidget(self.line_edit_suffix)
suffix_container.setLayout(suffix_container_layout)
suffix_container.setToolTip(help_text_suffix)
suffix_layout.addWidget(suffix_container)
layout.addLayout(suffix_layout)
buttons_layout = QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.btn_clear)
buttons_layout.addWidget(self.btn_go)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info - FLARES")
msg.setText(text)
msg.exec()
def handle_link_click(self, link):
if link == "custom_link":
msg = QMessageBox(self)
msg.setWindowTitle("Example Digitization File")
text = "nz: -1.91 85.175 -31.1525\n" \
"rpa: 80.3825 -17.1925 -57.2775\n" \
"lpa: -81.815 -17.1925 -57.965\n" \
"d1: 0.01 -97.5175 62.5875\n" \
"d2: 25.125 -103.415 45.045\n" \
"d3: 49.095 -97.9025 30.2075\n" \
"s1: 0.01 -112.43 32.595\n" \
"s2: 30.325 -84.3125 71.8975\n" \
"s3: 0.01 -70.6875 89.0925\n"
msg.setText(text)
msg.exec()
def browse_file_a(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path:
self.line_edit_file_a.setText(file_path)
def browse_file_b(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)")
if file_path:
self.line_edit_file_b.setText(file_path)
def clear_files(self):
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text()
suffix = self.line_edit_suffix.text().strip() or "flare"
if not file_a:
QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.")
return
if not file_b:
QMessageBox.critical(self, "Missing File", "Please select a TXT file.")
return
# Get original filename without extension
base_name = os.path.splitext(os.path.basename(file_a))[0]
suggested_name = f"{base_name}_{suffix}.snirf"
# Open save dialog with default name
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save SNIRF File As",
suggested_name,
"SNIRF Files (*.snirf)"
)
if not save_path:
print("Save cancelled.")
return
# Ensure .snirf extension
if not save_path.lower().endswith(".snirf"):
save_path += ".snirf"
try:
self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to write file:\n{e}")
return
QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}")
def update_optode_positions(self, file_a, file_b, save_path):
fiducials = {}
ch_positions = {}
extension = Path(file_b).suffix
# Read the lines from the optode file
if extension == '.txt':
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
elif extension == '.xlsx':
# TODO: Bad! Why assume sheet1 has the contents?
df = pd.read_excel(file_b, sheet_name='Sheet1')
def _get_block_data(df, block_id, row_mapping, scale=0.001):
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
# 1. Isolate and clean
block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
block = block.apply(pd.to_numeric, errors='coerce')
# 2. Extract into dictionary based on mapping
result = {}
# If row_mapping is a dict (like {0: 'nz'}), use it directly
if isinstance(row_mapping, dict):
for row_idx, key in row_mapping.items():
if row_idx < len(block):
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
elif isinstance(row_mapping, str):
for i in range(len(block)):
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
return result
# Identify blocks
is_empty = df.isnull().all(axis=1)
df['block_id'] = is_empty.cumsum()
clean_df = df[~is_empty].copy()
# Process Block 2: Landmarks
fiducials = _get_block_data(clean_df, 2, {0: 'nz', 2: 'rpa', 3: 'lpa'})
# Process Block 3: D-Points
d_points = _get_block_data(clean_df, 3, 'D')
# Process Block 4: S-Points
s_points = _get_block_data(clean_df, 4, 'S')
ch_positions = {**d_points, **s_points}
# 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
raw = read_raw_snirf(file_a, preload=True)
raw.set_montage(initial_montage)
write_raw_snirf(raw, save_path)
class EventUpdateMode(Enum):
WRITE_SNIRF = auto() # destructive
WRITE_JSON = auto() # non-destructive
class UpdateEventsWindow(QWidget):
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
super().__init__(parent, Qt.WindowType.Window)
self.mode = mode
self.caller = caller or self.__class__.__name__
self.setWindowTitle("Update event markers")
self.resize(760, 200)
print("INIT MODE:", mode)
self.label_file_a = QLabel("SNIRF file:")
self.line_edit_file_a = QLineEdit()
self.line_edit_file_a.setReadOnly(True)
self.btn_browse_a = QPushButton("Browse .snirf")
self.btn_browse_a.clicked.connect(self.browse_file_a)
self.label_file_b = QLabel("BORIS file:")
self.line_edit_file_b = QLineEdit()
self.line_edit_file_b.setReadOnly(True)
self.btn_browse_b = QPushButton("Browse .boris")
self.btn_browse_b.clicked.connect(self.browse_file_b)
self.label_suffix = QLabel("Filename in BORIS project file:")
self.combo_suffix = QComboBox()
self.combo_suffix.setEditable(False)
self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected)
self.label_events = QLabel("Events in selected observation:")
self.combo_events = QComboBox()
self.combo_events.setEnabled(False)
self.label_snirf_events = QLabel("Events in SNIRF file:")
self.combo_snirf_events = QComboBox()
self.combo_snirf_events.setEnabled(False)
self.btn_clear = QPushButton("Clear")
self.btn_go = QPushButton("Go")
self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout()
self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(True)
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.
"
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.
"
"The software BORIS is used to add these events to video files, and these events can be applied to the snirf file
"
"selected below by selecting the correct BORIS observation and time syncing it to an event that it shares with the snirf file.")
layout.addWidget(self.description)
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
file_a_layout = QHBoxLayout()
# Help button on the left
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
file_a_container = QWidget()
file_a_container_layout = QHBoxLayout()
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
file_a_container_layout.addWidget(self.label_file_a)
file_a_container_layout.addWidget(self.line_edit_file_a)
file_a_container_layout.addWidget(self.btn_browse_a)
file_a_container.setLayout(file_a_container_layout)
file_a_container.setToolTip(help_text_a)
file_a_layout.addWidget(file_a_container)
layout.addLayout(file_a_layout)
help_text_b = "Provide a .boris project file that contains events for this participant."
file_b_layout = QHBoxLayout()
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
file_b_container_layout = QHBoxLayout()
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
file_b_container_layout.addWidget(self.label_file_b)
file_b_container_layout.addWidget(self.line_edit_file_b)
file_b_container_layout.addWidget(self.btn_browse_b)
file_b_container.setLayout(file_b_container_layout)
file_b_container.setToolTip(help_text_b)
file_b_layout.addWidget(file_b_container)
layout.addLayout(file_b_layout)
help_text_suffix = "This participant from the .boris project file matches the .snirf file."
suffix_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget()
suffix_container_layout = QHBoxLayout()
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
suffix_container_layout.addWidget(self.label_suffix)
suffix_container_layout.addWidget(self.combo_suffix)
suffix_container.setLayout(suffix_container_layout)
suffix_container.setToolTip(help_text_suffix)
suffix_layout.addWidget(suffix_container)
layout.addLayout(suffix_layout)
help_text_suffix = "The events extracted from the BORIS project file for the selected observation."
suffix2_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget()
suffix2_container_layout = QHBoxLayout()
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
suffix2_container_layout.addWidget(self.label_events)
suffix2_container_layout.addWidget(self.combo_events)
suffix2_container.setLayout(suffix2_container_layout)
suffix2_container.setToolTip(help_text_suffix)
suffix2_layout.addWidget(suffix2_container)
layout.addLayout(suffix2_layout)
snirf_events_layout = QHBoxLayout()
help_text_snirf_events = "The event markers extracted from the SNIRF file."
help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25)
help_btn_snirf_events.setToolTip(help_text_snirf_events)
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget()
snirf_events_container_layout = QHBoxLayout()
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
snirf_events_container_layout.addWidget(self.label_snirf_events)
snirf_events_container_layout.addWidget(self.combo_snirf_events)
snirf_events_container.setLayout(snirf_events_container_layout)
snirf_events_container.setToolTip(help_text_snirf_events)
snirf_events_layout.addWidget(snirf_events_container)
layout.addLayout(snirf_events_layout)
buttons_layout = QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.btn_clear)
buttons_layout.addWidget(self.btn_go)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def browse_file_a(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
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
# Build individual event entries
event_entries = []
for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str)
if not event_entries:
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
return
self.combo_snirf_events.clear()
self.combo_snirf_events.addItems(event_entries)
self.combo_snirf_events.setEnabled(True)
except Exception as e:
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
def browse_file_b(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
if file_path:
self.line_edit_file_b.setText(file_path)
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.boris_data = data
observation_keys = self.extract_boris_observation_keys(data)
self.combo_suffix.clear()
self.combo_suffix.addItems(observation_keys)
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):
if "observations" not in data:
raise KeyError("Missing 'observations' key in BORIS file.")
observations = data["observations"]
if not isinstance(observations, dict):
raise TypeError("'observations' must be a dictionary.")
return list(observations.keys())
def on_observation_selected(self):
selected_obs = self.combo_suffix.currentText()
if not selected_obs or not hasattr(self, 'boris_data'):
self.combo_events.clear()
self.combo_events.setEnabled(False)
return
try:
events = self.boris_data["observations"][selected_obs]["events"]
except (KeyError, TypeError):
self.combo_events.clear()
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]
display = f"{label} @ {timestamp:.3f}"
event_entries.append(display)
self.combo_events.clear()
self.combo_events.addItems(event_entries)
self.combo_events.setEnabled(bool(event_entries))
def clear_files(self):
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
file_a = self.line_edit_file_a.text()
suffix = "flare"
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
return
# Extract BORIS anchor
try:
boris_label, boris_time_str = self.combo_events.currentText().split(" @ ")
boris_anchor_time = float(boris_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "BORIS Event Error", f"Could not parse BORIS anchor event:\n{e}")
return
# Extract SNIRF anchor
try:
snirf_label, snirf_time_str = self.combo_snirf_events.currentText().split(" @ ")
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor event:\n{e}")
return
time_shift = snirf_anchor_time - boris_anchor_time
selected_obs = self.combo_suffix.currentText()
if not selected_obs or selected_obs not in self.boris_data["observations"]:
QMessageBox.warning(self, "Invalid selection", "Selected observation not found in BORIS file.")
return
boris_events = self.boris_data["observations"][selected_obs].get("events", [])
if not boris_events:
QMessageBox.warning(self, "No BORIS events", "No events found in selected BORIS observation.")
return
snirf_path = self.line_edit_file_a.text()
if not snirf_path:
QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.")
return
boris_obs = self.boris_data["observations"][selected_obs]
# --- Extract videos + delays ---
files = boris_obs.get("file", {})
offsets = boris_obs.get("media_info", {}).get("offset", {})
videos = {}
for key, path in files.items():
if path: # only include videos that exist
delay = offsets.get(key, 0.0) # default 0 if missing
videos[key] = {"file": path, "delay": delay}
base_name = os.path.splitext(os.path.basename(file_a))[0]
if self.mode == EventUpdateMode.WRITE_SNIRF:
# Open save dialog for SNIRF
base_name = os.path.splitext(os.path.basename(file_a))[0]
suggested_name = f"{base_name}_{suffix}.snirf"
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save SNIRF File As",
suggested_name,
"SNIRF Files (*.snirf)"
)
if not save_path:
print("SNIRF save cancelled.")
return
if not save_path.lower().endswith(".snirf"):
save_path += ".snirf"
try:
raw = read_raw_snirf(file_a, preload=True)
# --- 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.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
elif self.mode == EventUpdateMode.WRITE_JSON:
# Open save dialog for JSON
base_name = os.path.splitext(os.path.basename(file_a))[0]
suggested_name = f"{base_name}_{suffix}_alignment.json"
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save Event Alignment JSON As",
suggested_name,
"JSON Files (*.json)"
)
if not save_path:
print("JSON save cancelled.")
return
if not save_path.lower().endswith(".json"):
save_path += ".json"
# Build JSON dict
json_data = {
"observation": selected_obs,
"snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time},
"boris_anchor": {"label": boris_label, "time": boris_anchor_time},
"time_shift": time_shift,
"videos": videos
}
# Write JSON
try:
with open(save_path, "w", encoding="utf-8") as f:
json.dump(json_data, f, indent=4)
QMessageBox.information(self, "Success", f"Event alignment saved to:\n{save_path}")
except Exception as 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):
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
super().__init__(parent, Qt.WindowType.Window)
self.mode = mode
self.caller = caller or self.__class__.__name__
self.setWindowTitle("Update event markers (BLAZES)")
self.resize(760, 200)
self.label_file_a = QLabel("SNIRF file:")
self.line_edit_file_a = QLineEdit()
self.line_edit_file_a.setReadOnly(True)
self.btn_browse_a = QPushButton("Browse .snirf")
self.btn_browse_a.clicked.connect(self.browse_file_a)
self.label_file_b = QLabel("BLAZES file:")
self.line_edit_file_b = QLineEdit()
self.line_edit_file_b.setReadOnly(True)
self.btn_browse_b = QPushButton("Browse .blaze")
self.btn_browse_b.clicked.connect(self.browse_file_b)
self.label_events = QLabel("Events in selected blazes file:")
self.combo_events = QComboBox()
self.combo_events.setEnabled(False)
self.label_snirf_events = QLabel("Events in SNIRF file:")
self.combo_snirf_events = QComboBox()
self.combo_snirf_events.setEnabled(False)
self.btn_clear = QPushButton("Clear")
self.btn_go = QPushButton("Go")
self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout()
self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(True)
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.
"
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.
"
"The software BLAZES is used to create these events in video files, and these events can be applied to the snirf file
"
"selected below by time syncing it to an event that it shares with the snirf file.")
layout.addWidget(self.description)
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
file_a_layout = QHBoxLayout()
# Help button on the left
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
file_a_container = QWidget()
file_a_container_layout = QHBoxLayout()
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
file_a_container_layout.addWidget(self.label_file_a)
file_a_container_layout.addWidget(self.line_edit_file_a)
file_a_container_layout.addWidget(self.btn_browse_a)
file_a_container.setLayout(file_a_container_layout)
file_a_container.setToolTip(help_text_a)
file_a_layout.addWidget(file_a_container)
layout.addLayout(file_a_layout)
help_text_b = "Provide a .blaze output file that contains events for this participant."
file_b_layout = QHBoxLayout()
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
file_b_container_layout = QHBoxLayout()
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
file_b_container_layout.addWidget(self.label_file_b)
file_b_container_layout.addWidget(self.line_edit_file_b)
file_b_container_layout.addWidget(self.btn_browse_b)
file_b_container.setLayout(file_b_container_layout)
file_b_container.setToolTip(help_text_b)
file_b_layout.addWidget(file_b_container)
layout.addLayout(file_b_layout)
help_text_suffix = "The events extracted from the blaze file."
suffix2_layout = QHBoxLayout()
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget()
suffix2_container_layout = QHBoxLayout()
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
suffix2_container_layout.addWidget(self.label_events)
suffix2_container_layout.addWidget(self.combo_events)
suffix2_container.setLayout(suffix2_container_layout)
suffix2_container.setToolTip(help_text_suffix)
suffix2_layout.addWidget(suffix2_container)
layout.addLayout(suffix2_layout)
snirf_events_layout = QHBoxLayout()
help_text_snirf_events = "The event markers extracted from the SNIRF file."
help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25)
help_btn_snirf_events.setToolTip(help_text_snirf_events)
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget()
snirf_events_container_layout = QHBoxLayout()
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
snirf_events_container_layout.addWidget(self.label_snirf_events)
snirf_events_container_layout.addWidget(self.combo_snirf_events)
snirf_events_container.setLayout(snirf_events_container_layout)
snirf_events_container.setToolTip(help_text_snirf_events)
snirf_events_layout.addWidget(snirf_events_container)
layout.addLayout(snirf_events_layout)
buttons_layout = QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.btn_clear)
buttons_layout.addWidget(self.btn_go)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def browse_file_a(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
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
# Build individual event entries
event_entries = []
for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str)
if not event_entries:
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
return
self.combo_snirf_events.clear()
self.combo_snirf_events.addItems(event_entries)
self.combo_snirf_events.setEnabled(True)
except Exception as e:
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
def browse_file_b(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
if file_path:
self.line_edit_file_b.setText(file_path)
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.json_data = data
obs_keys = self.extract_json_observation_strings(data)
self.combo_events.clear()
if obs_keys:
self.combo_events.addItems(obs_keys)
self.combo_events.setEnabled(True)
else:
QMessageBox.information(self, "No Events", "No events found in JSON file.")
self.combo_events.setEnabled(False)
except (json.JSONDecodeError, FileNotFoundError, KeyError, TypeError) as e:
QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}")
self.combo_events.clear()
self.combo_events.setEnabled(False)
def extract_json_observation_strings(self, data):
if "events" not in data:
raise KeyError("Missing 'events' key in JSON file.")
event_strings = []
# The new format is a 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)
return event_strings
def clear_files(self):
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text()
suffix = APP_NAME
if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.")
return
try:
json_text = self.combo_events.currentText()
_, json_time_str = json_text.split(" @ ")
json_anchor_time = float(json_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "JSON Event Error", f"Could not parse JSON anchor:\n{e}")
return
try:
snirf_text = self.combo_snirf_events.currentText()
_, snirf_time_str = snirf_text.split(" @ ")
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
except Exception as e:
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor:\n{e}")
return
time_shift = snirf_anchor_time - json_anchor_time
onsets, durations, descriptions = [], [], []
skipped_count = 0
try:
events_list = self.json_data.get("events", [])
for event in events_list:
track_name = event.get("track_name", "Unknown")
clean_name = track_name.replace("AI: ", "").strip()
original_start = event.get("start_sec", 0.0)
original_end = event.get("end_sec", original_start)
duration = original_end - original_start
# FILTER: Minimum 0.1s duration
if duration < 0.1:
skipped_count += 1
continue
# Apply shift
adjusted_onset = original_start + time_shift
onsets.append(round(adjusted_onset, 6))
durations.append(round(duration, 6))
descriptions.append(clean_name)
except Exception as e:
QMessageBox.critical(self, "Track Error", f"Failed to process tracks: {e}")
return
if not onsets:
QMessageBox.warning(self, "No Data", f"No events met the 0.1s threshold. (Skipped {skipped_count})")
return
if self.mode == EventUpdateMode.WRITE_SNIRF:
suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf"
save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)")
if not save_path: return
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)
QMessageBox.information(self, "Success",
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
class ProgressBubble(QWidget):
"""
A clickable widget displaying a progress bar made of colored rectangles and a label.
Args:
display_name (str): Text to display above the progress bar.
file_path (str): Associated file path stored with the bubble.
"""
clicked = Signal(object)
rightClicked = Signal(object, QPoint)
def __init__(self, display_name, file_path):
super().__init__()
self.layout = QVBoxLayout()
self.label = QLabel(display_name)
self.loading_timer = QTimer(self)
self.loading_timer.timeout.connect(self._rotate_spinner)
self.spinner_frames = ["◐", "◓", "◑", "◒"] #cute
self.spinner_idx = 0
self.is_loading = False
self.base_text = display_name
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label.setStyleSheet("""
QLabel {
border: 1px solid #888;
border-radius: 10px;
padding: 8px 12px;
background-color: #e0f0ff;
}
""")
self.progress_layout = QHBoxLayout()
self.rects = []
for _ in range(28):
rect = QFrame()
rect.setFixedSize(10, 18)
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
self.progress_layout.addWidget(rect)
self.rects.append(rect)
self.layout.addWidget(self.label)
self.layout.addLayout(self.progress_layout)
self.setLayout(self.layout)
# Store the file path
self.file_path = os.path.normpath(file_path)
self.current_step = 0
# Make the bubble appear to the user as clickable
self.setCursor(Qt.CursorShape.PointingHandCursor)
# Resize policy to make bubbles responsive
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
def set_loading_state(self, loading=True):
self.is_loading = loading
if loading:
self.loading_timer.start(150) # Rotate every 150ms
else:
self.loading_timer.stop()
# Transition to a green checkmark
self.setSuffixText(" ✔")
def update_progress(self, step_index, active=True):
self.current_step = step_index
for i, rect in enumerate(self.rects):
if i < step_index:
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
elif i == step_index:
color = "yellow" if active else "white"
rect.setStyleSheet(f"background-color: {color}; border: 1px solid gray;")
else:
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
def mark_cancelled(self):
if 0 <= self.current_step < len(self.rects):
rect = self.rects[self.current_step]
rect.setStyleSheet("background-color: red; border: 1px solid gray;")
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self)
elif event.button() == Qt.MouseButton.RightButton:
self.rightClicked.emit(self, event.globalPosition().toPoint())
super().mousePressEvent(event)
def setSuffixText(self, suffix):
if suffix:
self.label.setText(f"{self.base_text} {suffix}")
else:
self.label.setText(self.base_text)
def _rotate_spinner(self):
frame = self.spinner_frames[self.spinner_idx % len(self.spinner_frames)]
# Using HTML in setText allows us to style the spinner specifically
self.setSuffixText(f" {frame}")
self.spinner_idx += 1
class ParamSection(QWidget):
"""
A widget section that dynamically creates labeled input fields from parameter metadata.
Args:
section_data (dict): Dictionary containing section title and list of parameter info.
Expected format:
{
"title": str,
"params": [
{
"name": str,
"type": type,
"default": any,
"help": str (optional)
},
...
]
}
"""
def __init__(self, section_data, global_widgets):
super().__init__()
layout = QVBoxLayout()
self.setLayout(layout)
self.widgets = global_widgets
self.dependencies = []
self.selected_path = None
# Title label
title_label = QLabel(section_data["title"])
title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;")
layout.addWidget(title_label)
# Horizontal line
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
for param in section_data["params"]:
h_layout = QHBoxLayout()
label = QLabel(param["name"])
label.setToolTip(param.get("help", ""))
help_text = param.get("help", "")
help_btn = QPushButton("?")
help_btn.setFixedWidth(25)
help_btn.setToolTip(help_text)
help_btn.clicked.connect(lambda _, text=help_text: self.show_help_popup(text))
h_layout.addWidget(help_btn)
h_layout.addWidget(label)
h_layout.setStretch(0, 1)
h_layout.setStretch(1, 6)
default_val = param["default"]
# Create input widget based on type
if param["type"] == bool:
widget = QComboBox()
widget.addItems(["True", "False"])
widget.setCurrentText(str(default_val))
widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
widget.currentTextChanged.connect(self.notify_global_update)
elif param["type"] == int:
widget = QLineEdit()
widget.setValidator(QIntValidator())
widget.setText(str(default_val))
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
elif param["type"] == float:
widget = QLineEdit()
widget.setValidator(QDoubleValidator())
widget.setText(str(default_val))
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
elif param["type"] == list:
if param.get("exclusive", True):
widget = QComboBox()
widget.addItems(param.get("options", []))
widget.setCurrentText(str(default_val))
widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
widget.currentTextChanged.connect(self.notify_global_update)
else:
widget = self._create_multiselect_dropdown(None)
elif param["type"] == range:
widget = QSpinBox()
widget.setRange(0, 999) #NOTE: will this be a high enough limit?
# If default is "None" or range(15), handle it gracefully:
if isinstance(default_val, range):
widget.setValue(default_val.stop)
elif str(default_val).isdigit():
widget.setValue(int(default_val))
else:
widget.setValue(15) # Default fallback
widget.valueChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
else:
widget = QLineEdit()
widget.setText(str(default_val))
if "depends_on" in param:
self.dependencies.append({
"child_name": param["name"],
"parent_name": param["depends_on"],
"depends_value": param.get("depends_value", "True")
})
widget.setToolTip(help_text)
h_layout.addWidget(widget)
h_layout.setStretch(2, 3)
layout.addLayout(h_layout)
self.widgets[param["name"]] = {
"widget": widget,
"label": label,
"default": default_val,
"type": param["type"],
"h_layout": h_layout
}
self.update_dependencies()
def has_any_changes(self):
"""Returns True if any parameter in this section differs from its default."""
for name, info in self.widgets.items():
default = info["default"]
current_val = self.get_param_values().get(name)
if str(current_val) != str(default):
return True
return False
def check_if_changed(self, param_name, current_value):
"""Toggles bold font on the label if the value differs from default."""
info = self.widgets.get(param_name)
if not info:
return
label = info["label"]
default = info["default"]
is_changed = False
if info["type"] == list:
# If it's an exclusive ComboBox, current_value is a string.
# We wrap it in a list to compare it to the default list.
if isinstance(current_value, str):
normalized_current = [current_value]
else:
normalized_current = current_value # Already a list from multi-select
# Ensure default is a list for comparison
normalized_default = default if isinstance(default, list) else [default]
# Use sorted to ensure order doesn't matter
is_changed = sorted(normalized_current) != sorted(normalized_default)
# 2. Handle Range (SpinBox)
elif info["type"] == range:
ref = default.stop if isinstance(default, range) else default
try:
is_changed = int(current_value) != int(ref)
except (ValueError, TypeError):
is_changed = True
# 3. Standard Comparison (bool, int, float, str)
else:
is_changed = str(current_value) != str(default)
# Update Font
font = label.font()
font.setBold(is_changed)
label.setFont(font)
# Optional: Change color to make it even more obvious
if is_changed:
label.setStyleSheet("color: #3498db; font-weight: bold;") # Nice Blue
else:
label.setStyleSheet("color: none; font-weight: normal;")
def notify_global_update(self):
"""
Since dependencies can cross sections, we need to tell
all sections to refresh their enabled/disabled states.
"""
# If you have a reference to the parent container, call its update.
# Otherwise, you can iterate through the known param_sections:
for section in self.parent().findChildren(ParamSection):
section.update_dependencies()
def update_dependencies(self):
"""Disables/Enables widgets based on parent selection values."""
for dep in self.dependencies:
child_info = self.widgets.get(dep["child_name"])
parent_info = self.widgets.get(dep["parent_name"])
if child_info and parent_info:
parent_widget = parent_info["widget"]
# Get current value of parent (works for both bool-combos and list-combos)
current_parent_value = parent_widget.currentText()
# Check if it matches the required value
is_active = (current_parent_value == dep["depends_value"])
# Toggle the entire row (Button, Label, and Input)
h_layout = child_info["h_layout"]
for i in range(h_layout.count()):
item = h_layout.itemAt(i).widget()
if item:
item.setEnabled(is_active)
def _create_multiselect_dropdown(self, items):
combo = FullClickComboBox()
combo.setView(QListView())
model = QStandardItemModel()
combo.setModel(model)
combo.setEditable(True)
combo.lineEdit().setReadOnly(True)
combo.lineEdit().setPlaceholderText("Select...")
dummy_item = QStandardItem("")
dummy_item.setFlags(Qt.ItemIsEnabled)
model.appendRow(dummy_item)
toggle_item = QStandardItem("Toggle Select All")
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(toggle_item)
if items is not None:
for item in items:
standard_item = QStandardItem(item)
standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
standard_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(standard_item)
combo.setInsertPolicy(QComboBox.NoInsert)
def on_view_clicked(index):
item = model.itemFromIndex(index)
if item.isCheckable():
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
item.setCheckState(new_state)
combo.view().pressed.connect(on_view_clicked)
self._updating_checkstates = False
def on_item_changed(item):
if self._updating_checkstates:
return
self._updating_checkstates = True
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle
if item == toggle_item:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
if all_checked:
for i in normal_items:
i.setCheckState(Qt.Unchecked)
toggle_item.setCheckState(Qt.Unchecked)
else:
for i in normal_items:
i.setCheckState(Qt.Checked)
toggle_item.setCheckState(Qt.Checked)
elif item == dummy_item:
pass
else:
# When normal items change, update toggle item
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
self._updating_checkstates = False
for param_name, info in self.widgets.items():
if info["widget"] == combo:
self.update_dropdown_label(param_name)
break
model.itemChanged.connect(on_item_changed)
combo.setInsertPolicy(QComboBox.NoInsert)
return combo
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def get_param_values(self):
values = {}
for name, info in self.widgets.items():
widget = info["widget"]
expected_type = info["type"]
if name == "SHORT_CHANNEL_REGRESSION":
# If the widget is disabled (greyed out), force False
if not widget.isEnabled():
values[name] = False
continue
if expected_type == bool:
values[name] = widget.currentText() == "True"
elif expected_type == list:
if isinstance(widget, FullClickComboBox):
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()]
elif isinstance(widget, QComboBox):
values[name] = widget.currentText()
elif expected_type == range:
if isinstance(widget, QSpinBox):
# Convert the integer N into range(N)
values[name] = range(widget.value())
else:
values[name] = range(15) # Fallback
else:
raw_text = widget.text()
try:
if expected_type == int:
values[name] = int(raw_text)
elif expected_type == float:
values[name] = float(raw_text)
elif expected_type == str:
values[name] = raw_text
else:
values[name] = raw_text # Fallback
except Exception as e:
raise ValueError(f"Invalid value for {name}: {raw_text}") from e
return values
def update_dropdown_items(self, param_name, new_items):
"""
Updates the items in a multi-select dropdown parameter field.
Args:
param_name (str): The parameter name (must match one in self.widgets).
new_items (list): The new items to populate in the dropdown.
"""
widget_info = self.widgets.get(param_name)
#print("[ParamSection] Current widget keys:", list(self.widgets.keys()))
if not widget_info:
print(f"[ParamSection] No widget found for param '{param_name}'")
return
widget = widget_info["widget"]
if not isinstance(widget, FullClickComboBox):
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
return
# Replace the model on the existing widget
new_model = QStandardItemModel()
dummy_item = QStandardItem("")
dummy_item.setFlags(Qt.ItemIsEnabled)
new_model.appendRow(dummy_item)
toggle_item = QStandardItem("Toggle Select All")
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
new_model.appendRow(toggle_item)
for item_text in new_items:
item = QStandardItem(item_text)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setData(Qt.Unchecked, Qt.CheckStateRole)
new_model.appendRow(item)
widget.setModel(new_model)
widget.setView(QListView()) # Reset view to refresh properly
def on_view_clicked(index):
item = new_model.itemFromIndex(index)
if item.isCheckable():
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
item.setCheckState(new_state)
widget.view().pressed.connect(on_view_clicked)
def on_item_changed(item):
if getattr(self, "_updating_checkstates", False):
return
self._updating_checkstates = True
normal_items = [new_model.item(i) for i in range(2, new_model.rowCount())]
if item == toggle_item:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
for i in normal_items:
i.setCheckState(Qt.Unchecked if all_checked else Qt.Checked)
toggle_item.setCheckState(Qt.Unchecked if all_checked else Qt.Checked)
else:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
self._updating_checkstates = False
for param_name, info in self.widgets.items():
if info["widget"] == widget:
self.update_dropdown_label(param_name)
break
new_model.itemChanged.connect(on_item_changed)
widget.lineEdit().setText("")
def _get_checked_items(self, combo):
checked = []
model = combo.model()
for i in range(model.rowCount()):
item = model.item(i)
if item.text() in ("", "Toggle Select All"):
continue
if item.checkState() == Qt.Checked:
checked.append(item.text())
return checked
def update_dropdown_label(self, param_name):
widget_info = self.widgets.get(param_name)
if not widget_info:
print(f"[ParamSection] No widget found for param '{param_name}'")
return
widget = widget_info["widget"]
if not isinstance(widget, FullClickComboBox):
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
return
selected = self._get_checked_items(widget)
if not selected:
widget.lineEdit().setText("")
else:
# You can customize how you display selected items here:
widget.lineEdit().setText(", ".join(selected))
# def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1):
# file_paths = [bubble.file_path for bubble in bubble_widgets.values()]
# if not file_paths:
# return
# # 1. Start the UI immediately
# progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self)
# progress.setWindowModality(Qt.WindowModality.WindowModal)
# progress.setMinimumDuration(0)
# progress.setValue(0)
# # Force the UI to draw the window NOW before we start the loop
# progress.show()
# QApplication.processEvents()
# annotation_sets = []
# # 2. Use the persistent executor (don't use 'with' here!)
# for i, path in enumerate(file_paths):
# progress.setValue(i)
# progress.setLabelText(f"Reading file {i+1} of {len(file_paths)}...")
# QApplication.processEvents() # Keeps the UI snappy
# if progress.wasCanceled():
# break
# # This call is now nearly instant because the process is already warm
# future = self.file_executor.submit(_extract_annotations, path)
# try:
# labels_list = future.result()
# if labels_list:
# annotation_sets.append(set(labels_list))
# except Exception as e:
# print(f"Worker Error: {e}")
# progress.setValue(len(file_paths))
# # 3. Final Logic
# if not annotation_sets:
# self.update_dropdown_items("REMOVE_EVENTS", [])
# button1.setVisible(False)
# return
# common = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0]
# self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common)))
class FullClickComboBox(QComboBox):
def __init__(self, parent=None):
super().__init__(parent)
self.setEditable(True)
self.lineEdit().setReadOnly(True)
self.lineEdit().installEventFilter(self)
def eventFilter(self, obj, event):
if obj == self.lineEdit():
if event.type() == QEvent.MouseButtonPress:
return True
if event.type() == QEvent.MouseButtonRelease:
self.showPopup()
return True
return super().eventFilter(obj, event)
class FlaresBaseWidget(QWidget):
def __init__(self, caller):
super().__init__()
self.caller = caller
self.haemo_dict = None
self._updating_checkstates = False
self.participant_map = {}
self.show_all_events = True
# These will be defined by the children, but we'll
# initialize them as None so the code doesn't crash.
self.participant_dropdown = None
self.event_dropdown = None
self.image_index_dropdown = None
def _create_multiselect_dropdown(self, items):
combo = FullClickComboBox()
combo.setView(QListView())
model = QStandardItemModel()
combo.setModel(model)
combo.setEditable(True)
combo.lineEdit().setReadOnly(True)
combo.lineEdit().setPlaceholderText("Select...")
# Setup internal items
dummy = QStandardItem("")
dummy.setFlags(Qt.ItemIsEnabled)
model.appendRow(dummy)
toggle = QStandardItem("Toggle Select All")
toggle.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
toggle.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(toggle)
for text in items:
item = QStandardItem(text)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(item)
# Handle clicking the view directly
def on_view_clicked(index):
item = model.itemFromIndex(index)
if item.isCheckable():
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
item.setCheckState(new_state)
combo.view().pressed.connect(on_view_clicked)
# Logic for "Select All" and Signal Propagation
def on_item_changed(item):
if getattr(self, '_updating_checkstates', False):
return
self._updating_checkstates = True
normal_items = [model.item(i) for i in range(2, model.rowCount())]
if item == toggle:
state = toggle.checkState()
for i in normal_items:
i.setCheckState(state)
else:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
# Trigger the widget's update logic via the existing signal
combo.currentIndexChanged.emit(combo.currentIndex())
self._updating_checkstates = False
model.itemChanged.connect(on_item_changed)
combo.setInsertPolicy(QComboBox.NoInsert)
return combo
# def _get_checked_items(self, combo):
# model = combo.model()
# checked = []
# for i in range(2, model.rowCount()): # Start at 2 to skip dummy/toggle
# item = model.item(i)
# if item.checkState() == Qt.Checked:
# checked.append(item.text())
# return checked
def _get_checked_items(self, combo=None):
target = combo if combo is not None else getattr(self, 'participant_dropdown', None)
if target is None or target.model() is None:
return []
model = target.model()
checked_items = []
# Exclusion list: any item text that should never be treated as data
forbidden = {"Toggle All", "Select All", "", "Toggle"}
for row in range(model.rowCount()):
item = model.item(row)
if item.checkState() == Qt.CheckState.Checked:
text = item.text()
# Only add if it's not a 'UI control' item
if text not in forbidden and not text.startswith("Toggle"):
checked_items.append(text)
return checked_items
def update_participant_dropdown_label(self, combo=None):
"""
Handles label updates for ANY participant dropdown.
If 'combo' is None, it defaults to the standard self.participant_dropdown.
"""
if isinstance(combo, int):
combo = None
# 1. Figure out which dropdown we are talking to
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
if target_combo is None:
return # Safety check: nothing to update
# 2. Get the checked items and format the text
selected = self._get_checked_items(target_combo)
if not selected:
target_combo.lineEdit().setText("")
else:
# Extract just "Participant N"
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
target_combo.lineEdit().setText(", ".join(selected_short))
# 3. Conditional trigger for event updates
# We only update events if we aren't in one of the excluded viewers
excluded_viewers = {
"ParticipantViewer",
"ParticipantFoldChannels",
"ExportDataAsCSVViewer",
}
if getattr(self, "caller", None) not in excluded_viewers:
self._update_event_dropdown()
def update_image_index_dropdown_label(self):
selected = self._get_checked_items(self.image_index_dropdown)
if not selected:
self.image_index_dropdown.lineEdit().setText("")
else:
# Only show the index part
index_labels = [s.split(" ")[0] for s in selected]
self.image_index_dropdown.lineEdit().setText(", ".join(index_labels))
def _update_event_dropdown(self):
is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b')
bypass = False
main_win = next((w for w in QApplication.topLevelWidgets()
if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None)
if main_win:
bypass = getattr(main_win, "missing_events_bypass", False)
if is_split_group:
names_a = self._get_checked_items(self.participant_dropdown_a)
names_b = self._get_checked_items(self.participant_dropdown_b)
if not names_a or not names_b:
self._clear_event_dropdown()
return
map_a = getattr(self, 'participant_map_a', {})
rev_a = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_a.items()}
sets_a = []
for n in names_a:
raw = self.haemo_dict.get(rev_a.get(n))
if raw and hasattr(raw, "annotations"):
sets_a.append(set(raw.annotations.description))
map_b = getattr(self, 'participant_map_b', {})
rev_b = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_b.items()}
sets_b = []
for n in names_b:
raw = self.haemo_dict.get(rev_b.get(n))
if raw and hasattr(raw, "annotations"):
sets_b.append(set(raw.annotations.description))
if not sets_a or not sets_b:
self._clear_event_dropdown()
return
if not bypass:
final_annotations = set.intersection(*(sets_a + sets_b))
else:
all_events_a = {event for s in sets_a for event in s}
all_events_b = {event for s in sets_b for event in s}
valid_a = set()
for event in all_events_a:
count = sum(1 for s in sets_a if event in s)
if count >= 2:
valid_a.add(event)
valid_b = set()
for event in all_events_b:
count = sum(1 for s in sets_b if event in s)
if count >= 2:
valid_b.add(event)
final_annotations = valid_a.intersection(valid_b)
else:
names = self._get_checked_items(self.participant_dropdown)
if not names:
self._clear_event_dropdown()
return
map_single = getattr(self, 'participant_map', {})
rev_single = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_single.items()}
all_sets = []
for n in names:
raw = self.haemo_dict.get(rev_single.get(n))
if raw and hasattr(raw, "annotations"):
all_sets.append(set(raw.annotations.description))
if not all_sets:
self._clear_event_dropdown()
return
if not bypass:
final_annotations = set.intersection(*all_sets)
else:
final_annotations = set.union(*all_sets)
self.event_dropdown.clear()
self.event_dropdown.addItem("")
for ann in sorted(final_annotations):
self.event_dropdown.addItem(ann)
def _clear_event_dropdown(self):
if hasattr(self, 'event_dropdown'):
self.event_dropdown.clear()
self.event_dropdown.addItem("")
def _connect_select_all_toggle(self, toggle_item, model):
"""Helper function to connect the Select All functionality."""
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle
def on_item_changed(item):
if self._updating_checkstates:
return
self._updating_checkstates = True
if item == toggle_item:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
if all_checked:
for i in normal_items:
i.setCheckState(Qt.Unchecked)
toggle_item.setCheckState(Qt.Unchecked)
else:
for i in normal_items:
i.setCheckState(Qt.Checked)
toggle_item.setCheckState(Qt.Checked)
else:
# When normal items change, update toggle item
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
if hasattr(self, 'participant_dropdown_a') and model == self.participant_dropdown_a.model():
self.update_participant_dropdown_label(self.participant_dropdown_a)
elif hasattr(self, 'participant_dropdown_b') and model == self.participant_dropdown_b.model():
self.update_participant_dropdown_label(self.participant_dropdown_b)
# Update label text immediately after change
if self.participant_dropdown:
self.update_participant_dropdown_label()
self._updating_checkstates = False
model.itemChanged.connect(on_item_changed)
def update_participant_list_for_group(self, group_name=None, combo=None):
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
if not target_combo:
return
if isinstance(group_name, int) and combo is None:
target_group = self.group_dropdown.currentText()
elif group_name is not None:
target_group = group_name
else:
# If we have no group_name, look up the text from the correct dropdown
if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a:
target_group = self.group_a_dropdown.currentText()
elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b:
target_group = self.group_b_dropdown.currentText()
else:
target_group = self.group_dropdown.currentText()
if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a:
self.participant_map_a = {}
active_map = self.participant_map_a
elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b:
self.participant_map_b = {}
active_map = self.participant_map_b
else:
self.participant_map = {}
active_map = self.participant_map
# 4. Refresh the Model
model = target_combo.model()
model.clear()
for text in ["", "Toggle Select All"]:
item = QStandardItem(str(text))
if text == "Toggle Select All":
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setData(Qt.Unchecked, Qt.CheckStateRole)
toggle_ref = item
else:
item.setFlags(Qt.ItemIsEnabled)
model.appendRow(item)
# 5. Populate Data
if str(target_group) == "":
target_combo.setEnabled(False)
self.update_participant_dropdown_label(combo=target_combo)
return
target_combo.setEnabled(True)
# Get file paths (handles target_group as int or str)
group_file_paths = self.group_to_paths.get(target_group, [])
for i, file_path in enumerate(group_file_paths, start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
active_map[file_path] = short_label
item = QStandardItem(display_label)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(item)
self._connect_select_all_toggle(toggle_ref, model)
self.update_participant_dropdown_label(combo=target_combo)
class ParticipantViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, fig_bytes_dict):
super().__init__("ParticipantViewer")
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setWindowTitle("FLARES Participant Viewer")
self.haemo_dict = haemo_dict
self.fig_bytes_dict = fig_bytes_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
first_fig_dict = next(iter(self.fig_bytes_dict.values()))
image_label_items = list(first_fig_dict.keys())
self.image_index_dropdown = self._create_multiselect_dropdown(image_label_items)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_selected_images)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.save_button = QPushButton("Save Displayed Images")
self.save_button.clicked.connect(self.save_displayed_images)
self.top_bar.addWidget(self.save_button)
self.showMaximized()
def show_selected_images(self):
# Clear previous images
while self.grid_layout.count():
item = self.grid_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
selected_display_names = self._get_checked_items(self.participant_dropdown)
# Map from display names back to file paths
selected_file_paths = []
for display_name in selected_display_names:
# Find file_path by matching display name
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_labels = self._get_checked_items(self.image_index_dropdown)
row, col = 0, 0
for file_path in selected_file_paths:
fig_list = self.fig_bytes_dict.get(file_path, [])
participant_label = self.participant_map[file_path]
for label in selected_labels:
fig_bytes = fig_list.get(label)
if not fig_bytes:
continue
full_pixmap = QPixmap()
full_pixmap.loadFromData(fig_bytes)
thumbnail_pixmap = full_pixmap.scaled(
self.thumb_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
container = QWidget()
hlayout = QHBoxLayout(container)
hlayout.setContentsMargins(0, 0, 0, 0)
hlayout.setSpacing(0)
hlayout.setAlignment(Qt.AlignmentFlag.AlignCenter)
image_label = ClickableLabel(full_pixmap, thumbnail_pixmap)
image_label.setToolTip(f"{participant_label}\n{label}")
hlayout.addWidget(image_label)
self.grid_layout.addWidget(container, row, col)
col += 1
if col >= 6:
col = 0
row += 1
# Update dropdown labels after display
self.update_participant_dropdown_label()
self.update_image_index_dropdown_label()
def save_displayed_images(self):
# Ensure the folder exists
save_dir = Path("individual_images")
save_dir.mkdir(exist_ok=True)
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_image_labels = self._get_checked_items(self.image_index_dropdown)
for display_name in selected_display_names:
# Match display name to file path
for file_path, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(file_path)})"
if display_name == expected_display:
fig_dict = self.fig_bytes_dict.get(file_path, {})
for label in selected_image_labels:
if label not in fig_dict:
continue
fig_bytes = fig_dict[label]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png"
output_path = save_dir / filename
with open(output_path, "wb") as f:
f.write(fig_bytes)
break # file_path matched; stop loop
QMessageBox.information(self, "Save Complete", f"Images saved to {save_dir.resolve()}")
class ClickableLabel(QLabel):
def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap):
super().__init__()
self._pixmap_full = full_pixmap
self.setPixmap(thumbnail_pixmap)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setFixedSize(thumbnail_pixmap.size())
self.setStyleSheet("border: 1px solid gray; margin: 2px;")
def mousePressEvent(self, event):
#TODO: This will use 3MB or RAM for every image that gets opened, and this RAM is not cleared when the expanded view is closed but only when the parent gets closed.
viewer = QWidget()
viewer.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
viewer.setWindowTitle("Expanded View")
layout = QVBoxLayout(viewer)
label = QLabel()
label.setPixmap(self._pixmap_full)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
viewer.resize(1000, 800)
viewer.show()
self._expanded_viewer = viewer # keep reference alive
class ParticipantBrainViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict):
super().__init__("ParticipantBrainViewer")
self.setWindowTitle("FLARES Participant Brain Viewer")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("")
self.index_texts = [
"0 (Brain Landmarks)",
"1 (Brain Activity Visualization)",
# "2 (third image)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.showMaximized()
def show_brain_images(self):
import flares
selected_event = self.event_dropdown.currentText()
if selected_event == "":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
parameterized_indexes = {
0: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "show_brodmann",
"label": "Show common brodmann areas on the brain.",
"default": "True",
"type": bool,
}
],
1: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
"default": "False",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
}
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if selected_event:
participant_events = set(haemo_obj.annotations.description)
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
if haemo_obj is None:
raise Exception("How did we get here?")
cha = self.cha_dict.get(file_path)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
show_brodmann = params.get("show_brodmann", None)
if show_optodes is None or show_brodmann is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
elif idx == 1:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
else:
print(f"No method defined for index {idx}")
class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, epochs_dict):
super().__init__("FunctionalConnectivityWidget")
self.setWindowTitle("FLARES Functional Connectivity Viewer [BETA]")
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("")
self.index_texts = [
"0 (Spectral Connectivity Epochs)",
"1 (Envelope Correlation)",
"2 (Betas)",
"3 (Spectral Connectivity Epochs)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.showMaximized()
def show_brain_images(self):
import flares
selected_event = self.event_dropdown.currentText()
if selected_event == "":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
parameterized_indexes = {
0: [
{
"key": "n_lines",
"label": "",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "",
"default": "0.9",
"type": float,
},
],
1: [
{
"key": "n_lines",
"label": "",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "",
"default": "0.9",
"type": float,
},
],
2: [
{
"key": "n_lines",
"label": "",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "",
"default": "0.9",
"type": float,
},
],
3: [
{
"key": "n_lines",
"label": "",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "",
"default": "0.9",
"type": float,
},
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path)
if selected_event:
participant_events = set(haemo_obj.annotations.description)
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
if haemo_obj is None:
raise Exception("How did we get here?")
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
elif idx == 1:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_envelope(epochs_obj, n_lines, vmin)
elif idx == 2:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
elif idx == 3:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
else:
print(f"No method defined for index {idx}")
class GroupFunctionalConnectivityWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, group, config_dict):
super().__init__("GroupFunctionalConnectivityWidget")
self.setWindowTitle("FLARES Group Viewer")
self.haemo_dict = haemo_dict
self.group = group
self.config_dict = config_dict
self.show_all_events = True
self._updating_checkstates = False
QMessageBox.warning(self, "Warning - FLARES", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.group_to_paths = {}
for file_path, group_name in self.group.items():
self.group_to_paths.setdefault(group_name, []).append(file_path)
self.group_names = sorted(self.group_to_paths.keys())
self.group_dropdown = QComboBox()
self.group_dropdown.addItem("")
self.group_dropdown.addItems(self.group_names)
self.group_dropdown.setCurrentIndex(0)
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.participant_dropdown.setEnabled(False)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("")
self.index_texts = [
"0 (Betas)",
#"1 (Significance)",
#"2 (Brain Activity Visualization)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Group:"))
self.top_bar.addWidget(self.group_dropdown)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.showMaximized()
def show_brain_images(self):
import flares
selected_event = self.event_dropdown.currentText()
if selected_event == "":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
if selected_event:
valid_paths = []
for fp in selected_file_paths:
raw = self.haemo_dict.get(fp)
# Check if this participant actually has the event in their annotations
if raw is not None and hasattr(raw, "annotations"):
if selected_event in raw.annotations.description:
valid_paths.append(fp)
selected_file_paths = valid_paths
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
if not selected_file_paths:
print("No participants selected.")
return
# Only keep indexes 0 and 1 that need parameters
parameterized_indexes = {
0: [
{
"key": "n_lines",
"label": "",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "",
"default": "0.9",
"type": float,
},
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
elif idx == 1:
pass
elif idx == 2:
pass
elif idx == 3:
pass
else:
print(f"No method defined for index {idx}")
class MultiProgressDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("fOLD Analysis Progress")
self.setFixedWidth(400)
# Ensure it doesn't block the main thread
self.setWindowModality(Qt.WindowModality.NonModal)
self.layout = QVBoxLayout(self)
self.bars = {}
def add_participant(self, label, total_steps):
label_widget = QLabel(f"Analyzing {label}...")
pbar = QProgressBar()
pbar.setMaximum(total_steps)
self.layout.addWidget(label_widget)
self.layout.addWidget(pbar)
self.bars[label] = pbar
def update_bar(self, label, value):
if label in self.bars:
self.bars[label].setValue(value)
def single_participant_worker(file_path, raw_data, result_queue, progress_queue):
""" Runs inside its own dedicated process """
p_name = os.path.basename(file_path)
try:
import flares
# Perform the heavy fold_channels logic
print("we are here")
png_bytes = flares.fold_channels(raw_data)
# Hand back results and signal completion
result_queue.put({file_path: png_bytes})
progress_queue.put(p_name)
except Exception as e:
progress_queue.put(f"ERROR: {p_name} - {str(e)}")
class ParticipantFoldChannelsWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict):
super().__init__("ParticipantFoldChannels")
self.setWindowTitle("FLARES Participant Fold Channels Viewer")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.index_texts = [
"0 (Fold Channels)",
# "1 (second image)",
# "2 (third image)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_fold_images)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Fold Type:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.showMaximized()
def show_fold_images(self):
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)]
if not selected_display_names or 0 not in selected_indexes:
return
selected_files = [path for path, label in self.participant_map.items()
if f"{label} ({os.path.basename(path)})" in selected_display_names]
while self.grid_layout.count():
item = self.grid_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self.multi_progress = MultiProgressDialog(self)
for file_path in selected_files:
self.multi_progress.add_participant(os.path.basename(file_path), 1)
self.multi_progress.show()
if current_process().name == 'MainProcess':
# 2. Setup Multiprocessing Manager
self.manager = Manager()
self.result_queue = self.manager.Queue()
self.progress_queue = self.manager.Queue()
self.active_processes = []
# 3. Start ALL processes at once
for file_path in selected_files:
p = Process(
target=single_participant_worker,
args=(file_path, self.haemo_dict[file_path], self.result_queue, self.progress_queue)
)
p.start()
self.active_processes.append(p)
# 4. Start the GUI listener
self.completed_count = 0
self.result_timer = QTimer()
self.result_timer.timeout.connect(self.check_parallel_results)
self.result_timer.start()
def check_parallel_results(self):
# Check for progress/completion signals
while not self.progress_queue.empty():
msg = self.progress_queue.get()
self.completed_count += 1
if msg.startswith("ERROR"):
print(f"Worker Error: {msg}")
else:
# msg is p_name here
self.multi_progress.update_bar(msg, 1)
# Pull images as they become available
while not self.result_queue.empty():
result_dict = self.result_queue.get()
self.add_images_to_grid(result_dict)
# Clean up when all processes are done
if self.completed_count >= len(self.active_processes):
self.result_timer.stop()
# Close the custom multi-progress window
if hasattr(self, 'multi_progress'):
self.multi_progress.close()
# Clean up processes
for p in self.active_processes:
if p.is_alive():
p.join(timeout=1) # Give it a second to wrap up
p.close() # Explicitly close the process object
# Shut down the Manager process (the source of the 'rogue' process)
if hasattr(self, 'manager'):
self.manager.shutdown()
self.active_processes = []
print("Processing fully complete. All resources released.")
def add_images_to_grid(self, result_dict):
"""
result_dict format: { file_path: {"main": bytes, "legend": bytes} }
"""
for file_path, images in result_dict.items():
if self.grid_layout.count() == 0 and "legend" in images:
self._add_legend_to_grid(images["legend"])
# Create a container for this participant's results
container = QFrame()
container.setFrameShape(QFrame.StyledPanel)
vbox = QVBoxLayout(container)
participant_label = self.participant_map.get(file_path, os.path.basename(file_path))
title = QLabel(f"{participant_label}")
title.setAlignment(Qt.AlignCenter)
vbox.addWidget(title)
# We primarily want to show the 'main' plot in the grid
if "main" in images:
pixmap = self._bytes_to_pixmap(images["main"])
img_label = QLabel()
# Scale it to fit the thumbnail size defined in __init__
img_label.setPixmap(pixmap.scaled(
self.thumb_size,
Qt.KeepAspectRatio,
Qt.SmoothTransformation
))
img_label.setAlignment(Qt.AlignCenter)
# Optional: Click to open full size
img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t)
vbox.addWidget(img_label)
# Determine grid position (row-major order)
count = self.grid_layout.count()
row = count // 3 # 3 columns wide
col = count % 3
self.grid_layout.addWidget(container, row, col)
def _bytes_to_pixmap(self, png_bytes):
"""Converts raw bytes from the multiprocess queue to a QPixmap."""
image = QImage.fromData(png_bytes)
return QPixmap.fromImage(image)
def _open_full_size(self, pixmap, title):
"""Simple popup to view the image at a readable scale."""
view = QDialog(self)
view.setWindowTitle(f"Full View - {title}")
layout = QVBoxLayout(view)
label = QLabel()
label.setPixmap(pixmap)
layout.addWidget(label)
view.show()
def _add_legend_to_grid(self, legend_bytes):
"""Helper to put the legend in the first slot."""
container = QFrame()
container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;")
vbox = QVBoxLayout(container)
title = QLabel("Brodmann Area Legend")
title.setAlignment(Qt.AlignCenter)
vbox.addWidget(title)
pixmap = self._bytes_to_pixmap(legend_bytes)
legend_label = QLabel()
# Legends are usually tall, so we scale it differently or keep it smaller
legend_label.setPixmap(pixmap.scaled(
self.thumb_size,
Qt.KeepAspectRatio,
Qt.SmoothTransformation
))
legend_label.setAlignment(Qt.AlignCenter)
legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend")
vbox.addWidget(legend_label)
self.grid_layout.addWidget(container, 0, 0)
class ExportDataAsCSVViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
super().__init__("ExportDataAsCSVViewer")
self.setWindowTitle("FLARES Export Data As CSV Viewer")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind = df_ind
self.design_matrix = design_matrix
self.group = group
self.contrast_results_dict = contrast_results_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.index_texts = [
"0 (Export Data to CSV)",
"1 (CSV for SPARKS)",
# "2 (third image)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.generate_and_save_csv)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Export Type:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.showMaximized()
def generate_and_save_csv(self):
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
if not selected_file_paths or not selected_indexes:
QMessageBox.warning(self, "Selection Missing", "Please select at least one participant and one export type.")
return
# 2. ASK ONCE: Select Output Directory
output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports")
if not output_dir:
print("Export cancelled: No folder selected.")
return
success_count = 0
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
base_filename = os.path.splitext(os.path.basename(file_path))[0]
haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
cha = self.cha_dict.get(file_path)
for idx in selected_indexes:
try:
if idx == 0:
save_path = os.path.join(output_dir, f"{base_filename}_exported.csv")
if cha is not None:
cha.to_csv(save_path)
success_count += 1
elif idx == 1:
# SPARKS Export
save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv")
if haemo_obj is not None:
raw = haemo_obj
data, times = raw.get_data(return_times=True)
ann_col = np.full(times.shape, "", dtype=object)
if raw.annotations is not None and len(raw.annotations) > 0:
for onset, duration, desc in zip(
raw.annotations.onset,
raw.annotations.duration,
raw.annotations.description
):
mask = (times >= onset) & (times < onset + duration)
ann_col[mask] = desc
df = pd.DataFrame(data.T, columns=raw.ch_names)
df.insert(0, "annotation", ann_col)
df.insert(0, "time", times)
df.to_csv(save_path, index=False)
success_count += 1
else:
print(f"No method defined for index {idx}")
except Exception as e:
print(f"Failed to export {file_path} (Type {idx}): {e}")
# 4. Final Notification
if success_count > 0:
QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}")
# # If SPARKS export was included, show the Event Window once at the end
# if 1 in selected_indexes:
# win = UpdateEventsWindow(
# parent=self,
# mode=EventUpdateMode.WRITE_JSON,
# caller="Video Alignment Tool"
# )
# win.show()
class ClickableLabel(QLabel):
def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap):
super().__init__()
self._pixmap_full = full_pixmap
self.setPixmap(thumbnail_pixmap)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setFixedSize(thumbnail_pixmap.size())
self.setStyleSheet("border: 1px solid gray; margin: 2px;")
def mousePressEvent(self, event):
viewer = QWidget()
viewer.setWindowTitle("Expanded View")
layout = QVBoxLayout(viewer)
label = QLabel()
label.setPixmap(self._pixmap_full)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
viewer.resize(1000, 800)
viewer.show()
self._expanded_viewer = viewer # keep reference alive
class ParameterInputDialog(QDialog):
def __init__(self, params_dict, parent=None):
"""
params_dict format:
{
idx: [
{
"key": "p_val",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "graph_scale",
"label": "Graph scale factor",
"default": "1",
"type": int,
}
],
...
}
"""
super().__init__(parent)
self.setWindowTitle("Input Parameters")
self.params_dict = params_dict
self.inputs = {} # {(idx, param_key): QLineEdit}
layout = QVBoxLayout(self)
intro_label = QLabel(
"Some methods require parameters to continue:\n"
"Clicking OK will simply use default values if input is left empty."
)
layout.addWidget(intro_label)
for idx, param_list in params_dict.items():
full_text = param_list[0].get('full_text', f"Index [{idx}]")
group_label = QLabel(f"{full_text} requires parameters:")
group_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
layout.addWidget(group_label)
for param_info in param_list:
label = QLabel(param_info["label"])
layout.addWidget(label)
line_edit = QLineEdit(self)
line_edit.setPlaceholderText(str(param_info.get("default", "")))
layout.addWidget(line_edit)
self.inputs[(idx, param_info["key"])] = line_edit
# Buttons
btn_layout = QHBoxLayout()
ok_btn = QPushButton("OK", self)
cancel_btn = QPushButton("Cancel", self)
btn_layout.addWidget(ok_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
ok_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
def get_values(self):
"""
Validate and return values dict in form:
{
idx: {
param_key: value,
...
},
...
}
Returns None if validation fails (error dialog shown).
"""
values = {}
for (idx, param_key), line_edit in self.inputs.items():
text = line_edit.text().strip()
# Find param info dict
param_info = None
for p in self.params_dict[idx]:
if p['key'] == param_key:
param_info = p
break
if param_info is None:
# This shouldn't happen, but just in case:
self._show_error(f"Internal error: No param info for index {idx} key '{param_key}'")
return None
if not text:
text = str(param_info.get('default', ''))
param_type = param_info.get('type', str)
try:
if param_type == int:
val = int(text)
elif param_type == float:
val = float(text)
elif param_type == bool:
# Convert common bool strings to bool
val_lower = text.lower()
if val_lower in ('true', '1', 'yes', 'y'):
val = True
elif val_lower in ('false', '0', 'no', 'n'):
val = False
else:
raise ValueError(f"Invalid bool value: {text}")
elif param_type == str:
val = text
else:
val = text # fallback
except (ValueError, TypeError):
self._show_error(
f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n"
f"Expected type: {param_type.__name__}"
)
return None
if idx not in values:
values[idx] = {}
values[idx][param_key] = val
return values
def _show_error(self, message):
error_box = QMessageBox(self)
error_box.setIcon(QMessageBox.Critical)
error_box.setWindowTitle("Input Error")
error_box.setText(message)
error_box.exec_()
class GroupViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group):
super().__init__("GroupViewer")
self.setWindowTitle("FLARES Group Viewer")
self.haemo_dict = haemo_dict
self.cha = cha
self.df_ind = df_ind
self.design_matrix = design_matrix
self.contrast_results = contrast_results
self.group = group
self.show_all_events = True
self._updating_checkstates = False
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.group_to_paths = {}
for file_path, group_name in self.group.items():
self.group_to_paths.setdefault(group_name, []).append(file_path)
self.group_names = sorted(self.group_to_paths.keys())
self.group_dropdown = QComboBox()
self.group_dropdown.addItem("")
self.group_dropdown.addItems(self.group_names)
self.group_dropdown.setCurrentIndex(0)
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.participant_dropdown.setEnabled(False)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("")
self.index_texts = [
"0 (GLM Results)",
"1 (Significance)",
"2 (Brain Activity Visualization)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Group:"))
self.top_bar.addWidget(self.group_dropdown)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.showMaximized()
def show_brain_images(self):
import flares
selected_event = self.event_dropdown.currentText()
if selected_event == "":
selected_event = None
selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = []
for display_name in selected_display_names:
for fp, short_label in self.participant_map.items():
expected_display = f"{short_label} ({os.path.basename(fp)})"
if display_name == expected_display:
selected_file_paths.append(fp)
break
if selected_event:
valid_paths = []
for fp in selected_file_paths:
raw = self.haemo_dict.get(fp)
# Check if this participant actually has the event in their annotations
if raw is not None and hasattr(raw, "annotations"):
if selected_event in raw.annotations.description:
valid_paths.append(fp)
selected_file_paths = valid_paths
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
if not selected_file_paths:
print("No participants selected.")
return
# Only keep indexes 0 and 1 that need parameters
parameterized_indexes = {
0: [
{
"key": "lower_bound",
"label": "Lower bound + ",
"default": "-0.3",
"type": float, # specify int here
},
{
"key": "upper_bound",
"label": "Upper bound + ",
"default": "0.8",
"type": float, # specify int here
}
],
1: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "3.0",
"type": float,
}
],
2: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE",
"default": "False",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
}
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
all_cha = pd.DataFrame()
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
if selected_event:
participant_events = set(haemo_obj.annotations.description)
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
continue
if haemo_obj is None:
continue
cha_df = self.cha.get(file_path)
if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
# Pass the necessary arguments to each method
file_path = selected_file_paths[0]
p_haemo = self.haemo_dict.get(file_path)
p_design_matrix = self.design_matrix.get(file_path)
df_group = pd.DataFrame()
if selected_file_paths:
for file_path in selected_file_paths:
df = self.df_ind.get(file_path)
if df is not None:
df_group = pd.concat([df_group, df], ignore_index=True)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
lower_bound = params.get("lower_bound", None)
upper_bound = params.get("upper_bound", None)
if lower_bound is None or upper_bound is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
elif idx == 1:
params = param_values.get(idx, {})
p_val = params.get("p_value", None)
graph_bounds = params.get("graph_bounds", None)
if p_val is None or graph_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
all_contrasts = []
for fp in selected_file_paths:
condition_dfs = self.contrast_results.get(fp, {})
if selected_event in condition_dfs:
df = condition_dfs[selected_event].copy()
df["ID"] = fp
all_contrasts.append(df)
if not all_contrasts:
print("No contrast data found for selected participants and event.")
return
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds)
elif idx == 2:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
raw_list = [self.haemo_dict.get(fp) for fp in selected_file_paths]
if len(selected_file_paths) > 1:
print(f"Aggregating geometry for {len(selected_file_paths)} participants...")
processed_raw = flares.aggregate_fnirs_group_geometry(raw_list)
else:
processed_raw = raw_list[0].copy().pick(picks="hbo")
flares.brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
elif idx == 3:
pass
else:
print(f"No method defined for index {idx}")
class GroupBrainViewerWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict):
super().__init__("GroupBrainViewer")
self.setWindowTitle("Group Brain Viewer")
self.haemo_dict = haemo_dict
self.df_ind = df_ind
self.design_matrix = design_matrix
self.group = group
self.contrast_results_dict = contrast_results_dict
self.group_to_paths = {}
for file_path, group_name in self.group.items():
self.group_to_paths.setdefault(group_name, []).append(file_path)
self.group_names = sorted(self.group_to_paths.keys())
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.group_a_dropdown = QComboBox()
self.group_a_dropdown.addItem("")
self.group_a_dropdown.addItems(self.group_names)
self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options)
self.group_b_dropdown = QComboBox()
self.group_b_dropdown.addItem("")
self.group_b_dropdown.addItems(self.group_names)
self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options)
self.event_dropdown = QComboBox()
self.event_dropdown.addItem("")
self.participant_dropdown_a = self._create_multiselect_dropdown([])
self.participant_dropdown_a.lineEdit().setPlaceholderText("Select participants (Group A)")
self.participant_dropdown_a.model().itemChanged.connect(self._on_participants_changed)
self.participant_dropdown_b = self._create_multiselect_dropdown([])
self.participant_dropdown_b.lineEdit().setPlaceholderText("Select participants (Group B)")
self.participant_dropdown_b.model().itemChanged.connect(self._on_participants_changed)
self.index_texts = [
"0 (Contrast Image)",
# "1 (3D Brain Contrast)",
# "2 (third image)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images)
self.top_bar.addWidget(QLabel("Group A:"))
self.top_bar.addWidget(self.group_a_dropdown)
self.top_bar.addWidget(QLabel("Participants (Group A):"))
self.top_bar.addWidget(self.participant_dropdown_a)
self.top_bar.addWidget(QLabel("Group B:"))
self.top_bar.addWidget(self.group_b_dropdown)
self.top_bar.addWidget(QLabel("Participants (Group B):"))
self.top_bar.addWidget(self.participant_dropdown_b)
self.top_bar.addWidget(QLabel("Event:"))
self.top_bar.addWidget(self.event_dropdown)
self.top_bar.addWidget(QLabel("Image Indexes:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll_content = QWidget()
self.grid_layout = QGridLayout(self.scroll_content)
self.scroll.setWidget(self.scroll_content)
self.layout.addWidget(self.scroll)
self.thumb_size = QSize(280, 180)
self.showMaximized()
def _update_group_b_options(self):
"""Triggered when Group B changes: Update Group A to exclude B's choice"""
selected_b = self.group_b_dropdown.currentText()
# Refresh Group A and exclude what was just picked in Group B
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
# Update the participants for Group B
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
self._update_event_dropdown()
def _update_group_a_options(self):
"""Triggered when Group A changes: Update Group B to exclude A's choice"""
selected_a = self.group_a_dropdown.currentText()
# Refresh Group B and exclude what was just picked in Group A
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
# Update the participants for Group A
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
self._update_event_dropdown()
def _on_participants_changed(self, item=None):
self._update_event_dropdown()
def _refresh_group_dropdown(self, dropdown, exclude):
current = dropdown.currentText()
dropdown.blockSignals(True)
dropdown.clear()
dropdown.addItem("")
for group in self.group_names:
if group != exclude:
dropdown.addItem(group)
# Restore previous selection if still valid
if current != "" and current != exclude and dropdown.findText(current) != -1:
dropdown.setCurrentText(current)
else:
dropdown.setCurrentIndex(0) # Reset to ""
dropdown.blockSignals(False)
def _get_file_paths_from_labels(self, labels, group_name):
file_paths = []
if group_name == self.group_a_dropdown.currentText():
participant_map = self.participant_map_a
elif group_name == self.group_b_dropdown.currentText():
participant_map = self.participant_map_b
else:
return []
# Reverse map: display label -> file path
reverse_map = {
f"{label} ({os.path.basename(fp)})": fp
for fp, label in participant_map.items()
}
for label in labels:
file_path = reverse_map.get(label)
if file_path:
file_paths.append(file_path)
return file_paths
def show_brain_images(self):
import flares
selected_event = self.event_dropdown.currentText()
if selected_event == "":
selected_event = None
# Group A
participants_a = self._get_checked_items(self.participant_dropdown_a)
file_paths_a = self._get_file_paths_from_labels(participants_a, self.group_a_dropdown.currentText())
# Group B
participants_b = self._get_checked_items(self.participant_dropdown_b)
file_paths_b = self._get_file_paths_from_labels(participants_b, self.group_b_dropdown.currentText())
selected_indexes = [
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
]
all_selected_paths = list(set(file_paths_a + file_paths_b))
if not all_selected_paths:
print("No participants selected.")
return
parameterized_indexes = {
0: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "t_or_theta",
"label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'",
"default": "theta",
"type": str,
},
{
"key": "show_text",
"label": "Display informative text on the top left corner about the contrast.",
"default": "True",
"type": bool,
},
{
"key": "brain_bounds",
"label": "Graph Upper/Lower Limit",
"default": "1.0",
"type": float,
},
{
"key": "is_3d",
"label": "Should we display the results in a 3D interactive window?",
"default": "True",
"type": bool,
}
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
# Build group-level contrast DataFrames
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
group_df = pd.DataFrame()
for fp in file_paths:
print(f"Looking up contrast for: {fp}")
event_con_dict = self.contrast_results_dict.get(fp, {})
print("Available events for this file:", list(event_con_dict.keys()))
if event and event in event_con_dict:
df = event_con_dict[event]
print(f"Appending contrast df for event: {event}")
group_df = pd.concat([group_df, df], ignore_index=True)
else:
print(f"Event '{event}' not found for {fp}")
return group_df
print("Selected event:", selected_event)
print("File paths A:", file_paths_a)
print("File paths B:", file_paths_b)
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
print("contrast_df_a empty?", contrast_df_a.empty)
print("contrast_df_b empty?", contrast_df_b.empty)
all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)]
if len(all_raw_objs) > 1:
processed_raw = flares.aggregate_fnirs_group_geometry(all_raw_objs)
else:
processed_raw = all_raw_objs[0].copy().pick(picks="hbo")
# Visualizations
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
show_text = params.get("show_text", None)
brain_bounds = params.get("brain_bounds", None)
is_3d = params.get("is_3d", None)
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw:
flares.plot_2d_3d_contrasts_between_groups(
contrast_df_a,
contrast_df_b,
raw_haemo=processed_raw,
group_a_name=self.group_a_dropdown.currentText(),
group_b_name=self.group_b_dropdown.currentText(),
is_3d=is_3d,
t_or_theta=t_or_theta,
show_optodes=show_optodes,
show_text=show_text,
brain_bounds=brain_bounds
)
else:
print("no")
class ViewerLauncherWidget(QWidget):
def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict):
super().__init__()
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
group_dict = {
file_path: config.get("GROUP", "Unknown") # default if GROUP missing
for file_path, config in config_dict.items()
}
def launch(func, btn, *args):
func(*args)
self._trigger_success(btn)
layout = QVBoxLayout(self)
btn1 = QPushButton("Open Participant Viewer")
btn1.clicked.connect(lambda: launch(self.open_participant_viewer, btn1, haemo_dict, fig_bytes_dict))
btn2 = QPushButton("Open Participant Brain Viewer")
btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict))
btn3 = QPushButton("Open Participant Fold Channels Viewer")
btn3.clicked.connect(lambda: launch(self.open_participant_fold_channels_viewer, btn3, haemo_dict, cha_dict))
btn7 = QPushButton("Open Functional Connectivity Viewer [BETA]")
btn7.clicked.connect(lambda: launch(self.open_participant_functional_connectivity_viewer, btn7, haemo_dict, epochs_dict))
btn8 = QPushButton("Open Group Functional Connectivity Viewer [BETA]")
btn8.clicked.connect(lambda: launch(self.open_group_functional_connectivity_viewer, btn8, haemo_dict, group_dict, config_dict))
btn4 = QPushButton("Open Inter-Group Viewer")
btn4.clicked.connect(lambda: launch(self.open_group_viewer, btn4, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict))
btn5 = QPushButton("Open Cross Group Brain Viewer")
btn5.clicked.connect(lambda: launch(self.open_group_brain_viewer, btn5, haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict))
btn6 = QPushButton("Open Export Data As CSV Viewer")
btn6.clicked.connect(lambda: launch(self.open_export_data_as_csv_viewer, btn6, haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict))
layout.addWidget(btn1)
layout.addWidget(btn2)
layout.addWidget(btn3)
layout.addWidget(btn7)
layout.addWidget(btn8)
layout.addWidget(btn4)
layout.addWidget(btn5)
layout.addWidget(btn6)
def open_participant_viewer(self, haemo_dict, fig_bytes_dict):
self.participant_viewer = ParticipantViewerWidget(haemo_dict, fig_bytes_dict)
self.participant_viewer.show()
def open_participant_brain_viewer(self, haemo_dict, cha_dict):
self.participant_brain_viewer = ParticipantBrainViewerWidget(haemo_dict, cha_dict)
self.participant_brain_viewer.show()
def open_participant_fold_channels_viewer(self, haemo_dict, cha_dict):
self.participant_fold_channels_viewer = ParticipantFoldChannelsWidget(haemo_dict, cha_dict)
self.participant_fold_channels_viewer.show()
def open_participant_functional_connectivity_viewer(self, haemo_dict, epochs_dict):
self.participant_brain_viewer = ParticipantFunctionalConnectivityWidget(haemo_dict, epochs_dict)
self.participant_brain_viewer.show()
def open_group_functional_connectivity_viewer(self, haemo_dict, group, config_dict):
self.participant_brain_viewer = GroupFunctionalConnectivityWidget(haemo_dict, group, config_dict)
self.participant_brain_viewer.show()
def open_group_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group):
self.participant_brain_viewer = GroupViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group)
self.participant_brain_viewer.show()
def open_group_brain_viewer(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict):
self.participant_brain_viewer = GroupBrainViewerWidget(haemo_dict, df_ind, design_matrix, group, contrast_results_dict)
self.participant_brain_viewer.show()
def open_export_data_as_csv_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
self.export_data_as_csv_viewer = ExportDataAsCSVViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict)
self.export_data_as_csv_viewer.show()
def _trigger_success(self, button):
"""Temporarily adds a green checkmark to the button text."""
original_text = button.text()
button.setText(f"{original_text} ✔")
button.setStyleSheet("color: green; font-weight: bold;")
# Revert after 1 second
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
def _revert_button(self, button, original_text):
button.setText(original_text)
button.setStyleSheet("")
class MainApplication(QMainWindow):
"""
Main application window that creates and sets up the UI.
"""
progress_update_signal = Signal(str, int)
metadata_processed = Signal(str, int)
def __init__(self):
super().__init__()
self.setWindowTitle(f"{APP_NAME.upper()}")
self.setGeometry(100, 100, 1280, 720)
# Load the mne data in a seperate process
self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
self.about = None
self.help = None
self.optodes = None
self.events = None
self.terminal = None
self.bubble_widgets = {}
self.param_sections = []
self.folder_paths = []
self.section_widget = None
self.first_run = True
self.is_2d_bypass = False
self.incompatible_save_bypass = False
self.missing_events_bypass = False
self.analysis_clearing_bypass = False
# Initialization to ensure that saving can occur
self.raw_haemo_dict = {} # Processed Hemodynamic data
self.config_dict = {} # Analysis parameters/settings
self.epochs_dict = {} # Timing/Event data
self.cha_dict = {} # Channel configurations
self.contrast_results_dict = {} # Statistical results
self.df_ind_dict = {} # Individual dataframes
self.design_matrix_dict = {} # GLM Design matrices
self.valid_dict = {} # Quality control/Validity flags
self.fig_bytes_dict = {} # Cached plot images (serialized)
self.file_metadata = {} # AGE, GENDER, GROUP
self.metadata_cache = {} # Internal file/path information metadata cache
self.bubble_widgets = {} # References to the UI "Bubble" objects
self.current_file = None # Tracks the currently selected absolute path
self.metadata_processed.connect(self._safe_ui_update)
self.files_total = 0 # total number of files to process
self.files_done = set() # set of file paths done (success or fail)
self.files_failed = set() # set of failed file paths
self.files_results = {} # dict for successful results (if needed)
self.platform_suffix = "-" + PLATFORM_NAME
self.updater = UpdateManager(
main_window=self,
api_url=API_URL,
api_url_sec=API_URL_SECONDARY,
current_version=CURRENT_VERSION,
platform_name=PLATFORM_NAME,
platform_suffix=self.platform_suffix,
app_name=APP_NAME
)
self.init_ui()
self.create_menu_bar()
self.pending_update_version = None
self.pending_update_path = None
self.last_clicked_bubble = None
self.installEventFilter(self)
# Start local pending update check thread
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix, PLATFORM_NAME, APP_NAME)
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
self.local_check_thread.start()
def init_ui(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QHBoxLayout(central)
main_layout.setContentsMargins(5, 5, 5, 5)
self.main_h_splitter = QSplitter(Qt.Orientation.Horizontal)
self.main_h_splitter.setChildrenCollapsible(False)
main_layout.addWidget(self.main_h_splitter)
self.left_v_splitter = QSplitter(Qt.Orientation.Vertical)
self.left_v_splitter.setChildrenCollapsible(False)
self.left_v_splitter.setMinimumWidth(460)
top_left_container = QGroupBox("File information")
top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }")
top_left_container.setMinimumHeight(240)
top_left_layout = QHBoxLayout(top_left_container)
self.top_left_widget = QTextEdit()
self.top_left_widget.setReadOnly(True)
self.top_left_widget.setPlaceholderText("Click a file below to get started!")
top_left_layout.addWidget(self.top_left_widget, stretch=4)
self.right_column_widget = QWidget()
right_column_layout = QVBoxLayout(self.right_column_widget)
self.meta_fields = {"AGE": QLineEdit(), "GENDER": QLineEdit(), "GROUP": QLineEdit()}
for key, field in self.meta_fields.items():
label = QLabel(key.capitalize())
right_column_layout.addWidget(label)
right_column_layout.addWidget(field)
field.textChanged.connect(self.sync_bubble_data)
label_desc = QLabel('Why are these useful?')
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
label_desc.linkActivated.connect(lambda: QMessageBox.information(None, "Info", "Parameter Info..."))
right_column_layout.addWidget(label_desc)
right_column_layout.addStretch()
self.right_column_widget.hide()
top_left_layout.addWidget(self.right_column_widget, stretch=1)
self.bubble_container = QWidget()
self.bubble_layout = QGridLayout(self.bubble_container)
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setWidget(self.bubble_container)
self.scroll_area.setMinimumHeight(200)
self.left_v_splitter.addWidget(top_left_container)
self.left_v_splitter.addWidget(self.scroll_area)
self.right_container = QWidget()
self.right_container.setMinimumWidth(440)
right_container_layout = QVBoxLayout(self.right_container)
self.right_content_widget = QWidget()
right_content_layout = QVBoxLayout(self.right_content_widget)
self.rows_container = QWidget()
self.rows_layout = QVBoxLayout(self.rows_container)
right_content_layout.addWidget(self.rows_container)
right_content_layout.addStretch()
self.right_scroll_area = QScrollArea()
self.right_scroll_area.setWidgetResizable(True)
self.right_scroll_area.setWidget(self.right_content_widget)
buttons_widget = QWidget()
buttons_layout = QHBoxLayout(buttons_widget)
buttons_layout.addStretch()
self.button1, self.button2, self.button3 = QPushButton("Process"), QPushButton("Clear"), QPushButton("Analysis")
for btn in [self.button1, self.button2, self.button3]:
btn.setMinimumSize(100, 40)
buttons_layout.addWidget(btn)
self.button1.setVisible(False)
self.button3.setVisible(False)
self.button1.clicked.connect(self.on_run_task)
self.button2.clicked.connect(self.clear_all)
self.button3.clicked.connect(self.open_launcher_window)
right_container_layout.addWidget(self.right_scroll_area)
right_container_layout.addWidget(buttons_widget)
self.main_h_splitter.addWidget(self.left_v_splitter)
self.main_h_splitter.addWidget(self.right_container)
self.main_h_splitter.setSizes([600, 400])
self.left_v_splitter.setSizes([300, 700])
self.progress_update_signal.connect(self.update_file_progress)
self.update_sections(0)
#NOTE: leave this here for now
# def check_memory_leak(self):
# # 2. Take a snapshot
# snapshot = tracemalloc.take_snapshot()
# # 3. Filter to show the top 10 biggest "stayers"
# top_stats = snapshot.statistics('lineno')
# print("[ Top 10 Memory Consumers ]")
# for stat in top_stats[:10]:
# print(stat)
def create_menu_bar(self):
'''Menu Bar at the top of the screen'''
menu_bar = self.menuBar()
self.statusbar = self.statusBar()
def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None):
action = QAction(name, self)
if shortcut:
action.setShortcut(QKeySequence(shortcut))
if slot:
action.triggered.connect(slot)
if checkable:
action.setCheckable(True)
action.setChecked(checked)
if icon:
action.setIcon(QIcon(icon))
return action
# File menu and actions
file_menu = menu_bar.addMenu("File")
file_actions = [
("Open File...", "Ctrl+O", self.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")),
("Open Folder...", "Ctrl+Alt+O", self.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")),
# ("Open Folders...", "Ctrl+Shift+O", self.open_folder_dialog, resource_path("icons/folder_copy_24dp_1F1F1F.svg")),
("Load Project...", "Ctrl+L", self.load_project, resource_path("icons/article_24dp_1F1F1F.svg")),
("Save Project...", "Ctrl+S", self.save_project, resource_path("icons/save_24dp_1F1F1F.svg")),
("Save Project As...", "Ctrl+Shift+S", self.save_project, resource_path("icons/save_as_24dp_1F1F1F.svg")),
]
for i, (name, shortcut, slot, icon) in enumerate(file_actions):
file_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 1: # after the first 3 actions (0,1,2)
file_menu.addSeparator()
file_menu.addSeparator()
file_menu.addAction(make_action("Exit", "Ctrl+Q", QApplication.instance().quit, icon=resource_path("icons/exit_to_app_24dp_1F1F1F.svg")))
# Edit menu
edit_menu = menu_bar.addMenu("Edit")
edit_actions = [
("Cut", "Ctrl+X", self.cut_text, resource_path("icons/content_cut_24dp_1F1F1F.svg")),
("Copy", "Ctrl+C", self.copy_text, resource_path("icons/content_copy_24dp_1F1F1F.svg")),
("Paste", "Ctrl+V", self.paste_text, resource_path("icons/content_paste_24dp_1F1F1F.svg"))
]
for name, shortcut, slot, icon in edit_actions:
edit_menu.addAction(make_action(name, shortcut, slot, icon=icon))
# View menu
# TODO: Pretty this like the rest of the menus?
view_menu = menu_bar.addMenu("View")
toggle_statusbar_action = make_action("Toggle Status Bar", checkable=True, checked=True, slot=None)
view_menu.addAction(toggle_statusbar_action)
toggle_statusbar_action.toggled.connect(self.statusbar.setVisible)
# Reset Layout Action
view_menu.addSeparator()
reset_layout_action = make_action(
"Reset Window Layout",
"Ctrl+Shift+R",
self.reset_window_layout,
icon=resource_path("icons/grid_layout_side_24dp_1F1F1F.svg")
)
view_menu.addAction(reset_layout_action)
# Options menu (Help & About)
options_menu = menu_bar.addMenu("Options")
options_actions = [
("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")),
("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
("Update optodes in snirf file...", "F6", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
("Update events in snirf file (BORIS)...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
("Update events in snirf file (BLAZES)...", "F8", self.update_event_markers_blazes, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg"))
]
for i, (name, shortcut, slot, icon) in enumerate(options_actions):
options_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 1 or i == 4: # after the first 2 actions (0,1)
options_menu.addSeparator()
preferences_menu = menu_bar.addMenu("Preferences")
preferences_actions = [
("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg")),
("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg")),
("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg")),
("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/info_24dp_1F1F1F.svg"))
]
for name, shortcut, slot, icon in preferences_actions:
preferences_menu.addAction(make_action(name, shortcut, slot, icon=icon, checkable=True, checked=False))
terminal_menu = menu_bar.addMenu("Terminal")
terminal_actions = [
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
]
for name, shortcut, slot, icon in terminal_actions:
terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon))
self.statusbar.showMessage("Ready")
def update_sections(self, index):
# Clear previous sections
for i in reversed(range(self.rows_layout.count())):
widget = self.rows_layout.itemAt(i).widget()
if widget is not None:
widget.deleteLater()
self.param_sections.clear()
self.global_param_widgets = {}
# Add ParamSection widgets from SECTIONS
for section in SECTIONS:
self.section_widget = ParamSection(section, self.global_param_widgets)
self.rows_layout.addWidget(self.section_widget)
self.param_sections.append(self.section_widget)
for sec in self.param_sections:
sec.update_dependencies()
def clear_all(self):
"""
Forcefully purges all data, kills background tasks,
and resets the memory heap.
"""
self.top_left_widget.clear()
if hasattr(self, "last_clicked_bubble"):
self.last_clicked_bubble = None
if hasattr(self, "result_timer") and self.result_timer:
self.result_timer.stop()
self.result_timer.deleteLater()
self.result_timer = None
if hasattr(self, "result_process") and self.result_process:
if self.result_process.is_alive():
self.result_process.terminate()
self.result_process.join(timeout=1)
self.result_process = None
if hasattr(self, "file_executor") and self.file_executor:
self.file_executor.shutdown(wait=False, cancel_futures=True)
self.file_executor = None
self.pending_files_count = 0
# Increment session so any 'in-flight' callbacks are ignored
if hasattr(self, "loading_session_id"):
self.loading_session_id += 1
# Disconnect the buttons to break potential closures
for btn in [self.button1, self.button3]:
try:
btn.clicked.disconnect()
except (TypeError, RuntimeError): #NOTE: Till raises RuntimeWarnings?
pass
# UI Cleanup
self.right_column_widget.hide()
while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0)
widget = item.widget()
if widget:
# Forcefully disconnect signals to be safe
try:
widget.clicked.disconnect()
widget.rightClicked.disconnect()
except:
pass
widget.deleteLater()
self.bubble_layout.setSpacing(0)
self.bubble_layout.setContentsMargins(0, 0, 0, 0)
self.bubble_container.setMinimumSize(0, 0)
self.bubble_container.resize(0, 0)
self.scroll_area.updateGeometry()
# Data Purge
self.bubble_widgets = {}
self.files_results = {}
self.files_done = set()
self.files_failed = set()
self.raw_haemo_dict = {}
self.config_dict = {}
self.epochs_dict = {}
self.fig_bytes_dict = {}
self.cha_dict = {}
self.contrast_results_dict = {}
self.df_ind_dict = {}
self.design_matrix_dict = {}
self.valid_dict = {}
self.metadata_cache = {}
if hasattr(self, "selected_paths"): self.selected_paths = []
if hasattr(self, "selected_path"): self.selected_path = None
self.button1.setText("Process")
self.button1.clicked.connect(self.on_run_task)
self.button1.setVisible(False)
self.button3.setVisible(False)
self.statusBar().showMessage("All data has been cleared.")
#NOTE: leave this here for now. needs other parts uncommented to work
# self.check_memory_leak()
# self.find_referrers()
# snapshot2 = tracemalloc.take_snapshot()
# # 4. Show the "Compare" - This shows what REFUSED to die
# stats = snapshot2.compare_to(snapshot1, 'lineno')
# print("[ Memory that stayed after Clear ]")
# for stat in stats[:10]:
# print(stat)
# print("Top 10 growing object types in RAM:")
# objgraph.show_most_common_types(limit=10)
def reset_window_layout(self):
"""
Snaps all draggable splitters back to their default proportional positions.
"""
total_width = self.main_h_splitter.width()
left_w = int(total_width * 26 / 45)
right_w = total_width - left_w
self.main_h_splitter.setSizes([left_w, right_w])
total_height = self.left_v_splitter.height()
top_h = int(total_height * 0.30)
bottom_h = total_height - top_h
self.left_v_splitter.setSizes([top_h, bottom_h])
self.statusBar().showMessage("Window layout reset to default.", 2000)
def open_launcher_window(self):
self.launcher_window = ViewerLauncherWidget(self.raw_haemo_dict, self.config_dict, self.fig_bytes_dict, self.cha_dict, self.contrast_results_dict, self.df_ind_dict, self.design_matrix_dict, self.epochs_dict)
self.launcher_window.show()
def copy_text(self):
self.top_left_widget.copy() # Trigger copy
self.statusbar.showMessage("Copied to clipboard") # Show status message
def cut_text(self):
self.top_left_widget.cut() # Trigger cut
self.statusbar.showMessage("Cut to clipboard") # Show status message
def paste_text(self):
self.top_left_widget.paste() # Trigger paste
self.statusbar.showMessage("Pasted from clipboard") # Show status message
def is_2d_bypass_func(self, checked):
self.is_2d_bypass = checked
def incompatable_save_bypass_func(self, checked):
self.incompatible_save_bypass = checked
def missing_events_bypass_func(self, checked):
self.missing_events_bypass = checked
def analysis_clearing_bypass_func(self, checked):
self.analysis_clearing_bypass = checked
def about_window(self):
if self.about is None or not self.about.isVisible():
self.about = AboutWindow(self)
self.about.show()
def user_guide(self):
if self.help is None or not self.help.isVisible():
self.help = UserGuideWindow(self)
self.help.show()
def terminal_gui(self):
if self.terminal is None or not self.terminal.isVisible():
self.terminal = TerminalWindow(self)
self.terminal.show()
def update_optode_positions(self):
if self.optodes is None or not self.optodes.isVisible():
self.optodes = UpdateOptodesWindow(self)
self.optodes.show()
def update_event_markers(self):
if self.events is None or not self.events.isVisible():
self.events = UpdateEventsWindow(self, EventUpdateMode.WRITE_SNIRF, "Manual SNIRF Edit")
self.events.show()
def update_event_markers_blazes(self):
if self.events is None or not self.events.isVisible():
self.events = UpdateEventsBlazesWindow(self, EventUpdateMode.WRITE_SNIRF, "Manual SNIRF Edit")
self.events.show()
def open_file_dialog(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)")
if file_path:
self._load_files_into_pipeline([os.path.normpath(file_path)])
def open_folder_dialog(self):
folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", "")
if folder_path:
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).glob("*.snirf")]
self._load_files_into_pipeline(snirf_files)
def _load_files_into_pipeline(self, file_paths):
if not file_paths:
return
# 1. Warm up the executor if needed
if not hasattr(self, 'file_executor') or self.file_executor is None:
self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
# 2. Track this session to prevent ghost updates
if not hasattr(self, 'loading_session_id'): self.loading_session_id = 0
self.loading_session_id += 1
current_session = self.loading_session_id
# 3. Setup internal tracking if not exists
if not hasattr(self, 'bubble_widgets'): self.bubble_widgets = {}
if not hasattr(self, 'selected_paths'): self.selected_paths = []
if not hasattr(self, 'metadata_cache'): self.metadata_cache = {}
# Filter out files already in the UI to avoid duplicates
new_files = [p for p in file_paths if p not in self.selected_paths]
if not new_files:
return
# Update the pending count for the current load batch
if not hasattr(self, 'pending_files_count'): self.pending_files_count = 0
self.pending_files_count += len(new_files)
for path in new_files:
self.selected_paths.append(path)
# Create the UI Bubble (Disconnected by default)
display_name = os.path.basename(path)
bubble = ProgressBubble(display_name, path)
bubble.setCursor(Qt.CursorShape.WaitCursor)
bubble.set_loading_state(True)
self.bubble_widgets[path] = bubble
self.bubble_layout.addWidget(bubble)
# 4. Queue the background work
future = self.file_executor.submit(_extract_metadata_worker, path)
# Use lambda with defaults to freeze the path and session at this moment
future.add_done_callback(
lambda f, p=path, s=current_session: self._on_metadata_ready(f, p, s)
)
self.button1.setVisible(True)
self.statusBar().showMessage(f"Loading {len(new_files)} new file(s)...")
# TODO: Is this needed?
# def open_multiple_folders_dialog(self):
# while True:
# folder_path = QFileDialog.getExistingDirectory(self, "Select Folder")
# if not folder_path:
# break
# snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
# if not hasattr(self, 'selected_paths'):
# self.selected_paths = []
# for file_path in snirf_files:
# if file_path not in self.selected_paths:
# self.selected_paths.append(file_path)
# self.show_files_as_bubbles(folder_path)
# # for section_widget in self.param_sections:
# # if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'):
# # if "REMOVE_EVENTS" in section_widget.widgets:
# # section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1)
# # break
# # else:
# # print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget")
# # Ask if the user wants to add another
# more = QMessageBox.question(
# self,
# "Add Another?",
# "Do you want to select another folder?",
# QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
# )
# if more == QMessageBox.StandardButton.No:
# break
# self.button1.setVisible(True)
def save_project(self, onCrash=False):
if hasattr(self, 'current_file') and self.current_file:
self.file_metadata[self.current_file] = {
key: field.text().strip() for key, field in self.meta_fields.items()
}
has_metadata = any(
any(val for val in meta.values())
for meta in self.file_metadata.values()
)
has_param_changes = any(section.has_any_changes() for section in self.param_sections)
# Check if there is processed data
has_processed_data = bool(getattr(self, 'raw_haemo_dict', None))
if not (has_processed_data or has_metadata or has_param_changes):
if not onCrash: # Don't show popups during a crash/autosave
QMessageBox.warning(
self,
"Save Project",
"There is no processed data to save. Please process some data before saving."
)
return
if hasattr(self, 'current_file') and self.current_file:
self.file_metadata[self.current_file] = {
key: field.text() for key, field in self.meta_fields.items()
}
if not onCrash:
filename, _ = QFileDialog.getSaveFileName(
self, "Save Project", "", "FLARE Project (*.flare)"
)
if not filename:
return
else:
if PLATFORM_NAME == "darwin":
filename = os.path.join(os.path.dirname(sys.executable), "../../../flares_autosave.flare")
else:
filename = os.path.join(os.getcwd(), "flares_autosave.flare")
try:
# Ensure the filename has the proper extension
if not filename.endswith(".flare"):
filename += ".flare"
project_path = Path(filename).resolve()
project_dir = project_path.parent
file_list = [
self._get_safe_path(bubble.file_path, project_dir)
for bubble in self.bubble_widgets.values()
]
progress_states = {
self._get_safe_path(bubble.file_path, project_dir): bubble.current_step
for bubble in self.bubble_widgets.values()
}
rel_metadata = {}
for full_path, meta in self.metadata_cache.items():
try:
# Resolve to absolute to be safe, then make relative to project_dir
safe_path = self._get_safe_path(full_path, project_dir)
rel_metadata[safe_path] = meta
except Exception as e:
print(f"Metadata conversion failed for {full_path}: {e}")
print(rel_metadata)
rel_file_params = {
self._get_safe_path(f_path, project_dir): meta
for f_path, meta in self.file_metadata.items()
}
current_params = self.get_all_current_ui_params()
# fallback - if UI reading fails, try the first processed file's config
if not current_params and self.config_dict:
first_file = next(iter(self.config_dict.keys()))
current_params = self.config_dict[first_file]
version = CURRENT_VERSION
project_data = {
"version": version,
"file_list": file_list,
"progress_states": progress_states,
"raw_haemo_dict": self.raw_haemo_dict,
"file_metadata": rel_metadata,
"file_parameters": rel_file_params,
"config_dict": self.config_dict,
"epochs_dict": self.epochs_dict,
"fig_bytes_dict": self.fig_bytes_dict,
"cha_dict": self.cha_dict,
"current_ui_params": current_params,
"contrast_results_dict": self.contrast_results_dict,
"df_ind_dict": self.df_ind_dict,
"design_matrix_dict": self.design_matrix_dict,
"valid_dict": self.valid_dict,
}
def sanitize(obj):
if isinstance(obj, Path):
return str(PurePosixPath(obj))
elif isinstance(obj, dict):
return {sanitize(k): sanitize(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [sanitize(i) for i in obj]
return obj
project_data = sanitize(project_data)
self.saving_overlay = SavingOverlay(self)
self.saving_overlay.resize(self.size()) # Cover the main window
self.saving_overlay.show()
# Start the background save thread
self.save_thread = SaveProjectThread(filename, project_data)
# When finished, close overlay and show success
self.save_thread.finished_signal.connect(lambda f: (
self.saving_overlay.close(),
QMessageBox.information(self, "Success", f"Project saved to:\n{f}")
))
self.save_thread.error_signal.connect(lambda e: (
self.saving_overlay.close(),
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}")
))
self.save_thread.start()
except Exception as e:
if not onCrash:
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}")
def _get_safe_path(self, target_path, start_dir):
try:
# Convert both to absolute paths first
target = Path(target_path).resolve()
base = Path(start_dir).resolve()
rel = os.path.relpath(target, base)
return str(PurePosixPath(rel))
except ValueError:
return str(PurePosixPath(target))
def load_project(self):
filename, _ = QFileDialog.getOpenFileName(
self, "Load Project", "", "FLARE Project (*.flare)"
)
if not filename:
return
try:
with open(filename, "rb") as f:
data = pickle.load(f)
# Check for potentially broken saves
checks = [
("version", "<=1.1.7"),
("file_metadata", "<=1.2.2"),
("file_parameters", "<=1.3.0")
]
for key, ver_str in checks:
if key not in data:
msg = (f"This project was saved in an earlier version of FLARES ({ver_str}) "
"and is potentially not compatible with this version. ")
if self.incompatible_save_bypass:
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", msg +
"You are receiving this warning because you have 'Incompatible Save Bypass' turned on. "
"FLARES will now attempt to load the project. It is strongly recommended to recreate the project file.")
break
else:
QMessageBox.critical(self, f"Error - {APP_NAME.upper()}", msg +
"The file can attempt to be loaded if 'Incompatible Save Bypass' is selected in the 'Preferences' menu.")
return
self.raw_haemo_dict = data.get("raw_haemo_dict", {})
self.config_dict = data.get("config_dict", {})
self.epochs_dict = data.get("epochs_dict", {})
self.fig_bytes_dict = data.get("fig_bytes_dict", {})
self.cha_dict = data.get("cha_dict", {})
self.contrast_results_dict = data.get("contrast_results_dict", {})
self.df_ind_dict = data.get("df_ind_dict", {})
self.design_matrix_dict = data.get("design_matrix_dict", {})
self.valid_dict = data.get("valid_dict", {})
project_dir = Path(filename).parent
saved_cache = data.get("file_metadata", {})
raw_params = data.get("file_parameters", {})
self.metadata_cache = {}
self.file_metadata = {}
for rel_path, meta_content in saved_cache.items():
abs_path = str((project_dir / Path(rel_path)).resolve())
self.metadata_cache[abs_path] = meta_content
# Convert saved relative paths to absolute paths
file_list = [str((project_dir / Path(rel_path)).resolve()) for rel_path in data["file_list"]]
# Also resolve progress_states with updated paths
raw_progress = data.get("progress_states", {})
progress_states = {
str((project_dir / Path(rel_path)).resolve()): step
for rel_path, step in raw_progress.items()
}
for rel_path in data["file_list"]:
abs_path = str((project_dir / Path(rel_path)).resolve())
if rel_path in raw_params:
# Scenario A: New format found
self.file_metadata[abs_path] = raw_params[rel_path]
elif abs_path in self.config_dict:
# Scenario B: Fallback to old config_dict
old_cfg = self.config_dict[abs_path]
self.file_metadata[abs_path] = {
"AGE": str(old_cfg.get("AGE", "")),
"GENDER": str(old_cfg.get("GENDER", "")),
"GROUP": str(old_cfg.get("GROUP", ""))
}
else:
# Scenario C: Empty default
self.file_metadata[abs_path] = {"AGE": "", "GENDER": "", "GROUP": ""}
self.show_files_as_bubbles_from_list(file_list, progress_states, filename)
if "current_ui_params" in data:
self.restore_sections_from_config(data["current_ui_params"])
elif self.config_dict:
first_file = next(iter(self.config_dict.keys()))
self.restore_sections_from_config(self.config_dict[first_file])
has_data = bool(self.raw_haemo_dict)
self.button1.setVisible(not has_data)
self.button3.setVisible(has_data)
QMessageBox.information(self, "Loaded", f"Project loaded from:\n{filename}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load project:\n{e}")
def restore_sections_from_config(self, config):
"""
Fill all ParamSection widgets with values from a participant's config.
"""
for section_widget in self.param_sections:
widgets_dict = getattr(section_widget, 'widgets', None)
if widgets_dict is None:
continue
for name, widget_info in widgets_dict.items():
if name not in config:
continue
value = config[name]
widget = widget_info["widget"]
w_type = widget_info.get("type")
# QLineEdit (int, float, str)
if isinstance(widget, QLineEdit):
widget.blockSignals(True)
widget.setText(str(value))
widget.blockSignals(False)
widget.update()
# QComboBox (bool, list)
elif isinstance(widget, QComboBox):
widget.blockSignals(True)
widget.setCurrentText(str(value))
widget.blockSignals(False)
widget.update()
# QSpinBox (range)
elif isinstance(widget, QSpinBox):
widget.blockSignals(True)
try:
widget.setValue(int(value))
except Exception:
pass
widget.blockSignals(False)
widget.update()
# After restoring, make sure dependencies are updated
if hasattr(section_widget, 'update_dependencies'):
section_widget.update_dependencies()
# def show_files_as_bubbles(self, folder_paths):
# if isinstance(folder_paths, str):
# folder_paths = [folder_paths]
# # Clear previous bubbles
# # while self.bubble_layout.count():
# # item = self.bubble_layout.takeAt(0)
# # widget = item.widget()
# # if widget:
# # widget.deleteLater()
# temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement
# temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred)
# # temp_bubble.setAttribute(Qt.WA_OpaquePaintEvent) # Improve rendering?
# temp_bubble.adjustSize() # Adjust size after the widget is created
# bubble_width = temp_bubble.width() # Get the actual width of a bubble
# available_width = self.bubble_container.width()
# cols = max(1, available_width // bubble_width) # Ensure at least 1 column
# index = 0
# if not hasattr(self, 'selected_paths'):
# self.selected_paths = []
# for folder_path in folder_paths:
# if not os.path.isdir(folder_path):
# continue
# snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")]
# for full_path in snirf_files:
# display_name = f"{os.path.basename(folder_path)} / {os.path.basename(full_path)}"
# bubble = ProgressBubble(display_name, full_path)
# bubble.set_loading_state(True)
# bubble.setCursor(Qt.CursorShape.WaitCursor)
# self.bubble_widgets[full_path] = bubble
# if full_path not in self.selected_paths:
# self.selected_paths.append(full_path)
# row = index // cols
# col = index % cols
# self.bubble_layout.addWidget(bubble, row, col)
# index += 1
# self.statusBar().showMessage(f"{index} file(s) loaded from: {', '.join(folder_paths)}")
def get_all_current_ui_params(self):
"""Gathers current values from all UI widgets across all sections."""
current_ui_config = {}
try:
for section in self.param_sections:
# This calls the get_param_values() method you shared earlier
section_values = section.get_param_values()
current_ui_config.update(section_values)
return current_ui_config
except Exception as e:
print(f"Error reading UI parameters: {e}")
return None
def show_files_as_bubbles_from_list(self, file_list, progress_states=None, filenames=None):
if not hasattr(self, 'file_executor') or self.file_executor is None:
self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
progress_states = progress_states or {}
# Initialize trackers and clear layout
if not hasattr(self, 'selected_paths'):
self.selected_paths = []
self.bubble_widgets = {}
while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
# Process the file list
for index, file_path in enumerate(file_list):
file_path = str(file_path)
display_name = f"{os.path.basename(os.path.dirname(file_path))} / {os.path.basename(file_path)}"
# Create bubble
bubble = ProgressBubble(display_name, file_path)
bubble.clicked.connect(self.on_bubble_clicked)
bubble.rightClicked.connect(self.on_bubble_right_clicked)
if hasattr(self, 'file_metadata') and file_path in self.file_metadata:
meta = self.file_metadata[file_path]
parts = []
for key in ["AGE", "GENDER", "GROUP"]:
value = meta.get(key, "").strip()
if value:
parts.append(f"{key}: {value}")
suffix = f"{', '.join(parts)}" if parts else ""
bubble.setSuffixText(suffix)
# Track it
self.bubble_widgets[file_path] = bubble
if file_path not in self.selected_paths:
self.selected_paths.append(file_path)
# Restore saved progress but keep loading state active
step = progress_states.get(file_path, 0)
bubble.update_progress(step, active=False)
# Add to layout
self.bubble_layout.addWidget(bubble, index, 1)
# 4. Status Bar
msg = f"Project loaded: {len(file_list)} files."
if filenames:
msg += f" Source: {os.path.basename(filenames)}"
self.statusBar().showMessage(msg)
def get_suffix_from_meta_fields(self):
parts = []
for key, line_edit in self.meta_fields.items():
val = line_edit.text().strip()
if val:
parts.append(f"{key}: {val}")
return ", ".join(parts)
def on_bubble_clicked(self, bubble):
if self.current_file:
self.save_metadata(self.current_file)
if self.last_clicked_bubble and self.last_clicked_bubble != bubble:
suffix = self.get_suffix_from_meta_fields()
self.last_clicked_bubble.setSuffixText(suffix)
self.last_clicked_bubble = bubble
# show age / gender / group
self.right_column_widget.show()
file_path = bubble.file_path
if not os.path.exists(file_path):
self.top_left_widget.setText("File not found.")
return
size = os.path.getsize(file_path)
created = time.ctime(os.path.getctime(file_path))
modified = time.ctime(os.path.getmtime(file_path))
snirf_info = self.get_snirf_metadata_mne(file_path)
lines = [
f"File: {os.path.basename(file_path)}",
f"Size: {size:,} bytes",
f"Created: {created}",
f"Modified: {modified}",
f"Full Path: {file_path}\n",
]
info = "\n".join(lines)
if snirf_info is None:
info += f"\nSNIRF Metadata could not be loaded!"
else:
info += "\nSNIRF Metadata:\n"
for k, v in snirf_info.items():
if isinstance(v, list):
info += f" {k}:\n"
for item in v:
info += f" - {item}\n"
else:
info += f" {k}: {v}\n"
self.top_left_widget.setText(info)
clicked_bubble = self.sender()
file_path = clicked_bubble.file_path
# Save current file's metadata
if self.current_file:
self.save_metadata(self.current_file)
# Update current file
self.current_file = file_path
if file_path not in self.file_metadata:
self.file_metadata[file_path] = {key: "" for key in self.meta_fields}
# Load new file's metadata into the fields
metadata = self.file_metadata.get(file_path, {})
for key, field in self.meta_fields.items():
field.blockSignals(True)
field.setText(metadata.get(key, ""))
field.blockSignals(False)
def on_bubble_right_clicked(self, bubble, global_pos):
menu = QMenu(self)
action1 = menu.addAction(QIcon(resource_path("icons/folder_eye_24dp_1F1F1F.svg")), "Reveal")
action2 = menu.addAction(QIcon(resource_path("icons/remove_24dp_1F1F1F.svg")), "Remove")
action = menu.exec(global_pos)
if action == action1:
path = bubble.file_path
if os.path.exists(path):
if PLATFORM_NAME == "windows":
subprocess.run(["explorer", "/select,", os.path.normpath(path)])
elif PLATFORM_NAME == "darwin": # macOS
subprocess.run(["open", "-R", path])
else: # Linux
folder = os.path.dirname(path)
subprocess.run(["xdg-open", folder])
else:
print("File not found:", path)
elif action == action2:
if self.button3.isVisible():
reply = QMessageBox.warning(
self,
"Confirm Remove",
"Are you sure you want to remove this file? This will remove the analysis option and the processing will have to be performed again.",
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
)
if reply != QMessageBox.StandardButton.Ok:
return
else:
self.button3.setVisible(False)
self.top_left_widget.clear()
self.right_column_widget.hide()
parent_layout = bubble.parent().layout()
if parent_layout is not None:
parent_layout.removeWidget(bubble)
key_to_delete = None
for path, b in self.bubble_widgets.items():
if b is bubble:
key_to_delete = path
break
if key_to_delete:
del self.bubble_widgets[key_to_delete]
# Remove from selected_paths
if hasattr(self, 'selected_paths'):
try:
self.selected_paths.remove(bubble.file_path)
except ValueError:
pass
# Remove from selected_path (if used)
if hasattr(self, 'selected_path') and self.selected_path == bubble.file_path:
self.selected_path = None
# for section_widget in self.param_sections:
# if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'):
# if "REMOVE_EVENTS" in section_widget.widgets:
# section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1)
# break
bubble.setParent(None)
bubble.deleteLater()
if getattr(self, 'last_clicked_bubble', None) is bubble:
self.last_clicked_bubble = None
def sync_bubble_data(self):
"""Refreshes the bubble and saves data in real-time."""
if self.current_file and self.last_clicked_bubble:
# Save the current state of all fields
self.save_metadata(self.current_file)
# Grab the updated suffix and apply it immediately
suffix = self.get_suffix_from_meta_fields()
self.last_clicked_bubble.setSuffixText(suffix)
def placeholder(self):
QMessageBox.information(self, "Placeholder", "This feature is not implemented yet.")
def save_metadata(self, file_path):
if not file_path:
return
self.file_metadata[file_path] = {
key: field.text()
for key, field in self.meta_fields.items()
}
def get_all_metadata(self):
# First, make sure current file's edits are saved
for field in self.meta_fields.values():
field.clearFocus()
# Save current file's metadata
if self.current_file:
self.save_metadata(self.current_file)
return self.file_metadata
def cancel_task(self):
self.button1.clicked.disconnect(self.cancel_task)
self.button1.setText("Stopping...")
if hasattr(self, "result_process") and self.result_process.is_alive():
parent = psutil.Process(self.result_process.pid)
children = parent.children(recursive=True)
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
self.result_process.terminate()
self.result_process.join()
if hasattr(self, "result_timer") and self.result_timer.isActive():
self.result_timer.stop()
# if hasattr(self, "result_process") and self.result_process.is_alive():
# self.result_process.terminate() # Forcefully terminate the process
# self.result_process.join() # Wait for it to properly close
# # Stop the QTimer if running
# if hasattr(self, "result_timer") and self.result_timer.isActive():
# self.result_timer.stop()
for bubble in self.bubble_widgets.values():
bubble.mark_cancelled()
self.statusbar.showMessage("Processing cancelled.")
self.button1.clicked.connect(self.on_run_task)
self.button1.setText("Process")
'''MODULE FILE'''
def on_run_task(self):
#do the check
if not self.analysis_clearing_bypass:
if self.button3.isVisible():
msg = QMessageBox(self)
msg.setWindowTitle("Confirm - FLARES")
msg.setText("Processing new data will clear the current analysis. Continue? (If you do not want this dialog box to appear, toggle 'Analysis Clearing Bypass' from the Preferences menu.)")
# Add the OK and Cancel buttons
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
# Set the default button (highlighted)
msg.setDefaultButton(QMessageBox.StandardButton.Cancel)
# Capture the result
response = msg.exec()
if response == QMessageBox.StandardButton.Ok:
print("User clicked OK")
else:
return
self.button3.setVisible(False)
self.raw_haemo_dict = {}
self.config_dict = {}
self.epochs_dict = {}
self.fig_bytes_dict = {}
self.cha_dict = {}
self.contrast_results_dict = {}
self.df_ind_dict = {}
self.design_matrix_dict = {}
self.valid_dict = {}
self.button1.clicked.disconnect(self.on_run_task)
self.button1.setText("Cancel")
self.button1.clicked.connect(self.cancel_task)
if not self.first_run:
for bubble in self.bubble_widgets.values():
pass
# bubble.mark_cancelled()
self.first_run = False
# Collect all selected snirf files in a flat list
snirf_files = []
if hasattr(self, "selected_paths") and self.selected_paths:
for path in self.selected_paths:
p = Path(path)
if p.is_dir():
snirf_files += [str(f) for f in p.glob("*.snirf")]
elif p.is_file() and p.suffix == ".snirf":
snirf_files.append(str(p))
elif hasattr(self, "selected_path") and self.selected_path:
p = Path(self.selected_path)
if p.is_dir():
snirf_files += [str(f) for f in p.glob("*.snirf")]
elif p.is_file() and p.suffix == ".snirf":
snirf_files.append(str(p))
else:
raise ValueError("No file(s) selected")
if not snirf_files:
raise ValueError("No .snirf files found in selection")
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
# # validate
# for i in snirf_files:
# x_coords = set()
# y_coords = set()
# z_coords = set()
# raw = read_raw_snirf(i)
# dig = raw.info.get('dig', None)
# if dig is not None:
# for point in dig:
# if point['kind'] == 3:
# coord = point['r']
# x_coords.add(coord[0])
# y_coords.add(coord[1])
# z_coords.add(coord[2])
# print(f"Coord: {coord}")
# is_2d = (
# all(abs(x) < 1e-6 for x in x_coords) or
# all(abs(y) < 1e-6 for y in y_coords) or
# all(abs(z) < 1e-6 for z in z_coords)
# )
# if is_2d:
# if self.is_2d_bypass == False:
# QMessageBox.critical(None, "Error - 2D Data Detected - FLARES", f"Error: 2 dimensional data was found in {i}. "
# "Please update the coordinates using the 'Update optodes in snirf file...' option from the Options menu or by pressing 'F6'. "
# "You may also select the '2D Data Bypass' option from the Preferences menu to ignore this warning and process anyway. ")
# self.button1.clicked.disconnect(self.cancel_task)
# self.button1.setText("Process")
# self.button1.clicked.connect(self.on_run_task)
# return
# raw.close()
# del raw
self.files_total = len(snirf_files)
self.files_done = set()
self.files_failed = set()
self.files_results = {}
all_params = {}
for section_widget in self.param_sections:
section_params = section_widget.get_param_values()
all_params.update(section_params)
collected_data = {
"SNIRF_FILES": snirf_files,
"PARAMS": all_params, # add this line
"METADATA": self.get_all_metadata(), # optionally add metadata if needed
}
# Start processing
if current_process().name == 'MainProcess':
self.result_queue = Queue()
self.ack_queue = Queue()
self.progress_queue = Queue()
self.result_process = Process(
target=run_gui_entry_wrapper,
args=(collected_data, self.result_queue, self.progress_queue, self.ack_queue)
)
self.result_process.daemon = False
self.result_process.start()
self.statusbar.showMessage("Running processing in background...")
self.result_timer = QTimer()
self.result_timer.timeout.connect(self.check_for_pipeline_results)
self.result_timer.start()
self.statusbar.showMessage("Task started in separate process.")
def check_for_pipeline_results(self):
try:
while True:
try:
msg = self.result_queue.get_nowait()
except Empty:
break
if isinstance(msg, dict) and msg.get("type") == "file_done":
file_path = msg["file"]
self.files_done.add(file_path)
# print(f"[DEBUG] File Done: {os.path.basename(file_path)}")
# print(f"[DEBUG] Progress: {len(self.files_done)} / {self.files_total}")
if msg.get("success"):
# Unpack the massive tuple
raw_haemo, config, epochs, fig_bytes, cha, contrast, df_ind, design, valid = msg["result"]
# Initialize dictionaries once if needed
if not hasattr(self, 'raw_haemo_dict') or self.raw_haemo_dict is None:
attrs = ['raw_haemo_dict', 'config_dict', 'epochs_dict', 'fig_bytes_dict',
'cha_dict', 'contrast_results_dict', 'df_ind_dict',
'design_matrix_dict', 'valid_dict']
for attr in attrs:
setattr(self, attr, {})
self.files_results[file_path] = msg["result"]
self.raw_haemo_dict[file_path] = raw_haemo
self.config_dict[file_path] = config
self.epochs_dict[file_path] = epochs
self.fig_bytes_dict[file_path] = fig_bytes
self.cha_dict[file_path] = cha
self.contrast_results_dict[file_path] = contrast
self.df_ind_dict[file_path] = df_ind
self.design_matrix_dict[file_path] = design
self.valid_dict[file_path] = valid
self.statusbar.showMessage(f"Processed: {os.path.basename(file_path)}")
else:
self.files_failed.add(file_path)
error_msg = msg.get("error", "Unknown worker error")
print(f"[DEBUG] File Failed: {os.path.basename(file_path)} - {error_msg}")
self.show_error_popup(f"Error: {file_path}", error_msg, msg.get("traceback", ""))
self.statusbar.showMessage(f"Failed: {os.path.basename(file_path)}")
elif isinstance(msg, dict) and msg.get("type") == "FINISHED_SUCCESSFULLY":
# The child has finished its work AND its own cleanup.
# It is now safe for the GUI to stop the timer and clean up.
try:
self.ack_queue.put("ACK")
except: pass
self.result_timer.stop()
self.cleanup_after_process()
success_count = len(self.files_results)
fail_count = self.files_total - success_count
self.statusbar.showMessage(f"Complete: {success_count} succeeded, {fail_count} failed.")
if success_count > 0:
self.button3.setVisible(True)
# Reset the button
try: self.button1.clicked.disconnect()
except: pass
self.button1.setText("Process")
self.button1.clicked.connect(self.on_run_task)
return # Exit the method
elif isinstance(msg, dict) and msg.get("success") is True:
self.statusbar.showMessage("All files processed successfully!")
elif isinstance(msg, dict) and (msg.get("success") is False or msg.get("type") == "error"):
file_path = msg.get("file", "Process")
error_msg = msg.get("error", "Unknown error")
self.show_error_popup(f"Error: {file_path}", error_msg, msg.get("traceback", ""))
self.files_done.add(file_path)
if msg.get("success") is False: # Fatal crash
self.result_timer.stop()
self.cleanup_after_process()
return
elif isinstance(msg, tuple) and msg[0] == 'progress':
_, file_path, step_index = msg
self.progress_update_signal.emit(file_path, step_index)
except Exception as e:
print(f"Error in timer loop: {e}")
if not self.result_process.is_alive() and len(self.files_done) < self.files_total:
self.statusbar.showMessage("Background process died.")
self.result_timer.stop()
def show_error_popup(self, title, error_message, traceback_str=""):
msgbox = QMessageBox(self)
msgbox.setIcon(QMessageBox.Warning)
msgbox.setWindowTitle("Warning - FLARES")
message = (
f"FLARES has encountered an error processing the file {title}.
"
"This error was likely due to incorrect parameters on the right side of the screen and not an error with your data. "
"Processing of the remaining files continues in the background and this participant will be ignored in the analysis. "
"If you think the parameters on the right side are correct for your data, raise an issue here.
"
f"Error message: {error_message}"
)
msgbox.setTextFormat(Qt.TextFormat.RichText)
msgbox.setText(message)
msgbox.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
# Add traceback to detailed text
if traceback_str:
msgbox.setDetailedText(traceback_str)
msgbox.setStandardButtons(QMessageBox.Ok)
msgbox.show()
def cleanup_after_process(self):
if hasattr(self, 'result_process'):
self.result_process.join(timeout=0)
if self.result_process.is_alive():
self.result_process.terminate()
self.result_process.join()
if hasattr(self, 'result_queue'):
if 'AutoProxy' in repr(self.result_queue):
pass
else:
self.result_queue.close()
self.result_queue.join_thread()
if hasattr(self, 'progress_queue'):
if 'AutoProxy' in repr(self.progress_queue):
pass
else:
self.progress_queue.close()
self.progress_queue.join_thread()
def update_file_progress(self, file_path, step_index):
key = os.path.normpath(file_path)
bubble = self.bubble_widgets.get(key)
if bubble:
bubble.update_progress(step_index)
def get_snirf_metadata_mne(self, file_name):
# Check if we already have it (we should?)
if file_name in self.metadata_cache:
return self.metadata_cache[file_name]
print(self.metadata_cache)
# If the user clicked so fast it's not ready, do a one-off blocking call
print(f"Cache miss for {file_name}, fetching now...")
future = self.file_executor.submit(_extract_metadata_worker, file_name)
return future.result(timeout=5)
def closeEvent(self, event):
# Gracefully shut down multiprocessing children
print("Window is closing. Cleaning up...")
if hasattr(self, 'manager'):
self.manager.shutdown()
for child in self.findChildren(QWidget):
if child is not self and child.isVisible():
child.close()
kill_child_processes()
event.accept()
def _on_metadata_ready(self, future, file_path, session_id):
try:
data = future.result()
if data:
self.metadata_cache[file_path] = data
self.metadata_processed.emit(file_path, session_id)
except Exception as e:
print(f"Error pre-fetching {file_path}: {e}")
self.metadata_processed.emit(file_path, session_id)
def _safe_ui_update(self, file_path):
# 2. Update the Bubble safely
if file_path in self.bubble_widgets:
bubble = self.bubble_widgets[file_path]
# This is now thread-safe!
bubble.set_loading_state(False)
bubble.clicked.connect(self.on_bubble_clicked)
bubble.rightClicked.connect(self.on_bubble_right_clicked)
bubble.setCursor(Qt.CursorShape.PointingHandCursor)
# 3. Handle the global counter/cleanup
self.pending_files_count -= 1
if self.pending_files_count <= 0:
self._cleanup_executor()
self.statusbar.showMessage("All files loaded sucessfully.")
def _cleanup_executor(self):
"""Safely shuts down the executor and clears the reference."""
if hasattr(self, 'file_executor') and self.file_executor is not None:
self.file_executor.shutdown(wait=False)
self.file_executor = None
print("[System] Background worker dismissed. RAM reclaimed.")
def _extract_metadata_worker(file_name):
"""Runs in the separate worker process. Returns a clean dict."""
try:
# 1. Use preload=False! We only need metadata.
raw = read_raw_snirf(file_name, preload=False, verbose="ERROR")
snirf_info = {}
# 2. Measurement date
snirf_info['Measurement Date'] = str(raw.info.get('meas_date'))
# 3. Short Channels
try:
short_chans = get_short_channels(raw, max_dist=0.015)
names = list(short_chans.ch_names)
snirf_info['Short Channels'] = f"Likely - {names}"
if len(names) > 6:
snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!"
except:
snirf_info['Short Channels'] = "Unlikely"
# 4. Distances
dist_vals = source_detector_distances(raw.info)
snirf_info['Source-Detector Distances'] = [
f"{name}: {d:.4f} m" for name, d in zip(raw.info['ch_names'], dist_vals)
]
# 5. Digitization
dig = raw.info.get('dig', None)
if dig is not None:
snirf_info['Digitization Points'] = [
f"Kind: {p['kind']}, ID: {p['ident']}, Coord: {p['r']}" for p in dig
]
else:
snirf_info['Digitization Points'] = "Not found"
# 6. Annotations (using our copy-to-string trick)
if raw.annotations is not None and len(raw.annotations) > 0:
snirf_info['Annotations'] = [
f"Onset: {o:.2f}s, Duration: {d:.2f}s, Description: {str(desc)}"
for o, d, desc in zip(raw.annotations.onset, raw.annotations.duration, raw.annotations.description)
]
else:
snirf_info['Annotations'] = "No annotations found"
# 7. Explicit cleanup inside worker
raw.close()
return snirf_info
except Exception as e:
print(f"Worker failed on {file_name}: {e}")
return None
def run_gui_entry_wrapper(config, gui_queue, progress_queue, ack_queue):
"""
Where the processing happens
"""
try:
import flares
flares.gui_entry(config, gui_queue, progress_queue, ack_queue)
gui_queue.close()
# gui_queue.join_thread()
progress_queue.close()
# progress_queue.join_thread()
os._exit(0)
except Exception as e:
tb_str = traceback.format_exc()
gui_queue.put({
"success": False,
"error": f"Child process crashed: {str(e)}\nTraceback:\n{tb_str}"
})
os._exit(1)
def resource_path(relative_path):
"""
Get absolute path to resource regardless of running directly or packaged using PyInstaller
"""
if hasattr(sys, '_MEIPASS'):
# PyInstaller bundle path
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path)
def kill_child_processes():
"""
Goodbye children
"""
try:
parent = psutil.Process(os.getpid())
children = parent.children(recursive=True)
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
psutil.wait_procs(children, timeout=5)
except Exception as e:
print(f"Error killing child processes: {e}")
def exception_hook(exc_type, exc_value, exc_traceback):
"""
Method that will display a popup when the program hard crashes containg what went wrong
"""
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
print(error_msg) # also print to console
kill_child_processes()
# Show error message box
# Make sure QApplication exists (or create a minimal one)
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
show_critical_error(error_msg)
# Exit the app after user acknowledges
sys.exit(1)
def show_critical_error(error_msg):
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setWindowTitle("Something went wrong!")
if PLATFORM_NAME == "darwin":
log_path = os.path.join(os.path.dirname(sys.executable), "../../../flares.log")
log_path2 = os.path.join(os.path.dirname(sys.executable), "../../../flares_error.log")
save_path = os.path.join(os.path.dirname(sys.executable), "../../../flares_autosave.flare")
else:
log_path = os.path.join(os.getcwd(), "flares.log")
log_path2 = os.path.join(os.getcwd(), "flares_error.log")
save_path = os.path.join(os.getcwd(), "flares_autosave.flare")
shutil.copy(log_path, log_path2)
log_path2 = Path(log_path2).absolute().as_posix()
autosave_path = Path(save_path).absolute().as_posix()
log_link = f"file:///{log_path2}"
autosave_link = f"file:///{autosave_path}"
window.save_project(True) #TODO: If the window is the one to crash, the file can't get saved. Could be fine as the window is what was storing the data to begin with?
message = (
f"{APP_NAME.upper()} has encountered an unrecoverable error and needs to close.
"
f"We are sorry for the inconvenience. An autosave was attempted to be saved to {autosave_path}, but it may not have been saved. "
"If the file was saved, it still may not be intact, openable, or contain the correct data. Use the autosave at your discretion.
"
f"This unrecoverable error was likely due to an error with {APP_NAME.upper()} and not your data.
"
f"Please raise an issue here and attach the error file located at {log_path2}
"
f"{error_msg}"
)
msg_box.setTextFormat(Qt.TextFormat.RichText)
msg_box.setText(message)
msg_box.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
msg_box.exec()
if __name__ == "__main__":
# Redirect exceptions to the popup window
sys.excepthook = exception_hook
# Set up application logging
if PLATFORM_NAME == "darwin":
log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log")
else:
log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log")
try:
os.remove(log_path)
except:
pass
sys.stdout = open(log_path, "a", buffering=1)
sys.stderr = sys.stdout
print(f"\n=== App started at {datetime.now()} ===\n")
freeze_support() # Required for PyInstaller + multiprocessing
# Only run GUI in the main process
if current_process().name == 'MainProcess':
app = QApplication(sys.argv)
finish_update_if_needed(PLATFORM_NAME, APP_NAME)
window = MainApplication()
if PLATFORM_NAME == "darwin":
app.setWindowIcon(QIcon(resource_path("icons/main.icns")))
window.setWindowIcon(QIcon(resource_path("icons/main.icns")))
else:
app.setWindowIcon(QIcon(resource_path("icons/main.ico")))
window.setWindowIcon(QIcon(resource_path("icons/main.ico")))
window.show()
sys.exit(app.exec())
# Not 6000 lines yay!