6010 lines
241 KiB
Python
6010 lines
241 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
|
|
from enum import Enum, auto
|
|
|
|
# External library imports
|
|
import numpy as np
|
|
import pandas as pd
|
|
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, QSpinBox, QProgressDialog, QProgressBar
|
|
)
|
|
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint
|
|
from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage
|
|
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
|
|
|
|
|
CURRENT_VERSION = "1.2.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": "DOWNSAMPLE", "default": True, "type": bool, "help": "Should the snirf files be downsampled? If this is set to True, DOWNSAMPLE_FREQUENCY will be used as the target frequency to downsample to."},
|
|
{"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "depends_on": "DOWNSAMPLE", "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Trimming",
|
|
"params": [
|
|
{"name": "TRIM", "default": True, "type": bool, "help": "Should the start of the files be trimmed?"},
|
|
{"name": "SECONDS_TO_KEEP", "default": 5, "type": float, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Verify Optode Placement",
|
|
"params": [
|
|
{"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "help": "Should an image be generated for each participant outlining their optode placement on a head?"},
|
|
{"name": "SHOW_OPTODE_NAMES", "default": True, "type": bool, "depends_on": "OPTODE_PLACEMENT", "help": "Should the optode names be written next to their location in the image?"},
|
|
]
|
|
},
|
|
{
|
|
"title": "Short/Long Channels",
|
|
"params": [
|
|
{"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."},
|
|
{"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNEL", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
|
|
{"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Heart Rate",
|
|
"params": [
|
|
{"name": "HEART_RATE", "default": True, "type": bool, "help": "Should an attempt be made to calculate participants heart rates?"},
|
|
{"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "depends_on": "HEART_RATE", "help": "Will remove this many seconds from the start and end of the file. Useful if recording before cap is firmly placed, or participant removes cap while still recording."},
|
|
{"name": "MAX_LOW_HR", "default": 40, "type": int, "depends_on": "HEART_RATE", "help": "Any heart rate windows that average below this value will be rounded up to this value."},
|
|
{"name": "MAX_HIGH_HR", "default": 200, "type": int, "depends_on": "HEART_RATE", "help": "Any heart rate windows that average above this value will be rounded down to this value."},
|
|
{"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "depends_on": "HEART_RATE", "help": "How many individual data points to be used to create a single data point/window."},
|
|
{"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "depends_on": "HEART_RATE", "help": "Only used for visualization. Shows the 'range' of the calculated heart rate, which is just the average +- this value."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Scalp Coupling Index",
|
|
"params": [
|
|
{"name": "SCI", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."},
|
|
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "help": "Independent SCI calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."},
|
|
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "help": "SCI threshold on a scale of 0-1. A value of 0 is bad coupling while a value of 1 is perfect coupling. Any channels lower than this value will be marked as bad."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Signal to Noise Ratio",
|
|
"params": [
|
|
{"name": "SNR", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Signal to Noise Ratio. This metric calculates how much of the observed signal was noise versus how much of it was a useful signal."},
|
|
{"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "depends_on": "SNR", "help": "SNR threshold (dB). A typical scale would be 0-25, but it is possible for values to be both above and below this range. Higher values correspond to a better signal. If SNR is True, any channels lower than this value will be marked as bad."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Peak Spectral Power",
|
|
"params": [
|
|
{"name": "PSP", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of a frequency component that is most prominent in a particular frequency range or spectrum."},
|
|
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "help": "Independent PSP calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."},
|
|
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "help": "PSP threshold. A typical scale would be 0-0.5, but it is possible for values to be above this range. Higher values correspond to a better signal. If PSP is True, any channels lower than this value will be marked as bad."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Bad Channels Handling",
|
|
"params": [
|
|
{"name": "BAD_CHANNELS_HANDLING", "default": ["Interpolate"], "type": list, "options": ["Interpolate", "Remove", "None"], "exclusive": True, "help": "How should we deal with the bad channels that occurred? Note: Some analysis options will only work when this is set to 'Interpolate'."},
|
|
{"name": "MAX_DIST", "default": 0.03, "type": float, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "help": "The maximum distance to look for neighbours when interpolating. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
|
|
{"name": "MIN_NEIGHBORS", "default": 2, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "help": "The minimumn amount of neighbours needed within the MAX_DIST parameter. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Optical Density",
|
|
"params": [
|
|
# NOTE: Intentionally empty
|
|
]
|
|
},
|
|
{
|
|
"title": "Temporal Derivative Distribution Repair filtering",
|
|
"params": [
|
|
{"name": "TDDR", "default": True, "type": bool, "help": "Apply Temporal Derivitave Distribution Repair filtering - a method that removes baseline shift and spike artifacts from the data."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Wavelet filtering",
|
|
"params": [
|
|
{"name": "WAVELET", "default": True, "type": bool, "help": "Apply Wavelet filtering. It is a method to filter involving decomposition, threholding, and reconstruction."},
|
|
{"name": "IQR", "default": 1.5, "type": float, "depends_on": "WAVELET", "help": "Scaling factor for the Inter-Quartile Range."},
|
|
{"name": "WAVELET_TYPE", "default": "db4", "type": str, "depends_on": "WAVELET", "help": "Wavelet type. Valid values are ['bior1.1', 'bior1.3', 'bior1.5', 'bior2.2', 'bior2.4', 'bior2.6', 'bior2.8', 'bior3.1', 'bior3.3', 'bior3.5', 'bior3.7', 'bior3.9', 'bior4.4', 'bior5.5', 'bior6.8', 'coif1', 'coif2', 'coif3', 'coif4', 'coif5', 'coif6', 'coif7', 'coif8', 'coif9', 'coif10', 'coif11', 'coif12', 'coif13', 'coif14', 'coif15', 'coif16', 'coif17', 'db1', 'db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8', 'db9', 'db10', 'db11', 'db12', 'db13', 'db14', 'db15', 'db16', 'db17', 'db18', 'db19', 'db20', 'db21', 'db22', 'db23', 'db24', 'db25', 'db26', 'db27', 'db28', 'db29', 'db30', 'db31', 'db32', 'db33', 'db34', 'db35', 'db36', 'db37', 'db38', 'dmey', 'haar', 'rbio1.1', 'rbio1.3', 'rbio1.5', 'rbio2.2', 'rbio2.4', 'rbio2.6', 'rbio2.8', 'rbio3.1', 'rbio3.3', 'rbio3.5', 'rbio3.7', 'rbio3.9', 'rbio4.4', 'rbio5.5', 'rbio6.8', 'sym2', 'sym3', 'sym4', 'sym5', 'sym6', 'sym7', 'sym8', 'sym9', 'sym10', 'sym11', 'sym12', 'sym13', 'sym14', 'sym15', 'sym16', 'sym17', 'sym18', 'sym19', 'sym20']"},
|
|
{"name": "WAVELET_LEVEL", "default": 3, "type": int, "depends_on": "WAVELET", "help": "Wavelet Decomposition level (must be >= 0)."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Haemoglobin Concentration",
|
|
"params": [
|
|
# NOTE: Intentionally empty
|
|
]
|
|
},
|
|
{
|
|
"title": "Enhance Negative Correlation",
|
|
"params": [
|
|
{"name": "ENHANCE_NEGATIVE_CORRELATION", "default": False, "type": bool, "help": "Apply Enhance Negative Correlation."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Filtering",
|
|
"params": [
|
|
{"name": "FILTER", "default": True, "type": bool, "help": "Should the data be bandpass filtered?"},
|
|
{"name": "L_FREQ", "default": 0.005, "type": float, "depends_on": "FILTER", "help": "Any frequencies lower than this value will be removed."},
|
|
{"name": "H_FREQ", "default": 0.3, "type": float, "depends_on": "FILTER", "help": "Any frequencies higher than this value will be removed."},
|
|
{"name": "L_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "help": "How wide the transitional period should be so the data doesn't just drop off on the lower bound."},
|
|
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "help": "How wide the transitional period should be so the data doesn't just drop off on the upper bound."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Extracting Events*",
|
|
"params": [
|
|
#{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Epoch Calculations*",
|
|
"params": [
|
|
#{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Design Matrix",
|
|
"params": [
|
|
{"name": "RESAMPLE", "default": True, "type": bool, "help": "Should the data be resampled before calculating the design matrix? Downsampling is useful for speeding up calculations without loosing overall data shape."},
|
|
{"name": "RESAMPLE_FREQ", "default": 1, "type": int, "help": "The frequency the data should be resampled to."},
|
|
|
|
{"name": "HRF_MODEL", "default": ["fir"], "type": list, "options": ["fir", "glover", "spm", "spm + derivative", "spm + derivative + dispersion", "glover + derivative", "glover + derivative + dispersion"], "exclusive": True, "help": "Specifies the hemodynamic response function."},
|
|
{"name": "STIM_DUR", "default": 0.5, "type": float, "help": "The length of your stimulus."},
|
|
{"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Specifies the desired drift model."},
|
|
{"name": "HIGH_PASS", "default": 0.01, "type": float, "help": "High-pass frequency in case of a cosine model (in Hz)."},
|
|
{"name": "DRIFT_ORDER", "default": 1, "type": int, "help": "Order of the drift model (in case it is polynomial)"},
|
|
{"name": "FIR_DELAYS", "default": "None", "type": range, "help": "In case of FIR design, yields the array of delays used in the FIR model (in scans)."},
|
|
{"name": "MIN_ONSET", "default": -24, "type": int, "help": "Minimal onset relative to frame times (in seconds)"},
|
|
{"name": "OVERSAMPLING", "default": 50, "type": int, "help": "Oversampling factor used in temporal convolutions."},
|
|
{"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
|
|
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNEL", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
|
|
]
|
|
},
|
|
{
|
|
"title": "General Linear Model",
|
|
"params": [
|
|
{"name": "NOISE_MODEL", "default": "ar1", "type": str, "help": "The temporal variance model. Defaults to first order auto regressive model 'ar1'. The AR model can be set to any integer value by modifying the value of N. E.g. use ar5 for a fifth order model. If the string auto is provided a model with order 4 times the sample rate will be used."},
|
|
{"name": "BINS", "default": 0, "type": int, "help": "Maximum number of discrete bins for the AR coef histogram/clustering. By default the value is 0, which will set the number of bins to the number of channels, effectively estimating the AR model for each channel."},
|
|
{"name": "N_JOBS", "default": 1, "type": int, "help": "The number of CPUs to use to do the GLM computation. -1 means 'all CPUs'."},
|
|
]
|
|
},
|
|
{
|
|
"title": "Finishing Touches",
|
|
"params": [
|
|
# Intentionally empty (TODO)
|
|
]
|
|
},
|
|
{
|
|
"title": "Other",
|
|
"params": [
|
|
{"name": "TIME_WINDOW_START", "default": 0, "type": int, "help": "Where to start averaging the fir model bins. Only affects the significance and contrast images."},
|
|
{"name": "TIME_WINDOW_END", "default": 15, "type": int, "help": "Where to end averaging the fir model bins. Only affects the significance and contrast images."},
|
|
{"name": "MAX_WORKERS", "default": 6, "type": int, "help": "Number of files to be processed at once. Setting this to a small integer value may help on underpowered systems. Remove the value to use an automatic amount."},
|
|
{"name": "VERBOSITY", "default": False, "type": bool, "help": "Setting this to True will log lots of debugging information to the log file. Setting this to False will log minimal data."},
|
|
]
|
|
},
|
|
]
|
|
|
|
|
|
|
|
class 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: Preprocessing\n"
|
|
"Stage 2: Trimming\n"
|
|
"Stage 3: Verify Optode Placement\n"
|
|
"Stage 4: Short/Long Cannels\n"
|
|
"Stage 5: Heart Rate\n"
|
|
"Stage 6: Scalp Coupling Index\n"
|
|
"Stage 7: Signal to Noise Ratio\n"
|
|
"Stage 8: Peak Spectral Power\n"
|
|
"Stage 9: Bad Channels Handling\n"
|
|
"Stage 10: Optical Density\n"
|
|
"Stage 11: Temporal Derivative Distribution Repair Filtering\n"
|
|
"Stage 12: Wavelet Filtering\n"
|
|
"Stage 13: Haemoglobin Concentration\n"
|
|
"Stage 14: Enhance Negative Correlation\n"
|
|
"Stage 15: Filter\n"
|
|
"Stage 16: Extracting Events\n"
|
|
"Stage 17: Epoch Calculations\n"
|
|
"Stage 18: Design Matrix\n"
|
|
"Stage 19: General Linear Model\n"
|
|
"Stage 20: Generate GLM Results\n"
|
|
"Stage 21: Generate Channel Significance\n"
|
|
"Stage 22: Generate Channel, Region of Interest, and Contrast Results\n"
|
|
"Stage 23: Compute Contrast Results\n"
|
|
"Stage 24: Finishing Up\n", self)
|
|
|
|
label3 = QLabel("For more information, visit the Git wiki page <a href='https://git.research.dezeeuw.ca/tyler/flares/wiki'>here</a>.", self)
|
|
label3.setTextFormat(Qt.TextFormat.RichText)
|
|
label3.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
label3.setOpenExternalLinks(True)
|
|
layout.addWidget(label)
|
|
layout.addWidget(label2)
|
|
layout.addWidget(label3)
|
|
|
|
self.setLayout(layout)
|
|
|
|
|
|
|
|
class UpdateOptodesWindow(QWidget):
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent, Qt.WindowType.Window)
|
|
self.setWindowTitle("Update optode positions")
|
|
self.resize(760, 200)
|
|
|
|
self.label_file_a = QLabel("SNIRF file:")
|
|
self.line_edit_file_a = QLineEdit()
|
|
self.line_edit_file_a.setReadOnly(True)
|
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
|
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
|
|
|
self.label_file_b = QLabel("Text file:")
|
|
self.line_edit_file_b = QLineEdit()
|
|
self.line_edit_file_b.setReadOnly(True)
|
|
self.btn_browse_b = QPushButton("Browse .txt/.xlsx")
|
|
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
|
|
|
self.label_suffix = QLabel("Suffix to append to filename:")
|
|
self.line_edit_suffix = QLineEdit()
|
|
self.line_edit_suffix.setText("flare")
|
|
|
|
self.btn_clear = QPushButton("Clear")
|
|
self.btn_go = QPushButton("Go")
|
|
self.btn_clear.clicked.connect(self.clear_files)
|
|
self.btn_go.clicked.connect(self.go_action)
|
|
|
|
# ---
|
|
layout = QVBoxLayout()
|
|
self.description = QLabel()
|
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
self.description.setOpenExternalLinks(False) # Handle the click internally
|
|
|
|
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<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 or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<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>. Currently only .xlsx files directly exported from a<br>"
|
|
"Polhemus system are supported.")
|
|
|
|
self.description.linkActivated.connect(self.handle_link_click)
|
|
layout.addWidget(self.description)
|
|
|
|
help_text_a = "Select the SNIRF (.snirf) file to update with new optode positions."
|
|
|
|
file_a_layout = QHBoxLayout()
|
|
|
|
# Help button on the left
|
|
help_btn_a = QPushButton("?")
|
|
help_btn_a.setFixedWidth(25)
|
|
help_btn_a.setToolTip(help_text_a)
|
|
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
|
file_a_layout.addWidget(help_btn_a)
|
|
|
|
# Container for label + line_edit + browse button with tooltip
|
|
file_a_container = QWidget()
|
|
file_a_container_layout = QHBoxLayout()
|
|
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
|
file_a_container_layout.addWidget(self.label_file_a)
|
|
file_a_container_layout.addWidget(self.line_edit_file_a)
|
|
file_a_container_layout.addWidget(self.btn_browse_a)
|
|
file_a_container.setLayout(file_a_container_layout)
|
|
file_a_container.setToolTip(help_text_a)
|
|
|
|
file_a_layout.addWidget(file_a_container)
|
|
layout.addLayout(file_a_layout)
|
|
|
|
help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates, or a .xlsx file from a Polhemius system."
|
|
|
|
file_b_layout = QHBoxLayout()
|
|
|
|
help_btn_b = QPushButton("?")
|
|
help_btn_b.setFixedWidth(25)
|
|
help_btn_b.setToolTip(help_text_b)
|
|
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
|
file_b_layout.addWidget(help_btn_b)
|
|
|
|
file_b_container = QWidget()
|
|
file_b_container_layout = QHBoxLayout()
|
|
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
|
file_b_container_layout.addWidget(self.label_file_b)
|
|
file_b_container_layout.addWidget(self.line_edit_file_b)
|
|
file_b_container_layout.addWidget(self.btn_browse_b)
|
|
file_b_container.setLayout(file_b_container_layout)
|
|
file_b_container.setToolTip(help_text_b)
|
|
|
|
file_b_layout.addWidget(file_b_container)
|
|
layout.addLayout(file_b_layout)
|
|
|
|
|
|
help_text_suffix = "This text will be appended to the original filename when saving. Default is 'flare'."
|
|
|
|
suffix_layout = QHBoxLayout()
|
|
|
|
help_btn_suffix = QPushButton("?")
|
|
help_btn_suffix.setFixedWidth(25)
|
|
help_btn_suffix.setToolTip(help_text_suffix)
|
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
|
suffix_layout.addWidget(help_btn_suffix)
|
|
|
|
suffix_container = QWidget()
|
|
suffix_container_layout = QHBoxLayout()
|
|
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
|
|
suffix_container_layout.addWidget(self.label_suffix)
|
|
suffix_container_layout.addWidget(self.line_edit_suffix)
|
|
suffix_container.setLayout(suffix_container_layout)
|
|
suffix_container.setToolTip(help_text_suffix)
|
|
|
|
suffix_layout.addWidget(suffix_container)
|
|
layout.addLayout(suffix_layout)
|
|
|
|
|
|
buttons_layout = QHBoxLayout()
|
|
buttons_layout.addStretch()
|
|
buttons_layout.addWidget(self.btn_clear)
|
|
buttons_layout.addWidget(self.btn_go)
|
|
layout.addLayout(buttons_layout)
|
|
|
|
self.setLayout(layout)
|
|
|
|
|
|
def show_help_popup(self, text):
|
|
msg = QMessageBox(self)
|
|
msg.setWindowTitle("Parameter Info - FLARES")
|
|
msg.setText(text)
|
|
msg.exec()
|
|
|
|
def handle_link_click(self, link):
|
|
if link == "custom_link":
|
|
msg = QMessageBox(self)
|
|
msg.setWindowTitle("Example Digitization File")
|
|
|
|
text = "nz: -1.91 85.175 -31.1525\n" \
|
|
"rpa: 80.3825 -17.1925 -57.2775\n" \
|
|
"lpa: -81.815 -17.1925 -57.965\n" \
|
|
"d1: 0.01 -97.5175 62.5875\n" \
|
|
"d2: 25.125 -103.415 45.045\n" \
|
|
"d3: 49.095 -97.9025 30.2075\n" \
|
|
"s1: 0.01 -112.43 32.595\n" \
|
|
"s2: 30.325 -84.3125 71.8975\n" \
|
|
"s3: 0.01 -70.6875 89.0925\n"
|
|
msg.setText(text)
|
|
msg.exec()
|
|
|
|
def browse_file_a(self):
|
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
|
if file_path:
|
|
self.line_edit_file_a.setText(file_path)
|
|
|
|
def browse_file_b(self):
|
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Text Files (*.txt), Excel Files (*.xlsx)")
|
|
if file_path:
|
|
self.line_edit_file_b.setText(file_path)
|
|
|
|
def clear_files(self):
|
|
self.line_edit_file_a.clear()
|
|
self.line_edit_file_b.clear()
|
|
|
|
def go_action(self):
|
|
file_a = self.line_edit_file_a.text()
|
|
file_b = self.line_edit_file_b.text()
|
|
suffix = self.line_edit_suffix.text().strip() or "flare"
|
|
|
|
if not file_a:
|
|
QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.")
|
|
return
|
|
if not file_b:
|
|
QMessageBox.critical(self, "Missing File", "Please select a TXT file.")
|
|
return
|
|
|
|
# Get original filename without extension
|
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
|
suggested_name = f"{base_name}_{suffix}.snirf"
|
|
|
|
# Open save dialog with default name
|
|
save_path, _ = QFileDialog.getSaveFileName(
|
|
self,
|
|
"Save SNIRF File As",
|
|
suggested_name,
|
|
"SNIRF Files (*.snirf)"
|
|
)
|
|
|
|
if not save_path:
|
|
print("Save cancelled.")
|
|
return
|
|
|
|
# Ensure .snirf extension
|
|
if not save_path.lower().endswith(".snirf"):
|
|
save_path += ".snirf"
|
|
|
|
try:
|
|
self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path)
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to write file:\n{e}")
|
|
return
|
|
|
|
QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}")
|
|
|
|
|
|
def update_optode_positions(self, file_a, file_b, save_path):
|
|
|
|
fiducials = {}
|
|
ch_positions = {}
|
|
|
|
extension = Path(file_b).suffix
|
|
|
|
# Read the lines from the optode file
|
|
if extension == '.txt':
|
|
with open(file_b, 'r') as f:
|
|
for line in f:
|
|
if line.strip():
|
|
# Split by the semicolon and convert to meters
|
|
ch_name, coords_str = line.split(":")
|
|
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
|
|
|
|
# The key we have is a fiducial
|
|
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
|
|
fiducials[ch_name.lower()] = coords
|
|
|
|
# The key we have is a source or detector
|
|
else:
|
|
ch_positions[ch_name.upper()] = coords
|
|
|
|
elif extension == '.xlsx':
|
|
|
|
df = pd.read_excel(file_b, sheet_name='Sheet1')
|
|
|
|
def _get_block_data(df, block_id, row_mapping, scale=0.001):
|
|
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
|
|
# 1. Isolate and clean
|
|
block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
|
|
block = block.apply(pd.to_numeric, errors='coerce')
|
|
|
|
# 2. Extract into dictionary based on mapping
|
|
result = {}
|
|
|
|
# If row_mapping is a dict (like {0: 'nz'}), use it directly
|
|
if isinstance(row_mapping, dict):
|
|
for row_idx, key in row_mapping.items():
|
|
if row_idx < len(block):
|
|
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
|
|
|
|
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
|
|
elif isinstance(row_mapping, str):
|
|
for i in range(len(block)):
|
|
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
|
|
|
|
return result
|
|
|
|
|
|
# Identify blocks
|
|
is_empty = df.isnull().all(axis=1)
|
|
df['block_id'] = is_empty.cumsum()
|
|
clean_df = df[~is_empty].copy()
|
|
|
|
# Process Block 2: Landmarks
|
|
fiducials = _get_block_data(clean_df, 2, {0: 'nz', 2: 'rpa', 3: 'lpa'})
|
|
|
|
# Process Block 3: D-Points
|
|
d_points = _get_block_data(clean_df, 3, 'D')
|
|
|
|
# Process Block 4: S-Points
|
|
s_points = _get_block_data(clean_df, 4, 'S')
|
|
|
|
ch_positions = {**d_points, **s_points}
|
|
|
|
# Create montage with updated coords in head space
|
|
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
|
|
|
|
# Read the SNIRF file, set the montage, and write it back
|
|
raw = read_raw_snirf(file_a, preload=True)
|
|
raw.set_montage(initial_montage)
|
|
write_raw_snirf(raw, save_path)
|
|
|
|
|
|
class EventUpdateMode(Enum):
|
|
WRITE_SNIRF = auto() # destructive
|
|
WRITE_JSON = auto() # non-destructive
|
|
|
|
|
|
class UpdateEventsWindow(QWidget):
|
|
|
|
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
|
super().__init__(parent, Qt.WindowType.Window)
|
|
|
|
self.mode = mode
|
|
self.caller = caller or self.__class__.__name__
|
|
self.setWindowTitle("Update event markers")
|
|
self.resize(760, 200)
|
|
|
|
print("INIT MODE:", mode)
|
|
|
|
self.label_file_a = QLabel("SNIRF file:")
|
|
self.line_edit_file_a = QLineEdit()
|
|
self.line_edit_file_a.setReadOnly(True)
|
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
|
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
|
|
|
self.label_file_b = QLabel("BORIS file:")
|
|
self.line_edit_file_b = QLineEdit()
|
|
self.line_edit_file_b.setReadOnly(True)
|
|
self.btn_browse_b = QPushButton("Browse .boris")
|
|
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
|
|
|
self.label_suffix = QLabel("Filename in BORIS project file:")
|
|
self.combo_suffix = QComboBox()
|
|
self.combo_suffix.setEditable(False)
|
|
self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected)
|
|
|
|
self.label_events = QLabel("Events in selected observation:")
|
|
self.combo_events = QComboBox()
|
|
self.combo_events.setEnabled(False)
|
|
|
|
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
|
self.combo_snirf_events = QComboBox()
|
|
self.combo_snirf_events.setEnabled(False)
|
|
|
|
self.btn_clear = QPushButton("Clear")
|
|
self.btn_go = QPushButton("Go")
|
|
self.btn_clear.clicked.connect(self.clear_files)
|
|
self.btn_go.clicked.connect(self.go_action)
|
|
|
|
# ---
|
|
layout = QVBoxLayout()
|
|
self.description = QLabel()
|
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
self.description.setOpenExternalLinks(True)
|
|
|
|
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<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
|
|
|
|
|
|
boris_obs = self.boris_data["observations"][selected_obs]
|
|
|
|
# --- Extract videos + delays ---
|
|
files = boris_obs.get("file", {})
|
|
offsets = boris_obs.get("media_info", {}).get("offset", {})
|
|
|
|
videos = {}
|
|
for key, path in files.items():
|
|
if path: # only include videos that exist
|
|
delay = offsets.get(key, 0.0) # default 0 if missing
|
|
videos[key] = {"file": path, "delay": delay}
|
|
|
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
|
|
|
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
|
# Open save dialog for SNIRF
|
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
|
suggested_name = f"{base_name}_{suffix}.snirf"
|
|
save_path, _ = QFileDialog.getSaveFileName(
|
|
self,
|
|
"Save SNIRF File As",
|
|
suggested_name,
|
|
"SNIRF Files (*.snirf)"
|
|
)
|
|
if not save_path:
|
|
print("SNIRF save cancelled.")
|
|
return
|
|
if not save_path.lower().endswith(".snirf"):
|
|
save_path += ".snirf"
|
|
|
|
try:
|
|
raw = read_raw_snirf(file_a, preload=True)
|
|
|
|
# --- Align BORIS events to SNIRF ---
|
|
boris_events = boris_obs.get("events", [])
|
|
onsets, durations, descriptions = [], [], []
|
|
open_events = {} # label -> list of start times
|
|
label_counts = {}
|
|
used_times = set()
|
|
sfreq = raw.info['sfreq']
|
|
min_shift = 1.0 / sfreq
|
|
max_attempts = 10
|
|
|
|
for event in boris_events:
|
|
if not isinstance(event, list) or len(event) < 3:
|
|
continue
|
|
event_time = event[0]
|
|
label = event[2]
|
|
count = label_counts.get(label, 0) + 1
|
|
label_counts[label] = count
|
|
|
|
if label not in open_events:
|
|
open_events[label] = []
|
|
|
|
if count % 2 == 1:
|
|
open_events[label].append(event_time)
|
|
else:
|
|
if open_events[label]:
|
|
start_time = open_events[label].pop(0)
|
|
duration = event_time - start_time
|
|
if duration <= 0:
|
|
continue
|
|
|
|
adjusted_time = start_time + time_shift
|
|
attempts = 0
|
|
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
|
adjusted_time += min_shift
|
|
attempts += 1
|
|
if attempts == max_attempts:
|
|
continue
|
|
|
|
adjusted_time = round(adjusted_time, 6)
|
|
used_times.add(adjusted_time)
|
|
onsets.append(adjusted_time)
|
|
durations.append(duration)
|
|
descriptions.append(label)
|
|
# Handle unmatched starts
|
|
for label, starts in open_events.items():
|
|
for start_time in starts:
|
|
adjusted_time = start_time + time_shift
|
|
attempts = 0
|
|
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
|
adjusted_time += min_shift
|
|
attempts += 1
|
|
if attempts == max_attempts:
|
|
continue
|
|
adjusted_time = round(adjusted_time, 6)
|
|
used_times.add(adjusted_time)
|
|
onsets.append(adjusted_time)
|
|
durations.append(0.0)
|
|
descriptions.append(label)
|
|
|
|
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
|
|
raw.set_annotations(new_annotations)
|
|
write_raw_snirf(raw, save_path)
|
|
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
|
|
|
elif self.mode == EventUpdateMode.WRITE_JSON:
|
|
# Open save dialog for JSON
|
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
|
suggested_name = f"{base_name}_{suffix}_alignment.json"
|
|
save_path, _ = QFileDialog.getSaveFileName(
|
|
self,
|
|
"Save Event Alignment JSON As",
|
|
suggested_name,
|
|
"JSON Files (*.json)"
|
|
)
|
|
if not save_path:
|
|
print("JSON save cancelled.")
|
|
return
|
|
if not save_path.lower().endswith(".json"):
|
|
save_path += ".json"
|
|
|
|
# Build JSON dict
|
|
json_data = {
|
|
"observation": selected_obs,
|
|
"snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time},
|
|
"boris_anchor": {"label": boris_label, "time": boris_anchor_time},
|
|
"time_shift": time_shift,
|
|
"videos": videos
|
|
}
|
|
|
|
# Write JSON
|
|
try:
|
|
with open(save_path, "w", encoding="utf-8") as f:
|
|
json.dump(json_data, f, indent=4)
|
|
QMessageBox.information(self, "Success", f"Event alignment saved to:\n{save_path}")
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}")
|
|
|
|
|
|
def update_optode_positions(self, file_a, file_b, save_path):
|
|
|
|
fiducials = {}
|
|
ch_positions = {}
|
|
|
|
# Read the lines from the optode file
|
|
with open(file_b, 'r') as f:
|
|
for line in f:
|
|
if line.strip():
|
|
# Split by the semicolon and convert to meters
|
|
ch_name, coords_str = line.split(":")
|
|
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
|
|
|
|
# The key we have is a fiducial
|
|
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
|
|
fiducials[ch_name.lower()] = coords
|
|
|
|
# The key we have is a source or detector
|
|
else:
|
|
ch_positions[ch_name.upper()] = coords
|
|
|
|
# Create montage with updated coords in head space
|
|
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
|
|
|
|
# Read the SNIRF file, set the montage, and write it back
|
|
raw = read_raw_snirf(file_a, preload=True)
|
|
raw.set_montage(initial_montage)
|
|
write_raw_snirf(raw, save_path)
|
|
|
|
|
|
def _apply_events_to_snirf(self, raw, new_annotations, save_path):
|
|
raw.set_annotations(new_annotations)
|
|
write_raw_snirf(raw, save_path)
|
|
|
|
def _write_event_mapping_json(
|
|
self,
|
|
file_a,
|
|
file_b,
|
|
selected_obs,
|
|
snirf_anchor,
|
|
boris_anchor,
|
|
time_shift,
|
|
mapped_events,
|
|
save_path
|
|
):
|
|
import json
|
|
from datetime import datetime
|
|
import os
|
|
|
|
payload = {
|
|
"source": {
|
|
"called_from": self.caller,
|
|
"snirf_file": os.path.basename(file_a),
|
|
"boris_file": os.path.basename(file_b),
|
|
"observation": selected_obs
|
|
},
|
|
"alignment": {
|
|
"snirf_anchor": snirf_anchor,
|
|
"boris_anchor": boris_anchor,
|
|
"time_shift_seconds": time_shift
|
|
},
|
|
"events": mapped_events,
|
|
"created_at": datetime.utcnow().isoformat() + "Z"
|
|
}
|
|
|
|
with open(save_path, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2)
|
|
|
|
return save_path
|
|
|
|
|
|
class 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(24):
|
|
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.dependencies = []
|
|
self.selected_path = None
|
|
|
|
# Title label
|
|
title_label = QLabel(section_data["title"])
|
|
title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;")
|
|
layout.addWidget(title_label)
|
|
|
|
# Horizontal line
|
|
line = QFrame()
|
|
line.setFrameShape(QFrame.Shape.HLine)
|
|
line.setFrameShadow(QFrame.Shadow.Sunken)
|
|
layout.addWidget(line)
|
|
|
|
for param in section_data["params"]:
|
|
h_layout = QHBoxLayout()
|
|
|
|
label = QLabel(param["name"])
|
|
label.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"]))
|
|
widget.currentTextChanged.connect(self.update_dependencies)
|
|
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:
|
|
if param.get("exclusive", True):
|
|
widget = QComboBox()
|
|
widget.addItems(param.get("options", []))
|
|
widget.setCurrentText(str(param.get("default", "<None Selected>")))
|
|
widget.currentTextChanged.connect(self.update_dependencies)
|
|
else:
|
|
widget = self._create_multiselect_dropdown(None)
|
|
elif param["type"] == range:
|
|
widget = QSpinBox()
|
|
widget.setRange(0, 999) # Set a sensible maximum
|
|
# If default is "None" or range(15), handle it gracefully:
|
|
default_val = param["default"]
|
|
if isinstance(default_val, range):
|
|
widget.setValue(default_val.stop)
|
|
elif str(default_val).isdigit():
|
|
widget.setValue(int(default_val))
|
|
else:
|
|
widget.setValue(15) # Default fallback
|
|
else:
|
|
widget = QLineEdit()
|
|
widget.setText(str(param["default"]))
|
|
|
|
if "depends_on" in param:
|
|
self.dependencies.append({
|
|
"child_name": param["name"],
|
|
"parent_name": param["depends_on"],
|
|
"depends_value": param.get("depends_value", "True")
|
|
})
|
|
|
|
widget.setToolTip(help_text)
|
|
|
|
h_layout.addWidget(widget)
|
|
h_layout.setStretch(2, 5) # Set stretch factor for input field (50%)
|
|
|
|
layout.addLayout(h_layout)
|
|
self.widgets[param["name"]] = {
|
|
"widget": widget,
|
|
"type": param["type"],
|
|
"h_layout": h_layout
|
|
}
|
|
|
|
self.update_dependencies()
|
|
|
|
def update_dependencies(self):
|
|
"""Disables/Enables widgets based on parent selection values."""
|
|
for dep in self.dependencies:
|
|
child_info = self.widgets.get(dep["child_name"])
|
|
parent_info = self.widgets.get(dep["parent_name"])
|
|
|
|
if child_info and parent_info:
|
|
parent_widget = parent_info["widget"]
|
|
|
|
# Get current value of parent (works for both bool-combos and list-combos)
|
|
current_parent_value = parent_widget.currentText()
|
|
|
|
# Check if it matches the required value
|
|
is_active = (current_parent_value == dep["depends_value"])
|
|
|
|
# Toggle the entire row (Button, Label, and Input)
|
|
h_layout = child_info["h_layout"]
|
|
for i in range(h_layout.count()):
|
|
item = h_layout.itemAt(i).widget()
|
|
if item:
|
|
item.setEnabled(is_active)
|
|
|
|
def _create_multiselect_dropdown(self, items):
|
|
combo = FullClickComboBox()
|
|
combo.setView(QListView())
|
|
model = QStandardItemModel()
|
|
combo.setModel(model)
|
|
combo.setEditable(True)
|
|
combo.lineEdit().setReadOnly(True)
|
|
combo.lineEdit().setPlaceholderText("Select...")
|
|
|
|
dummy_item = QStandardItem("<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:
|
|
if isinstance(widget, FullClickComboBox):
|
|
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()]
|
|
elif isinstance(widget, QComboBox):
|
|
values[name] = widget.currentText()
|
|
elif expected_type == range:
|
|
if isinstance(widget, QSpinBox):
|
|
# Convert the integer N into range(N)
|
|
values[name] = range(widget.value())
|
|
else:
|
|
values[name] = range(15) # Fallback
|
|
else:
|
|
raw_text = widget.text()
|
|
try:
|
|
if expected_type == int:
|
|
values[name] = int(raw_text)
|
|
elif expected_type == float:
|
|
values[name] = float(raw_text)
|
|
elif expected_type == str:
|
|
values[name] = raw_text
|
|
else:
|
|
values[name] = raw_text # Fallback
|
|
except Exception as e:
|
|
raise ValueError(f"Invalid value for {name}: {raw_text}") from e
|
|
|
|
return values
|
|
|
|
def update_dropdown_items(self, param_name, new_items):
|
|
"""
|
|
Updates the items in a multi-select dropdown parameter field.
|
|
|
|
Args:
|
|
param_name (str): The parameter name (must match one in self.widgets).
|
|
new_items (list): The new items to populate in the dropdown.
|
|
"""
|
|
widget_info = self.widgets.get(param_name)
|
|
print("[ParamSection] Current widget keys:", list(self.widgets.keys()))
|
|
|
|
if not widget_info:
|
|
print(f"[ParamSection] No widget found for param '{param_name}'")
|
|
return
|
|
|
|
widget = widget_info["widget"]
|
|
if not isinstance(widget, FullClickComboBox):
|
|
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
|
|
return
|
|
|
|
# Replace the model on the existing widget
|
|
new_model = QStandardItemModel()
|
|
|
|
dummy_item = QStandardItem("<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 FullClickComboBox(QComboBox):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setEditable(True)
|
|
self.lineEdit().setReadOnly(True)
|
|
self.lineEdit().installEventFilter(self)
|
|
|
|
def eventFilter(self, obj, event):
|
|
if obj == self.lineEdit():
|
|
|
|
if event.type() == QEvent.MouseButtonPress:
|
|
return True
|
|
|
|
if event.type() == QEvent.MouseButtonRelease:
|
|
self.showPopup()
|
|
return True
|
|
|
|
return super().eventFilter(obj, event)
|
|
|
|
|
|
class ParticipantViewerWidget(QWidget):
|
|
def __init__(self, haemo_dict, fig_bytes_dict):
|
|
super().__init__()
|
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
self.setWindowTitle("FLARES Participant Viewer")
|
|
self.haemo_dict = haemo_dict
|
|
self.fig_bytes_dict = fig_bytes_dict
|
|
|
|
# Create mappings: file_path -> participant label and dropdown display text
|
|
self.participant_map = {} # file_path -> "Participant 1"
|
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
|
|
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
|
short_label = f"Participant {i}"
|
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
|
self.participant_map[file_path] = short_label
|
|
self.participant_dropdown_items.append(display_label)
|
|
|
|
self.layout = QVBoxLayout(self)
|
|
self.top_bar = QHBoxLayout()
|
|
self.layout.addLayout(self.top_bar)
|
|
|
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
|
|
|
first_fig_dict = next(iter(self.fig_bytes_dict.values()))
|
|
image_label_items = list(first_fig_dict.keys())
|
|
|
|
self.image_index_dropdown = self._create_multiselect_dropdown(image_label_items)
|
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
|
|
|
self.submit_button = QPushButton("Submit")
|
|
self.submit_button.clicked.connect(self.show_selected_images)
|
|
|
|
self.top_bar.addWidget(QLabel("Participants:"))
|
|
self.top_bar.addWidget(self.participant_dropdown)
|
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
self.top_bar.addWidget(self.image_index_dropdown)
|
|
self.top_bar.addWidget(self.submit_button)
|
|
|
|
self.scroll = QScrollArea()
|
|
self.scroll.setWidgetResizable(True)
|
|
self.scroll_content = QWidget()
|
|
self.grid_layout = QGridLayout(self.scroll_content)
|
|
self.scroll.setWidget(self.scroll_content)
|
|
self.layout.addWidget(self.scroll)
|
|
|
|
self.thumb_size = QSize(280, 180)
|
|
|
|
self.save_button = QPushButton("Save Displayed Images")
|
|
self.save_button.clicked.connect(self.save_displayed_images)
|
|
self.top_bar.addWidget(self.save_button)
|
|
|
|
self.showMaximized()
|
|
|
|
def _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
|
|
while self.grid_layout.count():
|
|
item = self.grid_layout.takeAt(0)
|
|
widget = item.widget()
|
|
if widget:
|
|
widget.deleteLater()
|
|
|
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
|
# Map from display names back to file paths
|
|
selected_file_paths = []
|
|
for display_name in selected_display_names:
|
|
# Find file_path by matching display name
|
|
for fp, short_label in self.participant_map.items():
|
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
|
if display_name == expected_display:
|
|
selected_file_paths.append(fp)
|
|
break
|
|
|
|
selected_labels = self._get_checked_items(self.image_index_dropdown)
|
|
|
|
row, col = 0, 0
|
|
for file_path in selected_file_paths:
|
|
fig_list = self.fig_bytes_dict.get(file_path, [])
|
|
participant_label = self.participant_map[file_path]
|
|
for label in selected_labels:
|
|
fig_bytes = fig_list.get(label)
|
|
if not fig_bytes:
|
|
continue
|
|
|
|
full_pixmap = QPixmap()
|
|
full_pixmap.loadFromData(fig_bytes)
|
|
|
|
thumbnail_pixmap = full_pixmap.scaled(
|
|
self.thumb_size,
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation
|
|
)
|
|
|
|
container = QWidget()
|
|
hlayout = QHBoxLayout(container)
|
|
hlayout.setContentsMargins(0, 0, 0, 0)
|
|
hlayout.setSpacing(0)
|
|
hlayout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
image_label = ClickableLabel(full_pixmap, thumbnail_pixmap)
|
|
image_label.setToolTip(f"{participant_label}\n{label}")
|
|
hlayout.addWidget(image_label)
|
|
|
|
self.grid_layout.addWidget(container, row, col)
|
|
|
|
col += 1
|
|
if col >= 6:
|
|
col = 0
|
|
row += 1
|
|
|
|
# Update dropdown labels after display
|
|
self.update_participant_dropdown_label()
|
|
self.update_image_index_dropdown_label()
|
|
|
|
|
|
def save_displayed_images(self):
|
|
# Ensure the folder exists
|
|
save_dir = Path("individual_images")
|
|
save_dir.mkdir(exist_ok=True)
|
|
|
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
|
selected_image_labels = self._get_checked_items(self.image_index_dropdown)
|
|
|
|
for display_name in selected_display_names:
|
|
# Match display name to file path
|
|
for file_path, short_label in self.participant_map.items():
|
|
expected_display = f"{short_label} ({os.path.basename(file_path)})"
|
|
if display_name == expected_display:
|
|
fig_dict = self.fig_bytes_dict.get(file_path, {})
|
|
for label in selected_image_labels:
|
|
if label not in fig_dict:
|
|
continue
|
|
fig_bytes = fig_dict[label]
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png"
|
|
output_path = save_dir / filename
|
|
with open(output_path, "wb") as f:
|
|
f.write(fig_bytes)
|
|
break # file_path matched; stop loop
|
|
|
|
QMessageBox.information(self, "Save Complete", f"Images saved to {save_dir.resolve()}")
|
|
|
|
|
|
class ClickableLabel(QLabel):
|
|
def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap):
|
|
super().__init__()
|
|
self._pixmap_full = full_pixmap
|
|
self.setPixmap(thumbnail_pixmap)
|
|
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.setFixedSize(thumbnail_pixmap.size())
|
|
self.setStyleSheet("border: 1px solid gray; margin: 2px;")
|
|
|
|
def mousePressEvent(self, event):
|
|
#TODO: This will use 3MB or RAM for every image that gets opened, and this RAM is not cleared when the expanded view is closed but only when the parent gets closed.
|
|
viewer = QWidget()
|
|
viewer.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
viewer.setWindowTitle("Expanded View")
|
|
layout = QVBoxLayout(viewer)
|
|
label = QLabel()
|
|
label.setPixmap(self._pixmap_full)
|
|
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(label)
|
|
viewer.resize(1000, 800)
|
|
viewer.show()
|
|
self._expanded_viewer = viewer # keep reference alive
|
|
|
|
|
|
|
|
class ParticipantBrainViewerWidget(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:
|
|
raise Exception("How did we get here?")
|
|
|
|
cha = self.cha_dict.get(file_path)
|
|
|
|
for idx in selected_indexes:
|
|
if idx == 0:
|
|
|
|
params = param_values.get(idx, {})
|
|
show_optodes = params.get("show_optodes", None)
|
|
show_brodmann = params.get("show_brodmann", None)
|
|
|
|
if show_optodes is None or show_brodmann is None:
|
|
print(f"Missing parameters for index {idx}, skipping.")
|
|
continue
|
|
|
|
flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
|
|
|
|
elif idx == 1:
|
|
params = param_values.get(idx, {})
|
|
show_optodes = params.get("show_optodes", None)
|
|
t_or_theta = params.get("t_or_theta", None)
|
|
show_text = params.get("show_text", None)
|
|
brain_bounds = params.get("brain_bounds", None)
|
|
|
|
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None:
|
|
print(f"Missing parameters for index {idx}, skipping.")
|
|
continue
|
|
|
|
flares.brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
|
|
|
|
else:
|
|
print(f"No method defined for index {idx}")
|
|
|
|
|
|
|
|
class MultiProgressDialog(QDialog):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("fOLD Analysis Progress")
|
|
self.setFixedWidth(400)
|
|
# Ensure it doesn't block the main thread
|
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
self.layout = QVBoxLayout(self)
|
|
self.bars = {}
|
|
|
|
def add_participant(self, label, total_steps):
|
|
label_widget = QLabel(f"Analyzing {label}...")
|
|
pbar = QProgressBar()
|
|
pbar.setMaximum(total_steps)
|
|
self.layout.addWidget(label_widget)
|
|
self.layout.addWidget(pbar)
|
|
self.bars[label] = pbar
|
|
|
|
def update_bar(self, label, value):
|
|
if label in self.bars:
|
|
self.bars[label].setValue(value)
|
|
|
|
|
|
|
|
def single_participant_worker(file_path, raw_data, result_queue, progress_queue):
|
|
""" Runs inside its own dedicated process """
|
|
p_name = os.path.basename(file_path)
|
|
try:
|
|
import flares
|
|
# Perform the heavy fold_channels logic
|
|
png_bytes = flares.fold_channels(raw_data)
|
|
|
|
# Hand back results and signal completion
|
|
result_queue.put({file_path: png_bytes})
|
|
progress_queue.put(p_name)
|
|
except Exception as e:
|
|
progress_queue.put(f"ERROR: {p_name} - {str(e)}")
|
|
|
|
|
|
|
|
class ParticipantFoldChannelsWidget(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(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):
|
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
|
selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)]
|
|
|
|
if not selected_display_names or 0 not in selected_indexes:
|
|
return
|
|
selected_files = [path for path, label in self.participant_map.items()
|
|
if f"{label} ({os.path.basename(path)})" in selected_display_names]
|
|
|
|
while self.grid_layout.count():
|
|
item = self.grid_layout.takeAt(0)
|
|
widget = item.widget()
|
|
if widget:
|
|
widget.deleteLater()
|
|
|
|
self.multi_progress = MultiProgressDialog(self)
|
|
for file_path in selected_files:
|
|
self.multi_progress.add_participant(os.path.basename(file_path), 1)
|
|
self.multi_progress.show()
|
|
|
|
|
|
if current_process().name == 'MainProcess':
|
|
|
|
# 2. Setup Multiprocessing Manager
|
|
self.manager = Manager()
|
|
self.result_queue = self.manager.Queue()
|
|
self.progress_queue = self.manager.Queue()
|
|
self.active_processes = []
|
|
|
|
# 3. Start ALL processes at once
|
|
for file_path in selected_files:
|
|
p = Process(
|
|
target=single_participant_worker,
|
|
args=(file_path, self.haemo_dict[file_path], self.result_queue, self.progress_queue)
|
|
)
|
|
p.start()
|
|
self.active_processes.append(p)
|
|
|
|
# 4. Start the GUI listener
|
|
self.completed_count = 0
|
|
self.result_timer = QTimer()
|
|
self.result_timer.timeout.connect(self.check_parallel_results)
|
|
self.result_timer.start()
|
|
|
|
|
|
def check_parallel_results(self):
|
|
# Check for progress/completion signals
|
|
|
|
while not self.progress_queue.empty():
|
|
msg = self.progress_queue.get()
|
|
self.completed_count += 1
|
|
|
|
if msg.startswith("ERROR"):
|
|
print(f"Worker Error: {msg}")
|
|
else:
|
|
# msg is p_name here
|
|
self.multi_progress.update_bar(msg, 1)
|
|
|
|
# Pull images as they become available
|
|
while not self.result_queue.empty():
|
|
result_dict = self.result_queue.get()
|
|
self.add_images_to_grid(result_dict)
|
|
|
|
# Clean up when all processes are done
|
|
if self.completed_count >= len(self.active_processes):
|
|
self.result_timer.stop()
|
|
|
|
# Close the custom multi-progress window
|
|
if hasattr(self, 'multi_progress'):
|
|
self.multi_progress.close()
|
|
|
|
# Clean up processes
|
|
for p in self.active_processes:
|
|
if p.is_alive():
|
|
p.join(timeout=1) # Give it a second to wrap up
|
|
p.close() # Explicitly close the process object
|
|
|
|
# Shut down the Manager process (the source of the 'rogue' process)
|
|
if hasattr(self, 'manager'):
|
|
self.manager.shutdown()
|
|
|
|
self.active_processes = []
|
|
print("Processing fully complete. All resources released.")
|
|
|
|
|
|
def add_images_to_grid(self, result_dict):
|
|
"""
|
|
result_dict format: { file_path: {"main": bytes, "legend": bytes} }
|
|
"""
|
|
for file_path, images in result_dict.items():
|
|
|
|
if self.grid_layout.count() == 0 and "legend" in images:
|
|
self._add_legend_to_grid(images["legend"])
|
|
|
|
# Create a container for this participant's results
|
|
container = QFrame()
|
|
container.setFrameShape(QFrame.StyledPanel)
|
|
vbox = QVBoxLayout(container)
|
|
|
|
participant_label = self.participant_map.get(file_path, os.path.basename(file_path))
|
|
title = QLabel(f"<b>{participant_label}</b>")
|
|
title.setAlignment(Qt.AlignCenter)
|
|
vbox.addWidget(title)
|
|
|
|
# We primarily want to show the 'main' plot in the grid
|
|
if "main" in images:
|
|
pixmap = self._bytes_to_pixmap(images["main"])
|
|
img_label = QLabel()
|
|
# Scale it to fit the thumbnail size defined in __init__
|
|
img_label.setPixmap(pixmap.scaled(
|
|
self.thumb_size,
|
|
Qt.KeepAspectRatio,
|
|
Qt.SmoothTransformation
|
|
))
|
|
img_label.setAlignment(Qt.AlignCenter)
|
|
|
|
# Optional: Click to open full size
|
|
img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t)
|
|
|
|
vbox.addWidget(img_label)
|
|
|
|
# Determine grid position (row-major order)
|
|
count = self.grid_layout.count()
|
|
row = count // 3 # 3 columns wide
|
|
col = count % 3
|
|
self.grid_layout.addWidget(container, row, col)
|
|
|
|
def _bytes_to_pixmap(self, png_bytes):
|
|
"""Converts raw bytes from the multiprocess queue to a QPixmap."""
|
|
image = QImage.fromData(png_bytes)
|
|
return QPixmap.fromImage(image)
|
|
|
|
def _open_full_size(self, pixmap, title):
|
|
"""Simple popup to view the image at a readable scale."""
|
|
view = QDialog(self)
|
|
view.setWindowTitle(f"Full View - {title}")
|
|
layout = QVBoxLayout(view)
|
|
label = QLabel()
|
|
label.setPixmap(pixmap)
|
|
layout.addWidget(label)
|
|
view.show()
|
|
|
|
def _add_legend_to_grid(self, legend_bytes):
|
|
"""Helper to put the legend in the first slot."""
|
|
container = QFrame()
|
|
container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;")
|
|
vbox = QVBoxLayout(container)
|
|
|
|
title = QLabel("<b>Brodmann Area Legend</b>")
|
|
title.setAlignment(Qt.AlignCenter)
|
|
vbox.addWidget(title)
|
|
|
|
pixmap = self._bytes_to_pixmap(legend_bytes)
|
|
legend_label = QLabel()
|
|
# Legends are usually tall, so we scale it differently or keep it smaller
|
|
legend_label.setPixmap(pixmap.scaled(
|
|
self.thumb_size,
|
|
Qt.KeepAspectRatio,
|
|
Qt.SmoothTransformation
|
|
))
|
|
legend_label.setAlignment(Qt.AlignCenter)
|
|
legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend")
|
|
|
|
vbox.addWidget(legend_label)
|
|
self.grid_layout.addWidget(container, 0, 0)
|
|
|
|
|
|
class ExportDataAsCSVViewerWidget(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 (CSV for SPARKS)",
|
|
# "2 (third image)",
|
|
# "3 (fourth image)",
|
|
]
|
|
|
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
|
|
|
self.submit_button = QPushButton("Submit")
|
|
self.submit_button.clicked.connect(self.generate_and_save_csv)
|
|
|
|
self.top_bar.addWidget(QLabel("Participants:"))
|
|
self.top_bar.addWidget(self.participant_dropdown)
|
|
self.top_bar.addWidget(QLabel("Export Type:"))
|
|
self.top_bar.addWidget(self.image_index_dropdown)
|
|
self.top_bar.addWidget(self.submit_button)
|
|
|
|
self.scroll = QScrollArea()
|
|
self.scroll.setWidgetResizable(True)
|
|
self.scroll_content = QWidget()
|
|
self.grid_layout = QGridLayout(self.scroll_content)
|
|
self.scroll.setWidget(self.scroll_content)
|
|
self.layout.addWidget(self.scroll)
|
|
|
|
self.thumb_size = QSize(280, 180)
|
|
self.showMaximized()
|
|
|
|
def _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}")
|
|
|
|
|
|
elif idx == 1:
|
|
try:
|
|
suggested_name = f"{file_path}_sparks.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
|
|
|
|
raw = haemo_obj
|
|
|
|
data, times = raw.get_data(return_times=True)
|
|
|
|
|
|
ann_col = np.full(times.shape, "", dtype=object)
|
|
|
|
if raw.annotations is not None and len(raw.annotations) > 0:
|
|
for onset, duration, desc in zip(
|
|
raw.annotations.onset,
|
|
raw.annotations.duration,
|
|
raw.annotations.description
|
|
):
|
|
mask = (times >= onset) & (times < onset + duration)
|
|
ann_col[mask] = desc
|
|
|
|
df = pd.DataFrame(data.T, columns=raw.ch_names)
|
|
df.insert(0, "annotation", ann_col)
|
|
|
|
df.insert(0, "time", times)
|
|
df.to_csv(save_path, index=False)
|
|
QMessageBox.information(self, "Success", "CSV file has been saved.")
|
|
|
|
win = UpdateEventsWindow(
|
|
parent=self,
|
|
mode=EventUpdateMode.WRITE_JSON,
|
|
caller="Video Alignment Tool"
|
|
)
|
|
win.show()
|
|
|
|
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)
|
|
|
|
# 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)
|
|
|
|
# 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, EventUpdateMode.WRITE_SNIRF, "Manual SNIRF Edit")
|
|
self.events.show()
|
|
|
|
def open_file_dialog(self):
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
self, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
|
|
)
|
|
if file_path:
|
|
# 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? Edit: yes very much so
|
|
if getattr(self, 'raw_haemo_dict', None) is None:
|
|
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) #TODO: If the window is the one to crash, the file can't get saved. Could be fine as the window is what was storing the data to begin with?
|
|
|
|
message = (
|
|
"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! |