""" 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 configparser 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 from matplotlib.figure import Figure 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, QTextBrowser, 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, QUrl from PySide6.QtGui import QAction, QDesktopServices, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest CURRENT_VERSION = "1.5.0" 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() DEFAULT_CONFIG = """ [File] recent_files = recent_projects = [Edit] [View] status_bar = true left_top = 0 left_bottom = 0 right = 0 [Options] show_welcome_dialog = true [Preferences] 2d_data_bypass = false incompatible_save_bypass = false missing_events_bypass = false analysis_clearing_bypass = false folding_bypass = false [Terminal] [General] """ PIPELINE_STAGES = [ "Preprocessing", "Trimming", "Verify Optode Placement", "Short/Long Channels", "Heart Rate", "Scalp Coupling Index", "Signal to Noise Ratio", "Peak Spectral Power", "Cross Validation", "Median Absolute Deviation", "Power Spectral Density Noise", "Channel Variance", "Bad Channels Handling", "Optical Density", "Temporal Derivative Distribution Repair Filtering", "Wavelet Filtering", "Haemoglobin Concentration", "Enhance Negative Correlation", "Filter", "Extracting Events", "Epoch Calculations", "Design Matrix", "General Linear Model", "Generate GLM Results", "Generate Channel Significance", "Generate Channel, Region of Interest, and Contrast Results", "Compute Contrast Results", "Finishing Up" ] # 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 WelcomeDialog(QDialog): def __init__(self, parent=None, direct=True): super().__init__(parent) self.setWindowTitle(f"What's New - {APP_NAME.upper()}") self.setMinimumSize(550, 450) self.resize(800, 500) # Main Layout layout = QVBoxLayout(self) # Header Layout (Logo + App Name) header_layout = QHBoxLayout() logo_label = QLabel(self) logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48)) # Fits cleanly in a header if direct: title_label = QLabel(f"

{APP_NAME.upper()} has been sucessfully updated to version {CURRENT_VERSION}!

", self) else: title_label = QLabel(f"

{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.

", self) header_layout.addWidget(logo_label) header_layout.addWidget(title_label) header_layout.addStretch() layout.addLayout(header_layout) # Text Browser Area (Automatically converts Markdown syntax into clean formatted UI text) self.text_browser = QTextBrowser(self) self.text_browser.setHtml("

Loading latest updates from server...

") self.text_browser.setOpenLinks(False) # Don't open links inside the viewer self.text_browser.anchorClicked.connect(QDesktopServices.openUrl) layout.addWidget(self.text_browser) # Footer Controls Layout footer_layout = QHBoxLayout() ok_button = QPushButton("OK", self) ok_button.setDefault(True) ok_button.clicked.connect(self.accept) # Closes the dialog with a success signal footer_layout.addStretch() footer_layout.addWidget(ok_button) layout.addLayout(footer_layout) # Fetch markdown from the web asynchronously self.network_manager = QNetworkAccessManager(self) self.network_manager.finished.connect(self._on_download_complete) md_url = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md" self.network_manager.get(QNetworkRequest(QUrl(md_url))) def _on_download_complete(self, reply): """Processes the downloaded markdown and drops it into the view frame.""" if reply.error() == reply.NetworkError.NoError: raw_bytes = reply.readAll() # Convert raw bytes to standard text string markdown_text = str(raw_bytes, encoding='utf-8') # Qt's QTextBrowser natively renders markdown arrays beautifully! self.text_browser.setMarkdown(markdown_text) else: self.text_browser.setHtml( f"

Failed to load content.
Error: {reply.errorString()}

" ) reply.deleteLater() 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_text = "\n".join(f"Stage {idx + 1}: {name}" for idx, name in enumerate(PIPELINE_STAGES)) + "\n" label2 = QLabel(label2_text, 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 i in range(28): rect = QFrame() rect.setFixedSize(10, 18) rect.setStyleSheet("background-color: white; border: 1px solid gray;") stage_name = PIPELINE_STAGES[i] rect.setToolTip(f"Stage {i + 1}: {stage_name}") 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) self.setWindowModality(Qt.WindowModality.NonModal) self.layout = QVBoxLayout(self) self.bars = {} def add_participant(self, label, total_steps): clean_key = str(label).strip() label_widget = QLabel(f"Analyzing {clean_key}...") pbar = QProgressBar() pbar.setMinimum(0) pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer pbar.setValue(0) self.layout.addWidget(label_widget) self.layout.addWidget(pbar) self.bars[label] = pbar def update_bar(self, label, value): if label in self.bars: # Force integers to prevent QProgressBar from breaking or flickering self.bars[label].setValue(int(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 channel_results = flares.fold_channels(raw_data, p_name, progress_queue) # Hand back results and signal completion result_queue.put({file_path: channel_results}) progress_queue.put(p_name) except Exception as e: progress_queue.put(f"ERROR: {p_name} - {str(e)}") def get_landmark_color_map(): """Generates the unified 40-color map for fOLD landmarks.""" landmarks = [ "1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex", "3 - Primary Somatosensory Cortex", "4 - Primary Motor Cortex", "5 - Somatosensory Association Cortex", "6 - Pre-Motor and Supplementary Motor Cortex", "7 - Somatosensory Association Cortex", "8 - Includes Frontal eye fields", "9 - Dorsolateral prefrontal cortex", "10 - Frontopolar area", "11 - Orbitofrontal area", "17 - Primary Visual Cortex (V1)", "18 - Visual Association Cortex (V2)", "19 - V3", "20 - Inferior Temporal gyrus", "21 - Middle Temporal gyrus", "22 - Superior Temporal Gyrus", "23 - Ventral Posterior cingulate cortex", "24 - Ventral Anterior cingulate cortex", "25 - Subgenual cortex", "32 - Dorsal anterior cingulate cortex", "37 - Fusiform gyrus", "38 - Temporopolar area", "39 - Angular gyrus, part of Wernicke's area", "40 - Supramarginal gyrus part of Wernicke's area", "41 - Primary and Auditory Association Cortex", "42 - Primary and Auditory Association Cortex", "43 - Subcentral area", "44 - pars opercularis, part of Broca's area", "45 - pars triangularis Broca's area", "46 - Dorsolateral prefrontal cortex", "47 - Inferior prefrontal gyrus", "48 - Retrosubicular area", "Brain_Outside" ] # Sort logically landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf'))) cmap1 = plt.get_cmap('tab20') cmap2 = plt.get_cmap('tab20b') colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] return {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)} import numpy as np import matplotlib.pyplot as plt from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from PySide6.QtWidgets import QToolTip from PySide6.QtCore import QPoint import traceback import numpy as np import matplotlib.pyplot as plt import matplotlib.image as mpimg # CRITICAL: For loading the PNG asset natively from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas import traceback class StaticChannelCanvas(FigureCanvas): """The Pop-up Window Canvas. Renders the interactive pie chart on the left, and a matching PNG image on the right. """ def __init__(self, channel_name, data_list, color_map, image_path=None, parent=None): # Create a 1-row, 2-column subplot array # figsize=(11.0, 5.5) creates a wide 2:1 widescreen aspect window layout self.fig, self.ax = plt.subplots(1, 2, figsize=(11.0, 5.5)) super().__init__(self.fig) self.setParent(parent) self.setMouseTracking(True) # --- 1. DATA PREPARATION --- self.wedge_data_list = list(data_list) total_specificity = sum(d['Specificity'] for d in self.wedge_data_list) if total_specificity < 100.0: remainder = 100.0 - total_specificity if remainder > 0.01: self.wedge_data_list.append({ 'Landmark': 'Other / Unclassified Regions', 'Specificity': remainder }) self.specificities = [d['Specificity'] for d in self.wedge_data_list] self.landmarks = [d['Landmark'] for d in self.wedge_data_list] self.colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in self.landmarks] self.labels = [f"{lm.split(' - ')[0]}" if 'Other' not in lm and lm != 'Brain_Outside' else 'Other' if 'Other' in lm else 'B' for lm in self.landmarks] # --- 2. LEFT SUBPLOT: PIE CHART --- # Note we explicitly target self.ax[0] now self.wedges, self.texts, self.autotexts = self.ax[0].pie( self.specificities, autopct='%1.1f%%', startangle=90, labels=self.labels, colors=self.colors, textprops={'fontsize': 10, 'fontweight': 'bold'}, labeldistance=1.1 ) self.ax[0].axis('equal') # --- 3. RIGHT SUBPLOT: PNG IMAGE DISPLAY --- # Note we explicitly target self.ax[1] now if image_path: try: img = mpimg.imread(image_path) self.ax[1].imshow(img) except Exception as e: self.ax[1].text(0.5, 0.5, f"Failed to load image:\n{e}", ha='center', va='center', fontsize=10, color='red') else: # Fallback message if no image path is passed down self.ax[1].text(0.5, 0.5, "No Reference Image\nProvided", ha='center', va='center', fontsize=12, fontweight='bold', color='#777') # Completely hide the background grid, spines, and axis lines for the image box self.ax[1].axis('off') # --- 4. CANVAS TEXT OVERLAY --- # Main Title centered globally over both subplots self.fig.suptitle(channel_name, fontsize=16, fontweight='bold', y=0.97) # Shared info box text overlay centered horizontally across the whole window figure self.info_text = self.ax[0].text( 0.5, 0.04, "", transform=self.fig.transFigure, ha="center", va="bottom", fontsize=12, fontweight="bold", bbox=dict(boxstyle="round,pad=0.5", facecolor="#fdfdfd", edgecolor="#bbb", alpha=0.95) ) self.info_text.set_visible(False) self.currently_exploded_idx = None # Layout space optimization self.fig.subplots_adjust(left=0.05, bottom=0.1, right=0.95, top=0.85, wspace=0.2) self.draw() self.mpl_connect('motion_notify_event', self._on_hover) def _on_hover(self, event): try: # FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart if event.inaxes != self.ax[0]: if self.currently_exploded_idx is not None: self._reset_wedges() self.info_text.set_visible(False) self.currently_exploded_idx = None self.draw_idle() return hovered_index = None for idx, wedge in enumerate(self.wedges): contained, _ = wedge.contains(event) if contained: hovered_index = idx break if hovered_index is not None: if self.currently_exploded_idx != hovered_index: self.currently_exploded_idx = hovered_index self._explode_wedge(hovered_index) displayed_pct = self.autotexts[hovered_index].get_text() full_desc = self.landmarks[hovered_index] self.info_text.set_text(f"{full_desc} | {displayed_pct}") self.info_text.set_visible(True) self.draw_idle() else: if self.currently_exploded_idx is not None: self._reset_wedges() self.info_text.set_visible(False) self.currently_exploded_idx = None self.draw_idle() except Exception as err: print("[ERROR] Internal failure inside _on_hover loop:") traceback.print_exc() def _explode_wedge(self, index_to_expand): changed = False for idx, wedge in enumerate(self.wedges): if idx == index_to_expand: theta = np.deg2rad((wedge.theta1 + wedge.theta2) / 2.0) explode_distance = 0.08 new_x = explode_distance * np.cos(theta) new_y = explode_distance * np.sin(theta) if wedge.center != (new_x, new_y): wedge.set_center((new_x, new_y)) changed = True else: if wedge.center != (0.0, 0.0): wedge.set_center((0.0, 0.0)) changed = True if changed: self.draw_idle() def _reset_wedges(self): changed = False for wedge in self.wedges: if wedge.center != (0.0, 0.0): wedge.set_center((0.0, 0.0)) changed = True if changed: self.draw_idle() from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure from PySide6.QtWidgets import QDialog, QVBoxLayout from PySide6.QtCore import Qt class StandaloneLegendDialog(QWidget): def __init__(self, canvas_engine, title_prefix, parent=None): super().__init__(None) self.setWindowTitle("Full View - Brodmann Legend") self.setMinimumSize(500, 600) self.resize(500, 900) layout = QVBoxLayout(self) layout.setContentsMargins(10, 10, 10, 10) # Reuse your exact card creation method to render inside the popup window legend_card = canvas_engine.create_legend_card(title_prefix, self) layout.addWidget(legend_card) class InteractiveParticipantGridCanvas(FigureCanvas): """The Big Grid Canvas. Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation. """ def __init__(self, channels_data, color_map, is_fullscreen_copy=False, parent=None): self.channels_data = channels_data self.color_map = color_map self.is_fullscreen_copy = is_fullscreen_copy num_channels = len(channels_data) # --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS --- target_ratio = 16 / 9 best_cols = 4 min_ratio_error = float('inf') # Test configurations from 4 columns up to the total number of channels for test_cols in range(4, num_channels + 1): test_rows = (num_channels + test_cols - 1) // test_cols # Approximate the visual aspect ratio based on cell dimensions # Mini charts are slightly wider than tall, roughly 1.15 to 1.0 factor current_ratio = (test_cols * 1.15) / (test_rows * 1.0) error = abs(current_ratio - target_ratio) if error < min_ratio_error: min_ratio_error = error best_cols = test_cols cols = best_cols rows = (num_channels + cols - 1) // cols # Base figure sizing dynamically scales off the optimal matrix constraints if is_fullscreen_copy: # Maximized views stretch cleanly across standard display panels figsize = (14.0, 14.0 / target_ratio) else: # Standard thumbnail views scaled down for participant cards figsize = (7.5, 7.5 / target_ratio) self.fig = Figure(figsize=figsize) super().__init__(self.fig) self.setParent(parent) self.axes_data_registry = {} for idx, (channel_name, data_list) in enumerate(channels_data.items()): ax = self.fig.add_subplot(rows, cols, idx + 1) padded_data_list = list(data_list) total_specificity = sum(d['Specificity'] for d in padded_data_list) if total_specificity < 100.0: remainder = 100.0 - total_specificity if remainder > 0.01: padded_data_list.append({ 'Landmark': 'Other / Unclassified Regions', 'Specificity': remainder }) self.axes_data_registry[ax] = { 'channel_name': channel_name, 'data_list': padded_data_list } specificities = [d['Specificity'] for d in padded_data_list] landmarks = [d['Landmark'] for d in padded_data_list] colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in landmarks] labels = [f"{lm.split(' - ')[0]}" if 'Other' not in lm and lm != 'Brain_Outside' else 'O' if 'Other' in lm else 'B' for lm in landmarks] # Adjust label sizing dynamically based on how crowded the grid gets font_sz = 5 if num_channels > 30 else (7 if is_fullscreen_copy else 6) title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7) ax.pie( specificities, startangle=90, colors=colors, labels=labels, textprops={'fontsize': font_sz, 'fontweight': 'bold'}, labeldistance=1.05, radius=0.75 ) ax.set_title(channel_name, fontsize=title_sz, fontweight='bold', pad=0, y=1.04) ax.axis('equal') # --- FIX: ADAPTIVE PADDING BOUNDS FOR EXTRA DENSE PLOTS --- # Large multi-column plots require less spacing overhead to prevent clipping label masks h_sp = 0.35 if num_channels > 30 else 0.18 w_sp = 0.25 if num_channels > 30 else 0.10 if is_fullscreen_copy: self.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.95, hspace=h_sp, wspace=w_sp) else: self.fig.set_layout_engine('constrained') self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.draw() self.mpl_connect('button_press_event', self._on_canvas_click) def create_matrix_card(self, title_prefix, layout_to_attach_to): """Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame.""" # 1. Create matching styled container card frame card_frame = QFrame() card_frame.setFrameShape(QFrame.Shape.StyledPanel) card_frame.setStyleSheet(""" QFrame { background-color: #ffffff; border: 2px solid #ced4da; border-radius: 6px; } QFrame:hover { border: 2px solid #4dabf7; background-color: #f8f9fa; } """) card_layout = QVBoxLayout(card_frame) card_layout.setContentsMargins(6, 6, 6, 6) card_layout.setSpacing(4) # 2. Add header matching the summary card type architecture header = QLabel(f"{title_prefix} - Channels Matrix") header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529; background: transparent;") header.setAlignment(Qt.AlignmentFlag.AlignCenter) card_layout.addWidget(header) # 3. Nest this canvas instance cleanly inside the card frame layout self.setParent(card_frame) card_layout.addWidget(self) card_layout.addStretch(0) # 4. Make the remaining empty whitespace frame areas trigger the maximization loop card_frame.mouseReleaseEvent = lambda event: self._open_fullscreen_grid() if event.button() == Qt.MouseButton.LeftButton else None # Ensure underlying child mouse hits tunnel downstream properly to our parent container frame header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) layout_to_attach_to.addWidget(card_frame) return card_frame def _on_canvas_click(self, event): # CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window if event.inaxes is None: self._open_fullscreen_grid() return # CASE 2: Specific Slice Clicked -> Open standard individual detailed channel popup clicked_subplot_data = self.axes_data_registry.get(event.inaxes) if clicked_subplot_data: self._open_expanded_view( clicked_subplot_data['channel_name'], clicked_subplot_data['data_list'] ) def _open_fullscreen_grid(self): """Creates a maximized dialog window duplicating the full participant matrix view.""" if getattr(self, 'is_fullscreen_copy', False) or hasattr(self, '_is_fullscreen_flag_set'): return fullscreen_window = QWidget(None) fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View") fullscreen_window.setWindowFlags( Qt.WindowType.Window | Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowCloseButtonHint ) layout = QVBoxLayout(fullscreen_window) layout.setContentsMargins(0, 0, 0, 0) # Instantiate the copy large_grid_canvas = InteractiveParticipantGridCanvas( self.channels_data, self.color_map, is_fullscreen_copy=True, parent=fullscreen_window ) # Explicitly tag the new canvas object internally to block further clicks large_grid_canvas._is_fullscreen_flag_set = True layout.addWidget(large_grid_canvas) # Open non-modally so it populates the taskbar and matches OS window behaviors fullscreen_window.showMaximized() # Keep a reference alive on the source canvas so Python doesn't garbage collect the window if not hasattr(self, '_fullscreen_refs'): self._fullscreen_refs = [] self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()] self._fullscreen_refs.append(fullscreen_window) def _calculate_total_brodmann_profile(self, channels_data): """Sums and normalizes the specificity profile across all channels.""" totals = {} num_channels = len(channels_data) if num_channels == 0: return [] # Sum up specificities across all channels for channel_name, data_list in channels_data.items(): for entry in data_list: landmark = entry['Landmark'] specificity = entry['Specificity'] totals[landmark] = totals.get(landmark, 0.0) + specificity # Normalize back down to 100% total scale normalized_data_list = [] for landmark, total_val in totals.items(): # If a landmark hit 20% in 10 channels, it's normalized relative to total channels normalized_val = total_val / num_channels if normalized_val > 0.01: normalized_data_list.append({ 'Landmark': landmark, 'Specificity': normalized_val }) # Ensure "Other / Unclassified" fills any remaining precision gap total_normalized = sum(d['Specificity'] for d in normalized_data_list) if total_normalized < 100.0: remainder = 100.0 - total_normalized if remainder > 0.01: normalized_data_list.append({ 'Landmark': 'Other / Unclassified Regions', 'Specificity': remainder }) return normalized_data_list def _open_expanded_view(self, channel_name, data_list): # 1. Create a plain QWidget with NO parent (None) # This instantly makes it a top-level desktop window popup = QWidget(None) popup.setWindowTitle(f"Channel Specificity Detail - {channel_name}") # 2. Add standard window control behaviors popup.setWindowFlags( Qt.WindowType.Window | Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowCloseButtonHint ) # 3. Build layout out exactly as before layout = QVBoxLayout(popup) layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing target_png_path = "images/brain.png" expanded_canvas = StaticChannelCanvas( channel_name, data_list, self.color_map, image_path=target_png_path, parent=popup ) layout.addWidget(expanded_canvas) popup.resize(900, 520) # 4. Display non-modally popup.show() # 5. Keep the reference alive so Python doesn't garbage collect it if not hasattr(self, '_open_popups'): self._open_popups = [] # Clean up closed windows from our tracking list to save memory self._open_popups = [w for w in self._open_popups if w.isVisible()] self._open_popups.append(popup) def create_total_summary_card(self, title_prefix, layout_to_attach_to): """Generates a highly compact, clickable embedded card on the main window showing aggregated data.""" # 1. Calculate the normalized profile data payload using the instance's own data summary_data = self._calculate_total_brodmann_profile(self.channels_data) # 2. Create a styled container card frame card_frame = QFrame() card_frame.setFrameShape(QFrame.Shape.StyledPanel) card_frame.setStyleSheet(""" QFrame { background-color: #ffffff; border: 2px solid #ced4da; border-radius: 6px; } QFrame:hover { border: 2px solid #4dabf7; /* Gives a subtle visual cue that it is clickable */ background-color: #f8f9fa; /* Slightly shifts background color on hover */ } """) card_layout = QVBoxLayout(card_frame) card_layout.setContentsMargins(4, 4, 4, 4) card_layout.setSpacing(2) # Add a clear section header label containing the specific participant identity header = QLabel(f"{title_prefix} - Total Profile") header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529;") header.setAlignment(Qt.AlignmentFlag.AlignCenter) card_layout.addWidget(header) target_png_path = "images/brain.png" # 3. Instantiate the canvas with a custom size flag or constraint # Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode summary_canvas = StaticChannelCanvas( channel_name=f"{title_prefix} Combined", data_list=summary_data, color_map=self.color_map, image_path=target_png_path, parent=card_frame, ) # --- CRITICAL: SHRINK MATPLOTLIB FIGURE ELEMENTS FOR THE EMBEDDED VIEWER --- # Scale down the underlying canvas container so it doesn't balloon the layout grid if hasattr(summary_canvas, 'fig'): summary_canvas.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.92, wspace=0.10) for ax in summary_canvas.fig.axes: for text in ax.texts: text.set_fontsize(6) summary_canvas.draw() summary_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) card_layout.addWidget(summary_canvas) card_layout.addStretch(0) def handle_card_click(event): # Only trigger expansion if it's a primary left-click action if event.button() == Qt.MouseButton.LeftButton: self._open_expanded_summary_window(title_prefix, summary_data) card_frame.mouseReleaseEvent = handle_card_click # Prevent clicks on the text/child elements from being swallowed up instead of passing to frame header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) summary_canvas.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) # 4. Inject completed card frame container assembly into target window layout position layout_to_attach_to.addWidget(card_frame) return card_frame def create_legend_card(self, title_prefix, layout_to_attach_to): card = QFrame() card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }") layout = QVBoxLayout(card) layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(10) header_label = QLabel(f"{title_prefix}\nLandmarks") header_label.setAlignment(Qt.AlignmentFlag.AlignCenter) header_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #1a252f; border: none;") layout.addWidget(header_label) layout.addSpacing(10) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }") scroll_content = QWidget() scroll_content.setStyleSheet("background: transparent;") scroll_layout = QVBoxLayout(scroll_content) scroll_layout.setSpacing(6) scroll_layout.setContentsMargins(0, 0, 0, 0) true_color_map = get_landmark_color_map() # Iterate over the sorted keys directly from your method for landmark_text in true_color_map.keys(): item_row = QHBoxLayout() item_row.setSpacing(12) # Extract the RGBA tuple value assigned by matplotlib rgba = true_color_map[landmark_text] # Convert float tuple components (0.0 - 1.0) to standard CSS integer scales (0 - 255) r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255) color_hex = f"rgb({r}, {g}, {b})" # Format display string nicely: "1 — Primary Somatosensory Cortex" if " - " in landmark_text: num, name = landmark_text.split(" - ", 1) display_string = f"{num} — {name}" else: display_string = f"{landmark_text}" dot = QLabel() dot.setFixedSize(14, 14) dot.setStyleSheet(f"background-color: {color_hex}; border-radius: 7px; border: none;") label = QLabel(display_string) label.setStyleSheet("font-size: 12px; color: #343a40; border: none;") item_row.addWidget(dot) item_row.addWidget(label, 1) scroll_layout.addLayout(item_row) scroll_area.setWidget(scroll_content) layout.addWidget(scroll_area) return card def _open_expanded_summary_window(self, title_prefix, summary_data): """Pops open a beautifully scaled, independent large window when the card is clicked.""" popup = QWidget(None) popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}") popup.setWindowFlags( Qt.WindowType.Window | Qt.WindowType.WindowMinMaxButtonsHint | Qt.WindowType.WindowCloseButtonHint ) layout = QVBoxLayout(popup) layout.setContentsMargins(10, 10, 10, 10) target_png_path = "images/brain.png" # This one renders full size (900x520) for analytical reading expanded_canvas = StaticChannelCanvas( f"{title_prefix} - All Channels Aggregated", summary_data, self.color_map, image_path=target_png_path, parent=popup, ) layout.addWidget(expanded_canvas) popup.resize(950, 550) popup.show() if not hasattr(self, '_summary_popups'): self._summary_popups = [] self._summary_popups.append(popup) 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_area = QScrollArea(self) self.scroll_area.setWidgetResizable(True) self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.scroll_area.setStyleSheet("QScrollArea { border: none; background-color: #f1f3f5; }") # 2. Create the central canvas widget that inside the scroll block self.scroll_content_widget = QWidget() self.scroll_content_widget.setStyleSheet("background-color: #f1f3f5;") # 3. Establish the strict 3-column layout grid engine self.grid_layout = QGridLayout(self.scroll_content_widget) self.grid_layout.setContentsMargins(12, 12, 12, 12) self.grid_layout.setSpacing(15) # Controls breathing room gaps between cards self.grid_layout.setColumnStretch(0, 1) self.grid_layout.setColumnStretch(1, 1) self.grid_layout.setColumnStretch(2, 1) # 2. Force a uniform structural minimum width per column # This blocks the dense matrices from hogging space and compressing the summary cards self.grid_layout.setColumnMinimumWidth(0, 400) self.grid_layout.setColumnMinimumWidth(1, 400) self.grid_layout.setColumnMinimumWidth(2, 400) # ---------------------------------------------------------- # Bind them together self.scroll_area.setWidget(self.scroll_content_widget) # Add the self.scroll_area widget to your root layout view frame panel self.layout.addWidget(self.scroll_area) 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.global_channels_data = {} self.multi_progress = MultiProgressDialog(self) for file_path in selected_files: raw_data = self.haemo_dict[file_path] # Dig out the exact channels list length matching your loop engine logic hbo_channels = getattr(raw_data.copy().pick(picks='hbo'), "ch_names", []) total_channels = len(hbo_channels) if hbo_channels else 1 self.multi_progress.add_participant(os.path.basename(file_path), total_channels) 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() # CASE 1: Micro-step channel increment (Tuple tracking) if isinstance(msg, tuple): p_name, completed_channels = msg clean_key = str(p_name).strip() if hasattr(self, 'multi_progress') and clean_key in self.multi_progress.bars: print(completed_channels) self.multi_progress.update_bar(clean_key, completed_channels) else: # DEBUG LOG: This tells us exactly why a bar isn't moving print(f"[DEBUG WARNING] Progress received for '{clean_key}' but no matching bar was found. Existing bars: {list(self.multi_progress.bars.keys())}") continue # CASE 2: Worker process crashed with an error string if isinstance(msg, str) and msg.startswith("ERROR"): print(f"Worker Error: {msg}") #self.completed_count += 1 # Count as finished so the UI doesn't hang # CASE 3: Final clean text string signal indicating complete file closure elif isinstance(msg, str): # Max out the progress bar visually on completion if hasattr(self, 'multi_progress'): if msg in self.multi_progress.bars: max_val = self.multi_progress.bars[msg].maximum() self.multi_progress.update_bar(msg, max_val) self.completed_count += 1 # Increment the master task tracker print(self.completed_count, time.time()) # 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.") if hasattr(self, 'global_channels_data') and self.global_channels_data: color_map = get_landmark_color_map() # We feed the entire channel pool directly to your existing canvas engine class global_canvas = InteractiveParticipantGridCanvas(self.global_channels_data, color_map) # Create the summary card using your exact visual method global_card = global_canvas.create_total_summary_card( title_prefix="Grand Global Layout", layout_to_attach_to=self.scroll_content_widget.layout() ) # Match your exact layout positioning logic to place it next in the grid count = self.grid_layout.count() - 1 row = count // 3 col = count % 3 self.grid_layout.addWidget(global_card, row, col) legend_title = "Grand Total Brodmann Mapping Profile" legend_card = global_canvas.create_legend_card( title_prefix=legend_title, layout_to_attach_to=self.scroll_content_widget.layout() ) def handle_legend_click(event): self.active_legend_window = StandaloneLegendDialog(global_canvas, legend_title, self) self.active_legend_window.show() legend_card.mousePressEvent = handle_legend_click count = self.grid_layout.count() row = count // 3 col = count % 3 self.grid_layout.addWidget(legend_card, row, col) # 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 add_images_to_grid(self, result_dict): color_map = get_landmark_color_map() for file_path, channels_data in result_dict.items(): participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) if hasattr(self, 'global_channels_data'): for ch_name, ch_data in channels_data.items(): unique_key = f"{participant_label}_{ch_name}" self.global_channels_data[unique_key] = ch_data # 1. Instantiate the background calculation engine matrix participant_grid_canvas = InteractiveParticipantGridCanvas(channels_data, color_map) # 2. Build Card A (Channels Matrix Frame Layout) # The matrix automatically installs inside its layout box container slot matrix_card = participant_grid_canvas.create_matrix_card( title_prefix=participant_label, layout_to_attach_to=self.scroll_content_widget.layout() # Maps directly to your grid layout ) # Pin Card A to the sequential grid coordinate tracker layout count = self.grid_layout.count() - 1 # Subtract 1 because widget registration steps index values forward row = count // 3 col = count % 3 self.grid_layout.addWidget(matrix_card, row, col) # 3. Build Card B (Total Summary Profile Frame Layout) summary_card = participant_grid_canvas.create_total_summary_card( title_prefix=participant_label, layout_to_attach_to=self.scroll_content_widget.layout() ) # Pin Card B directly next into the 3-column processing loop matrix layout tracker count = self.grid_layout.count() - 1 row = count // 3 col = count % 3 self.grid_layout.addWidget(summary_card, 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, folding_bypass): 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)) btn1.setEnabled(not folding_bypass) btn2 = QPushButton("Open Participant Brain Viewer") btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict)) btn2.setEnabled(not folding_bypass) 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)) btn7.setEnabled(not folding_bypass) 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)) btn8.setEnabled(not folding_bypass) 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)) btn4.setEnabled(not folding_bypass) 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)) btn5.setEnabled(not folding_bypass) 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)) btn6.setEnabled(not folding_bypass) 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) metadata_ui_signal = Signal(dict, 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 self.folding_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.metadata_ui_signal.connect(self._handle_metadata_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() self.show() # Check if we should pop up the welcome screen should_show_welcome = file_cfg.getboolean("Options", "show_welcome_dialog", fallback=True) if should_show_welcome: file_cfg.set("Options", "show_welcome_dialog", "false") try: with open(cfg_path, "w") as f: file_cfg.write(f) except Exception as e: print(f"Warning: Could not save preference: {e}") welcome = WelcomeDialog(self, direct=True) welcome.show() 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! No files below? Open one with File -> Open File!") 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: self.recent_files_menu = file_menu.addMenu("Recent Files") self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg"))) # optional icon file_menu.addSeparator() elif i == 2: self.recent_projects_menu = file_menu.addMenu("Recent Projects") self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg"))) 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")), ("Show Update Changelog", "F6", self.show_update_changelog, resource_path("icons/article_shortcut_24dp_1F1F1.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")), ("Update optodes in snirf file...", "F9", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")), ("Reset to Default Configuration", "F10", self.reset_to_default_configuration, resource_path("icons/reset_settings_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 == 2 or i == 5 or i == 6 or i == 7: options_menu.addSeparator() self.pref_actions = {} preferences_menu = menu_bar.addMenu("Preferences") preferences_actions = [ ("2D Data Bypass", "", self.is_2d_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "2d_data_bypass"), ("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "incompatible_save_bypass"), ("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"), ("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"), ("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"), ] for name, shortcut, slot, icon, config_key in preferences_actions: action = make_action(name, shortcut, slot, icon=icon, checkable=True) preferences_menu.addAction(action) self.pref_actions[config_key] = action 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.sync_app_with_config() 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 update_recent_projects_menu(self): """Clears and rebuilds the Recent Projects submenu items.""" self.recent_projects_menu.clear() raw_projects = file_cfg.get("File", "recent_projects", fallback="") projects = [p.strip() for p in raw_projects.split(",") if p.strip()] if not projects: no_recent = self.recent_projects_menu.addAction("No Recent Projects") no_recent.setEnabled(False) return for i, project_path in enumerate(projects): action = QAction(f"{i+1}: {project_path}", self) action.setToolTip(project_path) action.triggered.connect(lambda checked, path=project_path: self.open_recent_project(path)) self.recent_projects_menu.addAction(action) def add_to_recent_projects(self, project_path): """Adds a project path, moves it to the top, and hard caps at 10.""" raw_projects = file_cfg.get("File", "recent_projects", fallback="") projects = [p.strip() for p in raw_projects.split(",") if p.strip()] if project_path in projects: projects.remove(project_path) projects.insert(0, project_path) projects = projects[:10] # Hard cap of 10 items file_cfg.set("File", "recent_projects", ",".join(projects)) try: with open(cfg_path, "w") as f: file_cfg.write(f) except Exception as e: print(f"Warning: Could not save config history: {e}") self.update_recent_projects_menu() def open_recent_project(self, project_path): """The slot that executes when a recent project entry is clicked.""" if os.path.exists(project_path): print(f"Opening recent project: {project_path}") self.project_loader(project_path) self.add_to_recent_projects(project_path) else: QMessageBox.warning(self, "Project Not Found", f"The project file could not be found:\n{project_path}") # Clean out the broken path raw_projects = file_cfg.get("File", "recent_projects", fallback="") projects = [p.strip() for p in raw_projects.split(",") if p.strip() and p.strip() != project_path] file_cfg.set("File", "recent_projects", ",".join(projects)) self.update_recent_projects_menu() def update_recent_files_menu(self): """Clears and rebuilds the Recent Files submenu items.""" self.recent_files_menu.clear() raw_files = file_cfg.get("File", "recent_files", fallback="") files = [f.strip() for f in raw_files.split(",") if f.strip()] if not files: no_recent = self.recent_files_menu.addAction("No Recent Files") no_recent.setEnabled(False) return for i, file_path in enumerate(files): # Display just the file name (e.g. 'data.snirf'), but keep the full path as a tool tip action = QAction(f"{i+1}: {file_path}", self) # Connect it so it passes the specific path when clicked action.triggered.connect(lambda checked, path=file_path: self.open_recent_file(path)) self.recent_files_menu.addAction(action) def add_to_recent_files(self, file_path): """Adds a path, moves it to the top, and hard caps the list at 10.""" raw_files = file_cfg.get("File", "recent_files", fallback="") files = [f.strip() for f in raw_files.split(",") if f.strip()] if file_path in files: files.remove(file_path) files.insert(0, file_path) files = files[:10] file_cfg.set("File", "recent_files", ",".join(files)) try: with open(cfg_path, "w") as f: file_cfg.write(f) except Exception as e: print(f"Warning: Could not save config history: {e}") self.update_recent_files_menu() def open_recent_file(self, file_path): """The slot that executes when someone clicks a recent file entry.""" if os.path.exists(file_path): print(f"Opening recent file: {file_path}") self._load_files_into_pipeline([os.path.normpath(file_path)]) # Refresh position to top self.add_to_recent_files(file_path) else: QMessageBox.warning(self, "File Not Found", f"The file could not be found:\n{file_path}") # Clean up the broken link from history raw_files = file_cfg.get("File", "recent_files", fallback="") files = [f.strip() for f in raw_files.split(",") if f.strip() and f.strip() != file_path] file_cfg.set("File", "recent_files", ",".join(files)) self.update_recent_files_menu() 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.folding_bypass) 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 _update_config_setting(self, key, value): """Helper to update memory configuration and save to disk.""" # configparser expects string values file_cfg.set("Preferences", key, str(value).lower()) try: with open(cfg_path, "w") as f: file_cfg.write(f) except Exception as e: print(f"Warning: Could not save setting '{key}' to disk: {e}") def is_2d_bypass_func(self, checked): self.is_2d_bypass = checked self._update_config_setting("2d_data_bypass", checked) def incompatable_save_bypass_func(self, checked): self.incompatible_save_bypass = checked self._update_config_setting("incompatible_save_bypass", checked) def missing_events_bypass_func(self, checked): self.missing_events_bypass = checked self._update_config_setting("missing_events_bypass", checked) def analysis_clearing_bypass_func(self, checked): self.analysis_clearing_bypass = checked self._update_config_setting("analysis_clearing_bypass", checked) def folding_bypass_func(self, checked): self.folding_bypass = checked self._update_config_setting("folding_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 show_update_changelog(self): welcome = WelcomeDialog(self, direct=False) welcome.show() def reset_to_default_configuration(self): """Asks user for confirmation, then resets all settings to defaults.""" reply = QMessageBox.question( self, "Reset Configuration", "Are you sure you want to reset the application and all settings to their default values? This cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No # Default focus on 'No' ) # 2. If the user confirmed, perform the reset if reply == QMessageBox.StandardButton.Yes: try: # Overwrite the file with the template string constant with open(cfg_path, "w") as f: f.write(DEFAULT_CONFIG.strip()) # Reload the config parser from the freshly written file file_cfg.read(cfg_path) print("Configuration reset to defaults successfully.") except Exception as e: print(f"Error resetting config file ({e}). Resetting in-memory only.") # Fallback to loading the string into memory if file writing fails file_cfg.read_string(DEFAULT_CONFIG) self.sync_app_with_config() self.statusbar.showMessage("All settings have been reset to their default values.", 5000) def sync_app_with_config(self): """Reads values from file_cfg and updates both internal variables and UI checkmarks.""" # 1. Sync internal application state variables self.is_2d_bypass = file_cfg.getboolean("Preferences", "2d_data_bypass", fallback=False) self.incompatible_save_bypass = file_cfg.getboolean("Preferences", "incompatible_save_bypass", fallback=False) self.missing_events_bypass = file_cfg.getboolean("Preferences", "missing_events_bypass", fallback=False) self.analysis_clearing_bypass = file_cfg.getboolean("Preferences", "analysis_clearing_bypass", fallback=False) self.folding_bypass = file_cfg.getboolean("Preferences", "folding_bypass", fallback=False) self.show_welcome_dialog = file_cfg.getboolean("Options", "show_welcome_dialog", fallback=True) # 2. Sync the UI Menu checkmarks visually if hasattr(self, 'pref_actions'): self.pref_actions["2d_data_bypass"].setChecked(self.is_2d_bypass) self.pref_actions["incompatible_save_bypass"].setChecked(self.incompatible_save_bypass) self.pref_actions["missing_events_bypass"].setChecked(self.missing_events_bypass) self.pref_actions["analysis_clearing_bypass"].setChecked(self.analysis_clearing_bypass) self.pref_actions["folding_bypass"].setChecked(self.folding_bypass) if hasattr(self, 'recent_files_menu'): self.update_recent_files_menu() if hasattr(self, 'recent_projects_menu'): self.update_recent_projects_menu() 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) self.add_to_recent_files(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 self.project_loader(filename=filename) def project_loader(self, filename): 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) self.add_to_recent_projects(os.path.normpath(filename)) 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) if self.folding_bypass: all_params['FOLDING_BYP'] = True 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): if session_id != self.loading_session_id: return try: result = future.result() if result is None: result = {'status': 'error', 'reason': 'Worker returned no data.'} # If it's a successful extraction, it won't have 'status' set yet elif 'status' not in result: # Wrap the raw extraction dictionary into our unified UI format result = {'status': 'success', 'data': result} except Exception as e: result = {'status': 'error', 'reason': str(e)} # Safely emit to the Main thread. No brittle QMetaObject needed! self.metadata_ui_signal.emit(result, file_path, session_id) def _handle_metadata_ui_update(self, result, file_path, session_id): """Executes safely on the MAIN GUI thread via Signal connection.""" if result.get('status') == 'error': # 1. Pop up the warning safely on the main thread QMessageBox.warning( self, "Invalid File", f"Could not read metadata from: {os.path.basename(file_path)}\n\n" f"Details: {result.get('reason', 'Unknown error')}" ) # 2. Run your clean tracking removal self._remove_file_from_pipeline(file_path) return # Success path self.metadata_cache[file_path] = result.get('data', result) self.metadata_processed.emit(file_path, session_id) def _remove_file_from_pipeline(self, file_path): """Completely cleans up and removes all references to a file that failed to load.""" # 1. Decrement pending file count if hasattr(self, 'pending_files_count') and self.pending_files_count > 0: self.pending_files_count -= 1 # 2. Remove the UI widget cleanly if hasattr(self, 'bubble_widgets') and file_path in self.bubble_widgets: bubble = self.bubble_widgets.pop(file_path) self.bubble_layout.removeWidget(bubble) bubble.deleteLater() # Safely schedules the widget for deletion in Qt # 3. Remove from tracking lists if hasattr(self, 'selected_paths') and file_path in self.selected_paths: self.selected_paths.remove(file_path) # 4. Update Status Bar if hasattr(self, 'pending_files_count') and self.pending_files_count == 0: self.statusBar().showMessage("Ready.", 3000) else: self.statusBar().showMessage(f"Loading pending files... ({self.pending_files_count} left)") 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.""" # 1. Use preload=False! We only need metadata. raw = None try: 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" return snirf_info except Exception as e: print(f"Worker safely caught failure on {file_name}: {str(e)}") return {'status': 'error', 'reason': str(e)} finally: if raw is not None: try: raw.close() except: pass 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() def config_init(): ref_cfg.read_string(DEFAULT_CONFIG) if not os.path.exists(cfg_path): try: with open(cfg_path, "w") as f: f.write(DEFAULT_CONFIG.strip()) print(f"Created default configuration file at {cfg_path}") file_cfg.read_string(DEFAULT_CONFIG) except Exception as e: print(f"Warning: Could not create config file ({e}). Using in-memory defaults.") file_cfg.read_string(DEFAULT_CONFIG) else: try: # Load the user's actual file first file_cfg.read(cfg_path) has_changes = False for section in file_cfg.sections(): if not ref_cfg.has_section(section): file_cfg.remove_section(section) has_changes = True continue for option in file_cfg.options(section): if not ref_cfg.has_option(section, option): file_cfg.remove_option(section, option) has_changes = True for section in ref_cfg.sections(): if not file_cfg.has_section(section): file_cfg.add_section(section) has_changes = True for option in ref_cfg.options(section): if not file_cfg.has_option(section, option): default_val = ref_cfg.get(section, option) file_cfg.set(section, option, default_val) has_changes = True # 4. If we added or removed anything, save the sanitized file back to disk if has_changes: with open(cfg_path, "w") as f: file_cfg.write(f) print("Configuration file synchronized: removed old keys and appended new ones.") else: print("Configuration loaded successfully. Schema is up to date.") except Exception as e: print(f"Error validating config file ({e}). Falling back completely to defaults.") file_cfg.read_string(DEFAULT_CONFIG) if __name__ == "__main__": # Redirect exceptions to the popup window sys.excepthook = exception_hook # Set up application logging and configuration if PLATFORM_NAME == "darwin": log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log") cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg") else: log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log") cfg_path = os.path.join(os.getcwd(), f"{APP_NAME}.cfg") 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") file_cfg = configparser.ConfigParser() ref_cfg = configparser.ConfigParser() config_init() 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, cfg_path) 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!