functional connectivity, pylance, and other improvements

This commit is contained in:
2026-08-22 23:38:13 -07:00
parent 19bd3f1279
commit e37275a1bb
14 changed files with 1455 additions and 841 deletions
+82 -92
View File
@@ -6,11 +6,16 @@ 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
@@ -18,25 +23,27 @@ 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 QThread, Qt, QSize, QTimer
from PySide6.QtGui import QPixmap, QImage
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=None):
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.layout = QVBoxLayout(self)
self.bars = {}
self.main_layout = QVBoxLayout(self)
self.bars: Dict[str, QProgressBar] = {}
self.allow_closing = False
def add_participant(self, label, total_steps):
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()
@@ -44,16 +51,17 @@ class MultiProgressDialog(QDialog):
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
self.main_layout.addWidget(label_widget)
self.main_layout.addWidget(pbar)
self.bars[clean_key] = pbar
def update_bar(self, label, value):
if label in self.bars:
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[label].setValue(int(value))
self.bars[clean_key].setValue(int(value))
def closeEvent(self, event):
def closeEvent(self, event: QCloseEvent) -> None:
if self.allow_closing:
event.accept()
else:
@@ -64,8 +72,13 @@ class MultiProgressDialog(QDialog):
self.close()
def single_participant_worker(
file_path: str,
raw_data: Any,
result_queue: Any,
progress_queue: Any,
) -> None:
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:
@@ -81,8 +94,7 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue)
progress_queue.put(f"ERROR: {p_name} - {str(e)}")
def get_landmark_color_map():
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",
@@ -116,7 +128,15 @@ 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):
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)
@@ -194,7 +214,7 @@ class StaticChannelCanvas(FigureCanvas):
self.mpl_connect('motion_notify_event', self._on_hover)
def _on_hover(self, event):
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]:
@@ -231,10 +251,10 @@ class StaticChannelCanvas(FigureCanvas):
self.draw_idle()
except Exception as err:
print("[ERROR] Internal failure inside _on_hover loop:")
print(f"[ERROR] Internal failure inside _on_hover loop: {err}")
traceback.print_exc()
def _explode_wedge(self, index_to_expand):
def _explode_wedge(self, index_to_expand: int) -> None:
changed = False
for idx, wedge in enumerate(self.wedges):
if idx == index_to_expand:
@@ -252,7 +272,7 @@ class StaticChannelCanvas(FigureCanvas):
if changed:
self.draw_idle()
def _reset_wedges(self):
def _reset_wedges(self) -> None:
changed = False
for wedge in self.wedges:
if wedge.center != (0.0, 0.0):
@@ -274,7 +294,7 @@ class StandaloneLegendDialog(QWidget):
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)
legend_card = canvas_engine.create_legend_card(title_prefix)
layout.addWidget(legend_card)
@@ -381,7 +401,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self.mpl_connect('button_press_event', self._on_canvas_click)
def create_matrix_card(self, title_prefix, layout_to_attach_to):
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()
@@ -422,7 +442,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
layout_to_attach_to.addWidget(card_frame)
return card_frame
def _on_canvas_click(self, event):
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()
@@ -473,7 +493,8 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
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):
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)
@@ -553,7 +574,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._open_popups.append(popup)
def create_total_summary_card(self, title_prefix, layout_to_attach_to):
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)
@@ -609,7 +630,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
card_layout.addWidget(summary_canvas)
card_layout.addStretch(0)
def handle_card_click(event):
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)
@@ -626,7 +647,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
def create_legend_card(self, title_prefix, layout_to_attach_to):
def create_legend_card(self, title_prefix: str) -> QFrame:
card = QFrame()
card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }")
@@ -686,7 +707,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
return card
def _open_expanded_summary_window(self, title_prefix, summary_data):
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}")
@@ -719,16 +740,18 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._summary_popups.append(popup)
from PySide6.QtCore import QObject, Signal
from multiprocessing import Manager, Process
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, worker_func):
def __init__(self,
selected_files,
haemo_dict: dict[str | Path, BaseRaw],
worker_func
):
super().__init__()
self.selected_files = selected_files
self.haemo_dict = haemo_dict
@@ -758,7 +781,12 @@ class ProcessOrchestrator(QObject):
class ParticipantFoldChannelsWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict):
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
@@ -773,9 +801,9 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.main_layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
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)
@@ -829,7 +857,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
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.main_layout.addWidget(self.scroll_area)
self.thumb_size = QSize(280, 180)
self.showMaximized()
@@ -889,7 +917,14 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.orchestrator_thread.start()
print(f"After 4: {datetime.now()}")
def on_orchestration_success(self, manager, result_queue, progress_queue, active_processes):
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
@@ -902,15 +937,15 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.result_timer.timeout.connect(self.check_parallel_results)
self.result_timer.start()
def on_orchestration_failed(self, error_msg):
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):
def check_parallel_results(self) -> None:
# Check for progress/completion signals
while not self.progress_queue.empty():
@@ -991,8 +1026,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
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()
title_prefix=legend_title
)
def handle_legend_click(event):
@@ -1006,52 +1040,8 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
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"<b>{participant_label}</b>")
# title.setAlignment(Qt.AlignCenter)
# vbox.addWidget(title)
# # We primarily want to show the 'main' plot in the grid
# if "main" in images:
# pixmap = self._bytes_to_pixmap(images["main"])
# img_label = QLabel()
# # Scale it to fit the thumbnail size defined in __init__
# img_label.setPixmap(pixmap.scaled(
# self.thumb_size,
# Qt.KeepAspectRatio,
# Qt.SmoothTransformation
# ))
# img_label.setAlignment(Qt.AlignCenter)
# # Optional: Click to open full size
# img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t)
# vbox.addWidget(img_label)
# # Determine grid position (row-major order)
# count = self.grid_layout.count()
# row = count // 3 # 3 columns wide
# col = count % 3
# self.grid_layout.addWidget(container, row, col)
def add_images_to_grid(self, result_dict):
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():
@@ -1091,12 +1081,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.grid_layout.addWidget(summary_card, row, col)
def _bytes_to_pixmap(self, png_bytes):
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, title):
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}")
@@ -1106,7 +1096,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
layout.addWidget(label)
view.show()
def _add_legend_to_grid(self, legend_bytes):
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;")