5562 lines
216 KiB
Python
5562 lines
216 KiB
Python
"""
|
|
Filename: main.py
|
|
Description: FLARES main executable
|
|
|
|
Author: Tyler de Zeeuw
|
|
License: GPL-3.0
|
|
"""
|
|
|
|
# Built-in imports
|
|
import os
|
|
import re
|
|
import sys
|
|
import json
|
|
import time
|
|
import shlex
|
|
import pickle
|
|
import shutil
|
|
import zipfile
|
|
import platform
|
|
import traceback
|
|
import subprocess
|
|
from pathlib import Path, PurePosixPath
|
|
from datetime import datetime
|
|
from multiprocessing import Process, current_process, freeze_support, Manager
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
# External library imports
|
|
import psutil
|
|
import requests
|
|
|
|
from mne.io import read_raw_snirf
|
|
from mne.preprocessing.nirs import source_detector_distances
|
|
from mne_nirs.io import write_raw_snirf
|
|
from mne.channels import make_dig_montage
|
|
from mne_nirs.channels import get_short_channels # type: ignore
|
|
from mne import Annotations
|
|
|
|
from PySide6.QtWidgets import (
|
|
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout,
|
|
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QListView, QMenu
|
|
)
|
|
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint
|
|
from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem
|
|
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
|
|
|
|
|
CURRENT_VERSION = "1.0.0"
|
|
|
|
API_URL = "https://git.research.dezeeuw.ca/api/v1/repos/tyler/flares/releases"
|
|
API_URL_SECONDARY = "https://git.research2.dezeeuw.ca/api/v1/repos/tyler/flares/releases"
|
|
|
|
PLATFORM_NAME = platform.system().lower()
|
|
|
|
# Selectable parameters on the right side of the window
|
|
SECTIONS = [
|
|
{
|
|
"title": "Preprocessing",
|
|
"params": [
|
|
{"name": "SECONDS_TO_STRIP", "default": 0, "type": int, "help": "Seconds to remove from beginning of all loaded snirf files. Setting this to 0 will remove nothing from the files."},
|
|
{"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, "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated. Only used if DOWNSAMPLE is set to True"},
|
|
]
|
|
},
|
|
{
|
|
"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, "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, "help": "SCI threshold on a scale of 0-1. A value of 0 is bad coupling while a value of 1 is perfect coupling. If SCI is True, 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_TIME_WINDOW", "default": -1, "type": int, "help": "SNR time window."},
|
|
{"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "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, "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, "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": "Bad Channels Handling",
|
|
"params": [
|
|
# {"name": "NOT_IMPLEMENTED", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
|
|
# {"name": "NOT_IMPLEMENTED", "default": 3, "type": int, "help": "PSP time window."},
|
|
# {"name": "NOT_IMPLEMENTED", "default": 0.1, "type": float, "help": "PSP threshold."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Optical Density",
|
|
"params": [
|
|
# Intentionally empty (TODO)
|
|
]
|
|
},
|
|
{
|
|
"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": "Haemoglobin Concentration",
|
|
"params": [
|
|
# Intentionally empty (TODO)
|
|
]
|
|
},
|
|
{
|
|
"title": "Enhance Negative Correlation",
|
|
"params": [
|
|
#{"name": "ENHANCE_NEGATIVE_CORRELATION", "default": False, "type": bool, "help": "Calculate Peak Spectral Power."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Filtering",
|
|
"params": [
|
|
{"name": "L_FREQ", "default": 0.005, "type": float, "help": "Any frequencies lower than this value will be removed."},
|
|
{"name": "H_FREQ", "default": 0.7, "type": float, "help": "Any frequencies higher than this value will be removed."},
|
|
#{"name": "FILTER", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
|
|
|
|
]
|
|
},
|
|
{
|
|
"title": "Short 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."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Extracting Events",
|
|
"params": [
|
|
#{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Epoch Calculations",
|
|
"params": [
|
|
#{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Design Matrix",
|
|
"params": [
|
|
{"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
|
|
# {"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Drift model for GLM."},
|
|
# {"name": "DURATION_BETWEEN_ACTIVITIES", "default": 35, "type": int, "help": "Time between activities (s)."},
|
|
# {"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "help": "Use short channel regression."},
|
|
]
|
|
},
|
|
{
|
|
"title": "General Linear Model",
|
|
"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": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for GLM processing."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Finishing Touches",
|
|
"params": [
|
|
# Intentionally empty (TODO)
|
|
]
|
|
},
|
|
{
|
|
"title": "Other",
|
|
"params": [
|
|
{"name": "MAX_WORKERS", "default": 4, "type": int, "help": "Number of files to be processed at once. Lowering this value may help on underpowered systems."},
|
|
]
|
|
},
|
|
]
|
|
|
|
|
|
|
|
class TerminalWindow(QWidget):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent, Qt.WindowType.Window)
|
|
self.setWindowTitle("Terminal - FLARES")
|
|
|
|
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
|
|
}
|
|
|
|
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())}"
|
|
|
|
|
|
class UpdateDownloadThread(QThread):
|
|
"""
|
|
Thread that downloads and extracts an update package and emits a signal on completion or error.
|
|
|
|
Args:
|
|
download_url (str): URL of the update zip file to download.
|
|
latest_version (str): Version string of the latest update.
|
|
"""
|
|
|
|
update_ready = Signal(str, str)
|
|
error_occurred = Signal(str)
|
|
|
|
def __init__(self, download_url, latest_version):
|
|
super().__init__()
|
|
self.download_url = download_url
|
|
self.latest_version = latest_version
|
|
|
|
def run(self):
|
|
try:
|
|
local_filename = os.path.basename(self.download_url)
|
|
|
|
if PLATFORM_NAME == 'darwin':
|
|
tmp_dir = '/tmp/flarestempupdate'
|
|
os.makedirs(tmp_dir, exist_ok=True)
|
|
local_path = os.path.join(tmp_dir, local_filename)
|
|
else:
|
|
local_path = os.path.join(os.getcwd(), local_filename)
|
|
|
|
# Download the file
|
|
with requests.get(self.download_url, stream=True, timeout=15) as r:
|
|
r.raise_for_status()
|
|
with open(local_path, 'wb') as f:
|
|
for chunk in r.iter_content(chunk_size=8192):
|
|
if chunk:
|
|
f.write(chunk)
|
|
|
|
# Extract folder name (remove .zip)
|
|
if PLATFORM_NAME == 'darwin':
|
|
extract_folder = os.path.splitext(local_filename)[0]
|
|
extract_path = os.path.join(tmp_dir, extract_folder)
|
|
|
|
else:
|
|
extract_folder = os.path.splitext(local_filename)[0]
|
|
extract_path = os.path.join(os.getcwd(), extract_folder)
|
|
|
|
# Create the folder if not exists
|
|
os.makedirs(extract_path, exist_ok=True)
|
|
|
|
# Extract the zip file contents
|
|
if PLATFORM_NAME == 'darwin':
|
|
subprocess.run(['ditto', '-xk', local_path, extract_path], check=True)
|
|
else:
|
|
with zipfile.ZipFile(local_path, 'r') as zip_ref:
|
|
zip_ref.extractall(extract_path)
|
|
|
|
# Remove the zip once extracted and emit a signal
|
|
os.remove(local_path)
|
|
self.update_ready.emit(self.latest_version, extract_path)
|
|
|
|
except Exception as e:
|
|
# Emit a signal signifying failure
|
|
self.error_occurred.emit(str(e))
|
|
|
|
|
|
|
|
class UpdateCheckThread(QThread):
|
|
"""
|
|
Thread that checks for updates by querying the API and emits a signal based on the result.
|
|
|
|
Signals:
|
|
download_requested(str, str): Emitted with (download_url, latest_version) when an update is available.
|
|
no_update_available(): Emitted when no update is found or current version is up to date.
|
|
error_occurred(str): Emitted with an error message if the update check fails.
|
|
"""
|
|
|
|
download_requested = Signal(str, str)
|
|
no_update_available = Signal()
|
|
error_occurred = Signal(str)
|
|
|
|
def run(self):
|
|
if not getattr(sys, 'frozen', False):
|
|
self.error_occurred.emit("Application is not frozen (Development mode).")
|
|
return
|
|
try:
|
|
latest_version, download_url = self.get_latest_release_for_platform()
|
|
if not latest_version:
|
|
self.no_update_available.emit()
|
|
return
|
|
|
|
if not download_url:
|
|
self.error_occurred.emit(f"No download available for platform '{PLATFORM_NAME}'")
|
|
return
|
|
|
|
if self.version_compare(latest_version, CURRENT_VERSION) > 0:
|
|
self.download_requested.emit(download_url, latest_version)
|
|
else:
|
|
self.no_update_available.emit()
|
|
|
|
except Exception as e:
|
|
self.error_occurred.emit(f"Update check failed: {e}")
|
|
|
|
def version_compare(self, v1, v2):
|
|
def normalize(v): return [int(x) for x in v.split(".")]
|
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
|
|
|
def get_latest_release_for_platform(self):
|
|
urls = [API_URL, API_URL_SECONDARY]
|
|
for url in urls:
|
|
try:
|
|
|
|
response = requests.get(API_URL, timeout=5)
|
|
response.raise_for_status()
|
|
releases = response.json()
|
|
|
|
if not releases:
|
|
return None, None
|
|
|
|
latest = releases[0]
|
|
tag = latest["tag_name"].lstrip("v")
|
|
|
|
for asset in latest.get("assets", []):
|
|
if PLATFORM_NAME in asset["name"].lower():
|
|
return tag, asset["browser_download_url"]
|
|
|
|
return tag, None
|
|
except (requests.RequestException, ValueError) as e:
|
|
continue
|
|
return None, None
|
|
|
|
|
|
class LocalPendingUpdateCheckThread(QThread):
|
|
"""
|
|
Thread that checks for locally pending updates by scanning the download directory and emits a signal accordingly.
|
|
|
|
Args:
|
|
current_version (str): Current application version.
|
|
platform_suffix (str): Platform-specific suffix to identify update folders.
|
|
"""
|
|
|
|
pending_update_found = Signal(str, str)
|
|
no_pending_update = Signal()
|
|
|
|
def __init__(self, current_version, platform_suffix):
|
|
super().__init__()
|
|
self.current_version = current_version
|
|
self.platform_suffix = platform_suffix
|
|
|
|
def version_compare(self, v1, v2):
|
|
def normalize(v): return [int(x) for x in v.split(".")]
|
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
|
|
|
def run(self):
|
|
if PLATFORM_NAME == 'darwin':
|
|
cwd = '/tmp/flarestempupdate'
|
|
else:
|
|
cwd = os.getcwd()
|
|
|
|
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
|
|
found = False
|
|
|
|
try:
|
|
for item in os.listdir(cwd):
|
|
folder_path = os.path.join(cwd, item)
|
|
if os.path.isdir(folder_path) and item.endswith(self.platform_suffix):
|
|
match = pattern.match(item)
|
|
if match:
|
|
folder_version = match.group(1)
|
|
if self.version_compare(folder_version, self.current_version) > 0:
|
|
self.pending_update_found.emit(folder_version, folder_path)
|
|
found = True
|
|
break
|
|
except:
|
|
pass
|
|
|
|
if not found:
|
|
self.no_pending_update.emit()
|
|
|
|
|
|
|
|
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("About FLARES")
|
|
self.resize(250, 100)
|
|
|
|
layout = QVBoxLayout()
|
|
label = QLabel("About FLARES", self)
|
|
label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", self)
|
|
label3 = QLabel("FLARES 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("User Guide - FLARES")
|
|
self.resize(250, 100)
|
|
|
|
layout = QVBoxLayout()
|
|
label = QLabel("Progress Bar Stages:", self)
|
|
label2 = QLabel("Stage 1: Load the snirf file\n"
|
|
"Stage 2: Check the optode positions\n"
|
|
"Stage 3: Scalp Coupling Index\n"
|
|
"Stage 4: Signal to Noise Ratio\n"
|
|
"Stage 5: Peak Spectral Power\n"
|
|
"Stage 6: Identify bad channels\n"
|
|
"Stage 7: Interpolate bad channels\n"
|
|
"Stage 8: Optical Density\n"
|
|
"Stage 9: Temporal Derivative Distribution Repair\n"
|
|
"Stage 10: Beer Lambert Law\n"
|
|
"Stage 11: Heart Rate Filtering\n"
|
|
"Stage 12: Get Short/Long Channels\n"
|
|
"Stage 13: Calculate Events from Annotations\n"
|
|
"Stage 14: Epoch Calculations\n"
|
|
"Stage 15: Design Matrix\n"
|
|
"Stage 16: General Linear Model\n"
|
|
"Stage 17: Generate Plots from the GLM\n"
|
|
"Stage 18: Individual Significance\n"
|
|
"Stage 19: Channel, Region of Interest, and Contrast Results\n"
|
|
"Stage 20: Image Conversion\n", self)
|
|
|
|
layout.addWidget(label)
|
|
layout.addWidget(label2)
|
|
|
|
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("TXT file:")
|
|
self.line_edit_file_b = QLineEdit()
|
|
self.line_edit_file_b.setReadOnly(True)
|
|
self.btn_browse_b = QPushButton("Browse .txt")
|
|
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.<br>"
|
|
"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.<br>"
|
|
"Using a .txt file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>"
|
|
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>"
|
|
"An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>.")
|
|
|
|
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."
|
|
|
|
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 TXT File", "", "Text Files (*.txt)")
|
|
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 = {}
|
|
|
|
# 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
|
|
raw = read_raw_snirf(file_a, preload=True)
|
|
raw.set_montage(initial_montage)
|
|
write_raw_snirf(raw, save_path)
|
|
|
|
|
|
|
|
|
|
class UpdateEventsWindow(QWidget):
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent, Qt.WindowType.Window)
|
|
self.setWindowTitle("Update event markers")
|
|
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("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.<br>"
|
|
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
|
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
|
|
"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("Parameter Info - FLARES")
|
|
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:
|
|
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()
|
|
file_b = self.line_edit_file_b.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
|
|
|
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
|
suggested_name = f"{base_name}_{suffix}.snirf"
|
|
|
|
# Open save dialog
|
|
save_path, _ = QFileDialog.getSaveFileName(
|
|
self,
|
|
"Save SNIRF File As",
|
|
suggested_name,
|
|
"SNIRF Files (*.snirf)"
|
|
)
|
|
|
|
if not save_path:
|
|
print("Save cancelled.")
|
|
return
|
|
|
|
if not save_path.lower().endswith(".snirf"):
|
|
save_path += ".snirf"
|
|
|
|
try:
|
|
raw = read_raw_snirf(snirf_path, preload=True)
|
|
|
|
onsets = []
|
|
durations = []
|
|
descriptions = []
|
|
|
|
open_events = {} # label -> list of start times
|
|
label_counts = {}
|
|
|
|
used_times = set()
|
|
sfreq = raw.info['sfreq'] # sampling frequency in Hz
|
|
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:
|
|
# Odd occurrence = start event
|
|
open_events[label].append(event_time)
|
|
else:
|
|
# Even occurrence = end event
|
|
if open_events[label]:
|
|
matched_start = open_events[label].pop(0)
|
|
duration = event_time - matched_start
|
|
|
|
if duration <= 0:
|
|
print(f"Warning: Duration for {label} is non-positive ({duration}). Skipping.")
|
|
continue
|
|
|
|
shifted_start = matched_start + time_shift
|
|
|
|
adjusted_time = shifted_start
|
|
attempts = 0
|
|
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
|
adjusted_time += min_shift
|
|
attempts += 1
|
|
|
|
if attempts == max_attempts:
|
|
print(f"Warning: Couldn't find unique time for {label} @ {matched_start}s. Skipping.")
|
|
continue
|
|
|
|
adjusted_time = round(adjusted_time, 6)
|
|
used_times.add(adjusted_time)
|
|
|
|
print(f"Adding event: {label} @ {adjusted_time:.3f}s for {duration:.3f}s")
|
|
|
|
onsets.append(adjusted_time)
|
|
durations.append(duration)
|
|
descriptions.append(label)
|
|
else:
|
|
print(f"Warning: Unmatched end for label '{label}' at {event_time:.3f}s. Skipping.")
|
|
|
|
# Optionally warn about any unmatched starts left open
|
|
for label, starts in open_events.items():
|
|
for start_time in starts:
|
|
shifted_start = start_time + time_shift
|
|
|
|
adjusted_time = shifted_start
|
|
attempts = 0
|
|
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
|
adjusted_time += min_shift
|
|
attempts += 1
|
|
|
|
if attempts == max_attempts:
|
|
print(f"Warning: Couldn't find unique time for unmatched start {label} @ {start_time}s. Skipping.")
|
|
continue
|
|
|
|
adjusted_time = round(adjusted_time, 6)
|
|
used_times.add(adjusted_time)
|
|
|
|
print(f"Warning: Unmatched start for label '{label}' at {start_time:.3f}s. Adding with duration 0.")
|
|
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}")
|
|
|
|
|
|
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
|
|
raw = read_raw_snirf(file_a, preload=True)
|
|
raw.set_montage(initial_montage)
|
|
write_raw_snirf(raw, save_path)
|
|
|
|
|
|
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.base_text = display_name
|
|
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.label.setStyleSheet("""
|
|
QLabel {
|
|
border: 1px solid #888;
|
|
border-radius: 10px;
|
|
padding: 8px 12px;
|
|
background-color: #e0f0ff;
|
|
}
|
|
""")
|
|
|
|
self.progress_layout = QHBoxLayout()
|
|
|
|
self.rects = []
|
|
for _ in range(20):
|
|
rect = QFrame()
|
|
rect.setFixedSize(10, 18)
|
|
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
|
self.progress_layout.addWidget(rect)
|
|
self.rects.append(rect)
|
|
|
|
self.layout.addWidget(self.label)
|
|
self.layout.addLayout(self.progress_layout)
|
|
self.setLayout(self.layout)
|
|
|
|
# Store the file path
|
|
self.file_path = os.path.normpath(file_path)
|
|
|
|
self.current_step = 0
|
|
# Make the bubble clickable
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
|
|
# Resize policy to make bubbles responsive
|
|
# TODO: Not only do this once but when window is resized too. Also just doesnt work
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
|
|
|
def update_progress(self, step_index):
|
|
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:
|
|
rect.setStyleSheet("background-color: yellow; 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)
|
|
|
|
|
|
|
|
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):
|
|
super().__init__()
|
|
layout = QVBoxLayout()
|
|
self.setLayout(layout)
|
|
self.widgets = {}
|
|
|
|
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.setFixedWidth(180)
|
|
|
|
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.setStretch(0, 1) # Set stretch factor for button (10%)
|
|
|
|
h_layout.addWidget(label)
|
|
h_layout.setStretch(1, 3) # Set the stretch factor for label (40%)
|
|
|
|
|
|
# Create input widget based on type
|
|
if param["type"] == bool:
|
|
widget = QComboBox()
|
|
widget.addItems(["True", "False"])
|
|
widget.setCurrentText(str(param["default"]))
|
|
elif param["type"] == int:
|
|
widget = QLineEdit()
|
|
widget.setValidator(QIntValidator())
|
|
widget.setText(str(param["default"]))
|
|
elif param["type"] == float:
|
|
widget = QLineEdit()
|
|
widget.setValidator(QDoubleValidator())
|
|
widget.setText(str(param["default"]))
|
|
elif param["type"] == list:
|
|
widget = self._create_multiselect_dropdown(None)
|
|
else:
|
|
widget = QLineEdit()
|
|
widget.setText(str(param["default"]))
|
|
|
|
widget.setToolTip(help_text)
|
|
|
|
h_layout.addWidget(widget)
|
|
h_layout.setStretch(2, 5) # Set stretch factor for input field (50%)
|
|
|
|
layout.addLayout(h_layout)
|
|
self.widgets[param["name"]] = {
|
|
"widget": widget,
|
|
"type": param["type"]
|
|
}
|
|
|
|
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("<None Selected>")
|
|
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("Parameter Info - FLARES")
|
|
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 expected_type == bool:
|
|
values[name] = widget.currentText() == "True"
|
|
elif expected_type == list:
|
|
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()]
|
|
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("<None Selected>")
|
|
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("<None Selected>")
|
|
|
|
|
|
def _get_checked_items(self, combo):
|
|
checked = []
|
|
model = combo.model()
|
|
for i in range(model.rowCount()):
|
|
item = model.item(i)
|
|
if item.text() in ("<None Selected>", "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("<None Selected>")
|
|
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):
|
|
annotation_sets = []
|
|
|
|
print(f"[ParamSection] Number of loaded bubbles: {len(bubble_widgets)}")
|
|
for bubble in bubble_widgets.values():
|
|
file_path = bubble.file_path
|
|
print(f"[ParamSection] Trying file: {file_path}")
|
|
try:
|
|
raw = read_raw_snirf(file_path, preload=False, verbose="ERROR")
|
|
annotations = raw.annotations
|
|
if annotations is not None:
|
|
print(f"[ParamSection] Found annotations with descriptions: {annotations.description}")
|
|
labels = set(annotations.description)
|
|
annotation_sets.append(labels)
|
|
else:
|
|
print(f"[ParamSection] No annotations found in file: {file_path}")
|
|
except Exception:
|
|
raise
|
|
|
|
if not annotation_sets:
|
|
print("[ParamSection] No annotations found in loaded files")
|
|
self.update_dropdown_items("REMOVE_EVENTS", [])
|
|
button1.setVisible(False)
|
|
return
|
|
|
|
common_annotations = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0]
|
|
common_annotations = sorted(list(common_annotations))
|
|
|
|
print(f"[ParamSection] Common annotations: {common_annotations}")
|
|
|
|
self.update_dropdown_items("REMOVE_EVENTS", common_annotations)
|
|
|
|
|
|
|
|
class FullClickLineEdit(QLineEdit):
|
|
def mousePressEvent(self, event):
|
|
combo = self.parent()
|
|
if isinstance(combo, QComboBox):
|
|
combo.showPopup()
|
|
super().mousePressEvent(event)
|
|
|
|
|
|
class FullClickComboBox(QComboBox):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.setLineEdit(FullClickLineEdit(self))
|
|
self.lineEdit().setReadOnly(True)
|
|
|
|
|
|
|
|
class ParticipantViewerWidget(QWidget):
|
|
def __init__(self, haemo_dict, fig_bytes_dict):
|
|
super().__init__()
|
|
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 _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("<None Selected>")
|
|
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)
|
|
|
|
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)
|
|
|
|
# Update label text immediately after change
|
|
if combo == self.participant_dropdown:
|
|
self.update_participant_dropdown_label()
|
|
elif combo == self.image_index_dropdown:
|
|
self.update_image_index_dropdown_label()
|
|
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
|
|
combo.setInsertPolicy(QComboBox.NoInsert)
|
|
return combo
|
|
|
|
def _get_checked_items(self, combo):
|
|
checked = []
|
|
model = combo.model()
|
|
for i in range(model.rowCount()):
|
|
item = model.item(i)
|
|
# Skip dummy and toggle items:
|
|
if item.text() in ("<None Selected>", "Toggle Select All"):
|
|
continue
|
|
if item.checkState() == Qt.Checked:
|
|
checked.append(item.text())
|
|
return checked
|
|
|
|
def update_participant_dropdown_label(self):
|
|
selected = self._get_checked_items(self.participant_dropdown)
|
|
if not selected:
|
|
self.participant_dropdown.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Extract just "Participant N" from "Participant N (filename)"
|
|
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
|
|
self.participant_dropdown.lineEdit().setText(", ".join(selected_short))
|
|
|
|
|
|
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("<None Selected>")
|
|
else:
|
|
# Only show the index part
|
|
self.image_index_dropdown.lineEdit().setText(", ".join(selected))
|
|
|
|
|
|
def show_selected_images(self):
|
|
# Clear previous images
|
|
for i in reversed(range(self.grid_layout.count())):
|
|
widget = self.grid_layout.itemAt(i).widget()
|
|
if widget:
|
|
widget.setParent(None)
|
|
|
|
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):
|
|
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 ParticipantBrainViewerWidget(QWidget):
|
|
def __init__(self, haemo_dict, cha_dict):
|
|
super().__init__()
|
|
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("<None Selected>")
|
|
|
|
|
|
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 _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("<None Selected>")
|
|
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)
|
|
|
|
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)
|
|
|
|
# Update label text immediately after change
|
|
if combo == self.participant_dropdown:
|
|
self.update_participant_dropdown_label()
|
|
elif combo == self.image_index_dropdown:
|
|
self.update_image_index_dropdown_label()
|
|
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
|
|
combo.setInsertPolicy(QComboBox.NoInsert)
|
|
return combo
|
|
|
|
def _get_checked_items(self, combo):
|
|
checked = []
|
|
model = combo.model()
|
|
for i in range(model.rowCount()):
|
|
item = model.item(i)
|
|
# Skip dummy and toggle items:
|
|
if item.text() in ("<None Selected>", "Toggle Select All"):
|
|
continue
|
|
if item.checkState() == Qt.Checked:
|
|
checked.append(item.text())
|
|
return checked
|
|
|
|
def update_participant_dropdown_label(self):
|
|
selected = self._get_checked_items(self.participant_dropdown)
|
|
if not selected:
|
|
self.participant_dropdown.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Extract just "Participant N" from "Participant N (filename)"
|
|
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
|
|
self.participant_dropdown.lineEdit().setText(", ".join(selected_short))
|
|
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("<None Selected>")
|
|
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):
|
|
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 not selected_file_paths:
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
return
|
|
|
|
annotation_sets = []
|
|
|
|
for file_path in selected_file_paths:
|
|
raw = self.haemo_dict.get(file_path)
|
|
if raw is None or not hasattr(raw, "annotations"):
|
|
continue
|
|
annotations = set(raw.annotations.description)
|
|
annotation_sets.append(annotations)
|
|
|
|
if not annotation_sets:
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
return
|
|
|
|
shared_annotations = set.intersection(*annotation_sets)
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
for ann in sorted(shared_annotations):
|
|
self.event_dropdown.addItem(ann)
|
|
|
|
def show_brain_images(self):
|
|
import flares
|
|
|
|
selected_event = self.event_dropdown.currentText()
|
|
if selected_event == "<None Selected>":
|
|
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 haemo_obj is None:
|
|
continue
|
|
|
|
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 ParticipantFoldChannelsWidget(QWidget):
|
|
def __init__(self, haemo_dict, cha_dict):
|
|
super().__init__()
|
|
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(QLabel("This will cause the app to hang for ~30s/Participant!"))
|
|
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 _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("<None Selected>")
|
|
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)
|
|
|
|
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)
|
|
|
|
# Update label text immediately after change
|
|
if combo == self.participant_dropdown:
|
|
self.update_participant_dropdown_label()
|
|
elif combo == self.image_index_dropdown:
|
|
self.update_image_index_dropdown_label()
|
|
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
|
|
combo.setInsertPolicy(QComboBox.NoInsert)
|
|
return combo
|
|
|
|
def _get_checked_items(self, combo):
|
|
checked = []
|
|
model = combo.model()
|
|
for i in range(model.rowCount()):
|
|
item = model.item(i)
|
|
# Skip dummy and toggle items:
|
|
if item.text() in ("<None Selected>", "Toggle Select All"):
|
|
continue
|
|
if item.checkState() == Qt.Checked:
|
|
checked.append(item.text())
|
|
return checked
|
|
|
|
def update_participant_dropdown_label(self):
|
|
selected = self._get_checked_items(self.participant_dropdown)
|
|
if not selected:
|
|
self.participant_dropdown.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Extract just "Participant N" from "Participant N (filename)"
|
|
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
|
|
self.participant_dropdown.lineEdit().setText(", ".join(selected_short))
|
|
|
|
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("<None Selected>")
|
|
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 show_fold_images(self):
|
|
import flares
|
|
|
|
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)
|
|
]
|
|
|
|
# Pass the necessary arguments to each method
|
|
for file_path in selected_file_paths:
|
|
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:
|
|
if idx == 0:
|
|
|
|
flares.fold_channels(haemo_obj)
|
|
|
|
else:
|
|
print(f"No method defined for index {idx}")
|
|
|
|
|
|
|
|
class ExportDataAsCSVViewerWidget(QWidget):
|
|
def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
|
|
super().__init__()
|
|
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 (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.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 _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("<None Selected>")
|
|
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)
|
|
|
|
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)
|
|
|
|
# Update label text immediately after change
|
|
if combo == self.participant_dropdown:
|
|
self.update_participant_dropdown_label()
|
|
elif combo == self.image_index_dropdown:
|
|
self.update_image_index_dropdown_label()
|
|
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
|
|
combo.setInsertPolicy(QComboBox.NoInsert)
|
|
return combo
|
|
|
|
def _get_checked_items(self, combo):
|
|
checked = []
|
|
model = combo.model()
|
|
for i in range(model.rowCount()):
|
|
item = model.item(i)
|
|
# Skip dummy and toggle items:
|
|
if item.text() in ("<None Selected>", "Toggle Select All"):
|
|
continue
|
|
if item.checkState() == Qt.Checked:
|
|
checked.append(item.text())
|
|
return checked
|
|
|
|
def update_participant_dropdown_label(self):
|
|
selected = self._get_checked_items(self.participant_dropdown)
|
|
if not selected:
|
|
self.participant_dropdown.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Extract just "Participant N" from "Participant N (filename)"
|
|
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
|
|
self.participant_dropdown.lineEdit().setText(", ".join(selected_short))
|
|
|
|
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("<None Selected>")
|
|
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 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)
|
|
]
|
|
|
|
# Pass the necessary arguments to each method
|
|
for file_path in selected_file_paths:
|
|
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:
|
|
if idx == 0:
|
|
try:
|
|
suggested_name = f"{file_path}.csv"
|
|
|
|
# Open save dialog
|
|
save_path, _ = QFileDialog.getSaveFileName(
|
|
self,
|
|
"Save SNIRF File As",
|
|
suggested_name,
|
|
"CSV Files (*.csv)"
|
|
)
|
|
|
|
if not save_path:
|
|
print("Save cancelled.")
|
|
return
|
|
|
|
if not save_path.lower().endswith(".csv"):
|
|
save_path += ".csv"
|
|
# Save the CSV here
|
|
|
|
cha.to_csv(save_path)
|
|
QMessageBox.information(self, "Success", "CSV file has been saved.")
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
|
|
|
|
|
else:
|
|
print(f"No method defined for index {idx}")
|
|
|
|
|
|
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(QWidget):
|
|
def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group):
|
|
super().__init__()
|
|
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
|
|
|
|
|
|
# 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("<None Selected>")
|
|
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("<None Selected>")
|
|
|
|
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 _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("<None Selected>")
|
|
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)
|
|
|
|
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)
|
|
|
|
# Update label text immediately after change
|
|
if combo == self.participant_dropdown:
|
|
self.update_participant_dropdown_label()
|
|
elif combo == self.image_index_dropdown:
|
|
self.update_image_index_dropdown_label()
|
|
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
|
|
combo.setInsertPolicy(QComboBox.NoInsert)
|
|
return combo
|
|
|
|
def _get_checked_items(self, combo):
|
|
checked = []
|
|
model = combo.model()
|
|
for i in range(model.rowCount()):
|
|
item = model.item(i)
|
|
# Skip dummy and toggle items:
|
|
if item.text() in ("<None Selected>", "Toggle Select All"):
|
|
continue
|
|
if item.checkState() == Qt.Checked:
|
|
checked.append(item.text())
|
|
return checked
|
|
|
|
|
|
def update_participant_list_for_group(self):
|
|
selected_group = self.group_dropdown.currentText()
|
|
model = self.participant_dropdown.model()
|
|
model.clear()
|
|
self.participant_map.clear()
|
|
|
|
# Add dummy and toggle select all items again
|
|
dummy_item = QStandardItem("<None Selected>")
|
|
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 selected_group == "<None Selected>":
|
|
# Disable participant dropdown when no group selected
|
|
self.participant_dropdown.setEnabled(False)
|
|
self.update_participant_dropdown_label()
|
|
return
|
|
|
|
# Enable participant dropdown since a valid group is selected
|
|
self.participant_dropdown.setEnabled(True)
|
|
|
|
group_file_paths = self.group_to_paths.get(selected_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)})"
|
|
self.participant_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_item, model)
|
|
self.update_participant_dropdown_label()
|
|
|
|
|
|
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)
|
|
|
|
# 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_dropdown_label(self):
|
|
selected = self._get_checked_items(self.participant_dropdown)
|
|
if not selected:
|
|
self.participant_dropdown.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Extract just "Participant N" from "Participant N (filename)"
|
|
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
|
|
self.participant_dropdown.lineEdit().setText(", ".join(selected_short))
|
|
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("<None Selected>")
|
|
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):
|
|
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 not selected_file_paths:
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
return
|
|
|
|
annotation_sets = []
|
|
|
|
for file_path in selected_file_paths:
|
|
raw = self.haemo_dict.get(file_path)
|
|
if raw is None or not hasattr(raw, "annotations"):
|
|
continue
|
|
annotations = set(raw.annotations.description)
|
|
annotation_sets.append(annotations)
|
|
|
|
if not annotation_sets:
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
return
|
|
|
|
shared_annotations = set.intersection(*annotation_sets)
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
for ann in sorted(shared_annotations):
|
|
self.event_dropdown.addItem(ann)
|
|
|
|
def show_brain_images(self):
|
|
import flares
|
|
|
|
selected_event = self.event_dropdown.currentText()
|
|
if selected_event == "<None Selected>":
|
|
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)
|
|
]
|
|
|
|
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 + <description>",
|
|
"default": "-0.3",
|
|
"type": float, # specify int here
|
|
},
|
|
{
|
|
"key": "upper_bound",
|
|
"label": "Upper bound + <description>",
|
|
"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 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
|
|
|
|
flares.brain_3d_visualization(haemo_obj, 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(QWidget):
|
|
def __init__(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict):
|
|
super().__init__()
|
|
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("<None Selected>")
|
|
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("<None Selected>")
|
|
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("<None Selected>")
|
|
|
|
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):
|
|
selected = self.group_a_dropdown.currentText()
|
|
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected)
|
|
self._update_event_dropdown()
|
|
group_b = self.group_b_dropdown.currentText()
|
|
self.update_participant_list_for_group(group_b, self.participant_dropdown_b)
|
|
|
|
def _update_group_a_options(self):
|
|
selected = self.group_b_dropdown.currentText()
|
|
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected)
|
|
self._update_event_dropdown()
|
|
group_a = self.group_a_dropdown.currentText()
|
|
self.update_participant_list_for_group(group_a, self.participant_dropdown_a)
|
|
|
|
|
|
|
|
def update_participant_list_for_group(self, group_name: str, participant_dropdown: FullClickComboBox):
|
|
model = participant_dropdown.model()
|
|
model.clear()
|
|
|
|
# Maintain separate participant maps for A and B to avoid conflicts
|
|
if participant_dropdown == self.participant_dropdown_a:
|
|
participant_map = self.participant_map_a = {}
|
|
else:
|
|
participant_map = self.participant_map_b = {}
|
|
|
|
# Add dummy and toggle select all items again
|
|
dummy_item = QStandardItem("<None Selected>")
|
|
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 group_name == "<None Selected>":
|
|
participant_dropdown.setEnabled(False)
|
|
self._update_participant_dropdown_label(participant_dropdown)
|
|
return
|
|
|
|
participant_dropdown.setEnabled(True)
|
|
|
|
group_file_paths = self.group_to_paths.get(group_name, [])
|
|
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)})"
|
|
participant_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_item, model)
|
|
self._update_participant_dropdown_label(participant_dropdown)
|
|
|
|
def _update_participant_dropdown_label(self, participant_dropdown):
|
|
selected = self._get_checked_items(participant_dropdown)
|
|
if not selected:
|
|
participant_dropdown.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Extract just "Participant N" from "Participant N (filename)"
|
|
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
|
|
participant_dropdown.lineEdit().setText(", ".join(selected_short))
|
|
self._update_event_dropdown()
|
|
|
|
def _connect_select_all_toggle(self, toggle_item, model):
|
|
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle
|
|
|
|
def on_item_changed(item):
|
|
if getattr(self, "_updating_checkstates", False):
|
|
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:
|
|
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
|
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
|
|
|
# Update label text for participant dropdowns
|
|
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)
|
|
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
|
|
def _on_participants_changed(self, item=None):
|
|
self._update_event_dropdown()
|
|
|
|
|
|
|
|
def _update_event_dropdown(self):
|
|
participants_a = self._get_checked_items(self.participant_dropdown_a)
|
|
participants_b = self._get_checked_items(self.participant_dropdown_b)
|
|
|
|
if not participants_a or not participants_b:
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
return
|
|
|
|
selected_file_paths_a = [
|
|
fp for display_name in participants_a
|
|
for fp, short_label in self.participant_map_a.items()
|
|
if display_name == f"{short_label} ({os.path.basename(fp)})"
|
|
]
|
|
|
|
selected_file_paths_b = [
|
|
fp for display_name in participants_b
|
|
for fp, short_label in self.participant_map_b.items()
|
|
if display_name == f"{short_label} ({os.path.basename(fp)})"
|
|
]
|
|
|
|
all_selected_file_paths = set(selected_file_paths_a + selected_file_paths_b)
|
|
|
|
if not all_selected_file_paths:
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
return
|
|
|
|
annotation_sets = []
|
|
for file_path in all_selected_file_paths:
|
|
raw = self.haemo_dict.get(file_path)
|
|
if raw is None or not hasattr(raw, "annotations"):
|
|
continue
|
|
annotation_sets.append(set(raw.annotations.description))
|
|
|
|
if not annotation_sets:
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
return
|
|
|
|
shared_annotations = set.intersection(*annotation_sets)
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
for ann in sorted(shared_annotations):
|
|
self.event_dropdown.addItem(ann)
|
|
|
|
def _refresh_group_dropdown(self, dropdown, exclude):
|
|
current = dropdown.currentText()
|
|
dropdown.blockSignals(True)
|
|
dropdown.clear()
|
|
dropdown.addItem("<None Selected>")
|
|
for group in self.group_names:
|
|
if group != exclude:
|
|
dropdown.addItem(group)
|
|
# Restore previous selection if still valid
|
|
if current != "<None Selected>" and current != exclude and dropdown.findText(current) != -1:
|
|
dropdown.setCurrentText(current)
|
|
else:
|
|
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
|
|
dropdown.blockSignals(False)
|
|
|
|
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("<None Selected>")
|
|
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)
|
|
|
|
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)
|
|
|
|
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())]
|
|
|
|
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)
|
|
|
|
toggle_item.setCheckState(Qt.Checked if all(i.checkState() == Qt.Checked for i in normal_items) else Qt.Unchecked)
|
|
|
|
self.update_image_index_dropdown_label()
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
return combo
|
|
|
|
def _get_checked_items(self, combo):
|
|
checked = []
|
|
model = combo.model()
|
|
for i in range(model.rowCount()):
|
|
item = model.item(i)
|
|
if item.text() in ("<None Selected>", "Toggle Select All"):
|
|
continue
|
|
if item.checkState() == Qt.Checked:
|
|
checked.append(item.text())
|
|
return checked
|
|
|
|
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("<None Selected>")
|
|
else:
|
|
index_labels = [s.split(" ")[0] for s in selected]
|
|
self.image_index_dropdown.lineEdit().setText(", ".join(index_labels))
|
|
|
|
|
|
|
|
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)
|
|
|
|
# Update label text immediately after change
|
|
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)
|
|
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
|
|
def update_participant_dropdown_label(self):
|
|
selected = self._get_checked_items(self.participant_dropdown)
|
|
if not selected:
|
|
self.participant_dropdown.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Extract just "Participant N" from "Participant N (filename)"
|
|
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
|
|
self.participant_dropdown.lineEdit().setText(", ".join(selected_short))
|
|
|
|
|
|
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("<None Selected>")
|
|
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 _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 == "<None Selected>":
|
|
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)
|
|
]
|
|
|
|
|
|
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)
|
|
|
|
# Get one person for their layout
|
|
rep_raw = None
|
|
for fp in file_paths_a + file_paths_b:
|
|
rep_raw = self.haemo_dict.get(fp)
|
|
if rep_raw:
|
|
break
|
|
|
|
print(rep_raw)
|
|
|
|
# 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 rep_raw:
|
|
|
|
flares.plot_2d_3d_contrasts_between_groups(
|
|
contrast_df_a,
|
|
contrast_df_b,
|
|
raw_haemo=rep_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, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, group):
|
|
super().__init__()
|
|
self.setWindowTitle("Viewer Launcher")
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
btn1 = QPushButton("Open Participant Viewer")
|
|
btn1.clicked.connect(lambda: self.open_participant_viewer(haemo_dict, fig_bytes_dict))
|
|
|
|
btn2 = QPushButton("Open Participant Brain Viewer")
|
|
btn2.clicked.connect(lambda: self.open_participant_brain_viewer(haemo_dict, cha_dict))
|
|
|
|
btn3 = QPushButton("Open Participant Fold Channels Viewer")
|
|
btn3.clicked.connect(lambda: self.open_participant_fold_channels_viewer(haemo_dict, cha_dict))
|
|
|
|
btn4 = QPushButton("Open Inter-Group Viewer")
|
|
btn4.clicked.connect(lambda: self.open_group_viewer(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group))
|
|
|
|
btn5 = QPushButton("Open Cross Group Brain Viewer")
|
|
btn5.clicked.connect(lambda: self.open_group_brain_viewer(haemo_dict, df_ind, design_matrix, group, contrast_results_dict))
|
|
|
|
btn6 = QPushButton("Open Export Data As CSV Viewer")
|
|
btn6.clicked.connect(lambda: self.open_export_data_as_csv_viewer(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict))
|
|
|
|
|
|
layout.addWidget(btn1)
|
|
layout.addWidget(btn2)
|
|
layout.addWidget(btn3)
|
|
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_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()
|
|
|
|
|
|
class MainApplication(QMainWindow):
|
|
"""
|
|
Main application window that creates and sets up the UI.
|
|
"""
|
|
|
|
progress_update_signal = Signal(str, int)
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("FLARES")
|
|
self.setGeometry(100, 100, 1280, 720)
|
|
|
|
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.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.init_ui()
|
|
self.create_menu_bar()
|
|
|
|
self.platform_suffix = "-" + PLATFORM_NAME
|
|
self.pending_update_version = None
|
|
self.pending_update_path = None
|
|
self.last_clicked_bubble = None
|
|
self.installEventFilter(self)
|
|
|
|
self.file_metadata = {}
|
|
self.current_file = None
|
|
|
|
# Start local pending update check thread
|
|
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix)
|
|
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
|
|
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
|
|
self.local_check_thread.start()
|
|
|
|
|
|
def init_ui(self):
|
|
|
|
# Central widget and main horizontal layout
|
|
central = QWidget()
|
|
self.setCentralWidget(central)
|
|
|
|
main_layout = QHBoxLayout()
|
|
central.setLayout(main_layout)
|
|
|
|
# Left container with vertical layout: top left + bottom left
|
|
left_container = QWidget()
|
|
left_layout = QVBoxLayout()
|
|
left_container.setLayout(left_layout)
|
|
left_container.setMinimumWidth(300)
|
|
|
|
top_left_container = QGroupBox()
|
|
top_left_container.setTitle("File information")
|
|
top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }") # Style if needed
|
|
top_left_container.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
|
|
|
|
top_left_layout = QHBoxLayout()
|
|
|
|
top_left_container.setLayout(top_left_layout)
|
|
|
|
# QTextEdit with fixed height, but only 80% width
|
|
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 using File > Open!")
|
|
|
|
# Add QTextEdit to the layout with a stretch factor
|
|
top_left_layout.addWidget(self.top_left_widget, stretch=4) # 80%
|
|
|
|
# Create a vertical box layout for the right 20%
|
|
self.right_column_widget = QWidget()
|
|
right_column_layout = QVBoxLayout()
|
|
self.right_column_widget.setLayout(right_column_layout)
|
|
|
|
self.meta_fields = {
|
|
"AGE": QLineEdit(),
|
|
"GENDER": QLineEdit(),
|
|
"GROUP": QLineEdit(),
|
|
}
|
|
|
|
for key, field in self.meta_fields.items():
|
|
label = QLabel(key.capitalize())
|
|
field.setPlaceholderText(f"Enter {key}")
|
|
right_column_layout.addWidget(label)
|
|
right_column_layout.addWidget(field)
|
|
|
|
label_desc = QLabel('<a href="#">Why are these useful?</a>')
|
|
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
label_desc.setOpenExternalLinks(False)
|
|
|
|
def show_info_popup():
|
|
QMessageBox.information(None, "Parameter Info - FLARES",
|
|
"Age: Used to calculate the DPF factor.\nGender: Not currently used. "
|
|
"Will be able to sort into groups by gender in the near future.\nGroup: Allows contrast "
|
|
"images to be created comparing one group to another once the processing has completed.")
|
|
|
|
label_desc.linkActivated.connect(show_info_popup)
|
|
right_column_layout.addWidget(label_desc)
|
|
|
|
right_column_layout.addStretch() # Push fields to top
|
|
|
|
self.right_column_widget.hide()
|
|
|
|
# Add right column widget to the top-left layout (takes 20% width)
|
|
top_left_layout.addWidget(self.right_column_widget, stretch=1)
|
|
|
|
# Add top_left_container to the main left_layout
|
|
left_layout.addWidget(top_left_container)
|
|
|
|
# Bottom left: the bubbles inside the scroll area
|
|
self.bubble_container = QWidget()
|
|
self.bubble_layout = QGridLayout()
|
|
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
self.bubble_container.setLayout(self.bubble_layout)
|
|
|
|
self.scroll_area = QScrollArea()
|
|
self.scroll_area.setWidgetResizable(True)
|
|
self.scroll_area.setWidget(self.bubble_container)
|
|
self.scroll_area.setMinimumHeight(300)
|
|
|
|
# Add top left and bottom left to left layout
|
|
left_layout.addWidget(self.scroll_area)
|
|
|
|
self.progress_update_signal.connect(self.update_file_progress)
|
|
|
|
# Right widget (full height on right side)
|
|
self.right_container = QWidget()
|
|
right_container_layout = QVBoxLayout()
|
|
self.right_container.setLayout(right_container_layout)
|
|
|
|
# Content widget inside scroll area
|
|
self.right_content_widget = QWidget()
|
|
right_content_layout = QVBoxLayout()
|
|
self.right_content_widget.setLayout(right_content_layout)
|
|
|
|
# Option selector dropdown
|
|
self.option_selector = QComboBox()
|
|
self.option_selector.addItems(["FIR"])
|
|
right_content_layout.addWidget(self.option_selector)
|
|
|
|
# Container for the sections
|
|
self.rows_container = QWidget()
|
|
self.rows_layout = QVBoxLayout()
|
|
self.rows_layout.setSpacing(10)
|
|
self.rows_container.setLayout(self.rows_layout)
|
|
right_content_layout.addWidget(self.rows_container)
|
|
|
|
# Spacer at bottom inside scroll area content to push content up
|
|
right_content_layout.addStretch()
|
|
|
|
# Scroll area for the right side content
|
|
self.right_scroll_area = QScrollArea()
|
|
self.right_scroll_area.setWidgetResizable(True)
|
|
self.right_scroll_area.setWidget(self.right_content_widget)
|
|
|
|
# Buttons widget (fixed below the scroll area)
|
|
buttons_widget = QWidget()
|
|
buttons_layout = QHBoxLayout()
|
|
buttons_widget.setLayout(buttons_layout)
|
|
buttons_layout.addStretch()
|
|
|
|
self.button1 = QPushButton("Process")
|
|
self.button2 = QPushButton("Clear")
|
|
self.button3 = QPushButton("Analysis")
|
|
|
|
buttons_layout.addWidget(self.button1)
|
|
buttons_layout.addWidget(self.button2)
|
|
buttons_layout.addWidget(self.button3)
|
|
|
|
self.button1.setMinimumSize(100, 40)
|
|
self.button2.setMinimumSize(100, 40)
|
|
self.button3.setMinimumSize(100, 40)
|
|
|
|
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)
|
|
|
|
# Add scroll area and buttons widget to right container layout
|
|
right_container_layout.addWidget(self.right_scroll_area)
|
|
right_container_layout.addWidget(buttons_widget)
|
|
|
|
# Add left and right containers to main layout
|
|
main_layout.addWidget(left_container, stretch=55)
|
|
main_layout.addWidget(self.right_container, stretch=45)
|
|
|
|
# Set size policy to expand
|
|
self.right_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
self.right_scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
|
|
# Store ParamSection widgets
|
|
self.option_selector.currentIndexChanged.connect(self.update_sections)
|
|
|
|
# Initial build
|
|
self.update_sections(0)
|
|
|
|
|
|
def create_menu_bar(self):
|
|
'''Menu Bar at the top of the screen'''
|
|
|
|
menu_bar = self.menuBar()
|
|
|
|
def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None):
|
|
action = QAction(name, self)
|
|
|
|
if shortcut:
|
|
action.setShortcut(QKeySequence(shortcut))
|
|
if slot:
|
|
action.triggered.connect(slot)
|
|
if checkable:
|
|
action.setCheckable(True)
|
|
action.setChecked(checked)
|
|
if icon:
|
|
action.setIcon(QIcon(icon))
|
|
return action
|
|
|
|
# File menu and actions
|
|
file_menu = menu_bar.addMenu("File")
|
|
file_actions = [
|
|
("Open File...", "Ctrl+O", self.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")),
|
|
("Open Folder...", "Ctrl+Alt+O", self.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")),
|
|
# ("Open Folders...", "Ctrl+Shift+O", self.open_folder_dialog, resource_path("icons/folder_copy_24dp_1F1F1F.svg")),
|
|
("Load Project...", "Ctrl+L", self.load_project, resource_path("icons/article_24dp_1F1F1F.svg")),
|
|
("Save Project...", "Ctrl+S", self.save_project, resource_path("icons/save_24dp_1F1F1F.svg")),
|
|
("Save Project As...", "Ctrl+Shift+S", self.save_project, resource_path("icons/save_as_24dp_1F1F1F.svg")),
|
|
]
|
|
|
|
for i, (name, shortcut, slot, icon) in enumerate(file_actions):
|
|
file_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
|
if i == 1: # after the first 3 actions (0,1,2)
|
|
file_menu.addSeparator()
|
|
|
|
file_menu.addSeparator()
|
|
file_menu.addAction(make_action("Exit", "Ctrl+Q", QApplication.instance().quit, icon=resource_path("icons/exit_to_app_24dp_1F1F1F.svg")))
|
|
|
|
# Edit menu
|
|
edit_menu = menu_bar.addMenu("Edit")
|
|
edit_actions = [
|
|
("Cut", "Ctrl+X", self.cut_text, resource_path("icons/content_cut_24dp_1F1F1F.svg")),
|
|
("Copy", "Ctrl+C", self.copy_text, resource_path("icons/content_copy_24dp_1F1F1F.svg")),
|
|
("Paste", "Ctrl+V", self.paste_text, resource_path("icons/content_paste_24dp_1F1F1F.svg"))
|
|
]
|
|
for name, shortcut, slot, icon in edit_actions:
|
|
edit_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
|
|
|
# View menu
|
|
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)
|
|
|
|
# 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.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
|
|
("Update optodes in snirf file...", "F6", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
|
|
("Update events in snirf file...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
|
|
("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg"))
|
|
]
|
|
|
|
for i, (name, shortcut, slot, icon) in enumerate(options_actions):
|
|
options_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
|
if i == 1 or i == 3: # after the first 2 actions (0,1)
|
|
options_menu.addSeparator()
|
|
|
|
terminal_menu = menu_bar.addMenu("Terminal")
|
|
terminal_actions = [
|
|
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
|
|
]
|
|
for name, shortcut, slot, icon in terminal_actions:
|
|
terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon))
|
|
|
|
self.statusbar = self.statusBar()
|
|
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()
|
|
|
|
# Add ParamSection widgets from SECTIONS
|
|
for section in SECTIONS:
|
|
self.section_widget = ParamSection(section)
|
|
self.rows_layout.addWidget(self.section_widget)
|
|
|
|
self.param_sections.append(self.section_widget)
|
|
|
|
|
|
def clear_all(self):
|
|
|
|
self.cancel_task()
|
|
|
|
self.right_column_widget.hide()
|
|
|
|
# Clear the bubble layout
|
|
while self.bubble_layout.count():
|
|
item = self.bubble_layout.takeAt(0)
|
|
widget = item.widget()
|
|
if widget:
|
|
widget.deleteLater()
|
|
|
|
# Clear file data
|
|
self.bubble_widgets.clear()
|
|
self.statusBar().clearMessage()
|
|
|
|
self.raw_haemo_dict = None
|
|
self.epochs_dict = None
|
|
self.fig_bytes_dict = None
|
|
self.cha_dict = None
|
|
self.contrast_results_dict = None
|
|
self.df_ind_dict = None
|
|
self.design_matrix_dict = None
|
|
self.age_dict = None
|
|
self.gender_dict = None
|
|
self.group_dict = None
|
|
self.valid_dict = None
|
|
|
|
# Reset any visible UI elements
|
|
self.button1.setVisible(False)
|
|
self.button3.setVisible(False)
|
|
self.top_left_widget.clear()
|
|
|
|
|
|
def open_launcher_window(self):
|
|
|
|
self.launcher_window = ViewerLauncherWidget(self.raw_haemo_dict, self.fig_bytes_dict, self.cha_dict, self.contrast_results_dict, self.df_ind_dict, self.design_matrix_dict, self.group_dict)
|
|
self.launcher_window.show()
|
|
|
|
|
|
def copy_text(self):
|
|
self.top_left_widget.copy() # Trigger copy
|
|
self.statusbar.showMessage("Copied to clipboard") # Show status message
|
|
|
|
def cut_text(self):
|
|
self.top_left_widget.cut() # Trigger cut
|
|
self.statusbar.showMessage("Cut to clipboard") # Show status message
|
|
|
|
def paste_text(self):
|
|
self.top_left_widget.paste() # Trigger paste
|
|
self.statusbar.showMessage("Pasted from clipboard") # Show status message
|
|
|
|
|
|
def 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)
|
|
self.events.show()
|
|
|
|
def open_file_dialog(self):
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
self, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
|
|
)
|
|
if file_path:
|
|
# Create and display the bubble directly for this file
|
|
display_name = os.path.basename(file_path)
|
|
bubble = ProgressBubble(display_name, file_path)
|
|
bubble.clicked.connect(self.on_bubble_clicked)
|
|
bubble.rightClicked.connect(self.on_bubble_right_clicked)
|
|
|
|
if not hasattr(self, 'bubble_widgets'):
|
|
self.bubble_widgets = {}
|
|
if not hasattr(self, 'selected_paths'):
|
|
self.selected_paths = []
|
|
|
|
file_path = os.path.normpath(file_path)
|
|
self.bubble_widgets[file_path] = bubble
|
|
self.selected_paths.append(file_path)
|
|
|
|
self.bubble_layout.addWidget(bubble)
|
|
|
|
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")
|
|
|
|
self.statusBar().showMessage(f"{file_path} loaded.")
|
|
|
|
self.button1.setVisible(True)
|
|
|
|
|
|
def open_folder_dialog(self):
|
|
folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", "")
|
|
|
|
if folder_path:
|
|
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")
|
|
|
|
self.button1.setVisible(True)
|
|
|
|
|
|
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 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 = [
|
|
str(PurePosixPath(Path(bubble.file_path).resolve().relative_to(project_dir)))
|
|
for bubble in self.bubble_widgets.values()
|
|
]
|
|
|
|
progress_states = {
|
|
str(PurePosixPath(Path(bubble.file_path).resolve().relative_to(project_dir))): bubble.current_step
|
|
for bubble in self.bubble_widgets.values()
|
|
}
|
|
|
|
project_data = {
|
|
"file_list": file_list,
|
|
"progress_states": progress_states,
|
|
"raw_haemo_dict": self.raw_haemo_dict,
|
|
"epochs_dict": self.epochs_dict,
|
|
"fig_bytes_dict": self.fig_bytes_dict,
|
|
"cha_dict": self.cha_dict,
|
|
"contrast_results_dict": self.contrast_results_dict,
|
|
"df_ind_dict": self.df_ind_dict,
|
|
"design_matrix_dict": self.design_matrix_dict,
|
|
"age_dict": self.age_dict,
|
|
"gender_dict": self.gender_dict,
|
|
"group_dict": self.group_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)
|
|
|
|
with open(filename, "wb") as f:
|
|
pickle.dump(project_data, f)
|
|
|
|
QMessageBox.information(self, "Success", f"Project saved to:\n{filename}")
|
|
|
|
except Exception as e:
|
|
if not onCrash:
|
|
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}")
|
|
|
|
|
|
|
|
def load_project(self):
|
|
filename, _ = QFileDialog.getOpenFileName(
|
|
self, "Load Project", "", "FLARE Project (*.flare)"
|
|
)
|
|
if not filename:
|
|
return
|
|
|
|
try:
|
|
with open(filename, "rb") as f:
|
|
data = pickle.load(f)
|
|
|
|
self.raw_haemo_dict = data.get("raw_haemo_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.age_dict = data.get("age_dict", {})
|
|
self.gender_dict = data.get("gender_dict", {})
|
|
self.group_dict = data.get("group_dict", {})
|
|
self.valid_dict = data.get("valid_dict", {})
|
|
|
|
project_dir = Path(filename).parent
|
|
|
|
# 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()
|
|
}
|
|
|
|
self.show_files_as_bubbles_from_list(file_list, progress_states, filename)
|
|
|
|
# Re-enable buttons
|
|
# self.button1.setVisible(True)
|
|
self.button3.setVisible(True)
|
|
|
|
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 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.clicked.connect(self.on_bubble_clicked)
|
|
bubble.rightClicked.connect(self.on_bubble_right_clicked)
|
|
|
|
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 show_files_as_bubbles_from_list(self, file_list, progress_states=None, filenames=None):
|
|
progress_states = progress_states or {}
|
|
|
|
# Clear old
|
|
while self.bubble_layout.count():
|
|
item = self.bubble_layout.takeAt(0)
|
|
widget = item.widget()
|
|
if widget:
|
|
widget.deleteLater()
|
|
|
|
self.bubble_widgets = {}
|
|
|
|
temp_bubble = ProgressBubble("Test Bubble Test Bubble", "") # A dummy bubble for measurement
|
|
temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
|
# temp_bubble.setAttribute(Qt.WA_OpaquePaintEvent) # Improves 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
|
|
|
|
for index, file_path in enumerate(file_list):
|
|
filename = os.path.basename(file_path)
|
|
display_name = f"{os.path.basename(os.path.dirname(file_path))} / {filename}"
|
|
|
|
# Create bubble with full path
|
|
bubble = ProgressBubble(display_name, file_path)
|
|
bubble.clicked.connect(self.on_bubble_clicked)
|
|
bubble.rightClicked.connect(self.on_bubble_right_clicked)
|
|
self.bubble_widgets[file_path] = bubble
|
|
|
|
step = progress_states.get(file_path, 0)
|
|
bubble.update_progress(step)
|
|
|
|
row = index // cols
|
|
col = index % cols
|
|
self.bubble_layout.addWidget(bubble, row, col)
|
|
|
|
self.statusBar().showMessage(f"{len(file_list)} files loaded from from {os.path.abspath(filenames)}.")
|
|
|
|
|
|
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)
|
|
|
|
info = f"""\
|
|
File: {os.path.basename(file_path)}
|
|
Size: {size:,} bytes
|
|
Created: {created}
|
|
Modified: {modified}
|
|
Full Path: {file_path}
|
|
"""
|
|
|
|
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 eventFilter(self, watched, event):
|
|
if event.type() == QEvent.Type.MouseButtonPress:
|
|
widget = self.childAt(event.pos())
|
|
if isinstance(widget, ProgressBubble):
|
|
pass
|
|
else:
|
|
if self.last_clicked_bubble:
|
|
if not self.last_clicked_bubble.isAncestorOf(widget):
|
|
if self.current_file:
|
|
self.save_metadata(self.current_file)
|
|
suffix = self.get_suffix_from_meta_fields()
|
|
self.last_clicked_bubble.setSuffixText(suffix)
|
|
self.last_clicked_bubble = None
|
|
|
|
return super().eventFilter(watched, event)
|
|
|
|
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):
|
|
|
|
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")
|
|
|
|
# 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:
|
|
QMessageBox.critical(None, "Error - 2D Data Detected - FLARES", f"Error: 2 dimensional data was found in {i}. "
|
|
"It is not possible to process this file. Please update the coordinates "
|
|
"using the 'Update optodes in snirf file...' option from the Options menu or by pressing 'F6'.")
|
|
self.button1.clicked.disconnect(self.cancel_task)
|
|
self.button1.setText("Process")
|
|
self.button1.clicked.connect(self.on_run_task)
|
|
return
|
|
|
|
self.files_total = len(snirf_files)
|
|
self.files_done = set()
|
|
self.files_failed = set()
|
|
self.files_results = {}
|
|
|
|
all_params = {}
|
|
for section_widget in self.param_sections:
|
|
section_params = section_widget.get_param_values()
|
|
all_params.update(section_params)
|
|
|
|
collected_data = {
|
|
"SNIRF_FILES": snirf_files,
|
|
"PARAMS": all_params, # add this line
|
|
"METADATA": self.get_all_metadata(), # optionally add metadata if needed
|
|
}
|
|
|
|
# Start processing
|
|
if current_process().name == 'MainProcess':
|
|
self.manager = Manager()
|
|
self.result_queue = self.manager.Queue()
|
|
self.progress_queue = self.manager.Queue()
|
|
|
|
self.result_process = Process(
|
|
target=run_gui_entry_wrapper,
|
|
args=(collected_data, self.result_queue, self.progress_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):
|
|
while not self.result_queue.empty():
|
|
msg = self.result_queue.get()
|
|
|
|
if isinstance(msg, dict):
|
|
if msg.get("success"):
|
|
|
|
results = msg["result"] # from flares.py
|
|
|
|
for file_path, result_tuple in results.items():
|
|
self.files_done.add(file_path)
|
|
self.files_results[file_path] = result_tuple
|
|
|
|
# Initialize storage
|
|
# TODO: Is this check needed?
|
|
if not hasattr(self, 'raw_haemo_dict'):
|
|
self.raw_haemo_dict = {}
|
|
self.epochs_dict = {}
|
|
self.fig_bytes_dict = {}
|
|
self.cha_dict = {}
|
|
self.contrast_results_dict = {}
|
|
self.df_ind_dict = {}
|
|
self.design_matrix_dict = {}
|
|
self.age_dict = {}
|
|
self.gender_dict = {}
|
|
self.group_dict = {}
|
|
self.valid_dict = {}
|
|
|
|
# Combine all results into the dicts
|
|
for file_path, (raw_haemo, epochs, fig_bytes, cha, contrast_results, df_ind, design_matrix, age, gender, group, valid) in results.items():
|
|
self.raw_haemo_dict[file_path] = raw_haemo
|
|
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_results
|
|
self.df_ind_dict[file_path] = df_ind
|
|
self.design_matrix_dict[file_path] = design_matrix
|
|
self.age_dict[file_path] = age
|
|
self.gender_dict[file_path] = gender
|
|
self.group_dict[file_path] = group
|
|
self.valid_dict[file_path] = valid
|
|
|
|
# self.statusbar.showMessage(f"Processing complete! Time elapsed: {elapsed_time:.2f} seconds")
|
|
# self.statusbar.showMessage(f"Processing complete!")
|
|
|
|
self.button3.setVisible(True)
|
|
|
|
|
|
elif msg.get("success") is False:
|
|
error_msg = msg.get("error", "Unknown error")
|
|
traceback_str = msg.get("traceback", "")
|
|
self.show_error_popup("Processing failed!", error_msg, traceback_str)
|
|
self.files_done = set(self.files_results.keys())
|
|
self.statusbar.showMessage(f"Processing failed!")
|
|
self.result_timer.stop()
|
|
self.cleanup_after_process()
|
|
return
|
|
|
|
elif msg.get("type") == "error":
|
|
# Error forwarded from a single file (e.g. from a worker)
|
|
file_path = msg.get("file", "Unknown file")
|
|
error_msg = msg.get("error", "Unknown error")
|
|
traceback_str = msg.get("traceback", "")
|
|
|
|
self.files_done.add(file_path)
|
|
self.files_failed.add(file_path)
|
|
|
|
self.show_error_popup(f"{file_path}", error_msg, traceback_str)
|
|
self.statusbar.showMessage(f"Error processing {file_path}")
|
|
if file_path in self.bubble_widgets:
|
|
self.bubble_widgets[file_path].mark_cancelled()
|
|
|
|
|
|
elif isinstance(msg, tuple) and msg[0] == 'progress':
|
|
_, file_path, step_index = msg
|
|
self.progress_update_signal.emit(file_path, step_index)
|
|
|
|
|
|
if len(self.files_done) == self.files_total:
|
|
self.result_timer.stop()
|
|
self.cleanup_after_process()
|
|
|
|
success_count = len(self.files_results)
|
|
fail_count = len(self.files_failed)
|
|
|
|
summary_msg = f"Processing complete: {success_count} succeeded, {fail_count} failed."
|
|
self.statusbar.showMessage(summary_msg)
|
|
|
|
if success_count > 0:
|
|
self.button3.setVisible(True)
|
|
|
|
self.button1.clicked.disconnect(self.cancel_task)
|
|
self.button1.setText("Process")
|
|
self.button1.clicked.connect(self.on_run_task)
|
|
|
|
|
|
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}.<br><br>"
|
|
"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 <a href='https://git.research.dezeeuw.ca/tyler/flares/issues'>here</a>.<br><br>"
|
|
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.exec_()
|
|
|
|
|
|
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()
|
|
|
|
# Shutdown manager to kill its server process and clean up
|
|
if hasattr(self, 'manager'):
|
|
self.manager.shutdown()
|
|
|
|
|
|
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)
|
|
|
|
|
|
'''UPDATER'''
|
|
def manual_check_for_updates(self):
|
|
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix)
|
|
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
|
|
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
|
|
self.local_check_thread.start()
|
|
|
|
def on_pending_update_found(self, version, folder_path):
|
|
self.statusBar().showMessage(f"Pending update found: version {version}")
|
|
self.pending_update_version = version
|
|
self.pending_update_path = folder_path
|
|
self.show_pending_update_popup()
|
|
|
|
def on_no_pending_update(self):
|
|
# No pending update found locally, start server check directly
|
|
self.statusBar().showMessage("No pending local update found. Checking server...")
|
|
self.start_update_check_thread()
|
|
|
|
def show_pending_update_popup(self):
|
|
msg_box = QMessageBox(self)
|
|
msg_box.setWindowTitle("Pending Update Found")
|
|
msg_box.setText(f"A previously downloaded update (version {self.pending_update_version}) is available at:\n{self.pending_update_path}\nWould you like to install it now?")
|
|
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
|
|
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
|
msg_box.exec()
|
|
|
|
if msg_box.clickedButton() == install_now_button:
|
|
self.install_update(self.pending_update_path)
|
|
else:
|
|
self.statusBar().showMessage("Pending update available. Install later.")
|
|
# After user dismisses, still check the server for new updates
|
|
self.start_update_check_thread()
|
|
|
|
def start_update_check_thread(self):
|
|
self.check_thread = UpdateCheckThread()
|
|
self.check_thread.download_requested.connect(self.on_server_update_requested)
|
|
self.check_thread.no_update_available.connect(self.on_server_no_update)
|
|
self.check_thread.error_occurred.connect(self.on_error)
|
|
self.check_thread.start()
|
|
|
|
def on_server_no_update(self):
|
|
self.statusBar().showMessage("No new updates found on server.", 5000)
|
|
|
|
def on_server_update_requested(self, download_url, latest_version):
|
|
if self.pending_update_version:
|
|
cmp = self.version_compare(latest_version, self.pending_update_version)
|
|
if cmp > 0:
|
|
# Server version is newer than pending update
|
|
self.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
|
|
try:
|
|
shutil.rmtree(self.pending_update_path)
|
|
self.statusBar().showMessage(f"Deleted old update folder: {self.pending_update_path}")
|
|
except Exception as e:
|
|
self.statusBar().showMessage(f"Failed to delete old update folder: {e}")
|
|
|
|
# Clear pending update info so new download proceeds
|
|
self.pending_update_version = None
|
|
self.pending_update_path = None
|
|
|
|
# Download the new update
|
|
self.download_update(download_url, latest_version)
|
|
elif cmp == 0:
|
|
# Versions equal, no download needed
|
|
self.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
|
|
else:
|
|
# Server version older than pending? Unlikely but just keep pending update
|
|
self.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
|
|
else:
|
|
# No pending update, just download
|
|
self.download_update(download_url, latest_version)
|
|
|
|
def download_update(self, download_url, latest_version):
|
|
self.statusBar().showMessage("Downloading update...")
|
|
self.download_thread = UpdateDownloadThread(download_url, latest_version)
|
|
self.download_thread.update_ready.connect(self.on_update_ready)
|
|
self.download_thread.error_occurred.connect(self.on_error)
|
|
self.download_thread.start()
|
|
|
|
def on_update_ready(self, latest_version, extract_folder):
|
|
self.statusBar().showMessage("Update downloaded and extracted.")
|
|
|
|
msg_box = QMessageBox(self)
|
|
msg_box.setWindowTitle("Update Ready")
|
|
msg_box.setText(f"Version {latest_version} has been downloaded and extracted to:\n{extract_folder}\nWould you like to install it now?")
|
|
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
|
|
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
|
|
|
msg_box.exec()
|
|
|
|
if msg_box.clickedButton() == install_now_button:
|
|
self.install_update(extract_folder)
|
|
else:
|
|
self.statusBar().showMessage("Update ready. Install later.")
|
|
|
|
|
|
def install_update(self, extract_folder):
|
|
# Path to updater executable
|
|
|
|
if PLATFORM_NAME == 'windows':
|
|
updater_path = os.path.join(os.getcwd(), "flares_updater.exe")
|
|
elif PLATFORM_NAME == 'darwin':
|
|
if getattr(sys, 'frozen', False):
|
|
updater_path = os.path.join(os.path.dirname(sys.executable), "../../../flares_updater.app")
|
|
else:
|
|
updater_path = os.path.join(os.getcwd(), "../flares_updater.app")
|
|
|
|
elif PLATFORM_NAME == 'linux':
|
|
updater_path = os.path.join(os.getcwd(), "flares_updater")
|
|
else:
|
|
updater_path = os.getcwd()
|
|
|
|
if not os.path.exists(updater_path):
|
|
QMessageBox.critical(self, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
|
|
return
|
|
|
|
# Launch updater with extracted folder path as argument
|
|
try:
|
|
# Pass current app's executable path for updater to relaunch
|
|
main_app_executable = os.path.abspath(sys.argv[0])
|
|
|
|
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
|
|
|
|
if PLATFORM_NAME == 'darwin':
|
|
subprocess.Popen(['open', updater_path, '--args', extract_folder, main_app_executable])
|
|
else:
|
|
subprocess.Popen([updater_path, f'{extract_folder}', f'{main_app_executable}'], cwd=os.path.dirname(updater_path))
|
|
|
|
# Close the current app so updater can replace files
|
|
sys.exit(0)
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
|
|
|
|
def on_error(self, message):
|
|
# print(f"Error: {message}")
|
|
self.statusBar().showMessage(f"Error occurred during update process. {message}")
|
|
|
|
def version_compare(self, v1, v2):
|
|
def normalize(v): return [int(x) for x in v.split(".")]
|
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
|
|
|
def get_snirf_metadata_mne(self, file_name):
|
|
|
|
try:
|
|
raw = read_raw_snirf(file_name, preload=True)
|
|
|
|
snirf_info = {}
|
|
|
|
# Measurement date
|
|
snirf_info['Measurement Date'] = str(raw.info.get('meas_date'))
|
|
|
|
try:
|
|
short_chans = get_short_channels(raw, max_dist=0.015)
|
|
snirf_info['Short Channels'] = f"Likely - {short_chans.ch_names}"
|
|
if len(short_chans.ch_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"
|
|
|
|
# Source-detector distances
|
|
distances = source_detector_distances(raw.info)
|
|
distance_info = []
|
|
for ch_name, dist in zip(raw.info['ch_names'], distances):
|
|
distance_info.append(f"{ch_name}: {dist:.4f} m")
|
|
snirf_info['Source-Detector Distances'] = distance_info
|
|
|
|
# Digitization points / optode positions
|
|
dig = raw.info.get('dig', None)
|
|
if dig is not None:
|
|
dig_info = []
|
|
for point in dig:
|
|
kind = point['kind']
|
|
ident = point['ident']
|
|
coord = point['r']
|
|
dig_info.append(f"Kind: {kind}, ID: {ident}, Coord: {coord}")
|
|
snirf_info['Digitization Points'] = dig_info
|
|
else:
|
|
snirf_info['Digitization Points'] = "Not found"
|
|
|
|
if raw.annotations is not None and len(raw.annotations) > 0:
|
|
annot_info = []
|
|
for onset, duration, desc in zip(raw.annotations.onset,
|
|
raw.annotations.duration,
|
|
raw.annotations.description):
|
|
annot_info.append(f"Onset: {onset:.2f}s, Duration: {duration:.2f}s, Description: {desc}")
|
|
snirf_info['Annotations'] = annot_info
|
|
else:
|
|
snirf_info['Annotations'] = "No annotations found"
|
|
|
|
return snirf_info
|
|
except:
|
|
return None
|
|
|
|
|
|
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 wait_for_process_to_exit(process_name, timeout=10):
|
|
"""
|
|
Waits for a process with the specified name to exit within a timeout period.
|
|
|
|
Args:
|
|
process_name (str): Name (or part of the name) of the process to wait for.
|
|
timeout (int, optional): Maximum time to wait in seconds. Defaults to 10.
|
|
|
|
Returns:
|
|
bool: True if the process exited before the timeout, False otherwise.
|
|
"""
|
|
|
|
print(f"Waiting for {process_name} to exit...")
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
still_running = False
|
|
for proc in psutil.process_iter(['name']):
|
|
try:
|
|
if proc.info['name'] and process_name.lower() in proc.info['name'].lower():
|
|
still_running = True
|
|
print(f"Still running: {proc.info['name']} (PID: {proc.pid})")
|
|
break
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
continue
|
|
if not still_running:
|
|
print(f"{process_name} has exited.")
|
|
return True
|
|
time.sleep(0.5)
|
|
print(f"{process_name} did not exit in time.")
|
|
return False
|
|
|
|
|
|
def finish_update_if_needed():
|
|
"""
|
|
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
|
"""
|
|
|
|
if "--finish-update" in sys.argv:
|
|
print("Finishing update...")
|
|
|
|
if PLATFORM_NAME == 'darwin':
|
|
app_dir = '/tmp/flarestempupdate'
|
|
else:
|
|
app_dir = os.getcwd()
|
|
|
|
# 1. Find update folder
|
|
update_folder = None
|
|
for entry in os.listdir(app_dir):
|
|
entry_path = os.path.join(app_dir, entry)
|
|
if os.path.isdir(entry_path) and entry.startswith("flares-") and entry.endswith("-" + PLATFORM_NAME):
|
|
update_folder = os.path.join(app_dir, entry)
|
|
break
|
|
|
|
if update_folder is None:
|
|
print("No update folder found. Skipping update steps.")
|
|
return
|
|
|
|
if PLATFORM_NAME == 'darwin':
|
|
update_folder = os.path.join(update_folder, "flares-darwin")
|
|
|
|
# 2. Wait for flares_updater to exit
|
|
print("Waiting for flares_updater to exit...")
|
|
for proc in psutil.process_iter(['pid', 'name']):
|
|
if proc.info['name'] and "flares_updater" in proc.info['name'].lower():
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except psutil.TimeoutExpired:
|
|
print("Force killing lingering flares_updater")
|
|
proc.kill()
|
|
|
|
# 3. Replace the updater
|
|
if PLATFORM_NAME == 'windows':
|
|
new_updater = os.path.join(update_folder, "flares_updater.exe")
|
|
dest_updater = os.path.join(app_dir, "flares_updater.exe")
|
|
|
|
elif PLATFORM_NAME == 'darwin':
|
|
new_updater = os.path.join(update_folder, "flares_updater.app")
|
|
dest_updater = os.path.abspath(os.path.join(sys.executable, "../../../../flares_updater.app"))
|
|
|
|
elif PLATFORM_NAME == 'linux':
|
|
new_updater = os.path.join(update_folder, "flares_updater")
|
|
dest_updater = os.path.join(app_dir, "flares_updater")
|
|
|
|
else:
|
|
print("No platform??")
|
|
new_updater = os.getcwd()
|
|
dest_updater = os.getcwd()
|
|
|
|
print(f"New updater is {new_updater}")
|
|
print(f"Dest updater is {dest_updater}")
|
|
|
|
print("Writable?", os.access(dest_updater, os.W_OK))
|
|
print("Executable path:", sys.executable)
|
|
print("Trying to copy:", new_updater, "->", dest_updater)
|
|
|
|
if os.path.exists(new_updater):
|
|
try:
|
|
if os.path.exists(dest_updater):
|
|
if PLATFORM_NAME == 'darwin':
|
|
try:
|
|
if os.path.isdir(dest_updater):
|
|
shutil.rmtree(dest_updater)
|
|
print(f"Deleted directory: {dest_updater}")
|
|
else:
|
|
os.remove(dest_updater)
|
|
print(f"Deleted file: {dest_updater}")
|
|
except Exception as e:
|
|
print(f"Error deleting {dest_updater}: {e}")
|
|
else:
|
|
os.remove(dest_updater)
|
|
|
|
if PLATFORM_NAME == 'darwin':
|
|
wait_for_process_to_exit("flares_updater", timeout=10)
|
|
subprocess.check_call(["ditto", new_updater, dest_updater])
|
|
else:
|
|
shutil.copy2(new_updater, dest_updater)
|
|
|
|
if PLATFORM_NAME in ('linux', 'darwin'):
|
|
os.chmod(dest_updater, 0o755)
|
|
|
|
if PLATFORM_NAME == 'darwin':
|
|
remove_quarantine(dest_updater)
|
|
|
|
print("flares_updater replaced.")
|
|
except Exception as e:
|
|
print(f"Failed to replace flares_updater: {e}")
|
|
|
|
# 4. Delete the update folder
|
|
try:
|
|
if PLATFORM_NAME == 'darwin':
|
|
shutil.rmtree(app_dir)
|
|
else:
|
|
shutil.rmtree(update_folder)
|
|
except Exception as e:
|
|
print(f"Failed to delete update folder: {e}")
|
|
|
|
QMessageBox.information(None, "Update Complete", "The application has been successfully updated.")
|
|
sys.argv.remove("--finish-update")
|
|
|
|
|
|
def remove_quarantine(app_path):
|
|
"""
|
|
Removes the macOS quarantine attribute from the specified application path.
|
|
"""
|
|
|
|
script = f'''
|
|
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "FLARES needs privileges to finish the update. (2/2)"
|
|
'''
|
|
try:
|
|
subprocess.run(['osascript', '-e', script], check=True)
|
|
print("✅ Quarantine attribute removed.")
|
|
except subprocess.CalledProcessError as e:
|
|
print("❌ Failed to remove quarantine attribute.")
|
|
print(e)
|
|
|
|
|
|
def run_gui_entry_wrapper(config, gui_queue, progress_queue):
|
|
"""
|
|
Where the processing happens
|
|
"""
|
|
|
|
try:
|
|
import flares
|
|
flares.gui_entry(config, gui_queue, progress_queue)
|
|
|
|
sys.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}"
|
|
})
|
|
|
|
|
|
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)
|
|
|
|
message = (
|
|
"FLARES has encountered an unrecoverable error and needs to close.<br><br>"
|
|
f"We are sorry for the inconvenience. An autosave was attempted to be saved to <a href='{autosave_link}'>{autosave_path}</a>, 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.<br><br>"
|
|
"This unrecoverable error was likely due to an error with FLARES and not your data.<br>"
|
|
f"Please raise an issue <a href='https://git.research.dezeeuw.ca/tyler/flares/issues'>here</a> and attach the error file located at <a href='{log_link}'>{log_path2}</a><br><br>"
|
|
f"<pre>{error_msg}</pre>"
|
|
)
|
|
|
|
msg_box.setTextFormat(Qt.TextFormat.RichText)
|
|
msg_box.setText(message)
|
|
msg_box.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
|
|
|
|
msg_box.exec()
|
|
|
|
if __name__ == "__main__":
|
|
# Redirect exceptions to the popup window
|
|
sys.excepthook = exception_hook
|
|
|
|
# Set up application logging
|
|
if PLATFORM_NAME == "darwin":
|
|
log_path = os.path.join(os.path.dirname(sys.executable), "../../../flares.log")
|
|
else:
|
|
log_path = os.path.join(os.getcwd(), "flares.log")
|
|
|
|
try:
|
|
os.remove(log_path)
|
|
except:
|
|
pass
|
|
|
|
sys.stdout = open(log_path, "a", buffering=1)
|
|
sys.stderr = sys.stdout
|
|
print(f"\n=== App started at {datetime.now()} ===\n")
|
|
|
|
freeze_support() # Required for PyInstaller + multiprocessing
|
|
|
|
# Only run GUI in the main process
|
|
if current_process().name == 'MainProcess':
|
|
app = QApplication(sys.argv)
|
|
finish_update_if_needed()
|
|
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 4000 lines yay! |