diff --git a/main.py b/main.py index 69d1243..79154a1 100644 --- a/main.py +++ b/main.py @@ -9,57 +9,43 @@ License: GPL-3.0 # Built-in imports import os import sys -import json import time import pickle import shutil -import platform import traceback import subprocess import configparser import concurrent.futures from queue import Empty -from enum import Enum, auto from pathlib import Path, PurePosixPath from datetime import datetime -from multiprocessing import Process, current_process, freeze_support, Manager, Queue +from multiprocessing import Process, current_process, freeze_support, Queue # External library imports -from matplotlib.figure import Figure -import numpy as np -import pandas as pd import psutil -from src.analysis.groupfunctionalconnectivity import GroupFunctionalConnectivityWidget -from src.analysis.participant import ParticipantViewerWidget -from src.analysis.participantbrain import ParticipantBrainViewerWidget -from src.analysis.participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget -from src.shared.flaresbasewidget import ParamSection, ParameterInputDialog -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 - from mne.io import read_raw_snirf from mne.preprocessing.nirs import source_detector_distances -from mne_nirs.io import write_raw_snirf -from mne.channels import make_dig_montage from mne_nirs.channels import get_short_channels # type: ignore -from mne import Annotations from PySide6.QtWidgets import ( - QApplication, QTextBrowser, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QSplitter, - QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QListView, QMenu, QSpinBox, QProgressBar + QApplication, 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, QEvent, QSize, QPoint, QUrl -from PySide6.QtGui import QAction, QDesktopServices, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem, QImage +from PySide6.QtCore import QThread, Signal, Qt, QTimer, QPoint +from PySide6.QtGui import QAction, QKeySequence, QIcon from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen -from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest 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 ParamSection +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 = """ @@ -91,7 +77,6 @@ folding_bypass = false """ - # Selectable parameters on the right side of the window SECTIONS = [ { @@ -466,2303 +451,6 @@ class ProgressBubble(QWidget): -class FullClickComboBox(QComboBox): - def __init__(self, parent=None): - super().__init__(parent) - self.setEditable(True) - self.lineEdit().setReadOnly(True) - self.lineEdit().installEventFilter(self) - - def eventFilter(self, obj, event): - if obj == self.lineEdit(): - - if event.type() == QEvent.MouseButtonPress: - return True - - if event.type() == QEvent.MouseButtonRelease: - self.showPopup() - return True - - return super().eventFilter(obj, event) - - - -class FlaresBaseWidget(QWidget): - def __init__(self, caller): - super().__init__() - self.caller = caller - self.haemo_dict = None - self._updating_checkstates = False - self.participant_map = {} - self.show_all_events = True - - # These will be defined by the children, but we'll - # initialize them as None so the code doesn't crash. - self.participant_dropdown = None - self.event_dropdown = None - self.image_index_dropdown = None - - - def _create_multiselect_dropdown(self, items): - combo = FullClickComboBox() - combo.setView(QListView()) - model = QStandardItemModel() - combo.setModel(model) - combo.setEditable(True) - combo.lineEdit().setReadOnly(True) - combo.lineEdit().setPlaceholderText("Select...") - - # Setup internal items - dummy = QStandardItem("") - dummy.setFlags(Qt.ItemIsEnabled) - model.appendRow(dummy) - - toggle = QStandardItem("Toggle Select All") - toggle.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - toggle.setData(Qt.Unchecked, Qt.CheckStateRole) - model.appendRow(toggle) - - for text in items: - item = QStandardItem(text) - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - item.setData(Qt.Unchecked, Qt.CheckStateRole) - model.appendRow(item) - - # Handle clicking the view directly - def on_view_clicked(index): - item = model.itemFromIndex(index) - if item.isCheckable(): - new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked - item.setCheckState(new_state) - combo.view().pressed.connect(on_view_clicked) - - # Logic for "Select All" and Signal Propagation - def on_item_changed(item): - if getattr(self, '_updating_checkstates', False): - return - self._updating_checkstates = True - - normal_items = [model.item(i) for i in range(2, model.rowCount())] - - if item == toggle: - state = toggle.checkState() - for i in normal_items: - i.setCheckState(state) - else: - all_checked = all(i.checkState() == Qt.Checked for i in normal_items) - toggle.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) - - # Trigger the widget's update logic via the existing signal - combo.currentIndexChanged.emit(combo.currentIndex()) - self._updating_checkstates = False - - model.itemChanged.connect(on_item_changed) - combo.setInsertPolicy(QComboBox.NoInsert) - return combo - - - # def _get_checked_items(self, combo): - # model = combo.model() - # checked = [] - # for i in range(2, model.rowCount()): # Start at 2 to skip dummy/toggle - # item = model.item(i) - # if item.checkState() == Qt.Checked: - # checked.append(item.text()) - # return checked - - def _get_checked_items(self, combo=None): - target = combo if combo is not None else getattr(self, 'participant_dropdown', None) - - if target is None or target.model() is None: - return [] - - model = target.model() - checked_items = [] - - # Exclusion list: any item text that should never be treated as data - forbidden = {"Toggle All", "Select All", "", "Toggle"} - - for row in range(model.rowCount()): - item = model.item(row) - if item.checkState() == Qt.CheckState.Checked: - text = item.text() - # Only add if it's not a 'UI control' item - if text not in forbidden and not text.startswith("Toggle"): - checked_items.append(text) - - return checked_items - - - def update_participant_dropdown_label(self, combo=None): - """ - Handles label updates for ANY participant dropdown. - If 'combo' is None, it defaults to the standard self.participant_dropdown. - """ - if isinstance(combo, int): - combo = None - - # 1. Figure out which dropdown we are talking to - target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None) - - if target_combo is None: - return # Safety check: nothing to update - - # 2. Get the checked items and format the text - selected = self._get_checked_items(target_combo) - if not selected: - target_combo.lineEdit().setText("") - else: - # Extract just "Participant N" - selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected] - target_combo.lineEdit().setText(", ".join(selected_short)) - - # 3. Conditional trigger for event updates - # We only update events if we aren't in one of the excluded viewers - excluded_viewers = { - "ParticipantViewer", - "ParticipantFoldChannels", - "ExportDataAsCSVViewer", - } - - if getattr(self, "caller", None) not in excluded_viewers: - self._update_event_dropdown() - - - def update_image_index_dropdown_label(self): - selected = self._get_checked_items(self.image_index_dropdown) - if not selected: - self.image_index_dropdown.lineEdit().setText("") - else: - # Only show the index part - index_labels = [s.split(" ")[0] for s in selected] - self.image_index_dropdown.lineEdit().setText(", ".join(index_labels)) - - - def _update_event_dropdown(self): - is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b') - - bypass = False - main_win = next((w for w in QApplication.topLevelWidgets() - if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None) - if main_win: - bypass = getattr(main_win, "missing_events_bypass", False) - - if is_split_group: - names_a = self._get_checked_items(self.participant_dropdown_a) - names_b = self._get_checked_items(self.participant_dropdown_b) - - if not names_a or not names_b: - self._clear_event_dropdown() - return - - map_a = getattr(self, 'participant_map_a', {}) - rev_a = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_a.items()} - sets_a = [] - for n in names_a: - raw = self.haemo_dict.get(rev_a.get(n)) - if raw and hasattr(raw, "annotations"): - sets_a.append(set(raw.annotations.description)) - - map_b = getattr(self, 'participant_map_b', {}) - rev_b = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_b.items()} - sets_b = [] - for n in names_b: - raw = self.haemo_dict.get(rev_b.get(n)) - if raw and hasattr(raw, "annotations"): - sets_b.append(set(raw.annotations.description)) - - if not sets_a or not sets_b: - self._clear_event_dropdown() - return - - if not bypass: - final_annotations = set.intersection(*(sets_a + sets_b)) - else: - all_events_a = {event for s in sets_a for event in s} - all_events_b = {event for s in sets_b for event in s} - - valid_a = set() - for event in all_events_a: - count = sum(1 for s in sets_a if event in s) - if count >= 2: - valid_a.add(event) - - valid_b = set() - for event in all_events_b: - count = sum(1 for s in sets_b if event in s) - if count >= 2: - valid_b.add(event) - - final_annotations = valid_a.intersection(valid_b) - - else: - names = self._get_checked_items(self.participant_dropdown) - if not names: - self._clear_event_dropdown() - return - - map_single = getattr(self, 'participant_map', {}) - rev_single = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_single.items()} - all_sets = [] - for n in names: - raw = self.haemo_dict.get(rev_single.get(n)) - if raw and hasattr(raw, "annotations"): - all_sets.append(set(raw.annotations.description)) - - if not all_sets: - self._clear_event_dropdown() - return - - if not bypass: - final_annotations = set.intersection(*all_sets) - else: - final_annotations = set.union(*all_sets) - - self.event_dropdown.clear() - self.event_dropdown.addItem("") - for ann in sorted(final_annotations): - self.event_dropdown.addItem(ann) - - def _clear_event_dropdown(self): - if hasattr(self, 'event_dropdown'): - self.event_dropdown.clear() - self.event_dropdown.addItem("") - - - def _connect_select_all_toggle(self, toggle_item, model): - """Helper function to connect the Select All functionality.""" - normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle - - def on_item_changed(item): - if self._updating_checkstates: - return - self._updating_checkstates = True - - if item == toggle_item: - all_checked = all(i.checkState() == Qt.Checked for i in normal_items) - if all_checked: - for i in normal_items: - i.setCheckState(Qt.Unchecked) - toggle_item.setCheckState(Qt.Unchecked) - else: - for i in normal_items: - i.setCheckState(Qt.Checked) - toggle_item.setCheckState(Qt.Checked) - - else: - # When normal items change, update toggle item - all_checked = all(i.checkState() == Qt.Checked for i in normal_items) - toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked) - - if hasattr(self, 'participant_dropdown_a') and model == self.participant_dropdown_a.model(): - self.update_participant_dropdown_label(self.participant_dropdown_a) - elif hasattr(self, 'participant_dropdown_b') and model == self.participant_dropdown_b.model(): - self.update_participant_dropdown_label(self.participant_dropdown_b) - - # Update label text immediately after change - if self.participant_dropdown: - self.update_participant_dropdown_label() - - self._updating_checkstates = False - - model.itemChanged.connect(on_item_changed) - - - - def update_participant_list_for_group(self, group_name=None, combo=None): - - target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None) - if not target_combo: - return - - if isinstance(group_name, int) and combo is None: - target_group = self.group_dropdown.currentText() - elif group_name is not None: - target_group = group_name - else: - # If we have no group_name, look up the text from the correct dropdown - if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a: - target_group = self.group_a_dropdown.currentText() - elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b: - target_group = self.group_b_dropdown.currentText() - else: - target_group = self.group_dropdown.currentText() - - if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a: - self.participant_map_a = {} - active_map = self.participant_map_a - elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b: - self.participant_map_b = {} - active_map = self.participant_map_b - else: - self.participant_map = {} - active_map = self.participant_map - - # 4. Refresh the Model - model = target_combo.model() - model.clear() - - for text in ["", "Toggle Select All"]: - item = QStandardItem(str(text)) - if text == "Toggle Select All": - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - item.setData(Qt.Unchecked, Qt.CheckStateRole) - toggle_ref = item - else: - item.setFlags(Qt.ItemIsEnabled) - model.appendRow(item) - - # 5. Populate Data - if str(target_group) == "": - target_combo.setEnabled(False) - self.update_participant_dropdown_label(combo=target_combo) - return - - target_combo.setEnabled(True) - # Get file paths (handles target_group as int or str) - group_file_paths = self.group_to_paths.get(target_group, []) - - for i, file_path in enumerate(group_file_paths, start=1): - short_label = f"Participant {i}" - display_label = f"{short_label} ({os.path.basename(file_path)})" - active_map[file_path] = short_label - - item = QStandardItem(display_label) - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) - item.setData(Qt.Unchecked, Qt.CheckStateRole) - model.appendRow(item) - - self._connect_select_all_toggle(toggle_ref, model) - self.update_participant_dropdown_label(combo=target_combo) - - - - - - - - - -class ClickableLabel(QLabel): - def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): - super().__init__() - self._pixmap_full = full_pixmap - self.setPixmap(thumbnail_pixmap) - self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setFixedSize(thumbnail_pixmap.size()) - self.setStyleSheet("border: 1px solid gray; margin: 2px;") - - def mousePressEvent(self, event): - #TODO: This will use 3MB or RAM for every image that gets opened, and this RAM is not cleared when the expanded view is closed but only when the parent gets closed. - viewer = QWidget() - viewer.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - viewer.setWindowTitle("Expanded View") - layout = QVBoxLayout(viewer) - label = QLabel() - label.setPixmap(self._pixmap_full) - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(label) - viewer.resize(1000, 800) - viewer.show() - self._expanded_viewer = viewer # keep reference alive - - - - - - - - - - -class MultiProgressDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("fOLD Analysis Progress") - self.setFixedWidth(400) - self.setWindowModality(Qt.WindowModality.NonModal) - self.layout = QVBoxLayout(self) - self.bars = {} - - def add_participant(self, label, total_steps): - clean_key = str(label).strip() - label_widget = QLabel(f"Analyzing {clean_key}...") - pbar = QProgressBar() - pbar.setMinimum(0) - pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer - pbar.setValue(0) - - self.layout.addWidget(label_widget) - self.layout.addWidget(pbar) - self.bars[label] = pbar - - def update_bar(self, label, value): - if label in self.bars: - # Force integers to prevent QProgressBar from breaking or flickering - self.bars[label].setValue(int(value)) - - - -def single_participant_worker(file_path, raw_data, result_queue, progress_queue): - """ Runs inside its own dedicated process """ - p_name = os.path.basename(file_path) - try: - import flares as flares - # Perform the heavy fold_channels logic - channel_results = flares.fold_channels(raw_data, p_name, progress_queue) - - # Hand back results and signal completion - result_queue.put({file_path: channel_results}) - progress_queue.put(p_name) - - except Exception as e: - progress_queue.put(f"ERROR: {p_name} - {str(e)}") - - - - -def get_landmark_color_map(): - """Generates the unified 40-color map for fOLD landmarks.""" - landmarks = [ - "1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex", - "3 - Primary Somatosensory Cortex", "4 - Primary Motor Cortex", - "5 - Somatosensory Association Cortex", "6 - Pre-Motor and Supplementary Motor Cortex", - "7 - Somatosensory Association Cortex", "8 - Includes Frontal eye fields", - "9 - Dorsolateral prefrontal cortex", "10 - Frontopolar area", - "11 - Orbitofrontal area", "17 - Primary Visual Cortex (V1)", - "18 - Visual Association Cortex (V2)", "19 - V3", "20 - Inferior Temporal gyrus", - "21 - Middle Temporal gyrus", "22 - Superior Temporal Gyrus", - "23 - Ventral Posterior cingulate cortex", "24 - Ventral Anterior cingulate cortex", - "25 - Subgenual cortex", "32 - Dorsal anterior cingulate cortex", - "37 - Fusiform gyrus", "38 - Temporopolar area", - "39 - Angular gyrus, part of Wernicke's area", "40 - Supramarginal gyrus part of Wernicke's area", - "41 - Primary and Auditory Association Cortex", "42 - Primary and Auditory Association Cortex", - "43 - Subcentral area", "44 - pars opercularis, part of Broca's area", - "45 - pars triangularis Broca's area", "46 - Dorsolateral prefrontal cortex", - "47 - Inferior prefrontal gyrus", "48 - Retrosubicular area", "Brain_Outside" - ] - # Sort logically - landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf'))) - - cmap1 = plt.get_cmap('tab20') - cmap2 = plt.get_cmap('tab20b') - colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] - - return {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)} - -import numpy as np -import matplotlib.pyplot as plt -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas -from PySide6.QtWidgets import QToolTip -from PySide6.QtCore import QPoint -import traceback - - -import numpy as np -import matplotlib.pyplot as plt -import matplotlib.image as mpimg # CRITICAL: For loading the PNG asset natively -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas -import traceback - -class StaticChannelCanvas(FigureCanvas): - """The Pop-up Window Canvas. - Renders the interactive pie chart on the left, and a matching PNG image on the right. - """ - def __init__(self, channel_name, data_list, color_map, image_path=None, parent=None): - # Create a 1-row, 2-column subplot array - # figsize=(11.0, 5.5) creates a wide 2:1 widescreen aspect window layout - self.fig, self.ax = plt.subplots(1, 2, figsize=(11.0, 5.5)) - super().__init__(self.fig) - self.setParent(parent) - - self.setMouseTracking(True) - - # --- 1. DATA PREPARATION --- - self.wedge_data_list = list(data_list) - total_specificity = sum(d['Specificity'] for d in self.wedge_data_list) - if total_specificity < 100.0: - remainder = 100.0 - total_specificity - if remainder > 0.01: - self.wedge_data_list.append({ - 'Landmark': 'Other / Unclassified Regions', - 'Specificity': remainder - }) - - self.specificities = [d['Specificity'] for d in self.wedge_data_list] - self.landmarks = [d['Landmark'] for d in self.wedge_data_list] - self.colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in self.landmarks] - self.labels = [f"{lm.split(' - ')[0]}" if 'Other' not in lm and lm != 'Brain_Outside' else 'Other' if 'Other' in lm else 'B' for lm in self.landmarks] - - # --- 2. LEFT SUBPLOT: PIE CHART --- - # Note we explicitly target self.ax[0] now - self.wedges, self.texts, self.autotexts = self.ax[0].pie( - self.specificities, - autopct='%1.1f%%', - startangle=90, - labels=self.labels, - colors=self.colors, - textprops={'fontsize': 10, 'fontweight': 'bold'}, - labeldistance=1.1 - ) - self.ax[0].axis('equal') - - # --- 3. RIGHT SUBPLOT: PNG IMAGE DISPLAY --- - # Note we explicitly target self.ax[1] now - if image_path: - try: - img = mpimg.imread(image_path) - self.ax[1].imshow(img) - except Exception as e: - self.ax[1].text(0.5, 0.5, f"Failed to load image:\n{e}", - ha='center', va='center', fontsize=10, color='red') - else: - # Fallback message if no image path is passed down - self.ax[1].text(0.5, 0.5, "No Reference Image\nProvided", - ha='center', va='center', fontsize=12, fontweight='bold', color='#777') - - # Completely hide the background grid, spines, and axis lines for the image box - self.ax[1].axis('off') - - # --- 4. CANVAS TEXT OVERLAY --- - # Main Title centered globally over both subplots - self.fig.suptitle(channel_name, fontsize=16, fontweight='bold', y=0.97) - - # Shared info box text overlay centered horizontally across the whole window figure - self.info_text = self.ax[0].text( - 0.5, 0.04, "", - transform=self.fig.transFigure, - ha="center", va="bottom", - fontsize=12, fontweight="bold", - bbox=dict(boxstyle="round,pad=0.5", facecolor="#fdfdfd", edgecolor="#bbb", alpha=0.95) - ) - self.info_text.set_visible(False) - - self.currently_exploded_idx = None - - # Layout space optimization - self.fig.subplots_adjust(left=0.05, bottom=0.1, right=0.95, top=0.85, wspace=0.2) - self.draw() - - self.mpl_connect('motion_notify_event', self._on_hover) - - def _on_hover(self, event): - try: - # FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart - if event.inaxes != self.ax[0]: - if self.currently_exploded_idx is not None: - self._reset_wedges() - self.info_text.set_visible(False) - self.currently_exploded_idx = None - self.draw_idle() - return - - hovered_index = None - for idx, wedge in enumerate(self.wedges): - contained, _ = wedge.contains(event) - if contained: - hovered_index = idx - break - - if hovered_index is not None: - if self.currently_exploded_idx != hovered_index: - self.currently_exploded_idx = hovered_index - self._explode_wedge(hovered_index) - - displayed_pct = self.autotexts[hovered_index].get_text() - full_desc = self.landmarks[hovered_index] - - self.info_text.set_text(f"{full_desc} | {displayed_pct}") - self.info_text.set_visible(True) - self.draw_idle() - else: - if self.currently_exploded_idx is not None: - self._reset_wedges() - self.info_text.set_visible(False) - self.currently_exploded_idx = None - self.draw_idle() - - except Exception as err: - print("[ERROR] Internal failure inside _on_hover loop:") - traceback.print_exc() - - def _explode_wedge(self, index_to_expand): - changed = False - for idx, wedge in enumerate(self.wedges): - if idx == index_to_expand: - theta = np.deg2rad((wedge.theta1 + wedge.theta2) / 2.0) - explode_distance = 0.08 - new_x = explode_distance * np.cos(theta) - new_y = explode_distance * np.sin(theta) - if wedge.center != (new_x, new_y): - wedge.set_center((new_x, new_y)) - changed = True - else: - if wedge.center != (0.0, 0.0): - wedge.set_center((0.0, 0.0)) - changed = True - if changed: - self.draw_idle() - - def _reset_wedges(self): - changed = False - for wedge in self.wedges: - if wedge.center != (0.0, 0.0): - wedge.set_center((0.0, 0.0)) - changed = True - if changed: - self.draw_idle() - - -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure -from PySide6.QtWidgets import QDialog, QVBoxLayout -from PySide6.QtCore import Qt - -class StandaloneLegendDialog(QWidget): - def __init__(self, canvas_engine, title_prefix, parent=None): - super().__init__(None) - self.setWindowTitle("Full View - Brodmann Legend") - self.setMinimumSize(500, 600) - self.resize(500, 900) - - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - - # Reuse your exact card creation method to render inside the popup window - legend_card = canvas_engine.create_legend_card(title_prefix, self) - layout.addWidget(legend_card) - - -class InteractiveParticipantGridCanvas(FigureCanvas): - """The Big Grid Canvas. - Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation. - """ - def __init__(self, channels_data, color_map, is_fullscreen_copy=False, parent=None): - self.channels_data = channels_data - self.color_map = color_map - self.is_fullscreen_copy = is_fullscreen_copy - - num_channels = len(channels_data) - - # --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS --- - target_ratio = 16 / 9 - best_cols = 4 - min_ratio_error = float('inf') - - # Test configurations from 4 columns up to the total number of channels - for test_cols in range(4, num_channels + 1): - test_rows = (num_channels + test_cols - 1) // test_cols - - # Approximate the visual aspect ratio based on cell dimensions - # Mini charts are slightly wider than tall, roughly 1.15 to 1.0 factor - current_ratio = (test_cols * 1.15) / (test_rows * 1.0) - error = abs(current_ratio - target_ratio) - - if error < min_ratio_error: - min_ratio_error = error - best_cols = test_cols - - cols = best_cols - rows = (num_channels + cols - 1) // cols - - # Base figure sizing dynamically scales off the optimal matrix constraints - if is_fullscreen_copy: - # Maximized views stretch cleanly across standard display panels - figsize = (14.0, 14.0 / target_ratio) - else: - # Standard thumbnail views scaled down for participant cards - figsize = (7.5, 7.5 / target_ratio) - - self.fig = Figure(figsize=figsize) - - super().__init__(self.fig) - self.setParent(parent) - - self.axes_data_registry = {} - - for idx, (channel_name, data_list) in enumerate(channels_data.items()): - ax = self.fig.add_subplot(rows, cols, idx + 1) - - padded_data_list = list(data_list) - total_specificity = sum(d['Specificity'] for d in padded_data_list) - if total_specificity < 100.0: - remainder = 100.0 - total_specificity - if remainder > 0.01: - padded_data_list.append({ - 'Landmark': 'Other / Unclassified Regions', - 'Specificity': remainder - }) - - self.axes_data_registry[ax] = { - 'channel_name': channel_name, - 'data_list': padded_data_list - } - - specificities = [d['Specificity'] for d in padded_data_list] - landmarks = [d['Landmark'] for d in padded_data_list] - colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in landmarks] - labels = [f"{lm.split(' - ')[0]}" if 'Other' not in lm and lm != 'Brain_Outside' else 'O' if 'Other' in lm else 'B' for lm in landmarks] - - # Adjust label sizing dynamically based on how crowded the grid gets - font_sz = 5 if num_channels > 30 else (7 if is_fullscreen_copy else 6) - title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7) - - ax.pie( - specificities, - startangle=90, - colors=colors, - labels=labels, - textprops={'fontsize': font_sz, 'fontweight': 'bold'}, - labeldistance=1.05, - radius=0.75 - ) - - ax.set_title(channel_name, fontsize=title_sz, fontweight='bold', pad=0, y=1.04) - ax.axis('equal') - - # --- FIX: ADAPTIVE PADDING BOUNDS FOR EXTRA DENSE PLOTS --- - # Large multi-column plots require less spacing overhead to prevent clipping label masks - h_sp = 0.35 if num_channels > 30 else 0.18 - w_sp = 0.25 if num_channels > 30 else 0.10 - - if is_fullscreen_copy: - self.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.95, hspace=h_sp, wspace=w_sp) - else: - self.fig.set_layout_engine('constrained') - - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - - self.draw() - self.mpl_connect('button_press_event', self._on_canvas_click) - - - def create_matrix_card(self, title_prefix, layout_to_attach_to): - """Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame.""" - # 1. Create matching styled container card frame - card_frame = QFrame() - card_frame.setFrameShape(QFrame.Shape.StyledPanel) - card_frame.setStyleSheet(""" - QFrame { - background-color: #ffffff; - border: 2px solid #ced4da; - border-radius: 6px; - } - QFrame:hover { - border: 2px solid #4dabf7; - background-color: #f8f9fa; - } - """) - - card_layout = QVBoxLayout(card_frame) - card_layout.setContentsMargins(6, 6, 6, 6) - card_layout.setSpacing(4) - - # 2. Add header matching the summary card type architecture - header = QLabel(f"{title_prefix} - Channels Matrix") - header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529; background: transparent;") - header.setAlignment(Qt.AlignmentFlag.AlignCenter) - card_layout.addWidget(header) - - # 3. Nest this canvas instance cleanly inside the card frame layout - self.setParent(card_frame) - card_layout.addWidget(self) - card_layout.addStretch(0) - - # 4. Make the remaining empty whitespace frame areas trigger the maximization loop - card_frame.mouseReleaseEvent = lambda event: self._open_fullscreen_grid() if event.button() == Qt.MouseButton.LeftButton else None - - # Ensure underlying child mouse hits tunnel downstream properly to our parent container frame - header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - - layout_to_attach_to.addWidget(card_frame) - return card_frame - - def _on_canvas_click(self, event): - # CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window - if event.inaxes is None: - self._open_fullscreen_grid() - return - - # CASE 2: Specific Slice Clicked -> Open standard individual detailed channel popup - clicked_subplot_data = self.axes_data_registry.get(event.inaxes) - if clicked_subplot_data: - self._open_expanded_view( - clicked_subplot_data['channel_name'], - clicked_subplot_data['data_list'] - ) - - def _open_fullscreen_grid(self): - """Creates a maximized dialog window duplicating the full participant matrix view.""" - if getattr(self, 'is_fullscreen_copy', False) or hasattr(self, '_is_fullscreen_flag_set'): - return - fullscreen_window = QWidget(None) - fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View") - fullscreen_window.setWindowFlags( - Qt.WindowType.Window | - Qt.WindowType.WindowMinMaxButtonsHint | - Qt.WindowType.WindowCloseButtonHint - ) - - layout = QVBoxLayout(fullscreen_window) - layout.setContentsMargins(0, 0, 0, 0) - - # Instantiate the copy - large_grid_canvas = InteractiveParticipantGridCanvas( - self.channels_data, - self.color_map, - is_fullscreen_copy=True, - parent=fullscreen_window - ) - - # Explicitly tag the new canvas object internally to block further clicks - large_grid_canvas._is_fullscreen_flag_set = True - - layout.addWidget(large_grid_canvas) - - # Open non-modally so it populates the taskbar and matches OS window behaviors - fullscreen_window.showMaximized() - - # Keep a reference alive on the source canvas so Python doesn't garbage collect the window - if not hasattr(self, '_fullscreen_refs'): - self._fullscreen_refs = [] - self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()] - self._fullscreen_refs.append(fullscreen_window) - - def _calculate_total_brodmann_profile(self, channels_data): - """Sums and normalizes the specificity profile across all channels.""" - totals = {} - num_channels = len(channels_data) - - if num_channels == 0: - return [] - - # Sum up specificities across all channels - for channel_name, data_list in channels_data.items(): - for entry in data_list: - landmark = entry['Landmark'] - specificity = entry['Specificity'] - totals[landmark] = totals.get(landmark, 0.0) + specificity - - # Normalize back down to 100% total scale - normalized_data_list = [] - for landmark, total_val in totals.items(): - # If a landmark hit 20% in 10 channels, it's normalized relative to total channels - normalized_val = total_val / num_channels - if normalized_val > 0.01: - normalized_data_list.append({ - 'Landmark': landmark, - 'Specificity': normalized_val - }) - - # Ensure "Other / Unclassified" fills any remaining precision gap - total_normalized = sum(d['Specificity'] for d in normalized_data_list) - if total_normalized < 100.0: - remainder = 100.0 - total_normalized - if remainder > 0.01: - normalized_data_list.append({ - 'Landmark': 'Other / Unclassified Regions', - 'Specificity': remainder - }) - - return normalized_data_list - - def _open_expanded_view(self, channel_name, data_list): - # 1. Create a plain QWidget with NO parent (None) - # This instantly makes it a top-level desktop window - popup = QWidget(None) - popup.setWindowTitle(f"Channel Specificity Detail - {channel_name}") - - # 2. Add standard window control behaviors - popup.setWindowFlags( - Qt.WindowType.Window | - Qt.WindowType.WindowMinMaxButtonsHint | - Qt.WindowType.WindowCloseButtonHint - ) - - # 3. Build layout out exactly as before - layout = QVBoxLayout(popup) - layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing - - target_png_path = "images/brain.png" - - expanded_canvas = StaticChannelCanvas( - channel_name, - data_list, - self.color_map, - image_path=target_png_path, - parent=popup - ) - - layout.addWidget(expanded_canvas) - popup.resize(900, 520) - - # 4. Display non-modally - popup.show() - - # 5. Keep the reference alive so Python doesn't garbage collect it - if not hasattr(self, '_open_popups'): - self._open_popups = [] - - # Clean up closed windows from our tracking list to save memory - self._open_popups = [w for w in self._open_popups if w.isVisible()] - self._open_popups.append(popup) - - - def create_total_summary_card(self, title_prefix, layout_to_attach_to): - """Generates a highly compact, clickable embedded card on the main window showing aggregated data.""" - # 1. Calculate the normalized profile data payload using the instance's own data - summary_data = self._calculate_total_brodmann_profile(self.channels_data) - - # 2. Create a styled container card frame - card_frame = QFrame() - card_frame.setFrameShape(QFrame.Shape.StyledPanel) - card_frame.setStyleSheet(""" - QFrame { - background-color: #ffffff; - border: 2px solid #ced4da; - border-radius: 6px; - } - QFrame:hover { - border: 2px solid #4dabf7; /* Gives a subtle visual cue that it is clickable */ - background-color: #f8f9fa; /* Slightly shifts background color on hover */ - } - """) - - card_layout = QVBoxLayout(card_frame) - card_layout.setContentsMargins(4, 4, 4, 4) - card_layout.setSpacing(2) - - # Add a clear section header label containing the specific participant identity - header = QLabel(f"{title_prefix} - Total Profile") - header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529;") - header.setAlignment(Qt.AlignmentFlag.AlignCenter) - card_layout.addWidget(header) - - target_png_path = "images/brain.png" - - # 3. Instantiate the canvas with a custom size flag or constraint - # Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode - summary_canvas = StaticChannelCanvas( - channel_name=f"{title_prefix} Combined", - data_list=summary_data, - color_map=self.color_map, - image_path=target_png_path, - parent=card_frame, - ) - - # --- CRITICAL: SHRINK MATPLOTLIB FIGURE ELEMENTS FOR THE EMBEDDED VIEWER --- - # Scale down the underlying canvas container so it doesn't balloon the layout grid - if hasattr(summary_canvas, 'fig'): - summary_canvas.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.92, wspace=0.10) - - for ax in summary_canvas.fig.axes: - for text in ax.texts: - text.set_fontsize(6) - summary_canvas.draw() - - summary_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - card_layout.addWidget(summary_canvas) - card_layout.addStretch(0) - - def handle_card_click(event): - # Only trigger expansion if it's a primary left-click action - if event.button() == Qt.MouseButton.LeftButton: - self._open_expanded_summary_window(title_prefix, summary_data) - - card_frame.mouseReleaseEvent = handle_card_click - - # Prevent clicks on the text/child elements from being swallowed up instead of passing to frame - header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - summary_canvas.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - - # 4. Inject completed card frame container assembly into target window layout position - layout_to_attach_to.addWidget(card_frame) - return card_frame - - - - def create_legend_card(self, title_prefix, layout_to_attach_to): - card = QFrame() - card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }") - - layout = QVBoxLayout(card) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(10) - - header_label = QLabel(f"{title_prefix}\nLandmarks") - header_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - header_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #1a252f; border: none;") - layout.addWidget(header_label) - layout.addSpacing(10) - - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }") - scroll_content = QWidget() - scroll_content.setStyleSheet("background: transparent;") - scroll_layout = QVBoxLayout(scroll_content) - scroll_layout.setSpacing(6) - scroll_layout.setContentsMargins(0, 0, 0, 0) - - - true_color_map = get_landmark_color_map() - - # Iterate over the sorted keys directly from your method - for landmark_text in true_color_map.keys(): - item_row = QHBoxLayout() - item_row.setSpacing(12) - - # Extract the RGBA tuple value assigned by matplotlib - rgba = true_color_map[landmark_text] - # Convert float tuple components (0.0 - 1.0) to standard CSS integer scales (0 - 255) - r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255) - color_hex = f"rgb({r}, {g}, {b})" - - # Format display string nicely: "1 — Primary Somatosensory Cortex" - if " - " in landmark_text: - num, name = landmark_text.split(" - ", 1) - display_string = f"{num} — {name}" - else: - display_string = f"{landmark_text}" - - dot = QLabel() - dot.setFixedSize(14, 14) - dot.setStyleSheet(f"background-color: {color_hex}; border-radius: 7px; border: none;") - - label = QLabel(display_string) - label.setStyleSheet("font-size: 12px; color: #343a40; border: none;") - - item_row.addWidget(dot) - item_row.addWidget(label, 1) - scroll_layout.addLayout(item_row) - - scroll_area.setWidget(scroll_content) - layout.addWidget(scroll_area) - return card - - - def _open_expanded_summary_window(self, title_prefix, summary_data): - """Pops open a beautifully scaled, independent large window when the card is clicked.""" - popup = QWidget(None) - popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}") - popup.setWindowFlags( - Qt.WindowType.Window | - Qt.WindowType.WindowMinMaxButtonsHint | - Qt.WindowType.WindowCloseButtonHint - ) - - layout = QVBoxLayout(popup) - layout.setContentsMargins(10, 10, 10, 10) - - target_png_path = "images/brain.png" - - # This one renders full size (900x520) for analytical reading - expanded_canvas = StaticChannelCanvas( - f"{title_prefix} - All Channels Aggregated", - summary_data, - self.color_map, - image_path=target_png_path, - parent=popup, - ) - - layout.addWidget(expanded_canvas) - popup.resize(950, 550) - popup.show() - - if not hasattr(self, '_summary_popups'): - self._summary_popups = [] - self._summary_popups.append(popup) - - -class ParticipantFoldChannelsWidget(FlaresBaseWidget): - def __init__(self, haemo_dict, cha_dict): - super().__init__("ParticipantFoldChannels") - self.setWindowTitle("FLARES Participant Fold Channels Viewer") - self.haemo_dict = haemo_dict - self.cha_dict = cha_dict - # Create mappings: file_path -> participant label and dropdown display text - self.participant_map = {} # file_path -> "Participant 1" - self.participant_dropdown_items = [] # "Participant 1 (filename)" - - for i, file_path in enumerate(self.haemo_dict.keys(), start=1): - short_label = f"Participant {i}" - display_label = f"{short_label} ({os.path.basename(file_path)})" - self.participant_map[file_path] = short_label - self.participant_dropdown_items.append(display_label) - - self.layout = QVBoxLayout(self) - self.top_bar = QHBoxLayout() - self.layout.addLayout(self.top_bar) - - self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) - self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - - self.index_texts = [ - "0 (Fold Channels)", - # "1 (second image)", - # "2 (third image)", - # "3 (fourth image)", - ] - - self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) - self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) - - self.submit_button = QPushButton("Submit") - self.submit_button.clicked.connect(self.show_fold_images) - - self.top_bar.addWidget(QLabel("Participants:")) - self.top_bar.addWidget(self.participant_dropdown) - self.top_bar.addWidget(QLabel("Fold Type:")) - self.top_bar.addWidget(self.image_index_dropdown) - self.top_bar.addWidget(self.submit_button) - - self.scroll_area = QScrollArea(self) - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.scroll_area.setStyleSheet("QScrollArea { border: none; background-color: #f1f3f5; }") - - # 2. Create the central canvas widget that inside the scroll block - self.scroll_content_widget = QWidget() - self.scroll_content_widget.setStyleSheet("background-color: #f1f3f5;") - - # 3. Establish the strict 3-column layout grid engine - self.grid_layout = QGridLayout(self.scroll_content_widget) - self.grid_layout.setContentsMargins(12, 12, 12, 12) - self.grid_layout.setSpacing(15) # Controls breathing room gaps between cards - - self.grid_layout.setColumnStretch(0, 1) - self.grid_layout.setColumnStretch(1, 1) - self.grid_layout.setColumnStretch(2, 1) - - # 2. Force a uniform structural minimum width per column - # This blocks the dense matrices from hogging space and compressing the summary cards - self.grid_layout.setColumnMinimumWidth(0, 400) - self.grid_layout.setColumnMinimumWidth(1, 400) - self.grid_layout.setColumnMinimumWidth(2, 400) - # ---------------------------------------------------------- - - # Bind them together - self.scroll_area.setWidget(self.scroll_content_widget) - - # Add the self.scroll_area widget to your root layout view frame panel - self.layout.addWidget(self.scroll_area) - - self.thumb_size = QSize(280, 180) - self.showMaximized() - - - def show_fold_images(self): - selected_display_names = self._get_checked_items(self.participant_dropdown) - selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)] - - if not selected_display_names or 0 not in selected_indexes: - return - selected_files = [path for path, label in self.participant_map.items() - if f"{label} ({os.path.basename(path)})" in selected_display_names] - - while self.grid_layout.count(): - item = self.grid_layout.takeAt(0) - widget = item.widget() - if widget: - widget.deleteLater() - - self.global_channels_data = {} - - self.multi_progress = MultiProgressDialog(self) - for file_path in selected_files: - raw_data = self.haemo_dict[file_path] - # Dig out the exact channels list length matching your loop engine logic - hbo_channels = getattr(raw_data.copy().pick(picks='hbo'), "ch_names", []) - total_channels = len(hbo_channels) if hbo_channels else 1 - - self.multi_progress.add_participant(os.path.basename(file_path), total_channels) - - self.multi_progress.show() - - - if current_process().name == 'MainProcess': - - # 2. Setup Multiprocessing Manager - self.manager = Manager() - self.result_queue = self.manager.Queue() - self.progress_queue = self.manager.Queue() - self.active_processes = [] - - # 3. Start ALL processes at once - for file_path in selected_files: - p = Process( - target=single_participant_worker, - args=(file_path, self.haemo_dict[file_path], self.result_queue, self.progress_queue) - ) - p.start() - self.active_processes.append(p) - - # 4. Start the GUI listener - self.completed_count = 0 - self.result_timer = QTimer() - self.result_timer.timeout.connect(self.check_parallel_results) - self.result_timer.start() - - - def check_parallel_results(self): - # Check for progress/completion signals - - while not self.progress_queue.empty(): - msg = self.progress_queue.get() - - # CASE 1: Micro-step channel increment (Tuple tracking) - if isinstance(msg, tuple): - p_name, completed_channels = msg - clean_key = str(p_name).strip() - - if hasattr(self, 'multi_progress') and clean_key in self.multi_progress.bars: - print(completed_channels) - self.multi_progress.update_bar(clean_key, completed_channels) - else: - # DEBUG LOG: This tells us exactly why a bar isn't moving - print(f"[DEBUG WARNING] Progress received for '{clean_key}' but no matching bar was found. Existing bars: {list(self.multi_progress.bars.keys())}") - continue - - # CASE 2: Worker process crashed with an error string - if isinstance(msg, str) and msg.startswith("ERROR"): - print(f"Worker Error: {msg}") - #self.completed_count += 1 # Count as finished so the UI doesn't hang - - # CASE 3: Final clean text string signal indicating complete file closure - elif isinstance(msg, str): - # Max out the progress bar visually on completion - if hasattr(self, 'multi_progress'): - if msg in self.multi_progress.bars: - max_val = self.multi_progress.bars[msg].maximum() - self.multi_progress.update_bar(msg, max_val) - - self.completed_count += 1 # Increment the master task tracker - print(self.completed_count, time.time()) - - # Pull images as they become available - while not self.result_queue.empty(): - result_dict = self.result_queue.get() - self.add_images_to_grid(result_dict) - - # Clean up when all processes are done - if self.completed_count >= len(self.active_processes): - self.result_timer.stop() - - # Close the custom multi-progress window - if hasattr(self, 'multi_progress'): - self.multi_progress.close() - - # Clean up processes - for p in self.active_processes: - if p.is_alive(): - p.join(timeout=1) # Give it a second to wrap up - p.close() # Explicitly close the process object - - # Shut down the Manager process (the source of the 'rogue' process) - if hasattr(self, 'manager'): - self.manager.shutdown() - - self.active_processes = [] - print("Processing fully complete. All resources released.") - - if hasattr(self, 'global_channels_data') and self.global_channels_data: - color_map = get_landmark_color_map() - - # We feed the entire channel pool directly to your existing canvas engine class - global_canvas = InteractiveParticipantGridCanvas(self.global_channels_data, color_map) - - # Create the summary card using your exact visual method - global_card = global_canvas.create_total_summary_card( - title_prefix="Grand Global Layout", - layout_to_attach_to=self.scroll_content_widget.layout() - ) - - # Match your exact layout positioning logic to place it next in the grid - count = self.grid_layout.count() - 1 - row = count // 3 - col = count % 3 - self.grid_layout.addWidget(global_card, row, col) - - legend_title = "Grand Total Brodmann Mapping Profile" - legend_card = global_canvas.create_legend_card( - title_prefix=legend_title, - layout_to_attach_to=self.scroll_content_widget.layout() - ) - - def handle_legend_click(event): - self.active_legend_window = StandaloneLegendDialog(global_canvas, legend_title, self) - self.active_legend_window.show() - - legend_card.mousePressEvent = handle_legend_click - - count = self.grid_layout.count() - row = count // 3 - col = count % 3 - self.grid_layout.addWidget(legend_card, row, col) - - - - - # def add_images_to_grid(self, result_dict): - # """ - # result_dict format: { file_path: {"main": bytes, "legend": bytes} } - # """ - # for file_path, images in result_dict.items(): - - # if self.grid_layout.count() == 0 and "legend" in images: - # self._add_legend_to_grid(images["legend"]) - - # # Create a container for this participant's results - # container = QFrame() - # container.setFrameShape(QFrame.StyledPanel) - # vbox = QVBoxLayout(container) - - # participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) - # title = QLabel(f"{participant_label}") - # title.setAlignment(Qt.AlignCenter) - # vbox.addWidget(title) - - # # We primarily want to show the 'main' plot in the grid - # if "main" in images: - # pixmap = self._bytes_to_pixmap(images["main"]) - # img_label = QLabel() - # # Scale it to fit the thumbnail size defined in __init__ - # img_label.setPixmap(pixmap.scaled( - # self.thumb_size, - # Qt.KeepAspectRatio, - # Qt.SmoothTransformation - # )) - # img_label.setAlignment(Qt.AlignCenter) - - # # Optional: Click to open full size - # img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t) - - # vbox.addWidget(img_label) - - # # Determine grid position (row-major order) - # count = self.grid_layout.count() - # row = count // 3 # 3 columns wide - # col = count % 3 - # self.grid_layout.addWidget(container, row, col) - - def add_images_to_grid(self, result_dict): - color_map = get_landmark_color_map() - - for file_path, channels_data in result_dict.items(): - participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) - - if hasattr(self, 'global_channels_data'): - for ch_name, ch_data in channels_data.items(): - unique_key = f"{participant_label}_{ch_name}" - self.global_channels_data[unique_key] = ch_data - - # 1. Instantiate the background calculation engine matrix - participant_grid_canvas = InteractiveParticipantGridCanvas(channels_data, color_map) - - # 2. Build Card A (Channels Matrix Frame Layout) - # The matrix automatically installs inside its layout box container slot - matrix_card = participant_grid_canvas.create_matrix_card( - title_prefix=participant_label, - layout_to_attach_to=self.scroll_content_widget.layout() # Maps directly to your grid layout - ) - - # Pin Card A to the sequential grid coordinate tracker layout - count = self.grid_layout.count() - 1 # Subtract 1 because widget registration steps index values forward - row = count // 3 - col = count % 3 - self.grid_layout.addWidget(matrix_card, row, col) - - # 3. Build Card B (Total Summary Profile Frame Layout) - summary_card = participant_grid_canvas.create_total_summary_card( - title_prefix=participant_label, - layout_to_attach_to=self.scroll_content_widget.layout() - ) - - # Pin Card B directly next into the 3-column processing loop matrix layout tracker - count = self.grid_layout.count() - 1 - row = count // 3 - col = count % 3 - self.grid_layout.addWidget(summary_card, row, col) - - - def _bytes_to_pixmap(self, png_bytes): - """Converts raw bytes from the multiprocess queue to a QPixmap.""" - image = QImage.fromData(png_bytes) - return QPixmap.fromImage(image) - - def _open_full_size(self, pixmap, title): - """Simple popup to view the image at a readable scale.""" - view = QDialog(self) - view.setWindowTitle(f"Full View - {title}") - layout = QVBoxLayout(view) - label = QLabel() - label.setPixmap(pixmap) - layout.addWidget(label) - view.show() - - def _add_legend_to_grid(self, legend_bytes): - """Helper to put the legend in the first slot.""" - container = QFrame() - container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;") - vbox = QVBoxLayout(container) - - title = QLabel("Brodmann Area Legend") - title.setAlignment(Qt.AlignCenter) - vbox.addWidget(title) - - pixmap = self._bytes_to_pixmap(legend_bytes) - legend_label = QLabel() - # Legends are usually tall, so we scale it differently or keep it smaller - legend_label.setPixmap(pixmap.scaled( - self.thumb_size, - Qt.KeepAspectRatio, - Qt.SmoothTransformation - )) - legend_label.setAlignment(Qt.AlignCenter) - legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend") - - vbox.addWidget(legend_label) - self.grid_layout.addWidget(container, 0, 0) - - -class ExportDataAsCSVViewerWidget(FlaresBaseWidget): - def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict): - super().__init__("ExportDataAsCSVViewer") - self.setWindowTitle("FLARES Export Data As CSV Viewer") - self.haemo_dict = haemo_dict - self.cha_dict = cha_dict - self.df_ind = df_ind - self.design_matrix = design_matrix - self.group = group - self.contrast_results_dict = contrast_results_dict - - # Create mappings: file_path -> participant label and dropdown display text - self.participant_map = {} # file_path -> "Participant 1" - self.participant_dropdown_items = [] # "Participant 1 (filename)" - - for i, file_path in enumerate(self.haemo_dict.keys(), start=1): - short_label = f"Participant {i}" - display_label = f"{short_label} ({os.path.basename(file_path)})" - self.participant_map[file_path] = short_label - self.participant_dropdown_items.append(display_label) - - self.layout = QVBoxLayout(self) - self.top_bar = QHBoxLayout() - self.layout.addLayout(self.top_bar) - - self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) - self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - - self.index_texts = [ - "0 (Export Data to CSV)", - "1 (CSV for SPARKS)", - # "2 (third image)", - # "3 (fourth image)", - ] - - self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) - self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) - - self.submit_button = QPushButton("Submit") - self.submit_button.clicked.connect(self.generate_and_save_csv) - - self.top_bar.addWidget(QLabel("Participants:")) - self.top_bar.addWidget(self.participant_dropdown) - self.top_bar.addWidget(QLabel("Export Type:")) - self.top_bar.addWidget(self.image_index_dropdown) - self.top_bar.addWidget(self.submit_button) - - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll_content = QWidget() - self.grid_layout = QGridLayout(self.scroll_content) - self.scroll.setWidget(self.scroll_content) - self.layout.addWidget(self.scroll) - - self.thumb_size = QSize(280, 180) - self.showMaximized() - - - def generate_and_save_csv(self): - - selected_display_names = self._get_checked_items(self.participant_dropdown) - selected_file_paths = [] - for display_name in selected_display_names: - for fp, short_label in self.participant_map.items(): - expected_display = f"{short_label} ({os.path.basename(fp)})" - if display_name == expected_display: - selected_file_paths.append(fp) - break - - selected_indexes = [ - int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) - ] - - if not selected_file_paths or not selected_indexes: - QMessageBox.warning(self, "Selection Missing", "Please select at least one participant and one export type.") - return - - # 2. ASK ONCE: Select Output Directory - output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports") - - if not output_dir: - print("Export cancelled: No folder selected.") - return - - success_count = 0 - - # Pass the necessary arguments to each method - for file_path in selected_file_paths: - base_filename = os.path.splitext(os.path.basename(file_path))[0] - haemo_obj = self.haemo_dict.get(file_path) - if haemo_obj is None: - continue - - cha = self.cha_dict.get(file_path) - - for idx in selected_indexes: - try: - if idx == 0: - save_path = os.path.join(output_dir, f"{base_filename}_exported.csv") - if cha is not None: - cha.to_csv(save_path) - success_count += 1 - - - elif idx == 1: - # SPARKS Export - save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv") - if haemo_obj is not None: - raw = haemo_obj - data, times = raw.get_data(return_times=True) - ann_col = np.full(times.shape, "", dtype=object) - - if raw.annotations is not None and len(raw.annotations) > 0: - for onset, duration, desc in zip( - raw.annotations.onset, - raw.annotations.duration, - raw.annotations.description - ): - mask = (times >= onset) & (times < onset + duration) - ann_col[mask] = desc - - df = pd.DataFrame(data.T, columns=raw.ch_names) - df.insert(0, "annotation", ann_col) - df.insert(0, "time", times) - df.to_csv(save_path, index=False) - success_count += 1 - - else: - print(f"No method defined for index {idx}") - - except Exception as e: - print(f"Failed to export {file_path} (Type {idx}): {e}") - - # 4. Final Notification - if success_count > 0: - QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}") - - # # If SPARKS export was included, show the Event Window once at the end - # if 1 in selected_indexes: - # win = UpdateEventsWindow( - # parent=self, - # mode=EventUpdateMode.WRITE_JSON, - # caller="Video Alignment Tool" - # ) - # win.show() - - - - - - - -class GroupViewerWidget(FlaresBaseWidget): - def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group): - super().__init__("GroupViewer") - self.setWindowTitle("FLARES Group Viewer") - self.haemo_dict = haemo_dict - self.cha = cha - self.df_ind = df_ind - self.design_matrix = design_matrix - self.contrast_results = contrast_results - self.group = group - self.show_all_events = True - self._updating_checkstates = False - - # Create mappings: file_path -> participant label and dropdown display text - self.participant_map = {} # file_path -> "Participant 1" - self.participant_dropdown_items = [] # "Participant 1 (filename)" - - for i, file_path in enumerate(self.haemo_dict.keys(), start=1): - short_label = f"Participant {i}" - display_label = f"{short_label} ({os.path.basename(file_path)})" - self.participant_map[file_path] = short_label - self.participant_dropdown_items.append(display_label) - - self.layout = QVBoxLayout(self) - self.top_bar = QHBoxLayout() - self.layout.addLayout(self.top_bar) - - self.group_to_paths = {} - for file_path, group_name in self.group.items(): - self.group_to_paths.setdefault(group_name, []).append(file_path) - - self.group_names = sorted(self.group_to_paths.keys()) - - self.group_dropdown = QComboBox() - self.group_dropdown.addItem("") - self.group_dropdown.addItems(self.group_names) - self.group_dropdown.setCurrentIndex(0) - self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group) - - self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) - self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) - self.participant_dropdown.setEnabled(False) - - self.event_dropdown = QComboBox() - self.event_dropdown.addItem("") - - self.index_texts = [ - "0 (GLM Results)", - "1 (Significance)", - "2 (Brain Activity Visualization)", - # "3 (fourth image)", - ] - - self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) - self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) - - self.submit_button = QPushButton("Submit") - self.submit_button.clicked.connect(self.show_brain_images) - - self.top_bar.addWidget(QLabel("Group:")) - self.top_bar.addWidget(self.group_dropdown) - self.top_bar.addWidget(QLabel("Participants:")) - self.top_bar.addWidget(self.participant_dropdown) - self.top_bar.addWidget(QLabel("Event:")) - self.top_bar.addWidget(self.event_dropdown) - self.top_bar.addWidget(QLabel("Image Indexes:")) - self.top_bar.addWidget(self.image_index_dropdown) - self.top_bar.addWidget(self.submit_button) - - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll_content = QWidget() - self.grid_layout = QGridLayout(self.scroll_content) - self.scroll.setWidget(self.scroll_content) - self.layout.addWidget(self.scroll) - - self.thumb_size = QSize(280, 180) - self.showMaximized() - - - - def show_brain_images(self): - import flares as flares - - selected_event = self.event_dropdown.currentText() - if selected_event == "": - selected_event = None - - selected_display_names = self._get_checked_items(self.participant_dropdown) - selected_file_paths = [] - for display_name in selected_display_names: - for fp, short_label in self.participant_map.items(): - expected_display = f"{short_label} ({os.path.basename(fp)})" - if display_name == expected_display: - selected_file_paths.append(fp) - break - - if selected_event: - valid_paths = [] - for fp in selected_file_paths: - raw = self.haemo_dict.get(fp) - # Check if this participant actually has the event in their annotations - if raw is not None and hasattr(raw, "annotations"): - if selected_event in raw.annotations.description: - valid_paths.append(fp) - - selected_file_paths = valid_paths - - selected_indexes = [ - int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) - ] - - if not selected_file_paths: - print("No participants selected.") - return - - # Only keep indexes 0 and 1 that need parameters - parameterized_indexes = { - 0: [ - { - "key": "lower_bound", - "label": "Lower bound + ", - "default": "-0.3", - "type": float, # specify int here - }, - { - "key": "upper_bound", - "label": "Upper bound + ", - "default": "0.8", - "type": float, # specify int here - } - ], - 1: [ - { - "key": "p_value", - "label": "Significance threshold P-value (e.g. 0.05)", - "default": "0.05", - "type": float, - }, - { - "key": "graph_bounds", - "label": "Graph Upper/Lower Limit", - "default": "3.0", - "type": float, - } - ], - 2: [ - { - "key": "show_optodes", - "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", - "default": "all", - "type": str, - }, - { - "key": "t_or_theta", - "label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'", - "default": "theta", - "type": str, - }, - { - "key": "show_text", - "label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE", - "default": "False", - "type": bool, - }, - { - "key": "brain_bounds", - "label": "Graph Upper/Lower Limit", - "default": "1.0", - "type": float, - } - ], - } - - # Inject full_text from index_texts - for idx, params_list in parameterized_indexes.items(): - full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" - for param_info in params_list: - param_info["full_text"] = full_text - - indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} - - param_values = {} - if indexes_needing_params: - dialog = ParameterInputDialog(indexes_needing_params, parent=self) - if dialog.exec_() == QDialog.Accepted: - param_values = dialog.get_values() - if param_values is None: - return - else: - return - - - all_cha = pd.DataFrame() - for file_path in selected_file_paths: - haemo_obj = self.haemo_dict.get(file_path) - - if selected_event: - participant_events = set(haemo_obj.annotations.description) - if selected_event not in participant_events: - print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") - continue - - if haemo_obj is None: - continue - - cha_df = self.cha.get(file_path) - if cha_df is not None: - all_cha = pd.concat([all_cha, cha_df], ignore_index=True) - - # Pass the necessary arguments to each method - file_path = selected_file_paths[0] - p_haemo = self.haemo_dict.get(file_path) - p_design_matrix = self.design_matrix.get(file_path) - - df_group = pd.DataFrame() - - if selected_file_paths: - for file_path in selected_file_paths: - df = self.df_ind.get(file_path) - if df is not None: - df_group = pd.concat([df_group, df], ignore_index=True) - - - for idx in selected_indexes: - if idx == 0: - params = param_values.get(idx, {}) - lower_bound = params.get("lower_bound", None) - upper_bound = params.get("upper_bound", None) - - if lower_bound is None or upper_bound is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - - - flares.plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound) - - elif idx == 1: - params = param_values.get(idx, {}) - p_val = params.get("p_value", None) - graph_bounds = params.get("graph_bounds", None) - - if p_val is None or graph_bounds is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - - all_contrasts = [] - for fp in selected_file_paths: - condition_dfs = self.contrast_results.get(fp, {}) - if selected_event in condition_dfs: - df = condition_dfs[selected_event].copy() - df["ID"] = fp - all_contrasts.append(df) - - if not all_contrasts: - print("No contrast data found for selected participants and event.") - return - - df_contrasts = pd.concat(all_contrasts, ignore_index=True) - flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds) - - elif idx == 2: - params = param_values.get(idx, {}) - show_optodes = params.get("show_optodes", None) - t_or_theta = params.get("t_or_theta", None) - show_text = params.get("show_text", None) - brain_bounds = params.get("brain_bounds", None) - - if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - - raw_list = [self.haemo_dict.get(fp) for fp in selected_file_paths] - - if len(selected_file_paths) > 1: - print(f"Aggregating geometry for {len(selected_file_paths)} participants...") - processed_raw = flares.aggregate_fnirs_group_geometry(raw_list) - else: - processed_raw = raw_list[0].copy().pick(picks="hbo") - - flares.brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds) - - elif idx == 3: - pass - - else: - print(f"No method defined for index {idx}") - - - -class GroupBrainViewerWidget(FlaresBaseWidget): - def __init__(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict): - super().__init__("GroupBrainViewer") - self.setWindowTitle("Group Brain Viewer") - self.haemo_dict = haemo_dict - self.df_ind = df_ind - self.design_matrix = design_matrix - self.group = group - self.contrast_results_dict = contrast_results_dict - - self.group_to_paths = {} - for file_path, group_name in self.group.items(): - self.group_to_paths.setdefault(group_name, []).append(file_path) - - self.group_names = sorted(self.group_to_paths.keys()) - - self.layout = QVBoxLayout(self) - self.top_bar = QHBoxLayout() - self.layout.addLayout(self.top_bar) - - - self.group_a_dropdown = QComboBox() - self.group_a_dropdown.addItem("") - self.group_a_dropdown.addItems(self.group_names) - self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options) - - - self.group_b_dropdown = QComboBox() - self.group_b_dropdown.addItem("") - self.group_b_dropdown.addItems(self.group_names) - self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options) - - - self.event_dropdown = QComboBox() - self.event_dropdown.addItem("") - - self.participant_dropdown_a = self._create_multiselect_dropdown([]) - self.participant_dropdown_a.lineEdit().setPlaceholderText("Select participants (Group A)") - self.participant_dropdown_a.model().itemChanged.connect(self._on_participants_changed) - - - self.participant_dropdown_b = self._create_multiselect_dropdown([]) - self.participant_dropdown_b.lineEdit().setPlaceholderText("Select participants (Group B)") - self.participant_dropdown_b.model().itemChanged.connect(self._on_participants_changed) - - - self.index_texts = [ - "0 (Contrast Image)", - # "1 (3D Brain Contrast)", - # "2 (third image)", - # "3 (fourth image)", - ] - self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) - self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) - - - self.submit_button = QPushButton("Submit") - self.submit_button.clicked.connect(self.show_brain_images) - - - self.top_bar.addWidget(QLabel("Group A:")) - self.top_bar.addWidget(self.group_a_dropdown) - self.top_bar.addWidget(QLabel("Participants (Group A):")) - self.top_bar.addWidget(self.participant_dropdown_a) - self.top_bar.addWidget(QLabel("Group B:")) - self.top_bar.addWidget(self.group_b_dropdown) - self.top_bar.addWidget(QLabel("Participants (Group B):")) - self.top_bar.addWidget(self.participant_dropdown_b) - self.top_bar.addWidget(QLabel("Event:")) - self.top_bar.addWidget(self.event_dropdown) - self.top_bar.addWidget(QLabel("Image Indexes:")) - self.top_bar.addWidget(self.image_index_dropdown) - self.top_bar.addWidget(self.submit_button) - - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll_content = QWidget() - self.grid_layout = QGridLayout(self.scroll_content) - self.scroll.setWidget(self.scroll_content) - self.layout.addWidget(self.scroll) - - self.thumb_size = QSize(280, 180) - self.showMaximized() - - def _update_group_b_options(self): - """Triggered when Group B changes: Update Group A to exclude B's choice""" - selected_b = self.group_b_dropdown.currentText() - - # Refresh Group A and exclude what was just picked in Group B - self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b) - - # Update the participants for Group B - self.update_participant_list_for_group(selected_b, self.participant_dropdown_b) - self._update_event_dropdown() - - def _update_group_a_options(self): - """Triggered when Group A changes: Update Group B to exclude A's choice""" - selected_a = self.group_a_dropdown.currentText() - - # Refresh Group B and exclude what was just picked in Group A - self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a) - - # Update the participants for Group A - self.update_participant_list_for_group(selected_a, self.participant_dropdown_a) - self._update_event_dropdown() - - def _on_participants_changed(self, item=None): - self._update_event_dropdown() - - - def _refresh_group_dropdown(self, dropdown, exclude): - current = dropdown.currentText() - dropdown.blockSignals(True) - dropdown.clear() - dropdown.addItem("") - for group in self.group_names: - if group != exclude: - dropdown.addItem(group) - # Restore previous selection if still valid - if current != "" and current != exclude and dropdown.findText(current) != -1: - dropdown.setCurrentText(current) - else: - dropdown.setCurrentIndex(0) # Reset to "" - dropdown.blockSignals(False) - - - def _get_file_paths_from_labels(self, labels, group_name): - file_paths = [] - - if group_name == self.group_a_dropdown.currentText(): - participant_map = self.participant_map_a - elif group_name == self.group_b_dropdown.currentText(): - participant_map = self.participant_map_b - else: - return [] - - # Reverse map: display label -> file path - reverse_map = { - f"{label} ({os.path.basename(fp)})": fp - for fp, label in participant_map.items() - } - - for label in labels: - file_path = reverse_map.get(label) - if file_path: - file_paths.append(file_path) - - return file_paths - - def show_brain_images(self): - import flares as flares - - selected_event = self.event_dropdown.currentText() - if selected_event == "": - selected_event = None - - # Group A - participants_a = self._get_checked_items(self.participant_dropdown_a) - file_paths_a = self._get_file_paths_from_labels(participants_a, self.group_a_dropdown.currentText()) - - # Group B - participants_b = self._get_checked_items(self.participant_dropdown_b) - file_paths_b = self._get_file_paths_from_labels(participants_b, self.group_b_dropdown.currentText()) - - selected_indexes = [ - int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) - ] - - all_selected_paths = list(set(file_paths_a + file_paths_b)) - - if not all_selected_paths: - print("No participants selected.") - return - - parameterized_indexes = { - 0: [ - { - "key": "show_optodes", - "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", - "default": "all", - "type": str, - }, - { - "key": "t_or_theta", - "label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'", - "default": "theta", - "type": str, - }, - { - "key": "show_text", - "label": "Display informative text on the top left corner about the contrast.", - "default": "True", - "type": bool, - }, - { - "key": "brain_bounds", - "label": "Graph Upper/Lower Limit", - "default": "1.0", - "type": float, - }, - { - "key": "is_3d", - "label": "Should we display the results in a 3D interactive window?", - "default": "True", - "type": bool, - } - ], - } - - - # Inject full_text from index_texts - for idx, params_list in parameterized_indexes.items(): - full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" - for param_info in params_list: - param_info["full_text"] = full_text - - indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} - - param_values = {} - if indexes_needing_params: - dialog = ParameterInputDialog(indexes_needing_params, parent=self) - if dialog.exec_() == QDialog.Accepted: - param_values = dialog.get_values() - if param_values is None: - return - else: - return - - # Build group-level contrast DataFrames - def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame: - group_df = pd.DataFrame() - for fp in file_paths: - print(f"Looking up contrast for: {fp}") - event_con_dict = self.contrast_results_dict.get(fp, {}) - print("Available events for this file:", list(event_con_dict.keys())) - if event and event in event_con_dict: - df = event_con_dict[event] - print(f"Appending contrast df for event: {event}") - group_df = pd.concat([group_df, df], ignore_index=True) - else: - print(f"Event '{event}' not found for {fp}") - return group_df - - print("Selected event:", selected_event) - print("File paths A:", file_paths_a) - print("File paths B:", file_paths_b) - - contrast_df_a = concat_group_contrasts(file_paths_a, selected_event) - contrast_df_b = concat_group_contrasts(file_paths_b, selected_event) - - print("contrast_df_a empty?", contrast_df_a.empty) - print("contrast_df_b empty?", contrast_df_b.empty) - - all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)] - - if len(all_raw_objs) > 1: - processed_raw = flares.aggregate_fnirs_group_geometry(all_raw_objs) - else: - processed_raw = all_raw_objs[0].copy().pick(picks="hbo") - - # Visualizations - for idx in selected_indexes: - if idx == 0: - params = param_values.get(idx, {}) - show_optodes = params.get("show_optodes", None) - t_or_theta = params.get("t_or_theta", None) - show_text = params.get("show_text", None) - brain_bounds = params.get("brain_bounds", None) - is_3d = params.get("is_3d", None) - - if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None: - print(f"Missing parameters for index {idx}, skipping.") - continue - - if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw: - - flares.plot_2d_3d_contrasts_between_groups( - contrast_df_a, - contrast_df_b, - raw_haemo=processed_raw, - group_a_name=self.group_a_dropdown.currentText(), - group_b_name=self.group_b_dropdown.currentText(), - is_3d=is_3d, - t_or_theta=t_or_theta, - show_optodes=show_optodes, - show_text=show_text, - brain_bounds=brain_bounds - ) - else: - print("no") - - - -class ViewerLauncherWidget(QWidget): - def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict, folding_bypass): - super().__init__() - self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}") - - group_dict = { - file_path: config.get("GROUP", "Unknown") # default if GROUP missing - for file_path, config in config_dict.items() - } - - def launch(func, btn, *args): - func(*args) - self._trigger_success(btn) - - layout = QVBoxLayout(self) - - btn1 = QPushButton("Open Participant Viewer") - btn1.clicked.connect(lambda: launch(self.open_participant_viewer, btn1, haemo_dict, fig_bytes_dict)) - btn1.setEnabled(not folding_bypass) - - btn2 = QPushButton("Open Participant Brain Viewer") - btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict)) - btn2.setEnabled(not folding_bypass) - - btn3 = QPushButton("Open Participant Fold Channels Viewer") - btn3.clicked.connect(lambda: launch(self.open_participant_fold_channels_viewer, btn3, haemo_dict, cha_dict)) - - btn7 = QPushButton("Open Functional Connectivity Viewer [BETA]") - btn7.clicked.connect(lambda: launch(self.open_participant_functional_connectivity_viewer, btn7, haemo_dict, epochs_dict)) - btn7.setEnabled(not folding_bypass) - - btn8 = QPushButton("Open Group Functional Connectivity Viewer [BETA]") - btn8.clicked.connect(lambda: launch(self.open_group_functional_connectivity_viewer, btn8, haemo_dict, group_dict, config_dict)) - btn8.setEnabled(not folding_bypass) - - btn4 = QPushButton("Open Inter-Group Viewer") - btn4.clicked.connect(lambda: launch(self.open_group_viewer, btn4, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict)) - btn4.setEnabled(not folding_bypass) - - btn5 = QPushButton("Open Cross Group Brain Viewer") - btn5.clicked.connect(lambda: launch(self.open_group_brain_viewer, btn5, haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) - btn5.setEnabled(not folding_bypass) - - btn6 = QPushButton("Open Export Data As CSV Viewer") - btn6.clicked.connect(lambda: launch(self.open_export_data_as_csv_viewer, btn6, haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) - btn6.setEnabled(not folding_bypass) - - layout.addWidget(btn1) - layout.addWidget(btn2) - layout.addWidget(btn3) - layout.addWidget(btn7) - layout.addWidget(btn8) - layout.addWidget(btn4) - layout.addWidget(btn5) - layout.addWidget(btn6) - - def open_participant_viewer(self, haemo_dict, fig_bytes_dict): - self.participant_viewer = ParticipantViewerWidget(haemo_dict, fig_bytes_dict) - self.participant_viewer.show() - - def open_participant_brain_viewer(self, haemo_dict, cha_dict): - self.participant_brain_viewer = ParticipantBrainViewerWidget(haemo_dict, cha_dict) - self.participant_brain_viewer.show() - - def open_participant_fold_channels_viewer(self, haemo_dict, cha_dict): - self.participant_fold_channels_viewer = ParticipantFoldChannelsWidget(haemo_dict, cha_dict) - self.participant_fold_channels_viewer.show() - - def open_participant_functional_connectivity_viewer(self, haemo_dict, epochs_dict): - self.participant_brain_viewer = ParticipantFunctionalConnectivityWidget(haemo_dict, epochs_dict) - self.participant_brain_viewer.show() - - def open_group_functional_connectivity_viewer(self, haemo_dict, group, config_dict): - self.participant_brain_viewer = GroupFunctionalConnectivityWidget(haemo_dict, group, config_dict) - self.participant_brain_viewer.show() - - def open_group_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group): - self.participant_brain_viewer = GroupViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group) - self.participant_brain_viewer.show() - - def open_group_brain_viewer(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict): - self.participant_brain_viewer = GroupBrainViewerWidget(haemo_dict, df_ind, design_matrix, group, contrast_results_dict) - self.participant_brain_viewer.show() - - def open_export_data_as_csv_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict): - self.export_data_as_csv_viewer = ExportDataAsCSVViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict) - self.export_data_as_csv_viewer.show() - - def _trigger_success(self, button): - """Temporarily adds a green checkmark to the button text.""" - original_text = button.text() - button.setText(f"{original_text} ✔") - button.setStyleSheet("color: green; font-weight: bold;") - - # Revert after 1 second - QTimer.singleShot(1000, lambda: self._revert_button(button, original_text)) - - def _revert_button(self, button, original_text): - button.setText(original_text) - button.setStyleSheet("") - - class MainApplication(QMainWindow): """ diff --git a/src/analysis/exportcsv.py b/src/analysis/exportcsv.py new file mode 100644 index 0000000..18c2c81 --- /dev/null +++ b/src/analysis/exportcsv.py @@ -0,0 +1,166 @@ +""" +Filename: exportcsv.py +Description: Export data as csv analysis window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + +import os + +import numpy as np +import pandas as pd + +from PySide6.QtWidgets import QFileDialog, QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel +from PySide6.QtCore import QSize + +from src.shared.flaresbasewidget import FlaresBaseWidget +from src.shared.shareddata import APP_NAME + + +class ExportDataAsCSVViewerWidget(FlaresBaseWidget): + def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict): + super().__init__("ExportDataAsCSVViewer") + self.setWindowTitle(f"Export Data As CSV Viewer - {APP_NAME.upper()}") + self.haemo_dict = haemo_dict + self.cha_dict = cha_dict + self.df_ind = df_ind + self.design_matrix = design_matrix + self.group = group + self.contrast_results_dict = contrast_results_dict + + # Create mappings: file_path -> participant label and dropdown display text + self.participant_map = {} # file_path -> "Participant 1" + self.participant_dropdown_items = [] # "Participant 1 (filename)" + + for i, file_path in enumerate(self.haemo_dict.keys(), start=1): + short_label = f"Participant {i}" + display_label = f"{short_label} ({os.path.basename(file_path)})" + self.participant_map[file_path] = short_label + self.participant_dropdown_items.append(display_label) + + self.layout = QVBoxLayout(self) + self.top_bar = QHBoxLayout() + self.layout.addLayout(self.top_bar) + + self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) + self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) + + self.index_texts = [ + "0 (Export Data to CSV)", + "1 (CSV for SPARKS)", + # "2 (third image)", + # "3 (fourth image)", + ] + + self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) + self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.generate_and_save_csv) + + self.top_bar.addWidget(QLabel("Participants:")) + self.top_bar.addWidget(self.participant_dropdown) + self.top_bar.addWidget(QLabel("Export Type:")) + self.top_bar.addWidget(self.image_index_dropdown) + self.top_bar.addWidget(self.submit_button) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll_content = QWidget() + self.grid_layout = QGridLayout(self.scroll_content) + self.scroll.setWidget(self.scroll_content) + self.layout.addWidget(self.scroll) + + self.thumb_size = QSize(280, 180) + self.showMaximized() + + + def generate_and_save_csv(self): + + selected_display_names = self._get_checked_items(self.participant_dropdown) + selected_file_paths = [] + for display_name in selected_display_names: + for fp, short_label in self.participant_map.items(): + expected_display = f"{short_label} ({os.path.basename(fp)})" + if display_name == expected_display: + selected_file_paths.append(fp) + break + + selected_indexes = [ + int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) + ] + + if not selected_file_paths or not selected_indexes: + QMessageBox.warning(self, "Selection Missing", "Please select at least one participant and one export type.") + return + + # 2. ASK ONCE: Select Output Directory + output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports") + + if not output_dir: + print("Export cancelled: No folder selected.") + return + + success_count = 0 + + # Pass the necessary arguments to each method + for file_path in selected_file_paths: + base_filename = os.path.splitext(os.path.basename(file_path))[0] + haemo_obj = self.haemo_dict.get(file_path) + if haemo_obj is None: + continue + + cha = self.cha_dict.get(file_path) + + for idx in selected_indexes: + try: + if idx == 0: + save_path = os.path.join(output_dir, f"{base_filename}_exported.csv") + if cha is not None: + cha.to_csv(save_path) + success_count += 1 + + + elif idx == 1: + # SPARKS Export + save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv") + if haemo_obj is not None: + raw = haemo_obj + data, times = raw.get_data(return_times=True) + ann_col = np.full(times.shape, "", dtype=object) + + if raw.annotations is not None and len(raw.annotations) > 0: + for onset, duration, desc in zip( + raw.annotations.onset, + raw.annotations.duration, + raw.annotations.description + ): + mask = (times >= onset) & (times < onset + duration) + ann_col[mask] = desc + + df = pd.DataFrame(data.T, columns=raw.ch_names) + df.insert(0, "annotation", ann_col) + df.insert(0, "time", times) + df.to_csv(save_path, index=False) + success_count += 1 + + else: + print(f"No method defined for index {idx}") + + except Exception as e: + print(f"Failed to export {file_path} (Type {idx}): {e}") + + # 4. Final Notification + if success_count > 0: + QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}") + + # # If SPARKS export was included, show the Event Window once at the end + # if 1 in selected_indexes: + # win = UpdateEventsWindow( + # parent=self, + # mode=EventUpdateMode.WRITE_JSON, + # caller="Video Alignment Tool" + # ) + # win.show() + diff --git a/src/analysis/group.py b/src/analysis/group.py new file mode 100644 index 0000000..0ac708c --- /dev/null +++ b/src/analysis/group.py @@ -0,0 +1,306 @@ +""" +Filename: group.py +Description: Group analysis window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + +import os + +import pandas as pd + +from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel +from PySide6.QtCore import QSize + +from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog +from src.shared.shareddata import APP_NAME + + +class GroupViewerWidget(FlaresBaseWidget): + def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group): + super().__init__("GroupViewer") + self.setWindowTitle(f"Group Viewer - {APP_NAME.upper()}") + self.haemo_dict = haemo_dict + self.cha = cha + self.df_ind = df_ind + self.design_matrix = design_matrix + self.contrast_results = contrast_results + self.group = group + self.show_all_events = True + self._updating_checkstates = False + + # Create mappings: file_path -> participant label and dropdown display text + self.participant_map = {} # file_path -> "Participant 1" + self.participant_dropdown_items = [] # "Participant 1 (filename)" + + for i, file_path in enumerate(self.haemo_dict.keys(), start=1): + short_label = f"Participant {i}" + display_label = f"{short_label} ({os.path.basename(file_path)})" + self.participant_map[file_path] = short_label + self.participant_dropdown_items.append(display_label) + + self.layout = QVBoxLayout(self) + self.top_bar = QHBoxLayout() + self.layout.addLayout(self.top_bar) + + self.group_to_paths = {} + for file_path, group_name in self.group.items(): + self.group_to_paths.setdefault(group_name, []).append(file_path) + + self.group_names = sorted(self.group_to_paths.keys()) + + self.group_dropdown = QComboBox() + self.group_dropdown.addItem("") + self.group_dropdown.addItems(self.group_names) + self.group_dropdown.setCurrentIndex(0) + self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group) + + self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) + self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) + self.participant_dropdown.setEnabled(False) + + self.event_dropdown = QComboBox() + self.event_dropdown.addItem("") + + self.index_texts = [ + "0 (GLM Results)", + "1 (Significance)", + "2 (Brain Activity Visualization)", + # "3 (fourth image)", + ] + + self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) + self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.show_brain_images) + + self.top_bar.addWidget(QLabel("Group:")) + self.top_bar.addWidget(self.group_dropdown) + self.top_bar.addWidget(QLabel("Participants:")) + self.top_bar.addWidget(self.participant_dropdown) + self.top_bar.addWidget(QLabel("Event:")) + self.top_bar.addWidget(self.event_dropdown) + self.top_bar.addWidget(QLabel("Image Indexes:")) + self.top_bar.addWidget(self.image_index_dropdown) + self.top_bar.addWidget(self.submit_button) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll_content = QWidget() + self.grid_layout = QGridLayout(self.scroll_content) + self.scroll.setWidget(self.scroll_content) + self.layout.addWidget(self.scroll) + + self.thumb_size = QSize(280, 180) + self.showMaximized() + + + + def show_brain_images(self): + import flares as flares + + selected_event = self.event_dropdown.currentText() + if selected_event == "": + selected_event = None + + selected_display_names = self._get_checked_items(self.participant_dropdown) + selected_file_paths = [] + for display_name in selected_display_names: + for fp, short_label in self.participant_map.items(): + expected_display = f"{short_label} ({os.path.basename(fp)})" + if display_name == expected_display: + selected_file_paths.append(fp) + break + + if selected_event: + valid_paths = [] + for fp in selected_file_paths: + raw = self.haemo_dict.get(fp) + # Check if this participant actually has the event in their annotations + if raw is not None and hasattr(raw, "annotations"): + if selected_event in raw.annotations.description: + valid_paths.append(fp) + + selected_file_paths = valid_paths + + selected_indexes = [ + int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) + ] + + if not selected_file_paths: + print("No participants selected.") + return + + # Only keep indexes 0 and 1 that need parameters + parameterized_indexes = { + 0: [ + { + "key": "lower_bound", + "label": "Lower bound + ", + "default": "-0.3", + "type": float, # specify int here + }, + { + "key": "upper_bound", + "label": "Upper bound + ", + "default": "0.8", + "type": float, # specify int here + } + ], + 1: [ + { + "key": "p_value", + "label": "Significance threshold P-value (e.g. 0.05)", + "default": "0.05", + "type": float, + }, + { + "key": "graph_bounds", + "label": "Graph Upper/Lower Limit", + "default": "3.0", + "type": float, + } + ], + 2: [ + { + "key": "show_optodes", + "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", + "default": "all", + "type": str, + }, + { + "key": "t_or_theta", + "label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'", + "default": "theta", + "type": str, + }, + { + "key": "show_text", + "label": "Display informative text on the top left corner. THIS DOES NOT WORK AND SHOULD BE LEFT AT FALSE", + "default": "False", + "type": bool, + }, + { + "key": "brain_bounds", + "label": "Graph Upper/Lower Limit", + "default": "1.0", + "type": float, + } + ], + } + + # Inject full_text from index_texts + for idx, params_list in parameterized_indexes.items(): + full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" + for param_info in params_list: + param_info["full_text"] = full_text + + indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} + + param_values = {} + if indexes_needing_params: + dialog = ParameterInputDialog(indexes_needing_params, parent=self) + if dialog.exec_() == QDialog.Accepted: + param_values = dialog.get_values() + if param_values is None: + return + else: + return + + + all_cha = pd.DataFrame() + for file_path in selected_file_paths: + haemo_obj = self.haemo_dict.get(file_path) + + if selected_event: + participant_events = set(haemo_obj.annotations.description) + if selected_event not in participant_events: + print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") + continue + + if haemo_obj is None: + continue + + cha_df = self.cha.get(file_path) + if cha_df is not None: + all_cha = pd.concat([all_cha, cha_df], ignore_index=True) + + # Pass the necessary arguments to each method + file_path = selected_file_paths[0] + p_haemo = self.haemo_dict.get(file_path) + p_design_matrix = self.design_matrix.get(file_path) + + df_group = pd.DataFrame() + + if selected_file_paths: + for file_path in selected_file_paths: + df = self.df_ind.get(file_path) + if df is not None: + df_group = pd.concat([df_group, df], ignore_index=True) + + + for idx in selected_indexes: + if idx == 0: + params = param_values.get(idx, {}) + lower_bound = params.get("lower_bound", None) + upper_bound = params.get("upper_bound", None) + + if lower_bound is None or upper_bound is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + + + flares.plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound) + + elif idx == 1: + params = param_values.get(idx, {}) + p_val = params.get("p_value", None) + graph_bounds = params.get("graph_bounds", None) + + if p_val is None or graph_bounds is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + + all_contrasts = [] + for fp in selected_file_paths: + condition_dfs = self.contrast_results.get(fp, {}) + if selected_event in condition_dfs: + df = condition_dfs[selected_event].copy() + df["ID"] = fp + all_contrasts.append(df) + + if not all_contrasts: + print("No contrast data found for selected participants and event.") + return + + df_contrasts = pd.concat(all_contrasts, ignore_index=True) + flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds) + + elif idx == 2: + params = param_values.get(idx, {}) + show_optodes = params.get("show_optodes", None) + t_or_theta = params.get("t_or_theta", None) + show_text = params.get("show_text", None) + brain_bounds = params.get("brain_bounds", None) + + if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + + raw_list = [self.haemo_dict.get(fp) for fp in selected_file_paths] + + if len(selected_file_paths) > 1: + print(f"Aggregating geometry for {len(selected_file_paths)} participants...") + processed_raw = flares.aggregate_fnirs_group_geometry(raw_list) + else: + processed_raw = raw_list[0].copy().pick(picks="hbo") + + flares.brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds) + + elif idx == 3: + pass + + else: + print(f"No method defined for index {idx}") diff --git a/src/analysis/groupbrain.py b/src/analysis/groupbrain.py new file mode 100644 index 0000000..7dd9f06 --- /dev/null +++ b/src/analysis/groupbrain.py @@ -0,0 +1,311 @@ +""" +Filename: groupbrain.py +Description: Group brain analysis window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + +import os + +import pandas as pd + +from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel +from PySide6.QtCore import QSize + +from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog +from src.shared.shareddata import APP_NAME + + +class GroupBrainViewerWidget(FlaresBaseWidget): + def __init__(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict): + super().__init__("GroupBrainViewer") + self.setWindowTitle(f"Group Brain Viewer - {APP_NAME.upper()}") + self.haemo_dict = haemo_dict + self.df_ind = df_ind + self.design_matrix = design_matrix + self.group = group + self.contrast_results_dict = contrast_results_dict + + self.group_to_paths = {} + for file_path, group_name in self.group.items(): + self.group_to_paths.setdefault(group_name, []).append(file_path) + + self.group_names = sorted(self.group_to_paths.keys()) + + self.layout = QVBoxLayout(self) + self.top_bar = QHBoxLayout() + self.layout.addLayout(self.top_bar) + + + self.group_a_dropdown = QComboBox() + self.group_a_dropdown.addItem("") + self.group_a_dropdown.addItems(self.group_names) + self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options) + + + self.group_b_dropdown = QComboBox() + self.group_b_dropdown.addItem("") + self.group_b_dropdown.addItems(self.group_names) + self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options) + + + self.event_dropdown = QComboBox() + self.event_dropdown.addItem("") + + self.participant_dropdown_a = self._create_multiselect_dropdown([]) + self.participant_dropdown_a.lineEdit().setPlaceholderText("Select participants (Group A)") + self.participant_dropdown_a.model().itemChanged.connect(self._on_participants_changed) + + + self.participant_dropdown_b = self._create_multiselect_dropdown([]) + self.participant_dropdown_b.lineEdit().setPlaceholderText("Select participants (Group B)") + self.participant_dropdown_b.model().itemChanged.connect(self._on_participants_changed) + + + self.index_texts = [ + "0 (Contrast Image)", + # "1 (3D Brain Contrast)", + # "2 (third image)", + # "3 (fourth image)", + ] + self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) + self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) + + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.show_brain_images) + + + self.top_bar.addWidget(QLabel("Group A:")) + self.top_bar.addWidget(self.group_a_dropdown) + self.top_bar.addWidget(QLabel("Participants (Group A):")) + self.top_bar.addWidget(self.participant_dropdown_a) + self.top_bar.addWidget(QLabel("Group B:")) + self.top_bar.addWidget(self.group_b_dropdown) + self.top_bar.addWidget(QLabel("Participants (Group B):")) + self.top_bar.addWidget(self.participant_dropdown_b) + self.top_bar.addWidget(QLabel("Event:")) + self.top_bar.addWidget(self.event_dropdown) + self.top_bar.addWidget(QLabel("Image Indexes:")) + self.top_bar.addWidget(self.image_index_dropdown) + self.top_bar.addWidget(self.submit_button) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll_content = QWidget() + self.grid_layout = QGridLayout(self.scroll_content) + self.scroll.setWidget(self.scroll_content) + self.layout.addWidget(self.scroll) + + self.thumb_size = QSize(280, 180) + self.showMaximized() + + def _update_group_b_options(self): + """Triggered when Group B changes: Update Group A to exclude B's choice""" + selected_b = self.group_b_dropdown.currentText() + + # Refresh Group A and exclude what was just picked in Group B + self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b) + + # Update the participants for Group B + self.update_participant_list_for_group(selected_b, self.participant_dropdown_b) + self._update_event_dropdown() + + def _update_group_a_options(self): + """Triggered when Group A changes: Update Group B to exclude A's choice""" + selected_a = self.group_a_dropdown.currentText() + + # Refresh Group B and exclude what was just picked in Group A + self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a) + + # Update the participants for Group A + self.update_participant_list_for_group(selected_a, self.participant_dropdown_a) + self._update_event_dropdown() + + def _on_participants_changed(self, item=None): + self._update_event_dropdown() + + + def _refresh_group_dropdown(self, dropdown, exclude): + current = dropdown.currentText() + dropdown.blockSignals(True) + dropdown.clear() + dropdown.addItem("") + for group in self.group_names: + if group != exclude: + dropdown.addItem(group) + # Restore previous selection if still valid + if current != "" and current != exclude and dropdown.findText(current) != -1: + dropdown.setCurrentText(current) + else: + dropdown.setCurrentIndex(0) # Reset to "" + dropdown.blockSignals(False) + + + def _get_file_paths_from_labels(self, labels, group_name): + file_paths = [] + + if group_name == self.group_a_dropdown.currentText(): + participant_map = self.participant_map_a + elif group_name == self.group_b_dropdown.currentText(): + participant_map = self.participant_map_b + else: + return [] + + # Reverse map: display label -> file path + reverse_map = { + f"{label} ({os.path.basename(fp)})": fp + for fp, label in participant_map.items() + } + + for label in labels: + file_path = reverse_map.get(label) + if file_path: + file_paths.append(file_path) + + return file_paths + + def show_brain_images(self): + import flares as flares + + selected_event = self.event_dropdown.currentText() + if selected_event == "": + selected_event = None + + # Group A + participants_a = self._get_checked_items(self.participant_dropdown_a) + file_paths_a = self._get_file_paths_from_labels(participants_a, self.group_a_dropdown.currentText()) + + # Group B + participants_b = self._get_checked_items(self.participant_dropdown_b) + file_paths_b = self._get_file_paths_from_labels(participants_b, self.group_b_dropdown.currentText()) + + selected_indexes = [ + int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) + ] + + all_selected_paths = list(set(file_paths_a + file_paths_b)) + + if not all_selected_paths: + print("No participants selected.") + return + + parameterized_indexes = { + 0: [ + { + "key": "show_optodes", + "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", + "default": "all", + "type": str, + }, + { + "key": "t_or_theta", + "label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'", + "default": "theta", + "type": str, + }, + { + "key": "show_text", + "label": "Display informative text on the top left corner about the contrast.", + "default": "True", + "type": bool, + }, + { + "key": "brain_bounds", + "label": "Graph Upper/Lower Limit", + "default": "1.0", + "type": float, + }, + { + "key": "is_3d", + "label": "Should we display the results in a 3D interactive window?", + "default": "True", + "type": bool, + } + ], + } + + + # Inject full_text from index_texts + for idx, params_list in parameterized_indexes.items(): + full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)" + for param_info in params_list: + param_info["full_text"] = full_text + + indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes} + + param_values = {} + if indexes_needing_params: + dialog = ParameterInputDialog(indexes_needing_params, parent=self) + if dialog.exec_() == QDialog.Accepted: + param_values = dialog.get_values() + if param_values is None: + return + else: + return + + # Build group-level contrast DataFrames + def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame: + group_df = pd.DataFrame() + for fp in file_paths: + print(f"Looking up contrast for: {fp}") + event_con_dict = self.contrast_results_dict.get(fp, {}) + print("Available events for this file:", list(event_con_dict.keys())) + if event and event in event_con_dict: + df = event_con_dict[event] + print(f"Appending contrast df for event: {event}") + group_df = pd.concat([group_df, df], ignore_index=True) + else: + print(f"Event '{event}' not found for {fp}") + return group_df + + print("Selected event:", selected_event) + print("File paths A:", file_paths_a) + print("File paths B:", file_paths_b) + + contrast_df_a = concat_group_contrasts(file_paths_a, selected_event) + contrast_df_b = concat_group_contrasts(file_paths_b, selected_event) + + print("contrast_df_a empty?", contrast_df_a.empty) + print("contrast_df_b empty?", contrast_df_b.empty) + + all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)] + + if len(all_raw_objs) > 1: + processed_raw = flares.aggregate_fnirs_group_geometry(all_raw_objs) + else: + processed_raw = all_raw_objs[0].copy().pick(picks="hbo") + + # Visualizations + for idx in selected_indexes: + if idx == 0: + params = param_values.get(idx, {}) + show_optodes = params.get("show_optodes", None) + t_or_theta = params.get("t_or_theta", None) + show_text = params.get("show_text", None) + brain_bounds = params.get("brain_bounds", None) + is_3d = params.get("is_3d", None) + + if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None: + print(f"Missing parameters for index {idx}, skipping.") + continue + + if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw: + + flares.plot_2d_3d_contrasts_between_groups( + contrast_df_a, + contrast_df_b, + raw_haemo=processed_raw, + group_a_name=self.group_a_dropdown.currentText(), + group_b_name=self.group_b_dropdown.currentText(), + is_3d=is_3d, + t_or_theta=t_or_theta, + show_optodes=show_optodes, + show_text=show_text, + brain_bounds=brain_bounds + ) + else: + print("no") + + diff --git a/src/analysis/groupfunctionalconnectivity.py b/src/analysis/groupfunctionalconnectivity.py index 497409d..2f5ac55 100644 --- a/src/analysis/groupfunctionalconnectivity.py +++ b/src/analysis/groupfunctionalconnectivity.py @@ -1,3 +1,11 @@ +""" +Filename: groupfunctionalconnectivity.py +Description: Group functional connectivity analysis window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + import os from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel, QMessageBox @@ -10,14 +18,14 @@ from src.shared.shareddata import APP_NAME class GroupFunctionalConnectivityWidget(FlaresBaseWidget): def __init__(self, haemo_dict, group, config_dict): super().__init__("GroupFunctionalConnectivityWidget") - self.setWindowTitle(f"{APP_NAME.upper} Group Viewer") + self.setWindowTitle(f"Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}") self.haemo_dict = haemo_dict self.group = group self.config_dict = config_dict self.show_all_events = True self._updating_checkstates = False - QMessageBox.warning(self, f"Warning - {APP_NAME.upper}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " + QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " "By clicking OK, you accept that the images generated may not be factual.") diff --git a/src/analysis/participant.py b/src/analysis/participant.py index 125ad8e..c3e40c8 100644 --- a/src/analysis/participant.py +++ b/src/analysis/participant.py @@ -1,3 +1,11 @@ +""" +Filename: participant.py +Description: Participant analysis window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + import os from pathlib import Path from datetime import datetime @@ -14,7 +22,7 @@ class ParticipantViewerWidget(FlaresBaseWidget): def __init__(self, haemo_dict, fig_bytes_dict): super().__init__("ParticipantViewer") self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - self.setWindowTitle(f"{APP_NAME.upper} Participant Viewer") + self.setWindowTitle(f"Participant Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict self.fig_bytes_dict = fig_bytes_dict diff --git a/src/analysis/participantbrain.py b/src/analysis/participantbrain.py index 1d09916..93c196b 100644 --- a/src/analysis/participantbrain.py +++ b/src/analysis/participantbrain.py @@ -1,3 +1,11 @@ +""" +Filename: participantbrain.py +Description: Participant brain analysis window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + import os from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel @@ -10,7 +18,7 @@ from src.shared.shareddata import APP_NAME class ParticipantBrainViewerWidget(FlaresBaseWidget): def __init__(self, haemo_dict, cha_dict): super().__init__("ParticipantBrainViewer") - self.setWindowTitle(f"{APP_NAME.upper} Participant Brain Viewer") + self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict self.cha_dict = cha_dict diff --git a/src/analysis/participantfoldchannels.py b/src/analysis/participantfoldchannels.py new file mode 100644 index 0000000..8a100c3 --- /dev/null +++ b/src/analysis/participantfoldchannels.py @@ -0,0 +1,1059 @@ +""" +Filename: participantfoldchannels.py +Description: Participant fOLD channels analysis window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + +import os +import time +import traceback +from multiprocessing import Process, current_process, Manager + +import numpy as np + +import matplotlib.pyplot as plt +import matplotlib.image as mpimg +from matplotlib.figure import Figure +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas + +from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout +from PySide6.QtCore import Qt, QSize, QTimer +from PySide6.QtGui import QPixmap, QImage + +from src.shared.flaresbasewidget import FlaresBaseWidget +from src.shared.shareddata import APP_NAME + + +class MultiProgressDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("fOLD Analysis Progress") + self.setFixedWidth(400) + self.setWindowModality(Qt.WindowModality.NonModal) + self.layout = QVBoxLayout(self) + self.bars = {} + + def add_participant(self, label, total_steps): + clean_key = str(label).strip() + label_widget = QLabel(f"Analyzing {clean_key}...") + pbar = QProgressBar() + pbar.setMinimum(0) + pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer + pbar.setValue(0) + + self.layout.addWidget(label_widget) + self.layout.addWidget(pbar) + self.bars[label] = pbar + + def update_bar(self, label, value): + if label in self.bars: + # Force integers to prevent QProgressBar from breaking or flickering + self.bars[label].setValue(int(value)) + + + +def single_participant_worker(file_path, raw_data, result_queue, progress_queue): + """ Runs inside its own dedicated process """ + p_name = os.path.basename(file_path) + try: + import flares as flares + # Perform the heavy fold_channels logic + channel_results = flares.fold_channels(raw_data, p_name, progress_queue) + + # Hand back results and signal completion + result_queue.put({file_path: channel_results}) + progress_queue.put(p_name) + + except Exception as e: + progress_queue.put(f"ERROR: {p_name} - {str(e)}") + + + +def get_landmark_color_map(): + """Generates the unified 40-color map for fOLD landmarks.""" + landmarks = [ + "1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex", + "3 - Primary Somatosensory Cortex", "4 - Primary Motor Cortex", + "5 - Somatosensory Association Cortex", "6 - Pre-Motor and Supplementary Motor Cortex", + "7 - Somatosensory Association Cortex", "8 - Includes Frontal eye fields", + "9 - Dorsolateral prefrontal cortex", "10 - Frontopolar area", + "11 - Orbitofrontal area", "17 - Primary Visual Cortex (V1)", + "18 - Visual Association Cortex (V2)", "19 - V3", "20 - Inferior Temporal gyrus", + "21 - Middle Temporal gyrus", "22 - Superior Temporal Gyrus", + "23 - Ventral Posterior cingulate cortex", "24 - Ventral Anterior cingulate cortex", + "25 - Subgenual cortex", "32 - Dorsal anterior cingulate cortex", + "37 - Fusiform gyrus", "38 - Temporopolar area", + "39 - Angular gyrus, part of Wernicke's area", "40 - Supramarginal gyrus part of Wernicke's area", + "41 - Primary and Auditory Association Cortex", "42 - Primary and Auditory Association Cortex", + "43 - Subcentral area", "44 - pars opercularis, part of Broca's area", + "45 - pars triangularis Broca's area", "46 - Dorsolateral prefrontal cortex", + "47 - Inferior prefrontal gyrus", "48 - Retrosubicular area", "Brain_Outside" + ] + # Sort logically + landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf'))) + + cmap1 = plt.get_cmap('tab20') + cmap2 = plt.get_cmap('tab20b') + colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] + + return {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)} + + +class StaticChannelCanvas(FigureCanvas): + """The Pop-up Window Canvas. + Renders the interactive pie chart on the left, and a matching PNG image on the right. + """ + def __init__(self, channel_name, data_list, color_map, image_path=None, parent=None): + # Create a 1-row, 2-column subplot array + # figsize=(11.0, 5.5) creates a wide 2:1 widescreen aspect window layout + self.fig, self.ax = plt.subplots(1, 2, figsize=(11.0, 5.5)) + super().__init__(self.fig) + self.setParent(parent) + + self.setMouseTracking(True) + + # --- 1. DATA PREPARATION --- + self.wedge_data_list = list(data_list) + total_specificity = sum(d['Specificity'] for d in self.wedge_data_list) + if total_specificity < 100.0: + remainder = 100.0 - total_specificity + if remainder > 0.01: + self.wedge_data_list.append({ + 'Landmark': 'Other / Unclassified Regions', + 'Specificity': remainder + }) + + self.specificities = [d['Specificity'] for d in self.wedge_data_list] + self.landmarks = [d['Landmark'] for d in self.wedge_data_list] + self.colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in self.landmarks] + self.labels = [f"{lm.split(' - ')[0]}" if 'Other' not in lm and lm != 'Brain_Outside' else 'Other' if 'Other' in lm else 'B' for lm in self.landmarks] + + # --- 2. LEFT SUBPLOT: PIE CHART --- + # Note we explicitly target self.ax[0] now + self.wedges, self.texts, self.autotexts = self.ax[0].pie( + self.specificities, + autopct='%1.1f%%', + startangle=90, + labels=self.labels, + colors=self.colors, + textprops={'fontsize': 10, 'fontweight': 'bold'}, + labeldistance=1.1 + ) + self.ax[0].axis('equal') + + # --- 3. RIGHT SUBPLOT: PNG IMAGE DISPLAY --- + # Note we explicitly target self.ax[1] now + if image_path: + try: + img = mpimg.imread(image_path) + self.ax[1].imshow(img) + except Exception as e: + self.ax[1].text(0.5, 0.5, f"Failed to load image:\n{e}", + ha='center', va='center', fontsize=10, color='red') + else: + # Fallback message if no image path is passed down + self.ax[1].text(0.5, 0.5, "No Reference Image\nProvided", + ha='center', va='center', fontsize=12, fontweight='bold', color='#777') + + # Completely hide the background grid, spines, and axis lines for the image box + self.ax[1].axis('off') + + # --- 4. CANVAS TEXT OVERLAY --- + # Main Title centered globally over both subplots + self.fig.suptitle(channel_name, fontsize=16, fontweight='bold', y=0.97) + + # Shared info box text overlay centered horizontally across the whole window figure + self.info_text = self.ax[0].text( + 0.5, 0.04, "", + transform=self.fig.transFigure, + ha="center", va="bottom", + fontsize=12, fontweight="bold", + bbox=dict(boxstyle="round,pad=0.5", facecolor="#fdfdfd", edgecolor="#bbb", alpha=0.95) + ) + self.info_text.set_visible(False) + + self.currently_exploded_idx = None + + # Layout space optimization + self.fig.subplots_adjust(left=0.05, bottom=0.1, right=0.95, top=0.85, wspace=0.2) + self.draw() + + self.mpl_connect('motion_notify_event', self._on_hover) + + def _on_hover(self, event): + try: + # FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart + if event.inaxes != self.ax[0]: + if self.currently_exploded_idx is not None: + self._reset_wedges() + self.info_text.set_visible(False) + self.currently_exploded_idx = None + self.draw_idle() + return + + hovered_index = None + for idx, wedge in enumerate(self.wedges): + contained, _ = wedge.contains(event) + if contained: + hovered_index = idx + break + + if hovered_index is not None: + if self.currently_exploded_idx != hovered_index: + self.currently_exploded_idx = hovered_index + self._explode_wedge(hovered_index) + + displayed_pct = self.autotexts[hovered_index].get_text() + full_desc = self.landmarks[hovered_index] + + self.info_text.set_text(f"{full_desc} | {displayed_pct}") + self.info_text.set_visible(True) + self.draw_idle() + else: + if self.currently_exploded_idx is not None: + self._reset_wedges() + self.info_text.set_visible(False) + self.currently_exploded_idx = None + self.draw_idle() + + except Exception as err: + print("[ERROR] Internal failure inside _on_hover loop:") + traceback.print_exc() + + def _explode_wedge(self, index_to_expand): + changed = False + for idx, wedge in enumerate(self.wedges): + if idx == index_to_expand: + theta = np.deg2rad((wedge.theta1 + wedge.theta2) / 2.0) + explode_distance = 0.08 + new_x = explode_distance * np.cos(theta) + new_y = explode_distance * np.sin(theta) + if wedge.center != (new_x, new_y): + wedge.set_center((new_x, new_y)) + changed = True + else: + if wedge.center != (0.0, 0.0): + wedge.set_center((0.0, 0.0)) + changed = True + if changed: + self.draw_idle() + + def _reset_wedges(self): + changed = False + for wedge in self.wedges: + if wedge.center != (0.0, 0.0): + wedge.set_center((0.0, 0.0)) + changed = True + if changed: + self.draw_idle() + + + +class StandaloneLegendDialog(QWidget): + def __init__(self, canvas_engine, title_prefix, parent=None): + super().__init__(None) + self.setWindowTitle("Full View - Brodmann Legend") + self.setMinimumSize(500, 600) + self.resize(500, 900) + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + + # Reuse your exact card creation method to render inside the popup window + legend_card = canvas_engine.create_legend_card(title_prefix, self) + layout.addWidget(legend_card) + + +class InteractiveParticipantGridCanvas(FigureCanvas): + """The Big Grid Canvas. + Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation. + """ + def __init__(self, channels_data, color_map, is_fullscreen_copy=False, parent=None): + self.channels_data = channels_data + self.color_map = color_map + self.is_fullscreen_copy = is_fullscreen_copy + + num_channels = len(channels_data) + + # --- FIX: DYNAMICALLY CALCULATE OPTIMAL 16:9 COLUMNS --- + target_ratio = 16 / 9 + best_cols = 4 + min_ratio_error = float('inf') + + # Test configurations from 4 columns up to the total number of channels + for test_cols in range(4, num_channels + 1): + test_rows = (num_channels + test_cols - 1) // test_cols + + # Approximate the visual aspect ratio based on cell dimensions + # Mini charts are slightly wider than tall, roughly 1.15 to 1.0 factor + current_ratio = (test_cols * 1.15) / (test_rows * 1.0) + error = abs(current_ratio - target_ratio) + + if error < min_ratio_error: + min_ratio_error = error + best_cols = test_cols + + cols = best_cols + rows = (num_channels + cols - 1) // cols + + # Base figure sizing dynamically scales off the optimal matrix constraints + if is_fullscreen_copy: + # Maximized views stretch cleanly across standard display panels + figsize = (14.0, 14.0 / target_ratio) + else: + # Standard thumbnail views scaled down for participant cards + figsize = (7.5, 7.5 / target_ratio) + + self.fig = Figure(figsize=figsize) + + super().__init__(self.fig) + self.setParent(parent) + + self.axes_data_registry = {} + + for idx, (channel_name, data_list) in enumerate(channels_data.items()): + ax = self.fig.add_subplot(rows, cols, idx + 1) + + padded_data_list = list(data_list) + total_specificity = sum(d['Specificity'] for d in padded_data_list) + if total_specificity < 100.0: + remainder = 100.0 - total_specificity + if remainder > 0.01: + padded_data_list.append({ + 'Landmark': 'Other / Unclassified Regions', + 'Specificity': remainder + }) + + self.axes_data_registry[ax] = { + 'channel_name': channel_name, + 'data_list': padded_data_list + } + + specificities = [d['Specificity'] for d in padded_data_list] + landmarks = [d['Landmark'] for d in padded_data_list] + colors = [color_map.get(lm, '#ccc') if 'Other' not in lm else '#d3d3d3' for lm in landmarks] + labels = [f"{lm.split(' - ')[0]}" if 'Other' not in lm and lm != 'Brain_Outside' else 'O' if 'Other' in lm else 'B' for lm in landmarks] + + # Adjust label sizing dynamically based on how crowded the grid gets + font_sz = 5 if num_channels > 30 else (7 if is_fullscreen_copy else 6) + title_sz = 6 if num_channels > 30 else (9 if is_fullscreen_copy else 7) + + ax.pie( + specificities, + startangle=90, + colors=colors, + labels=labels, + textprops={'fontsize': font_sz, 'fontweight': 'bold'}, + labeldistance=1.05, + radius=0.75 + ) + + ax.set_title(channel_name, fontsize=title_sz, fontweight='bold', pad=0, y=1.04) + ax.axis('equal') + + # --- FIX: ADAPTIVE PADDING BOUNDS FOR EXTRA DENSE PLOTS --- + # Large multi-column plots require less spacing overhead to prevent clipping label masks + h_sp = 0.35 if num_channels > 30 else 0.18 + w_sp = 0.25 if num_channels > 30 else 0.10 + + if is_fullscreen_copy: + self.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.95, hspace=h_sp, wspace=w_sp) + else: + self.fig.set_layout_engine('constrained') + + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self.draw() + self.mpl_connect('button_press_event', self._on_canvas_click) + + + def create_matrix_card(self, title_prefix, layout_to_attach_to): + """Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame.""" + # 1. Create matching styled container card frame + card_frame = QFrame() + card_frame.setFrameShape(QFrame.Shape.StyledPanel) + card_frame.setStyleSheet(""" + QFrame { + background-color: #ffffff; + border: 2px solid #ced4da; + border-radius: 6px; + } + QFrame:hover { + border: 2px solid #4dabf7; + background-color: #f8f9fa; + } + """) + + card_layout = QVBoxLayout(card_frame) + card_layout.setContentsMargins(6, 6, 6, 6) + card_layout.setSpacing(4) + + # 2. Add header matching the summary card type architecture + header = QLabel(f"{title_prefix} - Channels Matrix") + header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529; background: transparent;") + header.setAlignment(Qt.AlignmentFlag.AlignCenter) + card_layout.addWidget(header) + + # 3. Nest this canvas instance cleanly inside the card frame layout + self.setParent(card_frame) + card_layout.addWidget(self) + card_layout.addStretch(0) + + # 4. Make the remaining empty whitespace frame areas trigger the maximization loop + card_frame.mouseReleaseEvent = lambda event: self._open_fullscreen_grid() if event.button() == Qt.MouseButton.LeftButton else None + + # Ensure underlying child mouse hits tunnel downstream properly to our parent container frame + header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + layout_to_attach_to.addWidget(card_frame) + return card_frame + + def _on_canvas_click(self, event): + # CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window + if event.inaxes is None: + self._open_fullscreen_grid() + return + + # CASE 2: Specific Slice Clicked -> Open standard individual detailed channel popup + clicked_subplot_data = self.axes_data_registry.get(event.inaxes) + if clicked_subplot_data: + self._open_expanded_view( + clicked_subplot_data['channel_name'], + clicked_subplot_data['data_list'] + ) + + def _open_fullscreen_grid(self): + """Creates a maximized dialog window duplicating the full participant matrix view.""" + if getattr(self, 'is_fullscreen_copy', False) or hasattr(self, '_is_fullscreen_flag_set'): + return + fullscreen_window = QWidget(None) + fullscreen_window.setWindowTitle("Participant Grid Monitor - Maximized View") + fullscreen_window.setWindowFlags( + Qt.WindowType.Window | + Qt.WindowType.WindowMinMaxButtonsHint | + Qt.WindowType.WindowCloseButtonHint + ) + + layout = QVBoxLayout(fullscreen_window) + layout.setContentsMargins(0, 0, 0, 0) + + # Instantiate the copy + large_grid_canvas = InteractiveParticipantGridCanvas( + self.channels_data, + self.color_map, + is_fullscreen_copy=True, + parent=fullscreen_window + ) + + # Explicitly tag the new canvas object internally to block further clicks + large_grid_canvas._is_fullscreen_flag_set = True + + layout.addWidget(large_grid_canvas) + + # Open non-modally so it populates the taskbar and matches OS window behaviors + fullscreen_window.showMaximized() + + # Keep a reference alive on the source canvas so Python doesn't garbage collect the window + if not hasattr(self, '_fullscreen_refs'): + self._fullscreen_refs = [] + self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()] + self._fullscreen_refs.append(fullscreen_window) + + def _calculate_total_brodmann_profile(self, channels_data): + """Sums and normalizes the specificity profile across all channels.""" + totals = {} + num_channels = len(channels_data) + + if num_channels == 0: + return [] + + # Sum up specificities across all channels + for channel_name, data_list in channels_data.items(): + for entry in data_list: + landmark = entry['Landmark'] + specificity = entry['Specificity'] + totals[landmark] = totals.get(landmark, 0.0) + specificity + + # Normalize back down to 100% total scale + normalized_data_list = [] + for landmark, total_val in totals.items(): + # If a landmark hit 20% in 10 channels, it's normalized relative to total channels + normalized_val = total_val / num_channels + if normalized_val > 0.01: + normalized_data_list.append({ + 'Landmark': landmark, + 'Specificity': normalized_val + }) + + # Ensure "Other / Unclassified" fills any remaining precision gap + total_normalized = sum(d['Specificity'] for d in normalized_data_list) + if total_normalized < 100.0: + remainder = 100.0 - total_normalized + if remainder > 0.01: + normalized_data_list.append({ + 'Landmark': 'Other / Unclassified Regions', + 'Specificity': remainder + }) + + return normalized_data_list + + def _open_expanded_view(self, channel_name, data_list): + # 1. Create a plain QWidget with NO parent (None) + # This instantly makes it a top-level desktop window + popup = QWidget(None) + popup.setWindowTitle(f"Channel Specificity Detail - {channel_name}") + + # 2. Add standard window control behaviors + popup.setWindowFlags( + Qt.WindowType.Window | + Qt.WindowType.WindowMinMaxButtonsHint | + Qt.WindowType.WindowCloseButtonHint + ) + + # 3. Build layout out exactly as before + layout = QVBoxLayout(popup) + layout.setContentsMargins(0, 0, 0, 0) # Strip extra outer layout spacing + + target_png_path = "images/brain.png" + + expanded_canvas = StaticChannelCanvas( + channel_name, + data_list, + self.color_map, + image_path=target_png_path, + parent=popup + ) + + layout.addWidget(expanded_canvas) + popup.resize(900, 520) + + # 4. Display non-modally + popup.show() + + # 5. Keep the reference alive so Python doesn't garbage collect it + if not hasattr(self, '_open_popups'): + self._open_popups = [] + + # Clean up closed windows from our tracking list to save memory + self._open_popups = [w for w in self._open_popups if w.isVisible()] + self._open_popups.append(popup) + + + def create_total_summary_card(self, title_prefix, layout_to_attach_to): + """Generates a highly compact, clickable embedded card on the main window showing aggregated data.""" + # 1. Calculate the normalized profile data payload using the instance's own data + summary_data = self._calculate_total_brodmann_profile(self.channels_data) + + # 2. Create a styled container card frame + card_frame = QFrame() + card_frame.setFrameShape(QFrame.Shape.StyledPanel) + card_frame.setStyleSheet(""" + QFrame { + background-color: #ffffff; + border: 2px solid #ced4da; + border-radius: 6px; + } + QFrame:hover { + border: 2px solid #4dabf7; /* Gives a subtle visual cue that it is clickable */ + background-color: #f8f9fa; /* Slightly shifts background color on hover */ + } + """) + + card_layout = QVBoxLayout(card_frame) + card_layout.setContentsMargins(4, 4, 4, 4) + card_layout.setSpacing(2) + + # Add a clear section header label containing the specific participant identity + header = QLabel(f"{title_prefix} - Total Profile") + header.setStyleSheet("font-weight: bold; font-size: 10pt; border: none; color: #212529;") + header.setAlignment(Qt.AlignmentFlag.AlignCenter) + card_layout.addWidget(header) + + target_png_path = "images/brain.png" + + # 3. Instantiate the canvas with a custom size flag or constraint + # Adjust your StaticChannelCanvas __init__ to check if it should render in 'compact' mode + summary_canvas = StaticChannelCanvas( + channel_name=f"{title_prefix} Combined", + data_list=summary_data, + color_map=self.color_map, + image_path=target_png_path, + parent=card_frame, + ) + + # --- CRITICAL: SHRINK MATPLOTLIB FIGURE ELEMENTS FOR THE EMBEDDED VIEWER --- + # Scale down the underlying canvas container so it doesn't balloon the layout grid + if hasattr(summary_canvas, 'fig'): + summary_canvas.fig.subplots_adjust(left=0.02, bottom=0.02, right=0.98, top=0.92, wspace=0.10) + + for ax in summary_canvas.fig.axes: + for text in ax.texts: + text.set_fontsize(6) + summary_canvas.draw() + + summary_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + card_layout.addWidget(summary_canvas) + card_layout.addStretch(0) + + def handle_card_click(event): + # Only trigger expansion if it's a primary left-click action + if event.button() == Qt.MouseButton.LeftButton: + self._open_expanded_summary_window(title_prefix, summary_data) + + card_frame.mouseReleaseEvent = handle_card_click + + # Prevent clicks on the text/child elements from being swallowed up instead of passing to frame + header.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + summary_canvas.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + # 4. Inject completed card frame container assembly into target window layout position + layout_to_attach_to.addWidget(card_frame) + return card_frame + + + + def create_legend_card(self, title_prefix, layout_to_attach_to): + card = QFrame() + card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }") + + layout = QVBoxLayout(card) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + + header_label = QLabel(f"{title_prefix}\nLandmarks") + header_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + header_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #1a252f; border: none;") + layout.addWidget(header_label) + layout.addSpacing(10) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setStyleSheet("QScrollArea { border: none; background: transparent; }") + scroll_content = QWidget() + scroll_content.setStyleSheet("background: transparent;") + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setSpacing(6) + scroll_layout.setContentsMargins(0, 0, 0, 0) + + + true_color_map = get_landmark_color_map() + + # Iterate over the sorted keys directly from your method + for landmark_text in true_color_map.keys(): + item_row = QHBoxLayout() + item_row.setSpacing(12) + + # Extract the RGBA tuple value assigned by matplotlib + rgba = true_color_map[landmark_text] + # Convert float tuple components (0.0 - 1.0) to standard CSS integer scales (0 - 255) + r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255) + color_hex = f"rgb({r}, {g}, {b})" + + # Format display string nicely: "1 — Primary Somatosensory Cortex" + if " - " in landmark_text: + num, name = landmark_text.split(" - ", 1) + display_string = f"{num} — {name}" + else: + display_string = f"{landmark_text}" + + dot = QLabel() + dot.setFixedSize(14, 14) + dot.setStyleSheet(f"background-color: {color_hex}; border-radius: 7px; border: none;") + + label = QLabel(display_string) + label.setStyleSheet("font-size: 12px; color: #343a40; border: none;") + + item_row.addWidget(dot) + item_row.addWidget(label, 1) + scroll_layout.addLayout(item_row) + + scroll_area.setWidget(scroll_content) + layout.addWidget(scroll_area) + return card + + + def _open_expanded_summary_window(self, title_prefix, summary_data): + """Pops open a beautifully scaled, independent large window when the card is clicked.""" + popup = QWidget(None) + popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}") + popup.setWindowFlags( + Qt.WindowType.Window | + Qt.WindowType.WindowMinMaxButtonsHint | + Qt.WindowType.WindowCloseButtonHint + ) + + layout = QVBoxLayout(popup) + layout.setContentsMargins(10, 10, 10, 10) + + target_png_path = "images/brain.png" + + # This one renders full size (900x520) for analytical reading + expanded_canvas = StaticChannelCanvas( + f"{title_prefix} - All Channels Aggregated", + summary_data, + self.color_map, + image_path=target_png_path, + parent=popup, + ) + + layout.addWidget(expanded_canvas) + popup.resize(950, 550) + popup.show() + + if not hasattr(self, '_summary_popups'): + self._summary_popups = [] + self._summary_popups.append(popup) + + +class ParticipantFoldChannelsWidget(FlaresBaseWidget): + def __init__(self, haemo_dict, cha_dict): + super().__init__("ParticipantFoldChannels") + self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}") + self.haemo_dict = haemo_dict + self.cha_dict = cha_dict + # Create mappings: file_path -> participant label and dropdown display text + self.participant_map = {} # file_path -> "Participant 1" + self.participant_dropdown_items = [] # "Participant 1 (filename)" + + for i, file_path in enumerate(self.haemo_dict.keys(), start=1): + short_label = f"Participant {i}" + display_label = f"{short_label} ({os.path.basename(file_path)})" + self.participant_map[file_path] = short_label + self.participant_dropdown_items.append(display_label) + + self.layout = QVBoxLayout(self) + self.top_bar = QHBoxLayout() + self.layout.addLayout(self.top_bar) + + self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items) + self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label) + + self.index_texts = [ + "0 (Fold Channels)", + # "1 (second image)", + # "2 (third image)", + # "3 (fourth image)", + ] + + self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts) + self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.show_fold_images) + + self.top_bar.addWidget(QLabel("Participants:")) + self.top_bar.addWidget(self.participant_dropdown) + self.top_bar.addWidget(QLabel("Fold Type:")) + self.top_bar.addWidget(self.image_index_dropdown) + self.top_bar.addWidget(self.submit_button) + + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll_area.setStyleSheet("QScrollArea { border: none; background-color: #f1f3f5; }") + + # 2. Create the central canvas widget that inside the scroll block + self.scroll_content_widget = QWidget() + self.scroll_content_widget.setStyleSheet("background-color: #f1f3f5;") + + # 3. Establish the strict 3-column layout grid engine + self.grid_layout = QGridLayout(self.scroll_content_widget) + self.grid_layout.setContentsMargins(12, 12, 12, 12) + self.grid_layout.setSpacing(15) # Controls breathing room gaps between cards + + self.grid_layout.setColumnStretch(0, 1) + self.grid_layout.setColumnStretch(1, 1) + self.grid_layout.setColumnStretch(2, 1) + + # 2. Force a uniform structural minimum width per column + # This blocks the dense matrices from hogging space and compressing the summary cards + self.grid_layout.setColumnMinimumWidth(0, 400) + self.grid_layout.setColumnMinimumWidth(1, 400) + self.grid_layout.setColumnMinimumWidth(2, 400) + # ---------------------------------------------------------- + + # Bind them together + self.scroll_area.setWidget(self.scroll_content_widget) + + # Add the self.scroll_area widget to your root layout view frame panel + self.layout.addWidget(self.scroll_area) + + self.thumb_size = QSize(280, 180) + self.showMaximized() + + + def show_fold_images(self): + selected_display_names = self._get_checked_items(self.participant_dropdown) + selected_indexes = [int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)] + + if not selected_display_names or 0 not in selected_indexes: + return + selected_files = [path for path, label in self.participant_map.items() + if f"{label} ({os.path.basename(path)})" in selected_display_names] + + while self.grid_layout.count(): + item = self.grid_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + self.global_channels_data = {} + + self.multi_progress = MultiProgressDialog(self) + for file_path in selected_files: + raw_data = self.haemo_dict[file_path] + # Dig out the exact channels list length matching your loop engine logic + hbo_channels = getattr(raw_data.copy().pick(picks='hbo'), "ch_names", []) + total_channels = len(hbo_channels) if hbo_channels else 1 + + self.multi_progress.add_participant(os.path.basename(file_path), total_channels) + + self.multi_progress.show() + + + if current_process().name == 'MainProcess': + + # 2. Setup Multiprocessing Manager + self.manager = Manager() + self.result_queue = self.manager.Queue() + self.progress_queue = self.manager.Queue() + self.active_processes = [] + + # 3. Start ALL processes at once + for file_path in selected_files: + p = Process( + target=single_participant_worker, + args=(file_path, self.haemo_dict[file_path], self.result_queue, self.progress_queue) + ) + p.start() + self.active_processes.append(p) + + # 4. Start the GUI listener + self.completed_count = 0 + self.result_timer = QTimer() + self.result_timer.timeout.connect(self.check_parallel_results) + self.result_timer.start() + + + def check_parallel_results(self): + # Check for progress/completion signals + + while not self.progress_queue.empty(): + msg = self.progress_queue.get() + + # CASE 1: Micro-step channel increment (Tuple tracking) + if isinstance(msg, tuple): + p_name, completed_channels = msg + clean_key = str(p_name).strip() + + if hasattr(self, 'multi_progress') and clean_key in self.multi_progress.bars: + print(completed_channels) + self.multi_progress.update_bar(clean_key, completed_channels) + else: + # DEBUG LOG: This tells us exactly why a bar isn't moving + print(f"[DEBUG WARNING] Progress received for '{clean_key}' but no matching bar was found. Existing bars: {list(self.multi_progress.bars.keys())}") + continue + + # CASE 2: Worker process crashed with an error string + if isinstance(msg, str) and msg.startswith("ERROR"): + print(f"Worker Error: {msg}") + #self.completed_count += 1 # Count as finished so the UI doesn't hang + + # CASE 3: Final clean text string signal indicating complete file closure + elif isinstance(msg, str): + # Max out the progress bar visually on completion + if hasattr(self, 'multi_progress'): + if msg in self.multi_progress.bars: + max_val = self.multi_progress.bars[msg].maximum() + self.multi_progress.update_bar(msg, max_val) + + self.completed_count += 1 # Increment the master task tracker + print(self.completed_count, time.time()) + + # Pull images as they become available + while not self.result_queue.empty(): + result_dict = self.result_queue.get() + self.add_images_to_grid(result_dict) + + # Clean up when all processes are done + if self.completed_count >= len(self.active_processes): + self.result_timer.stop() + + # Close the custom multi-progress window + if hasattr(self, 'multi_progress'): + self.multi_progress.close() + + # Clean up processes + for p in self.active_processes: + if p.is_alive(): + p.join(timeout=1) # Give it a second to wrap up + p.close() # Explicitly close the process object + + # Shut down the Manager process (the source of the 'rogue' process) + if hasattr(self, 'manager'): + self.manager.shutdown() + + self.active_processes = [] + print("Processing fully complete. All resources released.") + + if hasattr(self, 'global_channels_data') and self.global_channels_data: + color_map = get_landmark_color_map() + + # We feed the entire channel pool directly to your existing canvas engine class + global_canvas = InteractiveParticipantGridCanvas(self.global_channels_data, color_map) + + # Create the summary card using your exact visual method + global_card = global_canvas.create_total_summary_card( + title_prefix="Grand Global Layout", + layout_to_attach_to=self.scroll_content_widget.layout() + ) + + # Match your exact layout positioning logic to place it next in the grid + count = self.grid_layout.count() - 1 + row = count // 3 + col = count % 3 + self.grid_layout.addWidget(global_card, row, col) + + legend_title = "Grand Total Brodmann Mapping Profile" + legend_card = global_canvas.create_legend_card( + title_prefix=legend_title, + layout_to_attach_to=self.scroll_content_widget.layout() + ) + + def handle_legend_click(event): + self.active_legend_window = StandaloneLegendDialog(global_canvas, legend_title, self) + self.active_legend_window.show() + + legend_card.mousePressEvent = handle_legend_click + + count = self.grid_layout.count() + row = count // 3 + col = count % 3 + self.grid_layout.addWidget(legend_card, row, col) + + + + + # def add_images_to_grid(self, result_dict): + # """ + # result_dict format: { file_path: {"main": bytes, "legend": bytes} } + # """ + # for file_path, images in result_dict.items(): + + # if self.grid_layout.count() == 0 and "legend" in images: + # self._add_legend_to_grid(images["legend"]) + + # # Create a container for this participant's results + # container = QFrame() + # container.setFrameShape(QFrame.StyledPanel) + # vbox = QVBoxLayout(container) + + # participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) + # title = QLabel(f"{participant_label}") + # title.setAlignment(Qt.AlignCenter) + # vbox.addWidget(title) + + # # We primarily want to show the 'main' plot in the grid + # if "main" in images: + # pixmap = self._bytes_to_pixmap(images["main"]) + # img_label = QLabel() + # # Scale it to fit the thumbnail size defined in __init__ + # img_label.setPixmap(pixmap.scaled( + # self.thumb_size, + # Qt.KeepAspectRatio, + # Qt.SmoothTransformation + # )) + # img_label.setAlignment(Qt.AlignCenter) + + # # Optional: Click to open full size + # img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t) + + # vbox.addWidget(img_label) + + # # Determine grid position (row-major order) + # count = self.grid_layout.count() + # row = count // 3 # 3 columns wide + # col = count % 3 + # self.grid_layout.addWidget(container, row, col) + + def add_images_to_grid(self, result_dict): + color_map = get_landmark_color_map() + + for file_path, channels_data in result_dict.items(): + participant_label = self.participant_map.get(file_path, os.path.basename(file_path)) + + if hasattr(self, 'global_channels_data'): + for ch_name, ch_data in channels_data.items(): + unique_key = f"{participant_label}_{ch_name}" + self.global_channels_data[unique_key] = ch_data + + # 1. Instantiate the background calculation engine matrix + participant_grid_canvas = InteractiveParticipantGridCanvas(channels_data, color_map) + + # 2. Build Card A (Channels Matrix Frame Layout) + # The matrix automatically installs inside its layout box container slot + matrix_card = participant_grid_canvas.create_matrix_card( + title_prefix=participant_label, + layout_to_attach_to=self.scroll_content_widget.layout() # Maps directly to your grid layout + ) + + # Pin Card A to the sequential grid coordinate tracker layout + count = self.grid_layout.count() - 1 # Subtract 1 because widget registration steps index values forward + row = count // 3 + col = count % 3 + self.grid_layout.addWidget(matrix_card, row, col) + + # 3. Build Card B (Total Summary Profile Frame Layout) + summary_card = participant_grid_canvas.create_total_summary_card( + title_prefix=participant_label, + layout_to_attach_to=self.scroll_content_widget.layout() + ) + + # Pin Card B directly next into the 3-column processing loop matrix layout tracker + count = self.grid_layout.count() - 1 + row = count // 3 + col = count % 3 + self.grid_layout.addWidget(summary_card, row, col) + + + def _bytes_to_pixmap(self, png_bytes): + """Converts raw bytes from the multiprocess queue to a QPixmap.""" + image = QImage.fromData(png_bytes) + return QPixmap.fromImage(image) + + def _open_full_size(self, pixmap, title): + """Simple popup to view the image at a readable scale.""" + view = QDialog(self) + view.setWindowTitle(f"Full View - {title}") + layout = QVBoxLayout(view) + label = QLabel() + label.setPixmap(pixmap) + layout.addWidget(label) + view.show() + + def _add_legend_to_grid(self, legend_bytes): + """Helper to put the legend in the first slot.""" + container = QFrame() + container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;") + vbox = QVBoxLayout(container) + + title = QLabel("Brodmann Area Legend") + title.setAlignment(Qt.AlignCenter) + vbox.addWidget(title) + + pixmap = self._bytes_to_pixmap(legend_bytes) + legend_label = QLabel() + # Legends are usually tall, so we scale it differently or keep it smaller + legend_label.setPixmap(pixmap.scaled( + self.thumb_size, + Qt.KeepAspectRatio, + Qt.SmoothTransformation + )) + legend_label.setAlignment(Qt.AlignCenter) + legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend") + + vbox.addWidget(legend_label) + self.grid_layout.addWidget(container, 0, 0) \ No newline at end of file diff --git a/src/analysis/participantfunctionalconnectivity.py b/src/analysis/participantfunctionalconnectivity.py index 3bfd953..a898989 100644 --- a/src/analysis/participantfunctionalconnectivity.py +++ b/src/analysis/participantfunctionalconnectivity.py @@ -1,3 +1,11 @@ +""" +Filename: participantfunctionalconnectivity.py +Description: Participant functional connectivity analysis window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + import os from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel, QMessageBox @@ -10,11 +18,11 @@ from src.shared.shareddata import APP_NAME class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget): def __init__(self, haemo_dict, epochs_dict): super().__init__("FunctionalConnectivityWidget") - self.setWindowTitle(f"{APP_NAME.upper} Functional Connectivity Viewer [BETA]") + self.setWindowTitle(f"Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}") self.haemo_dict = haemo_dict self.epochs_dict = epochs_dict - QMessageBox.warning(self, f"Warning - {APP_NAME.upper}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " + QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. " "By clicking OK, you accept that the images generated may not be factual.") # Create mappings: file_path -> participant label and dropdown display text @@ -229,4 +237,4 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget): flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin) else: - print(f"No method defined for index {idx}") + print(f"No method defined for index {idx}") \ No newline at end of file diff --git a/src/shared/flaresbasewidget.py b/src/shared/flaresbasewidget.py index 2880df6..e105e9c 100644 --- a/src/shared/flaresbasewidget.py +++ b/src/shared/flaresbasewidget.py @@ -1,3 +1,11 @@ +""" +Filename: flaresbasewidget.py +Description: Custom window design and supporting methods for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + import os from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QVBoxLayout, QWidget, QFrame, QSpinBox @@ -5,9 +13,28 @@ from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValida from PySide6.QtCore import QEvent, Qt from src.shared.shareddata import APP_NAME -from src.shared.flaresbasewidget import FullClickComboBox +class FullClickComboBox(QComboBox): + def __init__(self, parent=None): + super().__init__(parent) + self.setEditable(True) + self.lineEdit().setReadOnly(True) + self.lineEdit().installEventFilter(self) + + def eventFilter(self, obj, event): + if obj == self.lineEdit(): + + if event.type() == QEvent.MouseButtonPress: + return True + + if event.type() == QEvent.MouseButtonRelease: + self.showPopup() + return True + + return super().eventFilter(obj, event) + + class ClickableLabel(QLabel): def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): super().__init__() diff --git a/src/shared/shareddata.py b/src/shared/shareddata.py index 4db4875..4a05aac 100644 --- a/src/shared/shareddata.py +++ b/src/shared/shareddata.py @@ -1,3 +1,11 @@ +""" +Filename: shareddata.py +Description: Shared constants and methods for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + import sys import os import platform @@ -7,6 +15,8 @@ APP_NAME = "flares" API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" PLATFORM_NAME = platform.system().lower() +CHANGELOG_URL = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md" +WIKI_URL = "https://git.research.dezeeuw.ca/tyler/flares/wiki" PIPELINE_STAGES = [ "Preprocessing", diff --git a/src/window/about.py b/src/window/about.py index 377e487..5a34af2 100644 --- a/src/window/about.py +++ b/src/window/about.py @@ -1,3 +1,11 @@ +""" +Filename: about.py +Description: About window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel from PySide6.QtCore import Qt diff --git a/src/window/terminal.py b/src/window/terminal.py index e4600a0..d733254 100644 --- a/src/window/terminal.py +++ b/src/window/terminal.py @@ -1,3 +1,11 @@ +""" +Filename: terminal.py +Description: Terminal window for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit from PySide6.QtCore import Qt diff --git a/src/window/updateevents.py b/src/window/updateevents.py index fcbe25d..9ba612c 100644 --- a/src/window/updateevents.py +++ b/src/window/updateevents.py @@ -1,3 +1,11 @@ +""" +Filename: updateevents.py +Description: Methods to update snirf events for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + import os import json from enum import Enum, auto @@ -26,7 +34,7 @@ class UpdateEventsWindow(QWidget): self.mode = mode self.caller = caller or self.__class__.__name__ - self.setWindowTitle("Update event markers") + self.setWindowTitle(f"Update event markers - {APP_NAME.upper()}") self.resize(760, 200) print("INIT MODE:", mode) @@ -844,6 +852,4 @@ class UpdateEventsBlazesWindow(QWidget): QMessageBox.information(self, "Success", f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)") except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}") - - + QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}") \ No newline at end of file diff --git a/src/window/updateoptodes.py b/src/window/updateoptodes.py index 7339540..8710f9c 100644 --- a/src/window/updateoptodes.py +++ b/src/window/updateoptodes.py @@ -1,3 +1,11 @@ +""" +Filename: updateoptodes.py +Description: Methods to update optode locations for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + import os from pathlib import Path @@ -9,6 +17,7 @@ from PySide6.QtCore import Qt from mne.io import read_raw_snirf from mne_nirs.io import write_raw_snirf +from mne.channels import make_dig_montage from src.shared.shareddata import APP_NAME @@ -17,7 +26,7 @@ class UpdateOptodesWindow(QWidget): def __init__(self, parent=None): super().__init__(parent, Qt.WindowType.Window) - self.setWindowTitle("Update optode positions") + self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}") self.resize(760, 200) self.label_file_a = QLabel("SNIRF file:") @@ -136,7 +145,7 @@ class UpdateOptodesWindow(QWidget): def show_help_popup(self, text): msg = QMessageBox(self) - msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper}") + msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setText(text) msg.exec() @@ -284,4 +293,4 @@ class UpdateOptodesWindow(QWidget): # Read the SNIRF file, set the montage, and write it back raw = read_raw_snirf(file_a, preload=True) raw.set_montage(initial_montage) - write_raw_snirf(raw, save_path) + write_raw_snirf(raw, save_path) \ No newline at end of file diff --git a/src/window/userguide.py b/src/window/userguide.py index a5061d2..4a7c522 100644 --- a/src/window/userguide.py +++ b/src/window/userguide.py @@ -1,7 +1,15 @@ +""" +Filename: userguide.py +Description: User guide for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel from PySide6.QtCore import Qt -from src.shared.shareddata import APP_NAME, PIPELINE_STAGES +from src.shared.shareddata import APP_NAME, PIPELINE_STAGES, WIKI_URL class UserGuideWindow(QWidget): @@ -22,7 +30,7 @@ class UserGuideWindow(QWidget): label2_text = "\n".join(f"Stage {idx + 1}: {name}" for idx, name in enumerate(PIPELINE_STAGES)) + "\n" label2 = QLabel(label2_text, self) - label3 = QLabel(f"For more information, visit the Git wiki page here.", self) + label3 = QLabel(f"For more information, visit the Git wiki page here.", self) label3.setTextFormat(Qt.TextFormat.RichText) label3.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) label3.setOpenExternalLinks(True) diff --git a/src/window/viewerlauncher.py b/src/window/viewerlauncher.py new file mode 100644 index 0000000..de25ef0 --- /dev/null +++ b/src/window/viewerlauncher.py @@ -0,0 +1,122 @@ +""" +Filename: viewerlauncher.py +Description: Analysis options launcher for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + +from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout +from PySide6.QtCore import QTimer + +from src.analysis.exportcsv import ExportDataAsCSVViewerWidget +from src.analysis.group import GroupViewerWidget +from src.analysis.groupbrain import GroupBrainViewerWidget +from src.analysis.groupfunctionalconnectivity import GroupFunctionalConnectivityWidget +from src.analysis.participant import ParticipantViewerWidget +from src.analysis.participantbrain import ParticipantBrainViewerWidget +from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget +from src.analysis.participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget +from src.shared.shareddata import APP_NAME + + +class ViewerLauncherWidget(QWidget): + def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict, folding_bypass): + super().__init__() + self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}") + + group_dict = { + file_path: config.get("GROUP", "Unknown") + for file_path, config in config_dict.items() + } + + def launch(func, btn, *args): + func(*args) + self._trigger_success(btn) + + layout = QVBoxLayout(self) + + btn1 = QPushButton("Open Participant Viewer") + btn1.clicked.connect(lambda: launch(self.open_participant_viewer, btn1, haemo_dict, fig_bytes_dict)) + btn1.setEnabled(not folding_bypass) + + btn2 = QPushButton("Open Participant Brain Viewer") + btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict)) + btn2.setEnabled(not folding_bypass) + + btn3 = QPushButton("Open Participant Fold Channels Viewer") + btn3.clicked.connect(lambda: launch(self.open_participant_fold_channels_viewer, btn3, haemo_dict, cha_dict)) + + btn7 = QPushButton("Open Functional Connectivity Viewer [BETA]") + btn7.clicked.connect(lambda: launch(self.open_participant_functional_connectivity_viewer, btn7, haemo_dict, epochs_dict)) + btn7.setEnabled(not folding_bypass) + + btn8 = QPushButton("Open Group Functional Connectivity Viewer [BETA]") + btn8.clicked.connect(lambda: launch(self.open_group_functional_connectivity_viewer, btn8, haemo_dict, group_dict, config_dict)) + btn8.setEnabled(not folding_bypass) + + btn4 = QPushButton("Open Inter-Group Viewer") + btn4.clicked.connect(lambda: launch(self.open_group_viewer, btn4, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict)) + btn4.setEnabled(not folding_bypass) + + btn5 = QPushButton("Open Cross Group Brain Viewer") + btn5.clicked.connect(lambda: launch(self.open_group_brain_viewer, btn5, haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) + btn5.setEnabled(not folding_bypass) + + btn6 = QPushButton("Open Export Data As CSV Viewer") + btn6.clicked.connect(lambda: launch(self.open_export_data_as_csv_viewer, btn6, haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) + btn6.setEnabled(not folding_bypass) + + layout.addWidget(btn1) + layout.addWidget(btn2) + layout.addWidget(btn3) + layout.addWidget(btn7) + layout.addWidget(btn8) + layout.addWidget(btn4) + layout.addWidget(btn5) + layout.addWidget(btn6) + + def open_participant_viewer(self, haemo_dict, fig_bytes_dict): + self.participant_viewer = ParticipantViewerWidget(haemo_dict, fig_bytes_dict) + self.participant_viewer.show() + + def open_participant_brain_viewer(self, haemo_dict, cha_dict): + self.participant_brain_viewer = ParticipantBrainViewerWidget(haemo_dict, cha_dict) + self.participant_brain_viewer.show() + + def open_participant_fold_channels_viewer(self, haemo_dict, cha_dict): + self.participant_fold_channels_viewer = ParticipantFoldChannelsWidget(haemo_dict, cha_dict) + self.participant_fold_channels_viewer.show() + + def open_participant_functional_connectivity_viewer(self, haemo_dict, epochs_dict): + self.participant_brain_viewer = ParticipantFunctionalConnectivityWidget(haemo_dict, epochs_dict) + self.participant_brain_viewer.show() + + def open_group_functional_connectivity_viewer(self, haemo_dict, group, config_dict): + self.participant_brain_viewer = GroupFunctionalConnectivityWidget(haemo_dict, group, config_dict) + self.participant_brain_viewer.show() + + def open_group_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group): + self.participant_brain_viewer = GroupViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group) + self.participant_brain_viewer.show() + + def open_group_brain_viewer(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict): + self.participant_brain_viewer = GroupBrainViewerWidget(haemo_dict, df_ind, design_matrix, group, contrast_results_dict) + self.participant_brain_viewer.show() + + def open_export_data_as_csv_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict): + self.export_data_as_csv_viewer = ExportDataAsCSVViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict) + self.export_data_as_csv_viewer.show() + + def _trigger_success(self, button): + """Temporarily adds a green checkmark to the button text.""" + original_text = button.text() + button.setText(f"{original_text} ✔") + button.setStyleSheet("color: green; font-weight: bold;") + + # Revert after 1 second + QTimer.singleShot(1000, lambda: self._revert_button(button, original_text)) + + def _revert_button(self, button, original_text): + button.setText(original_text) + button.setStyleSheet("") \ No newline at end of file diff --git a/src/window/welcome.py b/src/window/welcome.py index 2e11c6d..ceb96f5 100644 --- a/src/window/welcome.py +++ b/src/window/welcome.py @@ -1,9 +1,17 @@ +""" +Filename: welcome.py +Description: Welcome dialog for FLARES + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton from PySide6.QtGui import QDesktopServices, QIcon from PySide6.QtCore import QUrl from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest -from src.shared.shareddata import APP_NAME, CURRENT_VERSION, resource_path +from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path class WelcomeDialog(QDialog): @@ -13,13 +21,13 @@ class WelcomeDialog(QDialog): self.setMinimumSize(550, 450) self.resize(800, 500) - # Main Layout layout = QVBoxLayout(self) - # Header Layout (Logo + App Name) header_layout = QHBoxLayout() logo_label = QLabel(self) - logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48)) # Fits cleanly in a header + + # NOTE: might not work on mac and need the icns file + logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48)) if direct: title_label = QLabel(f"

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

", self) else: @@ -30,19 +38,19 @@ class WelcomeDialog(QDialog): header_layout.addStretch() layout.addLayout(header_layout) - # Text Browser Area (Automatically converts Markdown syntax into clean formatted UI text) self.text_browser = QTextBrowser(self) self.text_browser.setHtml("

Loading latest updates from server...

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