Files
flares/src/analysis/participantfoldchannels.py
T
2026-08-23 00:12:22 -07:00

1121 lines
47 KiB
Python

"""
Filename: participantfoldchannels.py
Description: Logic for the Participant fOLD Channels analysis window
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in Imports
import os
from pathlib import Path
import time
import traceback
from multiprocessing import Process, current_process, Manager
from typing import Any, Dict, List, Optional, Tuple, Union
# External library imports
from matplotlib.backend_bases import Event
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, QLayout, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout
from PySide6.QtCore import QThread, Qt, QSize, QTimer, QObject, Signal
from PySide6.QtGui import QCloseEvent, QMouseEvent, QPixmap, QImage
from pandas import DataFrame
from mne.io.base import BaseRaw
from src.shared.flaresbasewidget import FlaresBaseWidget
from src.shared.shareddata import APP_NAME, resource_path
class MultiProgressDialog(QDialog):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setWindowTitle("fOLD Analysis Progress")
self.setFixedWidth(400)
self.setWindowModality(Qt.WindowModality.NonModal)
self.main_layout = QVBoxLayout(self)
self.bars: Dict[str, QProgressBar] = {}
self.allow_closing = False
def add_participant(self, label: Any, total_steps: Union[int, float, str]) -> None:
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.main_layout.addWidget(label_widget)
self.main_layout.addWidget(pbar)
self.bars[clean_key] = pbar
def update_bar(self, label: Any, value: Union[int, float, str]) -> None:
clean_key = str(label).strip()
if clean_key in self.bars:
# Force integers to prevent QProgressBar from breaking or flickering
self.bars[clean_key].setValue(int(value))
def closeEvent(self, event: QCloseEvent) -> None:
if self.allow_closing:
event.accept()
else:
event.ignore()
def force_close(self):
self.allow_closing = True
self.close()
def single_participant_worker(
file_path: str,
raw_data: Any,
result_queue: Any,
progress_queue: Any,
) -> None:
""" Runs inside its own dedicated process """
p_name = os.path.basename(file_path)
try:
from flares import fold_channels
# Perform the heavy fold_channels logic
channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=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() -> Dict[str, Tuple[float, float, float, float]]:
"""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: str,
data_list: List[Dict[str, Any]],
color_map: Dict[str, Union[str, Tuple[float, float, float, float]]],
image_path: Optional[str] = None,
parent: Optional[QWidget] = None,
) -> None:
self.fig = Figure(figsize=(11.0, 5.5))
self.ax = self.fig.subplots(1, 2)
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: Event) -> None:
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(f"[ERROR] Internal failure inside _on_hover loop: {err}")
traceback.print_exc()
def _explode_wedge(self, index_to_expand: int) -> None:
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) -> None:
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)
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: str, layout_to_attach_to: QLayout) -> QFrame:
"""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: Any) -> None:
# 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: Dict[str, Any]):
"""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 = resource_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: str, layout_to_attach_to: QLayout) -> QFrame:
"""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 = resource_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: QMouseEvent) -> None:
# 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: str) -> QFrame:
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"<b>{num}</b> — {name}"
else:
display_string = f"<b>{landmark_text}</b>"
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: str, summary_data: List[Any]) -> None:
"""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 = resource_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 ProcessOrchestrator(QObject):
# Fires when Manager + Processes are completely ready
# Emits: (manager_instance, result_queue, progress_queue, active_processes_list)
setup_finished = Signal(object, object, object, list)
setup_failed = Signal(str)
def __init__(self,
selected_files,
haemo_dict: dict[str, BaseRaw],
worker_func
):
super().__init__()
self.selected_files = selected_files
self.haemo_dict = haemo_dict
self.worker_func = worker_func
def run(self):
try:
# Instantiate Manager completely off the main thread
manager = Manager()
result_queue = manager.Queue()
progress_queue = manager.Queue()
active_processes = []
# Perform heavy pickling loop safely in the background
for file_path in self.selected_files:
p = Process(
target=self.worker_func,
args=(file_path, self.haemo_dict[file_path], result_queue, progress_queue)
)
p.start()
active_processes.append(p)
# Deliver setup assets back to the GUI Main Thread
self.setup_finished.emit(manager, result_queue, progress_queue, active_processes)
except Exception as e:
self.setup_failed.emit(str(e))
class ParticipantFoldChannelsWidget(FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame]
) -> None:
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.main_layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.main_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.main_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)
from datetime import datetime
print(f"Before: {datetime.now()}")
self.multi_progress.show()
print(f"After 1: {datetime.now()}")
if current_process().name == 'MainProcess':
# Create a clean background thread worker execution channel
self.orchestrator_thread = QThread()
self.orchestrator = ProcessOrchestrator(selected_files, self.haemo_dict, single_participant_worker)
self.orchestrator.moveToThread(self.orchestrator_thread)
# Signal Routing
self.orchestrator_thread.started.connect(self.orchestrator.run)
self.orchestrator.setup_finished.connect(self.on_orchestration_success)
self.orchestrator.setup_failed.connect(self.on_orchestration_failed)
# Automatic lifecycle cleanup configuration
self.orchestrator.setup_finished.connect(self.orchestrator_thread.quit)
self.orchestrator.setup_failed.connect(self.orchestrator_thread.quit)
self.orchestrator_thread.finished.connect(self.orchestrator_thread.deleteLater)
self.orchestrator.setup_finished.connect(self.orchestrator.deleteLater)
self.orchestrator.setup_failed.connect(self.orchestrator.deleteLater)
self.orchestrator_thread.start()
print(f"After 4: {datetime.now()}")
def on_orchestration_success(
self,
manager: Any,
result_queue: Any,
progress_queue: Any,
active_processes: List[Any]
) -> None:
""" Executed on the Main GUI Thread once background process setup finishes """
self.manager = manager
self.result_queue = result_queue
self.progress_queue = progress_queue
self.active_processes = active_processes
# Safely initialize and trigger the polling listener
self.completed_count = 0
self.result_timer = QTimer()
self.result_timer.timeout.connect(self.check_parallel_results)
self.result_timer.start()
def on_orchestration_failed(self, error_msg: str) -> None:
""" Fallback handler if Windows permissions or pickling fails in background """
if hasattr(self, 'multi_progress'):
self.multi_progress.close()
print(f"[CRITICAL FAILURE] Background Orchestration Failed:\n{error_msg}")
def check_parallel_results(self) -> None:
# 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.force_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
)
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: Dict[str, Dict[str, Any]]) -> None:
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: bytes) -> QPixmap:
"""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: QPixmap, title: str) -> None:
"""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: bytes) -> None:
"""Helper to put the legend in the first slot."""
container = QFrame()
container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;")
vbox = QVBoxLayout(container)
title = QLabel("<b>Brodmann Area Legend</b>")
title.setAlignment(Qt.AlignCenter)
vbox.addWidget(title)
pixmap = self._bytes_to_pixmap(legend_bytes)
legend_label = QLabel()
# Legends are usually tall, so we scale it differently or keep it smaller
legend_label.setPixmap(pixmap.scaled(
self.thumb_size,
Qt.KeepAspectRatio,
Qt.SmoothTransformation
))
legend_label.setAlignment(Qt.AlignCenter)
legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend")
vbox.addWidget(legend_label)
self.grid_layout.addWidget(container, 0, 0)