""" Filename: main.py Description: LIGHTS main executable Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports import os import re import sys import time import pickle import shutil import traceback import subprocess import configparser import concurrent.futures from queue import Empty from pathlib import Path, PurePosixPath from datetime import datetime from multiprocessing import Process, current_process, freeze_support, Queue # External library imports import h5py from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure import psutil import pyqtgraph.opengl as gl from pyqtgraph.opengl import MeshData import numpy as np from mne.io import read_raw_snirf from mne.preprocessing.nirs import source_detector_distances from PySide6.QtWidgets import ( QApplication, QPlainTextEdit, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QMenu, QSpinBox ) from PySide6.QtCore import QThread, Signal, Qt, QTimer, QPoint from PySide6.QtGui import QAction, QKeySequence, QIcon, QVector3D from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen from src.window.about import AboutWindow from src.window.terminal import TerminalWindow # from src.window.updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow # from src.window.updateoptodes import UpdateOptodesWindow from src.window.userguide import UserGuideWindow # from src.window.viewerlauncher import ViewerLauncherWidget from src.window.welcome import WelcomeDialog from src.shared.flaresbasewidget import CameraQueryThread, CameraRowWidget from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PIPELINE_STAGES, PLATFORM_NAME from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread DEFAULT_CONFIG = """ [File] [Edit] [View] status_bar = true left_top = 0 left_bottom = 0 right = 0 [Options] show_welcome_dialog = false first_startup = true [Terminal] [General] """ # 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."}, ] }, ] PROJECT_ROOT = Path(__file__).parent # Venv folder (NOTE THE LEADING DOT) VENV_ROOT = PROJECT_ROOT / ".gphoto2_venv" # Python executable - try Scripts first (Windows), fallback to bin (Unix/MSYS2 style) PYTHON_EXE = VENV_ROOT / "bin" / "python.exe" # Bridge script location BRIDGE_SCRIPT = PROJECT_ROOT / "part_A.py" class LogConsoleDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Subprocess Execution Console") self.resize(600, 400) layout = QVBoxLayout(self) # The scrolling log field self.log_viewer = QPlainTextEdit(self) self.log_viewer.setReadOnly(True) layout.addWidget(self.log_viewer) # Trigger button inside the popup self.trigger_btn = QPushButton("Execute Synced Burst Capture", self) self.trigger_btn.setEnabled(False) # Disabled until setup finishes layout.addWidget(self.trigger_btn) def append_log(self, text): self.log_viewer.appendPlainText(text) # Auto-scroll to the bottom self.log_viewer.ensureCursorVisible() class SubprocessWorker(QThread): # Emits every line of output to append to our log window line_received = Signal(str) finished = Signal(int) def __init__(self, snirf_file_path, is_2d_bypass, parent=None): super().__init__(parent) self.snirf_file_path = snirf_file_path self.is_2d_bypass = is_2d_bypass def run(self): try: cmd_args = [str(PYTHON_EXE), "-u", str(BRIDGE_SCRIPT), "--cmd", "gui"] if self.is_2d_bypass: cmd_args.append("--bypass-2d") if self.snirf_file_path: cmd_args.append("--snirf") cmd_args.append(str(self.snirf_file_path)) self.process = subprocess.Popen( cmd_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) for line in iter(self.process.stdout.readline, ""): self.line_received.emit(line.strip()) # Loop drops here automatically when the process dies (sys.exit) return_code = self.process.wait() self.finished.emit(return_code) except Exception as e: self.line_received.emit(f"[Thread Error] {str(e)}") self.finished.emit(-1) def send_input(self, text): """Safely writes text directly to the running process's stdin""" if hasattr(self, 'process') and self.process.poll() is None: try: self.process.stdin.write(text + "\n") self.process.stdin.flush() # Force Windows to send it immediately except Exception as e: print(f"Failed to write to stdin: {e}") class SaveProjectThread(QThread): finished_signal = Signal(str) error_signal = Signal(str) def __init__(self, filename, project_data): super().__init__() self.filename = filename self.project_data = project_data def run(self): try: with open(self.filename, "wb") as f: pickle.dump(self.project_data, f) self.finished_signal.emit(self.filename) except Exception as e: self.error_signal.emit(str(e)) class SavingOverlay(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) self.setModal(True) self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) layout = QVBoxLayout() layout.setAlignment(Qt.AlignmentFlag.AlignCenter) label = QLabel("Saving Project…") label.setStyleSheet("font-size: 18px; color: white; background-color: rgba(0,0,0,150); padding: 20px; border-radius: 10px;") layout.addWidget(label) self.setLayout(layout) class MainApplication(QMainWindow): """ Main application window that creates and sets up the UI. """ progress_update_signal = Signal(str, int) metadata_processed = Signal(str, int) metadata_ui_signal = Signal(dict, str, int) def __init__(self): super().__init__() self.setWindowTitle(f"{APP_NAME.upper()}") self.setGeometry(100, 100, 1280, 720) # Load the mne data in a seperate process self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1) self.about = None self.help = None self.optodes = None self.events = None self.terminal = None self.bubble_widgets = {} self.param_sections = [] self.folder_paths = [] self.section_widget = None self.first_run = True self.is_2d_bypass = False self.incompatible_save_bypass = False self.missing_events_bypass = False self.analysis_clearing_bypass = False self.folding_bypass = False # Initialization to ensure that saving can occur self.raw_haemo_dict = {} # Processed Hemodynamic data self.config_dict = {} # Analysis parameters/settings self.epochs_dict = {} # Timing/Event data self.cha_dict = {} # Channel configurations self.contrast_results_dict = {} # Statistical results self.df_ind_dict = {} # Individual dataframes self.design_matrix_dict = {} # GLM Design matrices self.valid_dict = {} # Quality control/Validity flags self.fig_bytes_dict = {} # Cached plot images (serialized) self.file_metadata = {} # AGE, GENDER, GROUP self.metadata_cache = {} # Internal file/path information metadata cache self.bubble_widgets = {} # References to the UI "Bubble" objects self.current_file = None # Tracks the currently selected absolute path self.snirf_file_path = "" self.metadata_processed.connect(self._safe_ui_update) self.metadata_ui_signal.connect(self._handle_metadata_ui_update) self.files_total = 0 # total number of files to process self.files_done = set() # set of file paths done (success or fail) self.files_failed = set() # set of failed file paths self.files_results = {} # dict for successful results (if needed) self.platform_suffix = "-" + PLATFORM_NAME self.updater = UpdateManager( main_window=self, api_url=API_URL, api_url_sec=API_URL_SECONDARY, current_version=CURRENT_VERSION, platform_name=PLATFORM_NAME, platform_suffix=self.platform_suffix, app_name=APP_NAME ) self.init_ui() self.create_menu_bar() self.pending_update_version = None self.pending_update_path = None self.last_clicked_bubble = None self.installEventFilter(self) # Start local pending update check thread self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix, PLATFORM_NAME, APP_NAME) self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found) self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update) self.local_check_thread.start() self.show() # Check if we should pop up the welcome screen should_show_welcome = file_cfg.getboolean("Options", "show_welcome_dialog", fallback=True) first_startup = file_cfg.getboolean("Options", "first_startup", fallback=False) if first_startup: file_cfg.set("Options", "first_startup", "false") try: with open(cfg_path, "w") as f: file_cfg.write(f) except Exception as e: print(f"Warning: Could not save preference: {e}") welcome = WelcomeDialog(self, direct=True, first=first_startup) welcome.show() elif should_show_welcome: file_cfg.set("Options", "show_welcome_dialog", "false") try: with open(cfg_path, "w") as f: file_cfg.write(f) except Exception as e: print(f"Warning: Could not save preference: {e}") welcome = WelcomeDialog(self, direct=True, first=False) welcome.show() def init_ui(self): central = QWidget() self.setCentralWidget(central) main_layout = QHBoxLayout(central) main_layout.setContentsMargins(5, 5, 5, 5) self.main_h_splitter = QSplitter(Qt.Orientation.Horizontal) self.main_h_splitter.setChildrenCollapsible(False) main_layout.addWidget(self.main_h_splitter) self.left_v_splitter = QSplitter(Qt.Orientation.Vertical) self.left_v_splitter.setChildrenCollapsible(False) self.left_v_splitter.setMinimumWidth(400) top_left_container = QGroupBox("3D Viewer") top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }") top_left_container.setMinimumHeight(340) top_left_layout = QHBoxLayout(top_left_container) # --- NEW 3D VIEWER CODE START --- # Create the interactive 3D view widget self.top_left_widget = gl.GLViewWidget() self.top_left_widget.setBackgroundColor('k') self.top_left_widget.setCameraPosition(distance=28, elevation=30, azimuth=45) # 1. Base grid layout grid = gl.GLGridItem() grid.scale(1, 1, 1) self.top_left_widget.addItem(grid) # 2. Central Reference Sphere (Blue) sphere_md = MeshData.sphere(rows=20, cols=20, radius=2.0) self.sphere_item = gl.GLMeshItem(meshdata=sphere_md, smooth=True, color=(0.2, 0.6, 1.0, 1.0), shader='shaded') self.top_left_widget.addItem(self.sphere_item) # 3. Base Forward Direction Indicator (Arrow/Ray going down the -Y or +X axis) # Let's define Forward as along the +Y horizontal axis (Green line) arrow_points = np.array([ [0, 0, 0], [0, 3, 0], # Main shaft [0, 3, 0], [-0.5, 2, 0], # Left arrowhead barb [0, 3, 0], [0.5, 2, 0] # Right arrowhead barb ]) # Using mode='lines' treats every pairs of coordinates as independent lines self.forward_arrow = gl.GLLinePlotItem( pos=arrow_points, color=(0.0, 1.0, 0.0, 1.0), width=4, mode='lines' ) self.top_left_widget.addItem(self.forward_arrow) # 4. Storage registry to track rendering handles for active cameras self.rendered_camera_items = [] top_left_layout.addWidget(self.top_left_widget, stretch=4) self.camera_pool_wrapper = QWidget() wrapper_layout = QVBoxLayout(self.camera_pool_wrapper) wrapper_layout.setContentsMargins(0, 0, 0, 0) # 1. Add the Refresh Button to the wrapper layout self.refresh_cameras_btn = QPushButton("🔄 Refresh Connected Cameras") self.refresh_cameras_btn.setStyleSheet(""" QPushButton { padding: 6px; font-weight: bold; background-color: #2c3e50; color: white; border-radius: 4px; } QPushButton:hover { background-color: #34495e; } QPushButton:disabled { background-color: #7f8c8d; color: #bdc3c7; } """) self.refresh_cameras_btn.clicked.connect(self.refresh_hardware_cameras) wrapper_layout.addWidget(self.refresh_cameras_btn) # 2. Setup your existing scroll list setup self.camera_pool_container = QWidget() self.camera_pool_layout = QGridLayout(self.camera_pool_container) self.camera_pool_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.camera_scroll_area = QScrollArea() self.camera_scroll_area.setWidgetResizable(True) self.camera_scroll_area.setWidget(self.camera_pool_container) self.camera_scroll_area.setMinimumHeight(160) # Add the scroll area right under the button inside the wrapper wrapper_layout.addWidget(self.camera_scroll_area) # --- REPLACED SPLITTER LAYOUT ATTACHMENT --- self.left_v_splitter.addWidget(top_left_container) # Instead of adding the scroll area alone, add the wrapper containing both the button and scroll area self.left_v_splitter.addWidget(self.camera_pool_wrapper) # --- REST OF YOUR RIGHT CONTAINER SETUP UNCHANGED --- self.right_container = QWidget() self.right_container.setMinimumWidth(380) right_container_layout = QVBoxLayout(self.right_container) # Scroll area setup self.right_scroll_area = QScrollArea() self.right_scroll_area.setWidgetResizable(True) self.right_content_widget = QWidget() self.right_content_layout = QVBoxLayout(self.right_content_widget) self.right_content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Add Configuration Management Row Right below Scroll area control_actions_widget = QWidget() control_actions_layout = QHBoxLayout(control_actions_widget) control_actions_layout.setContentsMargins(5, 5, 5, 5) self.add_camera_btn = QPushButton("+ Add Camera") self.add_camera_btn.setMinimumHeight(35) self.add_camera_btn.setStyleSheet("font-weight: bold; background-color: #2ecc71; color: white; border-radius: 4px;") self.add_camera_btn.clicked.connect(self.add_new_camera_row) control_actions_layout.addWidget(self.add_camera_btn) # Inject the '+' button panel above your bottom process buttons right_container_layout.addWidget(control_actions_widget) # Dedicated layout container where rows will be dynamically injected self.rows_container = QWidget() self.rows_layout = QVBoxLayout(self.rows_container) self.rows_layout.setContentsMargins(0, 0, 0, 0) self.right_content_layout.addWidget(self.rows_container) self.right_scroll_area.setWidget(self.right_content_widget) right_container_layout.addWidget(self.right_scroll_area) self.right_scroll_area2 = QScrollArea() self.right_scroll_area2.setWidgetResizable(True) plot_container_widget = QWidget() plot_container_layout = QVBoxLayout(plot_container_widget) plot_container_layout.setContentsMargins(5, 5, 5, 5) # 1. Create the File Actions Row (Pick / Remove) file_actions_widget = QWidget() file_actions_layout = QHBoxLayout(file_actions_widget) file_actions_layout.setContentsMargins(0, 0, 0, 0) self.pick_file_btn = QPushButton("Pick File") self.pick_file_btn.setMinimumHeight(30) self.pick_file_btn.clicked.connect(self.select_file) # Connect to file picker self.remove_file_btn = QPushButton("Remove File") self.remove_file_btn.setMinimumHeight(30) self.remove_file_btn.clicked.connect(self.clear_file) # Connect to clearer file_actions_layout.addWidget(self.pick_file_btn) file_actions_layout.addWidget(self.remove_file_btn) plot_container_layout.addWidget(file_actions_widget) # NEW: Text label to display the active file path, located right beneath the buttons self.filepath_label = QLabel("No SNIRF file selected") self.filepath_label.setStyleSheet("color: #7f8c8d; font-style: italic; padding: 2px;") self.filepath_label.setWordWrap(True) # Keeps ultra-long file paths from clipping horizontally plot_container_layout.addWidget(self.filepath_label) file_actions_layout.addWidget(self.pick_file_btn) file_actions_layout.addWidget(self.remove_file_btn) plot_container_layout.addWidget(file_actions_widget) # 2. Setup the Matplotlib Figure and Canvas # tight_layout keeps the plot from clipping at smaller container sizes self.fig = Figure(figsize=(3, 2), dpi=100, tight_layout=True) self.canvas = FigureCanvas(self.fig) self.ax = self.fig.add_subplot(111) self.ax.axis('off') # Hides the X/Y axes ticks for a cleaner image look plot_container_layout.addWidget(self.canvas) # 3. Bind the container widget to the second scroll area self.right_scroll_area2.setWidget(plot_container_widget) right_container_layout.addWidget(self.right_scroll_area2) buttons_widget = QWidget() buttons_layout = QHBoxLayout(buttons_widget) buttons_layout.addStretch() self.button1, self.button2, self.button3 = QPushButton("Process"), QPushButton("Clear"), QPushButton("Analysis") for btn in [self.button1, self.button2, self.button3]: btn.setMinimumSize(100, 40) buttons_layout.addWidget(btn) self.button3.setVisible(False) self.button1.clicked.connect(self.on_run_task) self.button2.clicked.connect(self.clear_all) self.button3.clicked.connect(self.open_launcher_window) right_container_layout.addWidget(buttons_widget) self.main_h_splitter.addWidget(self.left_v_splitter) self.main_h_splitter.addWidget(self.right_container) self.main_h_splitter.setSizes([760, 240]) self.left_v_splitter.setSizes([800, 200]) self.progress_update_signal.connect(self.update_file_progress) self.update_sections(0) self.refresh_hardware_cameras() def select_file(self): file_path, _ = QFileDialog.getOpenFileName( self, "Select SNIRF File", "", "SNIRF Files (*.snirf)" ) if not file_path: return self.snirf_file_path = file_path # Update the UI text label with the path self.filepath_label.setText(file_path) self.filepath_label.setStyleSheet("color: #2c3e50; font-weight: bold; padding: 2px;") try: # Clear previous elements from the plot area self.ax.clear() # 1. H5PY Parse Routine with h5py.File(file_path, "r") as f: nirs_key = [k for k in f.keys() if "nirs" in k][0] nirs = f[nirs_key] probe = nirs["probe"] src_pos = probe["sourcePos2D"][:, :2] det_pos = probe["detectorPos2D"][:, :2] wavelengths = probe["wavelengths"][:] oxy_wavelength = max(wavelengths) channels = [] def collect_channels(name, obj): if "measurementList" in name and isinstance(obj, h5py.Group): short_name = name.split('/')[-1] num = int(re.search(r'\d+', short_name).group()) source_idx = obj["sourceIndex"][()] detector_idx = obj["detectorIndex"][()] wavelength_idx = obj["wavelengthIndex"][()] wl_value = wavelengths[wavelength_idx - 1] channels.append({ "num": num, "source": source_idx, "detector": detector_idx, "wavelength": wl_value }) nirs.visititems(collect_channels) # 2. Extract specific oxygenated layout linkages oxy_channels_for_plot = [] for ch in channels: if ch["wavelength"] == oxy_wavelength: oxy_channels_for_plot.append({ "source": ch["source"] - 1, "detector": ch["detector"] - 1 }) # 3. Build & Render plot layout onto the active widget figure self.ax.axis('on') # Re-enable the plot framing system # Draw measurement paths for ch in oxy_channels_for_plot: s_idx = ch["source"] d_idx = ch["detector"] self.ax.plot([src_pos[s_idx, 0], det_pos[d_idx, 0]], [src_pos[s_idx, 1], det_pos[d_idx, 1]], color='gray', linestyle='--', alpha=0.5, zorder=1) # Draw Sources (Red Circles) self.ax.scatter(src_pos[:, 0], src_pos[:, 1], color='#e74c3c', s=180, label='Sources (S)', zorder=2) for i, pos in enumerate(src_pos): self.ax.text(pos[0], pos[1], f"S{i+1}", color='white', ha='center', va='center', fontweight='bold', fontsize=9) # Draw Detectors (Blue Squares) self.ax.scatter(det_pos[:, 0], det_pos[:, 1], color='#3498db', marker='s', s=180, label='Detectors (D)', zorder=2) for i, pos in enumerate(det_pos): self.ax.text(pos[0], pos[1], f"D{i+1}", color='white', ha='center', va='center', fontweight='bold', fontsize=9) # Apply Titles and Spacing Styles self.ax.legend(loc='upper right', frameon=True, shadow=True, fontsize=6) self.ax.grid(True, linestyle=':', alpha=0.5) self.ax.axis('equal') # Force redraw canvas window frame self.canvas.draw() except Exception as e: self.filepath_label.setText(f"Error loading SNIRF file: {str(e)}") self.filepath_label.setStyleSheet("color: #c0392b; font-weight: bold;") self.clear_file() def clear_file(self): # Reset text to original placeholder state self.filepath_label.setText("No SNIRF file selected") self.filepath_label.setStyleSheet("color: #7f8c8d; font-style: italic; padding: 2px;") # Reset plot to an empty frame or default state self.ax.clear() self.ax.axis('off') self.canvas.draw() def create_menu_bar(self): '''Menu Bar at the top of the screen''' menu_bar = self.menuBar() self.statusbar = self.statusBar() def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None): action = QAction(name, self) if shortcut: action.setShortcut(QKeySequence(shortcut)) if slot: action.triggered.connect(slot) if checkable: action.setCheckable(True) action.setChecked(checked) if icon: action.setIcon(QIcon(icon)) return action # File menu and actions file_menu = menu_bar.addMenu("File") #file_actions = [ #("Open File...", "Ctrl+O", self.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")), #("Open Folder...", "Ctrl+Alt+O", self.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")), # ("Open Folders...", "Ctrl+Shift+O", self.open_folder_dialog, resource_path("icons/folder_copy_24dp_1F1F1F.svg")), #("Load Project...", "Ctrl+L", self.load_project, resource_path("icons/article_24dp_1F1F1F.svg")), #("Save Project...", "Ctrl+S", self.save_project, resource_path("icons/save_24dp_1F1F1F.svg")), #("Save Project As...", "Ctrl+Shift+S", self.save_project, resource_path("icons/save_as_24dp_1F1F1F.svg")), #] # for i, (name, shortcut, slot, icon) in enumerate(file_actions): # file_menu.addAction(make_action(name, shortcut, slot, icon=icon)) # if i == 1: # self.recent_files_menu = file_menu.addMenu("Recent Files") # self.recent_files_menu.setIcon(QIcon(resource_path("icons/history_24dp_1F1F1F.svg"))) # optional icon # file_menu.addSeparator() # elif i == 2: # self.recent_projects_menu = file_menu.addMenu("Recent Projects") # self.recent_projects_menu.setIcon(QIcon(resource_path("icons/history_2_24dp_1F1F1F.svg"))) # file_menu.addSeparator() file_menu.addSeparator() file_menu.addAction(make_action("Exit", "Ctrl+Q", QApplication.instance().quit, icon=resource_path("icons/exit_to_app_24dp_1F1F1F.svg"))) # Edit menu edit_menu = menu_bar.addMenu("Edit") edit_actions = [ ("Cut", "Ctrl+X", self.cut_text, resource_path("icons/content_cut_24dp_1F1F1F.svg")), ("Copy", "Ctrl+C", self.copy_text, resource_path("icons/content_copy_24dp_1F1F1F.svg")), ("Paste", "Ctrl+V", self.paste_text, resource_path("icons/content_paste_24dp_1F1F1F.svg")) ] for name, shortcut, slot, icon in edit_actions: edit_menu.addAction(make_action(name, shortcut, slot, icon=icon)) # View menu # TODO: Pretty this like the rest of the menus? view_menu = menu_bar.addMenu("View") toggle_statusbar_action = make_action("Toggle Status Bar", checkable=True, checked=True, slot=None) view_menu.addAction(toggle_statusbar_action) toggle_statusbar_action.toggled.connect(self.statusbar.setVisible) # Reset Layout Action view_menu.addSeparator() reset_layout_action = make_action( "Reset Window Layout", "Ctrl+Shift+R", self.reset_window_layout, icon=resource_path("icons/grid_layout_side_24dp_1F1F1F.svg") ) view_menu.addAction(reset_layout_action) # Options menu (Help & About) options_menu = menu_bar.addMenu("Options") options_actions = [ ("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")), ("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")), ("Show Update Changelog", "F6", self.show_update_changelog, resource_path("icons/article_shortcut_24dp_1F1F1.svg")), #("Update events in snirf file (BORIS)...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")), #("Update events in snirf file (BLAZES)...", "F8", self.update_event_markers_blazes, resource_path("icons/upgrade_24dp_1F1F1F.svg")), #("Update optodes in snirf file...", "F9", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")), ("Reset to Default Configuration", "F10", self.reset_to_default_configuration, resource_path("icons/reset_settings_24dp_1F1F1F.svg")), ("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg")) ] for i, (name, shortcut, slot, icon) in enumerate(options_actions): options_menu.addAction(make_action(name, shortcut, slot, icon=icon)) if i == 2 or i == 5 or i == 6 or i == 7: options_menu.addSeparator() self.pref_actions = {} preferences_menu = menu_bar.addMenu("Preferences") preferences_actions = [ ("Photo Taking Bypass", "", self.is_2d_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "2d_data_bypass"), # ("Incompatible Save Bypass", "", self.incompatable_save_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "incompatible_save_bypass"), # ("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"), # ("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"), # ("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"), ] for name, shortcut, slot, icon, config_key in preferences_actions: action = make_action(name, shortcut, slot, icon=icon, checkable=True) preferences_menu.addAction(action) self.pref_actions[config_key] = action terminal_menu = menu_bar.addMenu("Terminal") terminal_actions = [ ("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")), ] for name, shortcut, slot, icon in terminal_actions: terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon)) self.statusbar.showMessage("Ready") def update_sections(self, index=0): """Replaced the old method to act as our initial setup cleaner.""" self.clear_all_camera_rows() # Seed with one default camera row to begin with self.add_new_camera_row() def refresh_hardware_cameras(self): """Spins up the background hardware bridge subprocess.""" # 1. Lock the button UI feedback self.refresh_cameras_btn.setEnabled(False) self.refresh_cameras_btn.setText("⏳ Scanning USB Ports...") # 2. Clear old list items while scanning while self.camera_pool_layout.count() > 0: item = self.camera_pool_layout.takeAt(0) if item.widget(): item.widget().deleteLater() # 3. Inform the user visually in the grid area from PySide6.QtWidgets import QLabel self.camera_pool_layout.addWidget(QLabel("Querying subprocess bridge..."), 0, 0) # 4. Fire the thread self.query_thread = CameraQueryThread() self.query_thread.cameras_found.connect(self.on_cameras_detected) self.query_thread.query_failed.connect(self.on_camera_query_error) self.query_thread.start() def on_cameras_detected(self, camera_list): """Callback when hardware signals back successfully via ast.literal_eval.""" # Unlock refresh UI self.refresh_cameras_btn.setEnabled(True) self.refresh_cameras_btn.setText("🔄 Refresh Connected Cameras") # Clear the scanning placeholder while self.camera_pool_layout.count() > 0: item = self.camera_pool_layout.takeAt(0) if item.widget(): item.widget().deleteLater() if not camera_list: from PySide6.QtWidgets import QLabel self.camera_pool_layout.addWidget(QLabel("No hardware cameras detected over USB."), 0, 0) return # Populate your grid layout with your new tags from PySide6.QtWidgets import QLabel for idx, (name, port) in enumerate(camera_list): lbl = QLabel(f"📷 {name} [{port}]") lbl.setStyleSheet("padding: 5px; background: #27ae60; color: white; border-radius: 4px; font-weight: bold;") self.camera_pool_layout.addWidget(lbl, idx, 0) def on_camera_query_error(self, error_msg): """Callback handles process crash or script paths missing.""" # Unlock refresh UI self.refresh_cameras_btn.setEnabled(True) self.refresh_cameras_btn.setText("🔄 Refresh Connected Cameras") # Clear the scanning placeholder while self.camera_pool_layout.count() > 0: item = self.camera_pool_layout.takeAt(0) if item.widget(): item.widget().deleteLater() from PySide6.QtWidgets import QLabel err_lbl = QLabel(f"Scan Failure:\n{error_msg}") err_lbl.setWordWrap(True) err_lbl.setStyleSheet("color: #e74c3c; font-weight: bold; padding: 4px;") self.camera_pool_layout.addWidget(err_lbl, 0, 0) def add_new_camera_row(self): """Creates a configuration row and binds changes to 3D rendering updates.""" next_index = self.rows_layout.count() + 1 new_row = CameraRowWidget(row_number=next_index) # Connect signals new_row.remove_requested.connect(self.remove_camera_row) new_row.color_changed.connect(self.sync_3d_view) new_row.config_changed.connect(self.sync_3d_view) # Trigger on dropdown change self.rows_layout.addWidget(new_row) self.sync_3d_view() def remove_camera_row(self, row_widget): if self.rows_layout.count() <= 1: return self.rows_layout.removeWidget(row_widget) row_widget.deleteLater() from PySide6.QtCore import QTimer QTimer.singleShot(0, self.reindex_and_sync) def reindex_and_sync(self): self.reindex_camera_labels() self.sync_3d_view() def sync_3d_view(self): """Clears old 3D meshes and draws distinct cameras with non-touching indicators.""" # 1. Clean out the previous camera widgets for item in self.rendered_camera_items: self.top_left_widget.removeItem(item) self.rendered_camera_items.clear() # 2. Iterate through your configurations for i in range(self.rows_layout.count()): row = self.rows_layout.itemAt(i).widget() if not isinstance(row, CameraRowWidget): continue config = row.get_values() pos_preset = config["position"] rgb = row.current_color.getRgbF() # 3. Establish precise camera coordinates (assuming sphere radius is 2.0) distance = 6.0 # Orbit radius away from the center sphere cam_x, cam_y, cam_z = 0, 0, 0 # Target offset components for the arrowhead projection dx, dy, dz = 0, 0, 0 # Diagonal components for 45-degree angle layouts (4.24 ≈ 6.0 * cos(45°)) diag = 4.24 # Assuming standard coordinate space: # +Y is Forward (Front), -Y is Backward (Back) # +X is Right, -X is Left # +Z is Up (Top) if pos_preset == "Front": cam_x, cam_y, cam_z = 0.0, distance, 0.0 dy = -1.5 elif pos_preset == "Back": cam_x, cam_y, cam_z = 0.0, -distance, 0.0 dy = 1.5 elif pos_preset == "Left": cam_x, cam_y, cam_z = -distance, 0.0, 0.0 dx = 1.5 elif pos_preset == "Right": cam_x, cam_y, cam_z = distance, 0.0, 0.0 dx = -1.5 # Diagonals / Corner Angles elif pos_preset == "Front-Left": # Front-Left cam_x, cam_y, cam_z = -diag, diag, 0.0 dx, dy = 1.0, -1.0 elif pos_preset == "Front-Right": # Right-Front cam_x, cam_y, cam_z = diag, diag, 0.0 dx, dy = -1.0, -1.0 elif pos_preset == "Back-Left": # Left-Rear / Left-Back cam_x, cam_y, cam_z = -diag, -diag, 0.0 dx, dy = 1.0, 1.0 elif pos_preset == "Back-Right": # Right-Rear / Right-Back cam_x, cam_y, cam_z = diag, -diag, 0.0 dx, dy = -1.0, 1.0 # Overhead / Zenith Angle elif pos_preset == "Top": cam_x, cam_y, cam_z = 0.0, 0.0, distance dz = -1.5 else: # Custom/Fallback configuration if unexpected string input cam_x, cam_y, cam_z = 0.0, distance, 2.0 dy, dz = -1.5, -0.5 # 4. Draw the Camera Cube cleanly # We explicitly define dimensions and use GLBoxItem cam_mesh = gl.GLBoxItem() # Pass sizes as a standard Python tuple/list (X, Y, Z) to setSize cam_mesh.setSize(1.0, 1.0, 1.0) # Set the color using its dedicated method cam_mesh.setColor(rgb) # Center the box cleanly over our calculated coordinate point cam_mesh.translate(cam_x - 0.5, cam_y - 0.5, cam_z - 0.5) self.top_left_widget.addItem(cam_mesh) self.rendered_camera_items.append(cam_mesh) # 5. Draw a SHORT directional vector pointing out of the camera box # It starts at the camera and extends a short distance toward the sphere, never touching it. arrow_start = np.array([cam_x, cam_y, cam_z]) arrow_end = arrow_start + np.array([dx, dy, dz]) # Calculate standard orthogonal barbs to form a visible head tip # Normalize direction vector to scale the arrowhead barbs correctly vec = arrow_end - arrow_start length = np.linalg.norm(vec) if length > 0: u = vec / length # Unit vector pointing toward sphere # Create a simple perpendicular vector for the arrowhead wings # if looking along Z, use X; otherwise shift cross product perp = np.array([-u[1], u[0], 0]) if abs(u[2]) < 0.9 else np.array([0, -u[2], u[1]]) perp = (perp / np.linalg.norm(perp)) * 0.3 # barb width scale # Back-step along the arrow line for barb length barb_base = arrow_end - (u * 0.4) # Combine shaft and the two arrow wings into an independent lines matrix cam_arrow_points = np.array([ arrow_start, arrow_end, # Shaft arrow_end, barb_base + perp, # Barb Wing 1 arrow_end, barb_base - perp # Barb Wing 2 ]) else: cam_arrow_points = np.array([arrow_start, arrow_end]) camera_arrow = gl.GLLinePlotItem( pos=cam_arrow_points, color=rgb, width=3, mode='lines' # Renders pairs of coordinates cleanly as broken segments ) self.top_left_widget.addItem(camera_arrow) self.rendered_camera_items.append(camera_arrow) def clear_all_camera_rows(self): """Cleans out everything inside the list wrapper layout.""" while self.rows_layout.count() > 0: item = self.rows_layout.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() def get_all_camera_configs(self): """Call this inside on_run_task to export data as an array of dicts.""" configs = [] for i in range(self.rows_layout.count()): widget = self.rows_layout.itemAt(i).widget() if isinstance(widget, CameraRowWidget): configs.append(widget.get_values()) return configs def clear_all(self): """ Forcefully purges all data, kills background tasks, and resets the memory heap. """ # self.top_left_widget.clear() if hasattr(self, "last_clicked_bubble"): self.last_clicked_bubble = None if hasattr(self, "result_timer") and self.result_timer: self.result_timer.stop() self.result_timer.deleteLater() self.result_timer = None if hasattr(self, "result_process") and self.result_process: if self.result_process.is_alive(): self.result_process.terminate() self.result_process.join(timeout=1) self.result_process = None if hasattr(self, "file_executor") and self.file_executor: self.file_executor.shutdown(wait=False, cancel_futures=True) self.file_executor = None self.pending_files_count = 0 # Increment session so any 'in-flight' callbacks are ignored if hasattr(self, "loading_session_id"): self.loading_session_id += 1 # Disconnect the buttons to break potential closures for btn in [self.button1, self.button3]: try: btn.clicked.disconnect() except (TypeError, RuntimeError): #NOTE: Till raises RuntimeWarnings? pass # UI Cleanup self.right_column_widget.hide() while self.bubble_layout.count(): item = self.bubble_layout.takeAt(0) widget = item.widget() if widget: # Forcefully disconnect signals to be safe try: widget.clicked.disconnect() widget.rightClicked.disconnect() except: pass widget.deleteLater() self.bubble_layout.setSpacing(0) self.bubble_layout.setContentsMargins(0, 0, 0, 0) self.bubble_container.setMinimumSize(0, 0) self.bubble_container.resize(0, 0) self.scroll_area.updateGeometry() # Data Purge self.bubble_widgets = {} self.files_results = {} self.files_done = set() self.files_failed = set() self.raw_haemo_dict = {} self.config_dict = {} self.epochs_dict = {} self.fig_bytes_dict = {} self.cha_dict = {} self.contrast_results_dict = {} self.df_ind_dict = {} self.design_matrix_dict = {} self.valid_dict = {} self.metadata_cache = {} if hasattr(self, "selected_paths"): self.selected_paths = [] if hasattr(self, "selected_path"): self.selected_path = None self.button1.setText("Process") self.button1.clicked.connect(self.on_run_task) self.button3.setVisible(False) self.statusBar().showMessage("All data has been cleared.") def reset_window_layout(self): """ Snaps all draggable splitters back to their default proportional positions. """ total_width = self.main_h_splitter.width() left_w = int(total_width * 30 / 45) right_w = total_width - left_w self.main_h_splitter.setSizes([left_w, right_w]) total_height = self.left_v_splitter.height() top_h = int(total_height * 0.90) bottom_h = total_height - top_h self.left_v_splitter.setSizes([top_h, bottom_h]) self.statusBar().showMessage("Window layout reset to default.", 2000) def open_launcher_window(self): return def copy_text(self): self.top_left_widget.copy() # Trigger copy self.statusbar.showMessage("Copied to clipboard") # Show status message def cut_text(self): self.top_left_widget.cut() # Trigger cut self.statusbar.showMessage("Cut to clipboard") # Show status message def paste_text(self): self.top_left_widget.paste() # Trigger paste self.statusbar.showMessage("Pasted from clipboard") # Show status message def is_2d_bypass_func(self, checked): self.is_2d_bypass = checked # self._update_config_setting("2d_data_bypass", checked) def about_window(self): if self.about is None or not self.about.isVisible(): self.about = AboutWindow(self) self.about.show() def user_guide(self): if self.help is None or not self.help.isVisible(): self.help = UserGuideWindow(self) self.help.show() def terminal_gui(self): if self.terminal is None or not self.terminal.isVisible(): self.terminal = TerminalWindow(self) self.terminal.show() def show_update_changelog(self): welcome = WelcomeDialog(self, direct=False) welcome.show() def reset_to_default_configuration(self): """Asks user for confirmation, then resets all settings to defaults.""" reply = QMessageBox.question( self, "Reset Configuration", "Are you sure you want to reset the application and all settings to their default values? This cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No # Default focus on 'No' ) # 2. If the user confirmed, perform the reset if reply == QMessageBox.StandardButton.Yes: try: # Overwrite the file with the template string constant with open(cfg_path, "w") as f: f.write(DEFAULT_CONFIG.strip()) # Reload the config parser from the freshly written file file_cfg.read(cfg_path) print("Configuration reset to defaults successfully.") except Exception as e: print(f"Error resetting config file ({e}). Resetting in-memory only.") # Fallback to loading the string into memory if file writing fails file_cfg.read_string(DEFAULT_CONFIG) self.statusbar.showMessage("All settings have been reset to their default values.", 5000) def save_project(self, onCrash=False): if hasattr(self, 'current_file') and self.current_file: self.file_metadata[self.current_file] = { key: field.text().strip() for key, field in self.meta_fields.items() } has_metadata = any( any(val for val in meta.values()) for meta in self.file_metadata.values() ) has_param_changes = any(section.has_any_changes() for section in self.param_sections) # Check if there is processed data has_processed_data = bool(getattr(self, 'raw_haemo_dict', None)) if not (has_processed_data or has_metadata or has_param_changes): if not onCrash: # Don't show popups during a crash/autosave QMessageBox.warning( self, "Save Project", "There is no processed data to save. Please process some data before saving." ) return if hasattr(self, 'current_file') and self.current_file: self.file_metadata[self.current_file] = { key: field.text() for key, field in self.meta_fields.items() } if not onCrash: filename, _ = QFileDialog.getSaveFileName( self, "Save Project", "", "FLARE Project (*.flare)" ) if not filename: return else: if PLATFORM_NAME == "darwin": filename = os.path.join(os.path.dirname(sys.executable), "../../../flares_autosave.flare") else: filename = os.path.join(os.getcwd(), "flares_autosave.flare") try: # Ensure the filename has the proper extension if not filename.endswith(".flare"): filename += ".flare" project_path = Path(filename).resolve() project_dir = project_path.parent file_list = [ self._get_safe_path(bubble.file_path, project_dir) for bubble in self.bubble_widgets.values() ] progress_states = { self._get_safe_path(bubble.file_path, project_dir): bubble.current_step for bubble in self.bubble_widgets.values() } rel_metadata = {} for full_path, meta in self.metadata_cache.items(): try: # Resolve to absolute to be safe, then make relative to project_dir safe_path = self._get_safe_path(full_path, project_dir) rel_metadata[safe_path] = meta except Exception as e: print(f"Metadata conversion failed for {full_path}: {e}") print(rel_metadata) rel_file_params = { self._get_safe_path(f_path, project_dir): meta for f_path, meta in self.file_metadata.items() } current_params = self.get_all_current_ui_params() # fallback - if UI reading fails, try the first processed file's config if not current_params and self.config_dict: first_file = next(iter(self.config_dict.keys())) current_params = self.config_dict[first_file] version = CURRENT_VERSION project_data = { "version": version, "file_list": file_list, "progress_states": progress_states, "raw_haemo_dict": self.raw_haemo_dict, "file_metadata": rel_metadata, "file_parameters": rel_file_params, "config_dict": self.config_dict, "epochs_dict": self.epochs_dict, "fig_bytes_dict": self.fig_bytes_dict, "cha_dict": self.cha_dict, "current_ui_params": current_params, "contrast_results_dict": self.contrast_results_dict, "df_ind_dict": self.df_ind_dict, "design_matrix_dict": self.design_matrix_dict, "valid_dict": self.valid_dict, } def sanitize(obj): if isinstance(obj, Path): return str(PurePosixPath(obj)) elif isinstance(obj, dict): return {sanitize(k): sanitize(v) for k, v in obj.items()} elif isinstance(obj, list): return [sanitize(i) for i in obj] return obj project_data = sanitize(project_data) self.saving_overlay = SavingOverlay(self) self.saving_overlay.resize(self.size()) # Cover the main window self.saving_overlay.show() # Start the background save thread self.save_thread = SaveProjectThread(filename, project_data) # When finished, close overlay and show success self.save_thread.finished_signal.connect(lambda f: ( self.saving_overlay.close(), QMessageBox.information(self, "Success", f"Project saved to:\n{f}") )) self.save_thread.error_signal.connect(lambda e: ( self.saving_overlay.close(), QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}") )) self.save_thread.start() except Exception as e: if not onCrash: QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}") def _get_safe_path(self, target_path, start_dir): try: # Convert both to absolute paths first target = Path(target_path).resolve() base = Path(start_dir).resolve() rel = os.path.relpath(target, base) return str(PurePosixPath(rel)) except ValueError: return str(PurePosixPath(target)) def load_project(self): filename, _ = QFileDialog.getOpenFileName( self, "Load Project", "", "FLARE Project (*.flare)" ) if not filename: return self.project_loader(filename=filename) def project_loader(self, filename): try: with open(filename, "rb") as f: data = pickle.load(f) # Check for potentially broken saves checks = [ ("version", "<=1.1.7"), ("file_metadata", "<=1.2.2"), ("file_parameters", "<=1.3.0") ] for key, ver_str in checks: if key not in data: msg = (f"This project was saved in an earlier version of FLARES ({ver_str}) " "and is potentially not compatible with this version. ") if self.incompatible_save_bypass: QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", msg + "You are receiving this warning because you have 'Incompatible Save Bypass' turned on. " "FLARES will now attempt to load the project. It is strongly recommended to recreate the project file.") break else: QMessageBox.critical(self, f"Error - {APP_NAME.upper()}", msg + "The file can attempt to be loaded if 'Incompatible Save Bypass' is selected in the 'Preferences' menu.") return self.raw_haemo_dict = data.get("raw_haemo_dict", {}) self.config_dict = data.get("config_dict", {}) self.epochs_dict = data.get("epochs_dict", {}) self.fig_bytes_dict = data.get("fig_bytes_dict", {}) self.cha_dict = data.get("cha_dict", {}) self.contrast_results_dict = data.get("contrast_results_dict", {}) self.df_ind_dict = data.get("df_ind_dict", {}) self.design_matrix_dict = data.get("design_matrix_dict", {}) self.valid_dict = data.get("valid_dict", {}) project_dir = Path(filename).parent saved_cache = data.get("file_metadata", {}) raw_params = data.get("file_parameters", {}) self.metadata_cache = {} self.file_metadata = {} for rel_path, meta_content in saved_cache.items(): abs_path = str((project_dir / Path(rel_path)).resolve()) self.metadata_cache[abs_path] = meta_content # Convert saved relative paths to absolute paths file_list = [str((project_dir / Path(rel_path)).resolve()) for rel_path in data["file_list"]] # Also resolve progress_states with updated paths raw_progress = data.get("progress_states", {}) progress_states = { str((project_dir / Path(rel_path)).resolve()): step for rel_path, step in raw_progress.items() } for rel_path in data["file_list"]: abs_path = str((project_dir / Path(rel_path)).resolve()) if rel_path in raw_params: # Scenario A: New format found self.file_metadata[abs_path] = raw_params[rel_path] elif abs_path in self.config_dict: # Scenario B: Fallback to old config_dict old_cfg = self.config_dict[abs_path] self.file_metadata[abs_path] = { "AGE": str(old_cfg.get("AGE", "")), "GENDER": str(old_cfg.get("GENDER", "")), "GROUP": str(old_cfg.get("GROUP", "")) } else: # Scenario C: Empty default self.file_metadata[abs_path] = {"AGE": "", "GENDER": "", "GROUP": ""} self.show_files_as_bubbles_from_list(file_list, progress_states, filename) if "current_ui_params" in data: self.restore_sections_from_config(data["current_ui_params"]) elif self.config_dict: first_file = next(iter(self.config_dict.keys())) self.restore_sections_from_config(self.config_dict[first_file]) has_data = bool(self.raw_haemo_dict) self.button1.setVisible(not has_data) self.button3.setVisible(has_data) self.add_to_recent_projects(os.path.normpath(filename)) QMessageBox.information(self, "Loaded", f"Project loaded from:\n{filename}") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load project:\n{e}") def restore_sections_from_config(self, config): """ Fill all ParamSection widgets with values from a participant's config. """ for section_widget in self.param_sections: widgets_dict = getattr(section_widget, 'widgets', None) if widgets_dict is None: continue for name, widget_info in widgets_dict.items(): if name not in config: continue value = config[name] widget = widget_info["widget"] w_type = widget_info.get("type") # QLineEdit (int, float, str) if isinstance(widget, QLineEdit): widget.blockSignals(True) widget.setText(str(value)) widget.blockSignals(False) widget.update() # QComboBox (bool, list) elif isinstance(widget, QComboBox): widget.blockSignals(True) widget.setCurrentText(str(value)) widget.blockSignals(False) widget.update() # QSpinBox (range) elif isinstance(widget, QSpinBox): widget.blockSignals(True) try: widget.setValue(int(value)) except Exception: pass widget.blockSignals(False) widget.update() # After restoring, make sure dependencies are updated if hasattr(section_widget, 'update_dependencies'): section_widget.update_dependencies() # def show_files_as_bubbles(self, folder_paths): # if isinstance(folder_paths, str): # folder_paths = [folder_paths] # # Clear previous bubbles # # while self.bubble_layout.count(): # # item = self.bubble_layout.takeAt(0) # # widget = item.widget() # # if widget: # # widget.deleteLater() # temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement # temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred) # # temp_bubble.setAttribute(Qt.WA_OpaquePaintEvent) # Improve rendering? # temp_bubble.adjustSize() # Adjust size after the widget is created # bubble_width = temp_bubble.width() # Get the actual width of a bubble # available_width = self.bubble_container.width() # cols = max(1, available_width // bubble_width) # Ensure at least 1 column # index = 0 # if not hasattr(self, 'selected_paths'): # self.selected_paths = [] # for folder_path in folder_paths: # if not os.path.isdir(folder_path): # continue # snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")] # for full_path in snirf_files: # display_name = f"{os.path.basename(folder_path)} / {os.path.basename(full_path)}" # bubble = ProgressBubble(display_name, full_path) # bubble.set_loading_state(True) # bubble.setCursor(Qt.CursorShape.WaitCursor) # self.bubble_widgets[full_path] = bubble # if full_path not in self.selected_paths: # self.selected_paths.append(full_path) # row = index // cols # col = index % cols # self.bubble_layout.addWidget(bubble, row, col) # index += 1 # self.statusBar().showMessage(f"{index} file(s) loaded from: {', '.join(folder_paths)}") def get_all_current_ui_params(self): """Gathers current values from all UI widgets across all sections.""" current_ui_config = {} try: for section in self.param_sections: # This calls the get_param_values() method you shared earlier section_values = section.get_param_values() current_ui_config.update(section_values) return current_ui_config except Exception as e: print(f"Error reading UI parameters: {e}") return None def show_files_as_bubbles_from_list(self, file_list, progress_states=None, filenames=None): if not hasattr(self, 'file_executor') or self.file_executor is None: self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1) progress_states = progress_states or {} # Initialize trackers and clear layout if not hasattr(self, 'selected_paths'): self.selected_paths = [] self.bubble_widgets = {} while self.bubble_layout.count(): item = self.bubble_layout.takeAt(0) widget = item.widget() if widget: widget.deleteLater() # Process the file list for index, file_path in enumerate(file_list): file_path = str(file_path) display_name = f"{os.path.basename(os.path.dirname(file_path))} / {os.path.basename(file_path)}" # Create bubble bubble = ProgressBubble(display_name, file_path) bubble.clicked.connect(self.on_bubble_clicked) bubble.rightClicked.connect(self.on_bubble_right_clicked) if hasattr(self, 'file_metadata') and file_path in self.file_metadata: meta = self.file_metadata[file_path] parts = [] for key in ["AGE", "GENDER", "GROUP"]: value = meta.get(key, "").strip() if value: parts.append(f"{key}: {value}") suffix = f"{', '.join(parts)}" if parts else "" bubble.setSuffixText(suffix) # Track it self.bubble_widgets[file_path] = bubble if file_path not in self.selected_paths: self.selected_paths.append(file_path) # Restore saved progress but keep loading state active step = progress_states.get(file_path, 0) bubble.update_progress(step, active=False) # Add to layout self.bubble_layout.addWidget(bubble, index, 1) # 4. Status Bar msg = f"Project loaded: {len(file_list)} files." if filenames: msg += f" Source: {os.path.basename(filenames)}" self.statusBar().showMessage(msg) def get_suffix_from_meta_fields(self): parts = [] for key, line_edit in self.meta_fields.items(): val = line_edit.text().strip() if val: parts.append(f"{key}: {val}") return ", ".join(parts) def 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 current_process().name == 'MainProcess': # self.ack_queue = Queue() # self.progress_queue = Queue() self.console = LogConsoleDialog(self) self.console.trigger_btn.clicked.connect(self.send_burst_trigger_signal) self.console.show() self.worker = SubprocessWorker(self.snirf_file_path, self.is_2d_bypass) self.worker.line_received.connect(self.handle_live_logs) self.worker.finished.connect(self.on_subprocess_complete) self.worker.start() # 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 handle_live_logs(self, text): # Append raw log to the popup window text field self.console.append_log(text) # If the script says it is waiting, enable the action button if "waiting for gui trigger" in text.strip().lower(): self.console.trigger_btn.setEnabled(True) self.statusbar.showMessage("Hardware initialized. Awaiting user verification.") def send_burst_trigger_signal(self): # Disable the button so it can't be multi-clicked self.console.trigger_btn.setEnabled(False) self.statusbar.showMessage("Burst signal sent!") # Write the file token that part_A.py is waiting for self.worker.send_input("") def on_subprocess_complete(self, returncode): if returncode != 0: self.console.append_log(f"\n[GUI SYSTEM ERROR]\n{returncode}") self.statusbar.showMessage("Process failed.") else: self.statusbar.showMessage("Sequence completed successfully.") def show_error_popup(self, title, error_message, traceback_str=""): msgbox = QMessageBox(self) msgbox.setIcon(QMessageBox.Warning) msgbox.setWindowTitle("Warning - FLARES") message = ( f"FLARES has encountered an error processing the file {title}.

" "This error was likely due to incorrect parameters on the right side of the screen and not an error with your data. " "Processing of the remaining files continues in the background and this participant will be ignored in the analysis. " "If you think the parameters on the right side are correct for your data, raise an issue here.

" f"Error message: {error_message}" ) msgbox.setTextFormat(Qt.TextFormat.RichText) msgbox.setText(message) msgbox.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) # Add traceback to detailed text if traceback_str: msgbox.setDetailedText(traceback_str) msgbox.setStandardButtons(QMessageBox.Ok) msgbox.show() def cleanup_after_process(self): if hasattr(self, 'result_process'): self.result_process.join(timeout=0) if self.result_process.is_alive(): self.result_process.terminate() self.result_process.join() if hasattr(self, 'result_queue'): if 'AutoProxy' in repr(self.result_queue): pass else: self.result_queue.close() self.result_queue.join_thread() if hasattr(self, 'progress_queue'): if 'AutoProxy' in repr(self.progress_queue): pass else: self.progress_queue.close() self.progress_queue.join_thread() def update_file_progress(self, file_path, step_index): key = os.path.normpath(file_path) bubble = self.bubble_widgets.get(key) if bubble: bubble.update_progress(step_index) def get_snirf_metadata_mne(self, file_name): # Check if we already have it (we should?) if file_name in self.metadata_cache: return self.metadata_cache[file_name] print(self.metadata_cache) # If the user clicked so fast it's not ready, do a one-off blocking call print(f"Cache miss for {file_name}, fetching now...") future = self.file_executor.submit(_extract_metadata_worker, file_name) return future.result(timeout=5) def closeEvent(self, event): # Gracefully shut down multiprocessing children print("Window is closing. Cleaning up...") if hasattr(self, 'manager'): self.manager.shutdown() for child in self.findChildren(QWidget): if child is not self and child.isVisible(): child.close() kill_child_processes() event.accept() def _on_metadata_ready(self, future, file_path, session_id): if session_id != self.loading_session_id: return try: result = future.result() if result is None: result = {'status': 'error', 'reason': 'Worker returned no data.'} # If it's a successful extraction, it won't have 'status' set yet elif 'status' not in result: # Wrap the raw extraction dictionary into our unified UI format result = {'status': 'success', 'data': result} except Exception as e: result = {'status': 'error', 'reason': str(e)} # Safely emit to the Main thread. No brittle QMetaObject needed! self.metadata_ui_signal.emit(result, file_path, session_id) def _handle_metadata_ui_update(self, result, file_path, session_id): """Executes safely on the MAIN GUI thread via Signal connection.""" if result.get('status') == 'error': # 1. Pop up the warning safely on the main thread QMessageBox.warning( self, "Invalid File", f"Could not read metadata from: {os.path.basename(file_path)}\n\n" f"Details: {result.get('reason', 'Unknown error')}" ) # 2. Run your clean tracking removal self._remove_file_from_pipeline(file_path) return # Success path self.metadata_cache[file_path] = result.get('data', result) self.metadata_processed.emit(file_path, session_id) def _remove_file_from_pipeline(self, file_path): """Completely cleans up and removes all references to a file that failed to load.""" # 1. Decrement pending file count if hasattr(self, 'pending_files_count') and self.pending_files_count > 0: self.pending_files_count -= 1 # 2. Remove the UI widget cleanly if hasattr(self, 'bubble_widgets') and file_path in self.bubble_widgets: bubble = self.bubble_widgets.pop(file_path) self.bubble_layout.removeWidget(bubble) bubble.deleteLater() # Safely schedules the widget for deletion in Qt # 3. Remove from tracking lists if hasattr(self, 'selected_paths') and file_path in self.selected_paths: self.selected_paths.remove(file_path) # 4. Update Status Bar if hasattr(self, 'pending_files_count') and self.pending_files_count == 0: self.statusBar().showMessage("Ready.", 3000) else: self.statusBar().showMessage(f"Loading pending files... ({self.pending_files_count} left)") def _safe_ui_update(self, file_path): # 2. Update the Bubble safely if file_path in self.bubble_widgets: bubble = self.bubble_widgets[file_path] # This is now thread-safe! bubble.set_loading_state(False) bubble.clicked.connect(self.on_bubble_clicked) bubble.rightClicked.connect(self.on_bubble_right_clicked) bubble.setCursor(Qt.CursorShape.PointingHandCursor) # 3. Handle the global counter/cleanup self.pending_files_count -= 1 if self.pending_files_count <= 0: self._cleanup_executor() self.statusbar.showMessage("All files loaded sucessfully.") def _cleanup_executor(self): """Safely shuts down the executor and clears the reference.""" if hasattr(self, 'file_executor') and self.file_executor is not None: self.file_executor.shutdown(wait=False) self.file_executor = None print("[System] Background worker dismissed. RAM reclaimed.") def _extract_metadata_worker(file_name): """Runs in the separate worker process. Returns a clean dict.""" # 1. Use preload=False! We only need metadata. raw = None try: raw = read_raw_snirf(file_name, preload=False, verbose="ERROR") snirf_info = {} # 2. Measurement date snirf_info['Measurement Date'] = str(raw.info.get('meas_date')) # 3. Short Channels try: short_chans = get_short_channels(raw, max_dist=0.015) names = list(short_chans.ch_names) snirf_info['Short Channels'] = f"Likely - {names}" if len(names) > 6: snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!" except: snirf_info['Short Channels'] = "Unlikely" # 4. Distances dist_vals = source_detector_distances(raw.info) snirf_info['Source-Detector Distances'] = [ f"{name}: {d:.4f} m" for name, d in zip(raw.info['ch_names'], dist_vals) ] # 5. Digitization dig = raw.info.get('dig', None) if dig is not None: snirf_info['Digitization Points'] = [ f"Kind: {p['kind']}, ID: {p['ident']}, Coord: {p['r']}" for p in dig ] else: snirf_info['Digitization Points'] = "Not found" # 6. Annotations (using our copy-to-string trick) if raw.annotations is not None and len(raw.annotations) > 0: snirf_info['Annotations'] = [ f"Onset: {o:.2f}s, Duration: {d:.2f}s, Description: {str(desc)}" for o, d, desc in zip(raw.annotations.onset, raw.annotations.duration, raw.annotations.description) ] else: snirf_info['Annotations'] = "No annotations found" return snirf_info except Exception as e: print(f"Worker safely caught failure on {file_name}: {str(e)}") return {'status': 'error', 'reason': str(e)} finally: if raw is not None: try: raw.close() except: pass def run_gui_entry_wrapper(): """ Where the processing happens """ try: print("here") import part_A as light_a light_a.gui_entry() # gui_queue.join_thread() # progress_queue.join_thread() print("done") os._exit(0) except Exception as e: print(e) tb_str = traceback.format_exc() # gui_queue.put({ # "success": False, # "error": f"Child process crashed: {str(e)}\nTraceback:\n{tb_str}" # }) os._exit(1) def resource_path(relative_path): """ Get absolute path to resource regardless of running directly or packaged using PyInstaller """ if hasattr(sys, '_MEIPASS'): # PyInstaller bundle path base_path = sys._MEIPASS else: base_path = os.path.dirname(os.path.abspath(__file__)) return os.path.join(base_path, relative_path) def kill_child_processes(): """ Goodbye children """ try: parent = psutil.Process(os.getpid()) children = parent.children(recursive=True) for child in children: try: child.kill() except psutil.NoSuchProcess: pass psutil.wait_procs(children, timeout=5) except Exception as e: print(f"Error killing child processes: {e}") def exception_hook(exc_type, exc_value, exc_traceback): """ Method that will display a popup when the program hard crashes containg what went wrong """ error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) print(error_msg) # also print to console kill_child_processes() # Show error message box # Make sure QApplication exists (or create a minimal one) app = QApplication.instance() if app is None: app = QApplication(sys.argv) show_critical_error(error_msg) # Exit the app after user acknowledges sys.exit(1) def show_critical_error(error_msg): msg_box = QMessageBox() msg_box.setIcon(QMessageBox.Icon.Critical) msg_box.setWindowTitle("Something went wrong!") if PLATFORM_NAME == "darwin": log_path = os.path.join(os.path.dirname(sys.executable), "../../../flares.log") log_path2 = os.path.join(os.path.dirname(sys.executable), "../../../flares_error.log") save_path = os.path.join(os.path.dirname(sys.executable), "../../../flares_autosave.flare") else: log_path = os.path.join(os.getcwd(), "flares.log") log_path2 = os.path.join(os.getcwd(), "flares_error.log") save_path = os.path.join(os.getcwd(), "flares_autosave.flare") shutil.copy(log_path, log_path2) log_path2 = Path(log_path2).absolute().as_posix() autosave_path = Path(save_path).absolute().as_posix() log_link = f"file:///{log_path2}" autosave_link = f"file:///{autosave_path}" window.save_project(True) #TODO: If the window is the one to crash, the file can't get saved. Could be fine as the window is what was storing the data to begin with? message = ( f"{APP_NAME.upper()} has encountered an unrecoverable error and needs to close.

" f"We are sorry for the inconvenience. An autosave was attempted to be saved to {autosave_path}, but it may not have been saved. " "If the file was saved, it still may not be intact, openable, or contain the correct data. Use the autosave at your discretion.

" f"This unrecoverable error was likely due to an error with {APP_NAME.upper()} and not your data.
" f"Please raise an issue here and attach the error file located at {log_path2}

" f"
{error_msg}
" ) msg_box.setTextFormat(Qt.TextFormat.RichText) msg_box.setText(message) msg_box.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) msg_box.exec() def config_init(): ref_cfg.read_string(DEFAULT_CONFIG) if not os.path.exists(cfg_path): try: with open(cfg_path, "w") as f: f.write(DEFAULT_CONFIG.strip()) print(f"Created default configuration file at {cfg_path}") file_cfg.read_string(DEFAULT_CONFIG) except Exception as e: print(f"Warning: Could not create config file ({e}). Using in-memory defaults.") file_cfg.read_string(DEFAULT_CONFIG) else: try: # Load the user's actual file first file_cfg.read(cfg_path) has_changes = False for section in file_cfg.sections(): if not ref_cfg.has_section(section): file_cfg.remove_section(section) has_changes = True continue for option in list(file_cfg.options(section)): if not ref_cfg.has_option(section, option): file_cfg.remove_option(section, option) has_changes = True for section in ref_cfg.sections(): if not file_cfg.has_section(section): file_cfg.add_section(section) has_changes = True for option in list(ref_cfg.options(section)): if not file_cfg.has_option(section, option): default_val = ref_cfg.get(section, option) file_cfg.set(section, option, default_val) has_changes = True # 4. If we added or removed anything, save the sanitized file back to disk if has_changes: with open(cfg_path, "w") as f: file_cfg.write(f) print("Configuration file synchronized: removed old keys and appended new ones.") else: print("Configuration loaded successfully. Schema is up to date.") except Exception as e: print(f"Error validating config file ({e}). Falling back completely to defaults.") file_cfg.read_string(DEFAULT_CONFIG) if __name__ == "__main__": # Redirect exceptions to the popup window sys.excepthook = exception_hook # Set up application logging and configuration if PLATFORM_NAME == "darwin": log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log") cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg") else: log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log") cfg_path = os.path.join(os.getcwd(), f"{APP_NAME}.cfg") try: os.remove(log_path) except: pass sys.stdout = open(log_path, "a", buffering=1) sys.stderr = sys.stdout print(f"\n=== App started at {datetime.now()} ===\n") file_cfg = configparser.ConfigParser() ref_cfg = configparser.ConfigParser() config_init() freeze_support() # Required for PyInstaller + multiprocessing # Only run GUI in the main process if current_process().name == 'MainProcess': app = QApplication(sys.argv) finish_update_if_needed(PLATFORM_NAME, APP_NAME, cfg_path) window = MainApplication() if PLATFORM_NAME == "darwin": app.setWindowIcon(QIcon(resource_path("icons/main.icns"))) window.setWindowIcon(QIcon(resource_path("icons/main.icns"))) else: app.setWindowIcon(QIcon(resource_path("icons/main.ico"))) window.setWindowIcon(QIcon(resource_path("icons/main.ico"))) window.show() sys.exit(app.exec()) # Not 6000 lines yay!