diff --git a/changelog.md b/changelog.md index adbbf56..73cf58a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,29 @@ +# Version 1.3.0 + +- This is a save-changing release due to a new save file format. Please update your project files to ensure compatibility +- It is still potentially possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu +- Fixed workers not releasing memory when processing multiple participants. Fixes [Issue 55](https://git.research.dezeeuw.ca/tyler/flares/issues/55) +- Fixed part of an issue where memory could increase over time despite clicking the clear button. There is still some edge cases where this can occur +- Fixed an issue when clearing a bubble, reloading the same file, and clicking it would cause the app to crash. Fixes [Issue 57](https://git.research.dezeeuw.ca/tyler/flares/issues/57) +- Picking a .txt or .xlsx file now has both in the same file selection instead of having to select which extension was desired +- Fixed an issue where the fOLD files were not included in the Windows version. Fixes [Issue 60](https://git.research.dezeeuw.ca/tyler/flares/issues/60) +- Added a new parameter to the right side of the screen: EPOCH_EVENTS_HANDLING. Fixes [Issue 58](https://git.research.dezeeuw.ca/tyler/flares/issues/58) +- EPOCH_EVENTS_HANDLING defaults to 'shift' compared to previous versions where the default would have been equivalent to 'strict' +- The label for ENHANCE_NEGATIVE_CORRELATION no longer gets cut off by its dropdown selection +- Loading in files and folders have changes to immediately show their bubbles having a respective loading symbol on each bubble +- Once the file has been completely loaded and processed, the loading symbol will change to a green checkmark and clicking will be enabled +- The metadata in the File infomation widget is now saved to prevent recalculations every time the bubble is selected +- The status bar will now say loading while the bubbles are being processed, and loaded once the processing has completed +- This new loading method will prevent the application from hanging when loading lots of files at once. Fixes [Issue 59](https://git.research.dezeeuw.ca/tyler/flares/issues/59) +- Fixed text allignment for the first paragraph when a bubble is selected in the 'File information' widget +- The three main widgets are now resizable! All of them have minimum widths to ensure they do not get too squished +- Added a new option 'Reset Window Layout' under the View menu that will resize all widgets back to their default sizes +- Added a new terminal command 'version' that will print the applications current version + + # Version 1.2.2 -- Added 'Update events in snirf file (BLAZES)...' and renamed 'Update events in snirf file...' to 'Update events in snirf file (BORIS)...' +- Added 'Update events in snirf file (BLAZES)...' and renamed 'Update events in snirf file...' to 'Update events in snirf file (BORIS)...' under the Options menu - The BLAZES option will assign events that are exported directly from the software [BLAZES](https://git.research.dezeeuw.ca/tyler/blazes) - Moved the updating logic to a seperate file for better reusability and generalization - Fixed 'Toggle Status Bar' having no effect on the visibility of the status bar diff --git a/flares.py b/flares.py index 2629705..f9e2a06 100644 --- a/flares.py +++ b/flares.py @@ -16,12 +16,14 @@ from io import BytesIO from typing import Any, Optional, cast, Literal, Union from itertools import compress from copy import deepcopy -from multiprocessing import Queue +from multiprocessing import Queue, Pool import os.path as op import re import traceback from concurrent.futures import ProcessPoolExecutor, as_completed from queue import Empty +import time +import multiprocessing as mp # External library imports import matplotlib.pyplot as plt @@ -169,6 +171,8 @@ H_FREQ: float L_TRANS_BANDWIDTH: float H_TRANS_BANDWIDTH: float +EPOCH_HANDLING: str + RESAMPLE: bool RESAMPLE_FREQ: int STIM_DUR: float @@ -247,6 +251,28 @@ REQUIRED_KEYS: dict[str, Any] = { } + + +import logging +import os +import psutil +import traceback + +audit_log = logging.getLogger("memory_audit") +audit_log.setLevel(logging.INFO) +audit_log.propagate = False # This prevents it from talking to other loggers + +# 2. Add a file handler specifically for this audit logger +if not audit_log.handlers: + fh = logging.FileHandler('flares_memory_audit.log') + fh.setFormatter(logging.Formatter('%(asctime)s | PID: %(process)d | %(message)s')) + audit_log.addHandler(fh) + +def get_mem_mb(): + return psutil.Process(os.getpid()).memory_info().rss / 1024 / 1024 + + + class ProcessingError(Exception): def __init__(self, message: str = "Something went wrong!"): self.message = message @@ -370,58 +396,92 @@ def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue) - t.join(timeout=5) # prevent permanent hang - -def process_participant_worker(args): - file_path, file_params, file_metadata, progress_queue = args - - set_config_me(file_params) - set_metadata(file_path, file_metadata) - logger.info(f"DEBUG: Metadata for {file_path}: AGE={globals().get('AGE')}, GENDER={globals().get('GENDER')}, GROUP={globals().get('GROUP')}") - - def progress_callback(step_idx): - if progress_queue: - progress_queue.put(('progress', file_path, step_idx)) - +def process_participant_worker(file_path, file_params, file_metadata, result_queue, progress_queue): + file_name = os.path.basename(file_path) + try: + # 1. Setup + set_config_me(file_params) + set_metadata(file_path, file_metadata) + + def progress_callback(step_idx): + if progress_queue: + # We use put_nowait to prevent the worker from hanging on a full queue + try: + progress_queue.put_nowait(('progress', file_path, step_idx)) + except: pass + + # 2. Process result = process_participant(file_path, progress_callback=progress_callback) - return file_path, result, None + + # 3. Report Success + result_queue.put((file_path, result, None)) + except Exception as e: - error_trace = traceback.format_exc() - return file_path, None, (str(e), error_trace) + result_queue.put((file_path, None, str(e))) + + finally: + # --- THE FIX: MANDATORY EXIT --- + # Explicitly flush the logs and force the process to terminate + audit_log.info(f"Worker for {file_name} calling hard exit.") + sys.stdout.flush() + sys.stderr.flush() + # We use os._exit(0) as a nuclear option if sys.exit() is being caught by a try/except + os._exit(0) -def process_multiple_participants(file_paths, file_params, file_metadata, progress_queue=None, max_workers=None): +def process_multiple_participants(file_paths, file_params, file_metadata, progress_queue=None, max_workers=6): + audit_log.info(f"--- SESSION START: {len(file_paths)} files ---") + + pending_files = list(file_paths) + active_processes = [] # List of tuples: (Process object, file_path) results_by_file = {} + + # We use a manager queue so it handles IPC serialization cleanly + manager = mp.Manager() + result_queue = manager.Queue() - file_args = [(file_path, file_params, file_metadata, progress_queue) for file_path in file_paths] + # Loop continues as long as there are files to process OR workers still running + while pending_files or active_processes: + + # 1. SPWAN WORKERS: Only spawn if we are under the limit AND have files left + while len(active_processes) < max_workers and pending_files: + file_path = pending_files.pop(0) + + p = mp.Process( + target=process_participant_worker, + args=(file_path, file_params, file_metadata, result_queue, progress_queue) + ) + p.start() + active_processes.append((p, file_path)) + audit_log.info(f"Spawned worker. Active processes: {len(active_processes)}") - with ProcessPoolExecutor(max_workers=max_workers) as executor: - futures = {executor.submit(process_participant_worker, arg): arg[0] for arg in file_args} - - for future in as_completed(futures): - file_path = futures[future] + # 2. COLLECT RESULTS: Drain the queue continuously so workers don't deadlock + while not result_queue.empty(): try: - file_path, result, error = future.result() - if error: - error_message, error_traceback = error - if progress_queue: - progress_queue.put({ - "type": "error", - "file": file_path, - "error": error_message, - "traceback": error_traceback - }) - continue - - results_by_file[file_path] = result - except Exception as e: - print(f"Unexpected error processing {file_path}: {e}") + res_path, result, error = result_queue.get_nowait() + if not error: + results_by_file[res_path] = result + else: + audit_log.error(f"Worker failed on {os.path.basename(res_path)}: {error}") + except Exception: + break # Queue is empty or busy + # 3. CLEANUP: Check for finished processes and remove them + for p, f_path in active_processes[:]: # Iterate over a slice copy + if not p.is_alive(): + p.join() # Formally close the process to free OS resources + active_processes.remove((p, f_path)) + audit_log.info(f"Worker finished. Active processes dropping to: {len(active_processes)}") + + # Brief pause to prevent this while loop from pegging your CPU to 100% + time.sleep(0.5) + + audit_log.info("--- SESSION COMPLETE ---") return results_by_file - def markbad(data, ax, ch_names: list[str]) -> None: """ Add a strikethrough to a plot for channels marked as bad. @@ -1143,16 +1203,48 @@ def filter_the_data(raw_haemo): +def safe_create_epochs(raw, events, event_dict, tmin, tmax, baseline): + """ + Attempts to create epochs, shifting event times slightly if + sample collisions are detected. + """ + shift_increment = 1.0 / raw.info['sfreq'] # The duration of exactly one sample + + for attempt in range(10): # Limit attempts to avoid infinite loops + try: + epochs = Epochs( + raw, events, event_id=event_dict, + tmin=tmin, tmax=tmax, baseline=baseline, + preload=True, verbose=False + ) + return epochs + except RuntimeError as e: + if "Event time samples were not unique" in str(e): + # Find duplicates in the events array (column 0 is the sample index) + vals, counts = np.unique(events[:, 0], return_counts=True) + duplicates = vals[counts > 1] + + # Shift the second occurrence of every duplicate by 1 sample + for dup in duplicates: + idx = np.where(events[:, 0] == dup)[0][1:] # Get all but the first + events[idx, 0] += 1 + + print(f"Collision detected. Nudging events by {shift_increment:.4f}s and retrying...") + continue + else: + raise e # Raise if it's a different Runtime Error + + raise RuntimeError("Could not resolve event collisions after 10 attempts.") + + + def epochs_calculations(raw_haemo, events, event_dict): fig_epochs = [] # List to store figures - # Create epochs from raw data - epochs = Epochs(raw_haemo, - events, - event_id=event_dict, - tmin=-5, - tmax=15, - baseline=(None, 0)) + if EPOCH_HANDLING == 'shift': + epochs = safe_create_epochs(raw=raw_haemo, events=events, event_dict=event_dict, tmin=-5, tmax=15, baseline=(None, 0)) + else: + epochs = Epochs(raw_haemo, events, event_id=event_dict, tmin=-5, tmax=15, baseline=(None, 0)) # Make a copy of the epochs and drop bad ones epochs2 = epochs.copy() @@ -1582,15 +1674,12 @@ def resource_path(relative_path): def fold_channels(raw: BaseRaw) -> None: - # if getattr(sys, 'frozen', False): - path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary" - logger.info(path) - set_config('MNE_NIRS_FOLD_PATH', resource_path(path)) # type: ignore - - # # Locate the fOLD excel files - # else: - # logger.info("yabba") - # set_config('MNE_NIRS_FOLD_PATH', resource_path("../../mne_data/fOLD/fOLD-public-master/Supplementary")) # type: ignore + # Locate the fOLD excel files + if getattr(sys, 'frozen', False): + set_config('MNE_NIRS_FOLD_PATH', resource_path("../../mne_data/fOLD/fOLD-public-master/Supplementary")) # type: ignore + else: + path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary" + set_config('MNE_NIRS_FOLD_PATH', resource_path(path)) # type: ignore output = None diff --git a/icons/desktop.ini b/icons/desktop.ini deleted file mode 100644 index 54844b6..0000000 --- a/icons/desktop.ini +++ /dev/null @@ -1,2 +0,0 @@ -[LocalizedFileNames] -updater.png=@updater.png,0 diff --git a/icons/grid_layout_side_24dp_1F1F1F.svg b/icons/grid_layout_side_24dp_1F1F1F.svg new file mode 100644 index 0000000..739596e --- /dev/null +++ b/icons/grid_layout_side_24dp_1F1F1F.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/main.py b/main.py index e14eb0b..b8f5baa 100644 --- a/main.py +++ b/main.py @@ -16,10 +16,11 @@ import shutil import platform import traceback import subprocess +import concurrent.futures +from enum import Enum, auto from pathlib import Path, PurePosixPath from datetime import datetime -from multiprocessing import Process, current_process, freeze_support, Manager -from enum import Enum, auto +from multiprocessing import Process, current_process, freeze_support, Manager, Queue # External library imports import numpy as np @@ -36,15 +37,15 @@ 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 + QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, + QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QListView, QMenu, QSpinBox, QProgressBar ) from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize, QPoint from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen -CURRENT_VERSION = "1.2.1" +CURRENT_VERSION = "1.3.0" APP_NAME = "flares" API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" @@ -174,9 +175,10 @@ SECTIONS = [ ] }, { - "title": "Epoch Calculations*", + "title": "Epoch Calculations", "params": [ - #{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."}, + # TODO: implement drop + {"name": "EPOCH_HANDLING", "default": ["shift"], "type": list, "options": ["shift", "strict"], "help": "What to do if two unique events occur at the same time. Shift will automatically move one event to the first valid free index. Strict will raise an error processing the file. Drop will remove one of the events."}, ] }, { @@ -222,6 +224,7 @@ SECTIONS = [ ] + class SaveProjectThread(QThread): finished_signal = Signal(str) error_signal = Signal(str) @@ -233,7 +236,6 @@ class SaveProjectThread(QThread): def run(self): try: - import pickle with open(self.filename, "wb") as f: pickle.dump(self.project_data, f) self.finished_signal.emit(self.filename) @@ -241,6 +243,7 @@ class SaveProjectThread(QThread): self.error_signal.emit(str(e)) + class SavingOverlay(QDialog): def __init__(self, parent=None): super().__init__(parent) @@ -258,10 +261,11 @@ class SavingOverlay(QDialog): self.setLayout(layout) + class TerminalWindow(QWidget): def __init__(self, parent=None): super().__init__(parent, Qt.WindowType.Window) - self.setWindowTitle("Terminal - FLARES") + self.setWindowTitle(f"Terminal - {APP_NAME.upper()}") self.output_area = QTextEdit() self.output_area.setReadOnly(True) @@ -276,7 +280,8 @@ class TerminalWindow(QWidget): self.commands = { "hello": self.cmd_hello, - "help": self.cmd_help + "help": self.cmd_help, + "version": self.cmd_version, } def handle_command(self): @@ -309,6 +314,10 @@ class TerminalWindow(QWidget): def cmd_help(self, *args): return f"Available commands: {', '.join(self.commands.keys())}" + def cmd_version(self, *args): + return f"{CURRENT_VERSION}" + + class AboutWindow(QWidget): """ @@ -503,7 +512,6 @@ class UpdateOptodesWindow(QWidget): suffix_layout.addWidget(suffix_container) layout.addLayout(suffix_layout) - buttons_layout = QHBoxLayout() buttons_layout.addStretch() buttons_layout.addWidget(self.btn_clear) @@ -512,7 +520,6 @@ class UpdateOptodesWindow(QWidget): self.setLayout(layout) - def show_help_popup(self, text): msg = QMessageBox(self) msg.setWindowTitle("Parameter Info - FLARES") @@ -590,7 +597,6 @@ class UpdateOptodesWindow(QWidget): 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 = {} @@ -640,7 +646,6 @@ class UpdateOptodesWindow(QWidget): 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) @@ -667,13 +672,14 @@ class UpdateOptodesWindow(QWidget): write_raw_snirf(raw, save_path) + class EventUpdateMode(Enum): WRITE_SNIRF = auto() # destructive WRITE_JSON = auto() # non-destructive -class UpdateEventsWindow(QWidget): +class UpdateEventsWindow(QWidget): def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None): super().__init__(parent, Qt.WindowType.Window) @@ -752,7 +758,6 @@ class UpdateEventsWindow(QWidget): 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() @@ -775,7 +780,6 @@ class UpdateEventsWindow(QWidget): 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() @@ -797,7 +801,6 @@ class UpdateEventsWindow(QWidget): 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() @@ -847,10 +850,9 @@ class UpdateEventsWindow(QWidget): self.setLayout(layout) - def show_help_popup(self, text): msg = QMessageBox(self) - msg.setWindowTitle("Parameter Info - FLARES") + msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setText(text) msg.exec() @@ -859,6 +861,7 @@ class UpdateEventsWindow(QWidget): if file_path: self.line_edit_file_a.setText(file_path) try: + # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed raw = read_raw_snirf(file_path, preload=False) annotations = raw.annotations @@ -940,11 +943,9 @@ class UpdateEventsWindow(QWidget): 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: @@ -984,7 +985,6 @@ class UpdateEventsWindow(QWidget): QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.") return - boris_obs = self.boris_data["observations"][selected_obs] # --- Extract videos + delays --- @@ -1144,6 +1144,7 @@ class UpdateEventsWindow(QWidget): initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore # Read the SNIRF file, set the montage, and write it back + # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed raw = read_raw_snirf(file_a, preload=True) raw.set_montage(initial_montage) write_raw_snirf(raw, save_path) @@ -1164,9 +1165,6 @@ class UpdateEventsWindow(QWidget): mapped_events, save_path ): - import json - from datetime import datetime - import os payload = { "source": { @@ -1264,7 +1262,6 @@ class UpdateEventsBlazesWindow(QWidget): file_a_layout.addWidget(file_a_container) layout.addLayout(file_a_layout) - help_text_b = "Provide a .blaze output file that contains events for this participant." file_b_layout = QHBoxLayout() @@ -1287,7 +1284,6 @@ class UpdateEventsBlazesWindow(QWidget): file_b_layout.addWidget(file_b_container) layout.addLayout(file_b_layout) - help_text_suffix = "The events extracted from the blaze file." suffix2_layout = QHBoxLayout() @@ -1340,7 +1336,7 @@ class UpdateEventsBlazesWindow(QWidget): def show_help_popup(self, text): msg = QMessageBox(self) - msg.setWindowTitle("Parameter Info - FLARES") + msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setText(text) msg.exec() @@ -1349,6 +1345,7 @@ class UpdateEventsBlazesWindow(QWidget): if file_path: self.line_edit_file_a.setText(file_path) try: + # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed raw = read_raw_snirf(file_path, preload=False) annotations = raw.annotations @@ -1509,7 +1506,6 @@ class UpdateEventsBlazesWindow(QWidget): QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}") - def update_optode_positions(self, file_a, file_b, save_path): fiducials = {} @@ -1535,6 +1531,7 @@ class UpdateEventsBlazesWindow(QWidget): initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore # Read the SNIRF file, set the montage, and write it back + # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed raw = read_raw_snirf(file_a, preload=True) raw.set_montage(initial_montage) write_raw_snirf(raw, save_path) @@ -1555,9 +1552,6 @@ class UpdateEventsBlazesWindow(QWidget): mapped_events, save_path ): - import json - from datetime import datetime - import os payload = { "source": { @@ -1582,7 +1576,6 @@ class UpdateEventsBlazesWindow(QWidget): - class ProgressBubble(QWidget): """ A clickable widget displaying a progress bar made of colored rectangles and a label. @@ -1601,6 +1594,11 @@ class ProgressBubble(QWidget): self.layout = QVBoxLayout() self.label = QLabel(display_name) + self.loading_timer = QTimer(self) + self.loading_timer.timeout.connect(self._rotate_spinner) + self.spinner_frames = ["◐", "◓", "◑", "◒"] #cute + self.spinner_idx = 0 + self.is_loading = False self.base_text = display_name self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.label.setStyleSheet(""" @@ -1630,13 +1628,22 @@ class ProgressBubble(QWidget): self.file_path = os.path.normpath(file_path) self.current_step = 0 - # Make the bubble clickable + + # Make the bubble appear to the user as 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 set_loading_state(self, loading=True): + self.is_loading = loading + if loading: + self.loading_timer.start(150) # Rotate every 150ms + else: + self.loading_timer.stop() + # Transition to a green checkmark + self.setSuffixText(" ") + def update_progress(self, step_index): self.current_step = step_index for i, rect in enumerate(self.rects): @@ -1665,6 +1672,11 @@ class ProgressBubble(QWidget): else: self.label.setText(self.base_text) + def _rotate_spinner(self): + frame = self.spinner_frames[self.spinner_idx % len(self.spinner_frames)] + # Using HTML in setText allows us to style the spinner specifically + self.setSuffixText(f" {frame}") + self.spinner_idx += 1 class ParamSection(QWidget): @@ -1696,6 +1708,9 @@ class ParamSection(QWidget): self.dependencies = [] self.selected_path = None + # Load the mne data in a seperate process + self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1) + # Title label title_label = QLabel(section_data["title"]) title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;") @@ -1711,7 +1726,6 @@ class ParamSection(QWidget): h_layout = QHBoxLayout() label = QLabel(param["name"]) - label.setFixedWidth(180) label.setToolTip(param.get("help", "")) @@ -1722,12 +1736,11 @@ class ParamSection(QWidget): 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%) + h_layout.setStretch(0, 1) + h_layout.setStretch(1, 6) default_val = param["default"] @@ -1759,7 +1772,7 @@ class ParamSection(QWidget): widget = self._create_multiselect_dropdown(None) elif param["type"] == range: widget = QSpinBox() - widget.setRange(0, 999) # Set a sensible maximum + widget.setRange(0, 999) #NOTE: will this be a high enough limit? # If default is "None" or range(15), handle it gracefully: if isinstance(default_val, range): widget.setValue(default_val.stop) @@ -1782,7 +1795,7 @@ class ParamSection(QWidget): widget.setToolTip(help_text) h_layout.addWidget(widget) - h_layout.setStretch(2, 5) # Set stretch factor for input field (50%) + h_layout.setStretch(2, 3) layout.addLayout(h_layout) self.widgets[param["name"]] = { @@ -1953,7 +1966,7 @@ class ParamSection(QWidget): def show_help_popup(self, text): msg = QMessageBox(self) - msg.setWindowTitle("Parameter Info - FLARES") + msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setText(text) msg.exec() @@ -2008,7 +2021,7 @@ class ParamSection(QWidget): 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())) + #print("[ParamSection] Current widget keys:", list(self.widgets.keys())) if not widget_info: print(f"[ParamSection] No widget found for param '{param_name}'") @@ -2103,37 +2116,52 @@ class ParamSection(QWidget): # 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 = [] + # def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1): + # file_paths = [bubble.file_path for bubble in bubble_widgets.values()] + # if not file_paths: + # return - 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 + # # 1. Start the UI immediately + # progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self) + # progress.setWindowModality(Qt.WindowModality.WindowModal) + # progress.setMinimumDuration(0) + # progress.setValue(0) + + # # Force the UI to draw the window NOW before we start the loop + # progress.show() + # QApplication.processEvents() - if not annotation_sets: - print("[ParamSection] No annotations found in loaded files") - self.update_dropdown_items("REMOVE_EVENTS", []) - button1.setVisible(False) - return + # annotation_sets = [] - common_annotations = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0] - common_annotations = sorted(list(common_annotations)) + # # 2. Use the persistent executor (don't use 'with' here!) + # for i, path in enumerate(file_paths): + # progress.setValue(i) + # progress.setLabelText(f"Reading file {i+1} of {len(file_paths)}...") + # QApplication.processEvents() # Keeps the UI snappy - print(f"[ParamSection] Common annotations: {common_annotations}") + # if progress.wasCanceled(): + # break + + # # This call is now nearly instant because the process is already warm + # future = self.file_executor.submit(_extract_annotations, path) + # try: + # labels_list = future.result() + # if labels_list: + # annotation_sets.append(set(labels_list)) + # except Exception as e: + # print(f"Worker Error: {e}") + + # progress.setValue(len(file_paths)) + + # # 3. Final Logic + # if not annotation_sets: + # self.update_dropdown_items("REMOVE_EVENTS", []) + # button1.setVisible(False) + # return + + # common = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0] + # self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common))) - self.update_dropdown_items("REMOVE_EVENTS", common_annotations) class FullClickComboBox(QComboBox): @@ -2157,9 +2185,6 @@ class FullClickComboBox(QComboBox): - - - class FlaresBaseWidget(QWidget): def __init__(self, caller): super().__init__() @@ -3236,6 +3261,7 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue) try: import flares # Perform the heavy fold_channels logic + print("we are here") png_bytes = flares.fold_channels(raw_data) # Hand back results and signal completion @@ -4485,10 +4511,11 @@ class MainApplication(QMainWindow): """ progress_update_signal = Signal(str, int) + metadata_processed = Signal(str, int) def __init__(self): super().__init__() - self.setWindowTitle("FLARES") + self.setWindowTitle(f"{APP_NAME.upper()}") self.setGeometry(100, 100, 1280, 720) self.about = None @@ -4506,6 +4533,9 @@ class MainApplication(QMainWindow): self.missing_events_bypass = False self.analysis_clearing_bypass = False + self.metadata_cache = {} # { "file_path": {metadata_dict} } + self.metadata_processed.connect(self._safe_ui_update) + self.files_total = 0 # total number of files to process self.files_done = set() # set of file paths done (success or fail) self.files_failed = set() # set of failed file paths @@ -4542,159 +4572,111 @@ class MainApplication(QMainWindow): def init_ui(self): - - # Central widget and main horizontal layout central = QWidget() self.setCentralWidget(central) + main_layout = QHBoxLayout(central) + main_layout.setContentsMargins(5, 5, 5, 5) - main_layout = QHBoxLayout() - central.setLayout(main_layout) + self.main_h_splitter = QSplitter(Qt.Orientation.Horizontal) + self.main_h_splitter.setChildrenCollapsible(False) + main_layout.addWidget(self.main_h_splitter) - # Left container with vertical layout: top left + bottom left - left_container = QWidget() - left_layout = QVBoxLayout() - left_container.setLayout(left_layout) - left_container.setMinimumWidth(300) + self.left_v_splitter = QSplitter(Qt.Orientation.Vertical) + self.left_v_splitter.setChildrenCollapsible(False) + self.left_v_splitter.setMinimumWidth(460) - 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_container = QGroupBox("File information") + top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }") + top_left_container.setMinimumHeight(240) + top_left_layout = QHBoxLayout(top_left_container) - 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% + self.top_left_widget.setPlaceholderText("Click a file below to get started!") + top_left_layout.addWidget(self.top_left_widget, stretch=4) - # 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(), - } - + right_column_layout = QVBoxLayout(self.right_column_widget) + self.meta_fields = {"AGE": QLineEdit(), "GENDER": QLineEdit(), "GROUP": QLineEdit()} for key, field in self.meta_fields.items(): label = QLabel(key.capitalize()) - field.setPlaceholderText(f"Enter {key}") right_column_layout.addWidget(label) right_column_layout.addWidget(field) label_desc = QLabel('Why are these useful?') 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) + label_desc.linkActivated.connect(lambda: QMessageBox.information(None, "Info", "Parameter Info...")) right_column_layout.addWidget(label_desc) - - right_column_layout.addStretch() # Push fields to top - + right_column_layout.addStretch() 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 = QGridLayout(self.bubble_container) 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) + self.scroll_area.setMinimumHeight(200) - # Add top left and bottom left to left layout - left_layout.addWidget(self.scroll_area) + self.left_v_splitter.addWidget(top_left_container) + self.left_v_splitter.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) + self.right_container.setMinimumWidth(440) + right_container_layout = QVBoxLayout(self.right_container) - # 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 + right_content_layout = QVBoxLayout(self.right_content_widget) self.rows_container = QWidget() - self.rows_layout = QVBoxLayout() - self.rows_layout.setSpacing(10) - self.rows_container.setLayout(self.rows_layout) + self.rows_layout = QVBoxLayout(self.rows_container) 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 = QHBoxLayout(buttons_widget) 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, self.button2, self.button3 = QPushButton("Process"), QPushButton("Clear"), QPushButton("Analysis") + for btn in [self.button1, self.button2, self.button3]: + btn.setMinimumSize(100, 40) + buttons_layout.addWidget(btn) self.button1.setVisible(False) - self.button3.setVisible(False) - + self.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) + self.main_h_splitter.addWidget(self.left_v_splitter) + self.main_h_splitter.addWidget(self.right_container) - # 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) + self.main_h_splitter.setSizes([600, 400]) + self.left_v_splitter.setSizes([300, 700]) - # Initial build + self.progress_update_signal.connect(self.update_file_progress) self.update_sections(0) - + #NOTE: leave this here for now + # def check_memory_leak(self): + # # 2. Take a snapshot + # snapshot = tracemalloc.take_snapshot() + + # # 3. Filter to show the top 10 biggest "stayers" + # top_stats = snapshot.statistics('lineno') + + # print("[ Top 10 Memory Consumers ]") + # for stat in top_stats[:10]: + # print(stat) + def create_menu_bar(self): '''Menu Bar at the top of the screen''' @@ -4745,11 +4727,22 @@ class MainApplication(QMainWindow): 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") @@ -4809,41 +4802,114 @@ class MainApplication(QMainWindow): def clear_all(self): - - self.cancel_task() - + """ + 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 + 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() - - # Clear the bubble layout 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() + + # 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 = {} - if hasattr(self, "selected_paths"): - self.selected_paths = [] - if hasattr(self, "selected_path"): - self.selected_path = None + self.metadata_cache = {} - # Clear file data - self.bubble_widgets.clear() - self.statusBar().clearMessage() + if hasattr(self, "selected_paths"): self.selected_paths = [] + if hasattr(self, "selected_path"): self.selected_path = None - self.raw_haemo_dict = None - self.config_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.valid_dict = None - - # Reset any visible UI elements + self.button1.setText("Process") + self.button1.clicked.connect(self.on_run_task) self.button1.setVisible(False) self.button3.setVisible(False) - self.top_left_widget.clear() + + self.statusBar().showMessage("All data has been cleared.") + + #NOTE: leave this here for now + # self.check_memory_leak() + # self.find_referrers() + + # snapshot2 = tracemalloc.take_snapshot() + + # # 4. Show the "Compare" - This shows what REFUSED to die + # stats = snapshot2.compare_to(snapshot1, 'lineno') + # print("[ Memory that stayed after Clear ]") + # for stat in stats[:10]: + # print(stat) + # print("Top 10 growing object types in RAM:") + # objgraph.show_most_common_types(limit=10) + + + def reset_window_layout(self): + """ + Snaps all draggable splitters back to their default proportional positions. + """ + total_width = self.main_h_splitter.width() + left_w = int(total_width * 26 / 45) + right_w = total_width - left_w + self.main_h_splitter.setSizes([left_w, right_w]) + + total_height = self.left_v_splitter.height() + top_h = int(total_height * 0.30) + bottom_h = total_height - top_h + self.left_v_splitter.setSizes([top_h, bottom_h]) + + self.statusBar().showMessage("Window layout reset to default.", 2000) def open_launcher_window(self): @@ -4905,66 +4971,68 @@ class MainApplication(QMainWindow): self.events.show() def open_file_dialog(self): - file_path, _ = QFileDialog.getOpenFileName( - self, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)" - ) + 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) - + self._load_files_into_pipeline([os.path.normpath(file_path)]) def open_folder_dialog(self): folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", "") - if folder_path: - snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")] + snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).glob("*.snirf")] + self._load_files_into_pipeline(snirf_files) - 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) + def _load_files_into_pipeline(self, file_paths): + if not file_paths: + return - 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") + # 1. Warm up the executor if needed + if not hasattr(self, 'file_executor') or self.file_executor is None: + self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1) + + # 2. Track this session to prevent ghost updates + if not hasattr(self, 'loading_session_id'): self.loading_session_id = 0 + self.loading_session_id += 1 + current_session = self.loading_session_id + + # 3. Setup internal tracking if not exists + if not hasattr(self, 'bubble_widgets'): self.bubble_widgets = {} + if not hasattr(self, 'selected_paths'): self.selected_paths = [] + if not hasattr(self, 'metadata_cache'): self.metadata_cache = {} + # Filter out files already in the UI to avoid duplicates + new_files = [p for p in file_paths if p not in self.selected_paths] + if not new_files: + return + + # Update the pending count for the current load batch + if not hasattr(self, 'pending_files_count'): self.pending_files_count = 0 + self.pending_files_count += len(new_files) + + for path in new_files: + self.selected_paths.append(path) + + # Create the UI Bubble (Disconnected by default) + display_name = os.path.basename(path) + bubble = ProgressBubble(display_name, path) + bubble.setCursor(Qt.CursorShape.WaitCursor) + bubble.set_loading_state(True) + + self.bubble_widgets[path] = bubble + self.bubble_layout.addWidget(bubble) + + # 4. Queue the background work + future = self.file_executor.submit(_extract_metadata_worker, path) + # Use lambda with defaults to freeze the path and session at this moment + future.add_done_callback( + lambda f, p=path, s=current_session: self._on_metadata_ready(f, p, s) + ) + self.button1.setVisible(True) + self.statusBar().showMessage(f"Loading {len(new_files)} new file(s)...") + # TODO: Is this needed? def open_multiple_folders_dialog(self): while True: folder_path = QFileDialog.getExistingDirectory(self, "Select Folder") @@ -4982,13 +5050,13 @@ class MainApplication(QMainWindow): 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") + # 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 @@ -5045,12 +5113,25 @@ class MainApplication(QMainWindow): 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 + abs_path = str(Path(full_path).resolve()) + rel_path = str(PurePosixPath(os.path.relpath(abs_path, project_dir))) + rel_metadata[rel_path] = meta + except Exception as e: + print(f"Metadata conversion failed for {full_path}: {e}") + + print(rel_metadata) + 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, "config_dict": self.config_dict, "epochs_dict": self.epochs_dict, "fig_bytes_dict": self.fig_bytes_dict, @@ -5096,7 +5177,6 @@ class MainApplication(QMainWindow): QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}") - def load_project(self): filename, _ = QFileDialog.getOpenFileName( self, "Load Project", "", "FLARE Project (*.flare)" @@ -5108,17 +5188,26 @@ class MainApplication(QMainWindow): with open(filename, "rb") as f: data = pickle.load(f) - # Check for saves prior to 1.2.0 - if "version" not in data: - print(self.incompatible_save_bypass) - if self.incompatible_save_bypass: - QMessageBox.warning(self, "Warning - FLARES", f"This project was saved in an earlier version of FLARES (<=1.1.7) and is potentially not compatible with this version. " - "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.") - else: - QMessageBox.critical(self, "Error - FLARES", f"This project was saved in an earlier version of FLARES (<=1.1.7) and is potentially not compatible with this version. " - "The file can attempt to be loaded if 'Incompatible Save Bypass' is selected in the 'Preferences' menu.") - return + # Check for potentially broken saves + checks = [ + ("version", "<=1.1.7"), + ("file_metadata", "<=1.2.2") + ] + + 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", {}) @@ -5133,6 +5222,13 @@ class MainApplication(QMainWindow): project_dir = Path(filename).parent + saved_cache = data.get("file_metadata", {}) + self.metadata_cache = {} + + 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"]] @@ -5189,7 +5285,6 @@ class MainApplication(QMainWindow): continue value = config[name] - print(f"Restoring {name} = {value}") widget = widget_info["widget"] w_type = widget_info.get("type") @@ -5259,8 +5354,8 @@ class MainApplication(QMainWindow): 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) + bubble.set_loading_state(True) + bubble.setCursor(Qt.CursorShape.WaitCursor) self.bubble_widgets[full_path] = bubble @@ -5276,46 +5371,50 @@ class MainApplication(QMainWindow): 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 = [] - # Clear old + self.bubble_widgets = {} + 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 - + # Process the file list 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 + 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) - self.bubble_widgets[file_path] = bubble + # 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) - row = index // cols - col = index % cols - self.bubble_layout.addWidget(bubble, row, col) + # Add to layout + self.bubble_layout.addWidget(bubble, index, 1) - self.statusBar().showMessage(f"{len(file_list)} files loaded from from {os.path.abspath(filenames)}.") + # 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): @@ -5351,13 +5450,15 @@ class MainApplication(QMainWindow): 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} - """ + lines = [ + f"File: {os.path.basename(file_path)}", + f"Size: {size:,} bytes", + f"Created: {created}", + f"Modified: {modified}", + f"Full Path: {file_path}\n", + ] + + info = "\n".join(lines) if snirf_info is None: info += f"\nSNIRF Metadata could not be loaded!" @@ -5453,11 +5554,11 @@ class MainApplication(QMainWindow): 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 + # 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() @@ -5610,36 +5711,40 @@ class MainApplication(QMainWindow): 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) - ) + # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed + # # validate + # for i in snirf_files: + # x_coords = set() + # y_coords = set() + # z_coords = set() + # raw = read_raw_snirf(i) + # dig = raw.info.get('dig', None) + # if dig is not None: + # for point in dig: + # if point['kind'] == 3: + # coord = point['r'] + # x_coords.add(coord[0]) + # y_coords.add(coord[1]) + # z_coords.add(coord[2]) + # print(f"Coord: {coord}") + # is_2d = ( + # all(abs(x) < 1e-6 for x in x_coords) or + # all(abs(y) < 1e-6 for y in y_coords) or + # all(abs(z) < 1e-6 for z in z_coords) + # ) - if is_2d: - if self.is_2d_bypass == False: - QMessageBox.critical(None, "Error - 2D Data Detected - FLARES", f"Error: 2 dimensional data was found in {i}. " - "Please update the coordinates using the 'Update optodes in snirf file...' option from the Options menu or by pressing 'F6'. " - "You may also select the '2D Data Bypass' option from the Preferences menu to ignore this warning and process anyway. ") - self.button1.clicked.disconnect(self.cancel_task) - self.button1.setText("Process") - self.button1.clicked.connect(self.on_run_task) - return + # if is_2d: + # if self.is_2d_bypass == False: + # QMessageBox.critical(None, "Error - 2D Data Detected - FLARES", f"Error: 2 dimensional data was found in {i}. " + # "Please update the coordinates using the 'Update optodes in snirf file...' option from the Options menu or by pressing 'F6'. " + # "You may also select the '2D Data Bypass' option from the Preferences menu to ignore this warning and process anyway. ") + # self.button1.clicked.disconnect(self.cancel_task) + # self.button1.setText("Process") + # self.button1.clicked.connect(self.on_run_task) + # return + + # raw.close() + # del raw self.files_total = len(snirf_files) self.files_done = set() @@ -5656,12 +5761,10 @@ class MainApplication(QMainWindow): "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_queue = Queue() + self.progress_queue = Queue() self.result_process = Process( target=run_gui_entry_wrapper, @@ -5832,56 +5935,16 @@ class MainApplication(QMainWindow): 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) - 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 + # 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): @@ -5899,7 +5962,96 @@ class MainApplication(QMainWindow): event.accept() + + def _on_metadata_ready(self, future, file_path, session_id): + try: + data = future.result() + if data: + self.metadata_cache[file_path] = data + self.metadata_processed.emit(file_path, session_id) + except Exception as e: + print(f"Error pre-fetching {file_path}: {e}") + self.metadata_processed.emit(file_path, session_id) + + def _safe_ui_update(self, file_path): + + # 2. Update the Bubble safely + if file_path in self.bubble_widgets: + bubble = self.bubble_widgets[file_path] + # This is now thread-safe! + bubble.set_loading_state(False) + bubble.clicked.connect(self.on_bubble_clicked) + bubble.rightClicked.connect(self.on_bubble_right_clicked) + bubble.setCursor(Qt.CursorShape.PointingHandCursor) + + # 3. Handle the global counter/cleanup + self.pending_files_count -= 1 + if self.pending_files_count <= 0: + self._cleanup_executor() + self.statusbar.showMessage("All files loaded sucessfully.") + + def _cleanup_executor(self): + """Safely shuts down the executor and clears the reference.""" + if hasattr(self, 'file_executor') and self.file_executor is not None: + self.file_executor.shutdown(wait=False) + self.file_executor = None + print("[System] Background worker dismissed. RAM reclaimed.") + + +def _extract_metadata_worker(file_name): + """Runs in the separate worker process. Returns a clean dict.""" + + try: + # 1. Use preload=False! We only need metadata. + raw = read_raw_snirf(file_name, preload=False, verbose="ERROR") + snirf_info = {} + + # 2. Measurement date + snirf_info['Measurement Date'] = str(raw.info.get('meas_date')) + + # 3. Short Channels + try: + short_chans = get_short_channels(raw, max_dist=0.015) + names = list(short_chans.ch_names) + snirf_info['Short Channels'] = f"Likely - {names}" + if len(names) > 6: + snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!" + except: + snirf_info['Short Channels'] = "Unlikely" + + # 4. Distances + dist_vals = source_detector_distances(raw.info) + snirf_info['Source-Detector Distances'] = [ + f"{name}: {d:.4f} m" for name, d in zip(raw.info['ch_names'], dist_vals) + ] + + # 5. Digitization + dig = raw.info.get('dig', None) + if dig is not None: + snirf_info['Digitization Points'] = [ + f"Kind: {p['kind']}, ID: {p['ident']}, Coord: {p['r']}" for p in dig + ] + else: + snirf_info['Digitization Points'] = "Not found" + + # 6. Annotations (using our copy-to-string trick) + if raw.annotations is not None and len(raw.annotations) > 0: + snirf_info['Annotations'] = [ + f"Onset: {o:.2f}s, Duration: {d:.2f}s, Description: {str(desc)}" + for o, d, desc in zip(raw.annotations.onset, raw.annotations.duration, raw.annotations.description) + ] + else: + snirf_info['Annotations'] = "No annotations found" + + # 7. Explicit cleanup inside worker + raw.close() + return snirf_info + + except Exception as e: + print(f"Worker failed on {file_name}: {e}") + return None + def run_gui_entry_wrapper(config, gui_queue, progress_queue): """ Where the processing happens @@ -5908,7 +6060,8 @@ def run_gui_entry_wrapper(config, gui_queue, progress_queue): try: import flares flares.gui_entry(config, gui_queue, progress_queue) - + gui_queue.close() + gui_queue.join_thread() sys.exit(0) except Exception as e: