3 Commits

Author SHA1 Message Date
3e0f70ea49 fixes to build version 1.1.3 2025-10-15 12:59:24 -07:00
d6c71e0ab2 changelog fixes and further updates to cancel running process 2025-10-15 10:48:07 -07:00
87073fb218 more boris implementation 2025-10-15 10:00:44 -07:00
5 changed files with 693 additions and 42 deletions

View File

@@ -1,3 +1,22 @@
# Version 1.1.3
- Added back the ability to use the fOLD dataset. Fixes [Issue 23](https://git.research.dezeeuw.ca/tyler/flares/issues/23)
- 5th option has been added under Analysis to get to fOLD channels per participant
- Added an option to cancel the running process. Fixes [Issue 15](https://git.research.dezeeuw.ca/tyler/flares/issues/15)
- Prevented graph images from showing when participants are being processed. Fixes [Issue 24](https://git.research.dezeeuw.ca/tyler/flares/issues/24)
- Allow the option to remove all events of a type from all loaded snirfs. Fixes [Issue 25](https://git.research.dezeeuw.ca/tyler/flares/issues/25)
- Added new icons in the menu bar
- Added a terminal to interact with the app in a more command-like form
- Currently the terminal has no functionality but some features for batch operations will be coming soon!
- Inter-Group viewer now has the option to visualize the average response on the brain of all participants in the group. Fixes [Issue 26](https://git.research.dezeeuw.ca/tyler/flares/issues/24)
# Version 1.1.2
- Fixed incorrect colormaps being applied
- Added functionality to utilize external event markers from a file. Fixes [Issue 6](https://git.research.dezeeuw.ca/tyler/flares/issues/6)
# Version 1.1.1 # Version 1.1.1
- Fixed the number of rectangles in the progress bar to 19 - Fixed the number of rectangles in the progress bar to 19

View File

@@ -55,7 +55,7 @@ import pyvistaqt # type: ignore
# External library imports for mne # External library imports for mne
from mne import ( from mne import (
EvokedArray, SourceEstimate, Info, Epochs, Label, EvokedArray, SourceEstimate, Info, Epochs, Label, Annotations,
events_from_annotations, read_source_spaces, events_from_annotations, read_source_spaces,
stc_near_sensors, pick_types, grand_average, get_config, set_config, read_labels_from_annot stc_near_sensors, pick_types, grand_average, get_config, set_config, read_labels_from_annot
) # type: ignore ) # type: ignore
@@ -132,6 +132,8 @@ ENHANCE_NEGATIVE_CORRELATION: bool
SHORT_CHANNEL: bool SHORT_CHANNEL: bool
REMOVE_EVENTS: list
VERBOSITY = True VERBOSITY = True
# FIXME: Shouldn't need each ordering - just order it before checking # FIXME: Shouldn't need each ordering - just order it before checking
@@ -179,6 +181,7 @@ REQUIRED_KEYS: dict[str, Any] = {
"PSP_THRESHOLD": float, "PSP_THRESHOLD": float,
"SHORT_CHANNEL": bool, "SHORT_CHANNEL": bool,
"REMOVE_EVENTS": list,
# "REJECT_PAIRS": bool, # "REJECT_PAIRS": bool,
# "FORCE_DROP_ANNOTATIONS": list, # "FORCE_DROP_ANNOTATIONS": list,
# "FILTER_LOW_PASS": float, # "FILTER_LOW_PASS": float,
@@ -1074,7 +1077,7 @@ def epochs_calculations(raw_haemo, events, event_dict):
# Plot drop log # Plot drop log
# TODO: Why show this if we never use epochs2? # TODO: Why show this if we never use epochs2?
fig_epochs_dropped = epochs2.plot_drop_log() fig_epochs_dropped = epochs2.plot_drop_log(show=False)
fig_epochs.append(("fig_epochs_dropped", fig_epochs_dropped)) fig_epochs.append(("fig_epochs_dropped", fig_epochs_dropped))
# Plot for each condition # Plot for each condition
@@ -1470,9 +1473,15 @@ def resource_path(relative_path):
def fold_channels(raw: BaseRaw) -> None: def fold_channels(raw: BaseRaw) -> None:
# if getattr(sys, 'frozen', False):
path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary"
logger.info(path)
set_config('MNE_NIRS_FOLD_PATH', resource_path(path)) # type: ignore
# Locate the fOLD excel files # # Locate the fOLD excel files
set_config('MNE_NIRS_FOLD_PATH', resource_path("../../mne_data/fOLD/fOLD-public-master/Supplementary")) # type: ignore # else:
# logger.info("yabba")
# set_config('MNE_NIRS_FOLD_PATH', resource_path("../../mne_data/fOLD/fOLD-public-master/Supplementary")) # type: ignore
output = None output = None
@@ -1534,8 +1543,8 @@ def fold_channels(raw: BaseRaw) -> None:
"Brain_Outside", "Brain_Outside",
] ]
cmap1 = plt.cm.get_cmap('tab20') # First 20 colors cmap1 = plt.get_cmap('tab20') # First 20 colors
cmap2 = plt.cm.get_cmap('tab20b') # Next 20 colors cmap2 = plt.get_cmap('tab20b') # Next 20 colors
# Combine the colors from both colormaps # Combine the colors from both colormaps
colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] # Total 40 colors colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] # Total 40 colors
@@ -1611,6 +1620,7 @@ def fold_channels(raw: BaseRaw) -> None:
for ax in axes[len(hbo_channel_names):]: for ax in axes[len(hbo_channel_names):]:
ax.axis('off') ax.axis('off')
plt.show()
return fig, legend_fig return fig, legend_fig
@@ -2935,6 +2945,19 @@ def process_participant(file_path, progress_callback=None):
logger.info("14") logger.info("14")
# Step 14: Design Matrix # Step 14: Design Matrix
events_to_remove = REMOVE_EVENTS
filtered_annotations = [ann for ann in raw.annotations if ann['description'] not in events_to_remove]
new_annot = Annotations(
onset=[ann['onset'] for ann in filtered_annotations],
duration=[ann['duration'] for ann in filtered_annotations],
description=[ann['description'] for ann in filtered_annotations]
)
# Set the new annotations
raw_haemo.set_annotations(new_annot)
design_matrix, fig_design_matrix = make_design_matrix(raw_haemo, short_chans) design_matrix, fig_design_matrix = make_design_matrix(raw_haemo, short_chans)
fig_individual["Design Matrix"] = fig_design_matrix fig_individual["Design Matrix"] = fig_design_matrix
if progress_callback: progress_callback(15) if progress_callback: progress_callback(15)

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H160v400Zm140-40-56-56 103-104-104-104 57-56 160 160-160 160Zm180 0v-80h240v80H480Z"/></svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M280-160v-80h400v80H280Zm160-160v-327L336-544l-56-56 200-200 200 200-56 56-104-103v327h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 216 B

679
main.py
View File

@@ -42,11 +42,14 @@ from PySide6.QtWidgets import (
) )
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize from PySide6.QtCore import QThread, Signal, Qt, QTimer, QEvent, QSize
from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator, QPixmap, QStandardItemModel, QStandardItem
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
CURRENT_VERSION = "1.0.1" CURRENT_VERSION = "1.0.1"
API_URL = "https://git.research.dezeeuw.ca/api/v1/repos/tyler/flares/releases" API_URL = "https://git.research.dezeeuw.ca/api/v1/repos/tyler/flares/releases"
API_URL_SECONDARY = "https://git.research2.dezeeuw.ca/api/v1/repos/tyler/flares/releases"
PLATFORM_NAME = platform.system().lower() PLATFORM_NAME = platform.system().lower()
# Selectable parameters on the right side of the window # Selectable parameters on the right side of the window
@@ -143,6 +146,7 @@ SECTIONS = [
{ {
"title": "Design Matrix", "title": "Design Matrix",
"params": [ "params": [
{"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
# {"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Drift model for GLM."}, # {"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Drift model for GLM."},
# {"name": "DURATION_BETWEEN_ACTIVITIES", "default": 35, "type": int, "help": "Time between activities (s)."}, # {"name": "DURATION_BETWEEN_ACTIVITIES", "default": 35, "type": int, "help": "Time between activities (s)."},
# {"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "help": "Use short channel regression."}, # {"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "help": "Use short channel regression."},
@@ -170,6 +174,58 @@ SECTIONS = [
class TerminalWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("Terminal - FLARES")
self.output_area = QTextEdit()
self.output_area.setReadOnly(True)
self.input_line = QLineEdit()
self.input_line.returnPressed.connect(self.handle_command)
layout = QVBoxLayout()
layout.addWidget(self.output_area)
layout.addWidget(self.input_line)
self.setLayout(layout)
self.commands = {
"hello": self.cmd_hello,
"help": self.cmd_help
}
def handle_command(self):
command_text = self.input_line.text()
self.input_line.clear()
self.output_area.append(f"> {command_text}")
parts = command_text.strip().split()
if not parts:
return
command_name = parts[0]
args = parts[1:]
func = self.commands.get(command_name)
if func:
try:
result = func(*args)
if result:
self.output_area.append(str(result))
except Exception as e:
self.output_area.append(f"[Error] {e}")
else:
self.output_area.append(f"[Unknown command] '{command_name}'")
def cmd_hello(self, *args):
return "Hello from the terminal!"
def cmd_help(self, *args):
return f"Available commands: {', '.join(self.commands.keys())}"
class UpdateDownloadThread(QThread): class UpdateDownloadThread(QThread):
""" """
Thread that downloads and extracts an update package and emits a signal on completion or error. Thread that downloads and extracts an update package and emits a signal on completion or error.
@@ -276,22 +332,28 @@ class UpdateCheckThread(QThread):
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2)) return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def get_latest_release_for_platform(self): def get_latest_release_for_platform(self):
response = requests.get(API_URL, timeout=5) urls = [API_URL, API_URL_SECONDARY]
response.raise_for_status() for url in urls:
releases = response.json() try:
response = requests.get(API_URL, timeout=5)
response.raise_for_status()
releases = response.json()
if not releases: if not releases:
return None, None return None, None
latest = releases[0] latest = releases[0]
tag = latest["tag_name"].lstrip("v") tag = latest["tag_name"].lstrip("v")
for asset in latest.get("assets", []): for asset in latest.get("assets", []):
if PLATFORM_NAME in asset["name"].lower(): if PLATFORM_NAME in asset["name"].lower():
return tag, asset["browser_download_url"] return tag, asset["browser_download_url"]
return tag, None return tag, None
except (requests.RequestException, ValueError) as e:
continue
return None, None
class LocalPendingUpdateCheckThread(QThread): class LocalPendingUpdateCheckThread(QThread):
@@ -668,13 +730,12 @@ class UpdateEventsWindow(QWidget):
self.description = QLabel() self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextFormat(Qt.TextFormat.RichText)
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
self.description.setOpenExternalLinks(False) # Handle the click internally self.description.setOpenExternalLinks(True)
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>" self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>" "Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
"Using a .txt file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>" "The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>" "selected below by selecting the correct BORIS observation and time syncing it to an event that it shares with the snirf file.")
"An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>.")
layout.addWidget(self.description) layout.addWidget(self.description)
@@ -1112,6 +1173,11 @@ class ProgressBubble(QWidget):
rect.setStyleSheet("background-color: yellow; border: 1px solid gray;") rect.setStyleSheet("background-color: yellow; border: 1px solid gray;")
else: else:
rect.setStyleSheet("background-color: white; border: 1px solid gray;") rect.setStyleSheet("background-color: white; border: 1px solid gray;")
def mark_cancelled(self):
if 0 <= self.current_step < len(self.rects):
rect = self.rects[self.current_step]
rect.setStyleSheet("background-color: red; border: 1px solid gray;")
def mousePressEvent(self, event): def mousePressEvent(self, event):
self.clicked.emit(self) self.clicked.emit(self)
@@ -1201,6 +1267,8 @@ class ParamSection(QWidget):
widget = QLineEdit() widget = QLineEdit()
widget.setValidator(QDoubleValidator()) widget.setValidator(QDoubleValidator())
widget.setText(str(param["default"])) widget.setText(str(param["default"]))
elif param["type"] == list:
widget = self._create_multiselect_dropdown(None)
else: else:
widget = QLineEdit() widget = QLineEdit()
widget.setText(str(param["default"])) widget.setText(str(param["default"]))
@@ -1216,6 +1284,81 @@ class ParamSection(QWidget):
"type": param["type"] "type": param["type"]
} }
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...")
dummy_item = QStandardItem("<None Selected>")
dummy_item.setFlags(Qt.ItemIsEnabled)
model.appendRow(dummy_item)
toggle_item = QStandardItem("Toggle Select All")
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(toggle_item)
if items is not None:
for item in items:
standard_item = QStandardItem(item)
standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
standard_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(standard_item)
combo.setInsertPolicy(QComboBox.NoInsert)
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)
self._updating_checkstates = False
def on_item_changed(item):
if self._updating_checkstates:
return
self._updating_checkstates = True
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle
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)
elif item == dummy_item:
pass
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)
self._updating_checkstates = False
for param_name, info in self.widgets.items():
if info["widget"] == combo:
self.update_dropdown_label(param_name)
break
model.itemChanged.connect(on_item_changed)
combo.setInsertPolicy(QComboBox.NoInsert)
return combo
def show_help_popup(self, text): def show_help_popup(self, text):
msg = QMessageBox(self) msg = QMessageBox(self)
@@ -1232,6 +1375,8 @@ class ParamSection(QWidget):
if expected_type == bool: if expected_type == bool:
values[name] = widget.currentText() == "True" values[name] = widget.currentText() == "True"
elif expected_type == list:
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()]
else: else:
raw_text = widget.text() raw_text = widget.text()
try: try:
@@ -1241,9 +1386,6 @@ class ParamSection(QWidget):
values[name] = float(raw_text) values[name] = float(raw_text)
elif expected_type == str: elif expected_type == str:
values[name] = raw_text values[name] = raw_text
elif expected_type == list:
# Very basic CSV parsing - fix?
values[name] = [x.strip() for x in raw_text.split(",") if x.strip()]
else: else:
values[name] = raw_text # Fallback values[name] = raw_text # Fallback
except Exception as e: except Exception as e:
@@ -1251,6 +1393,155 @@ class ParamSection(QWidget):
return values return values
def update_dropdown_items(self, param_name, new_items):
"""
Updates the items in a multi-select dropdown parameter field.
Args:
param_name (str): The parameter name (must match one in self.widgets).
new_items (list): The new items to populate in the dropdown.
"""
widget_info = self.widgets.get(param_name)
print("[ParamSection] Current widget keys:", list(self.widgets.keys()))
if not widget_info:
print(f"[ParamSection] No widget found for param '{param_name}'")
return
widget = widget_info["widget"]
if not isinstance(widget, FullClickComboBox):
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
return
# Replace the model on the existing widget
new_model = QStandardItemModel()
dummy_item = QStandardItem("<None Selected>")
dummy_item.setFlags(Qt.ItemIsEnabled)
new_model.appendRow(dummy_item)
toggle_item = QStandardItem("Toggle Select All")
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
new_model.appendRow(toggle_item)
for item_text in new_items:
item = QStandardItem(item_text)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setData(Qt.Unchecked, Qt.CheckStateRole)
new_model.appendRow(item)
widget.setModel(new_model)
widget.setView(QListView()) # Reset view to refresh properly
def on_view_clicked(index):
item = new_model.itemFromIndex(index)
if item.isCheckable():
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
item.setCheckState(new_state)
widget.view().pressed.connect(on_view_clicked)
def on_item_changed(item):
if getattr(self, "_updating_checkstates", False):
return
self._updating_checkstates = True
normal_items = [new_model.item(i) for i in range(2, new_model.rowCount())]
if item == toggle_item:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
for i in normal_items:
i.setCheckState(Qt.Unchecked if all_checked else Qt.Checked)
toggle_item.setCheckState(Qt.Unchecked if all_checked else Qt.Checked)
else:
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
self._updating_checkstates = False
for param_name, info in self.widgets.items():
if info["widget"] == widget:
self.update_dropdown_label(param_name)
break
new_model.itemChanged.connect(on_item_changed)
widget.lineEdit().setText("<None Selected>")
def _get_checked_items(self, combo):
checked = []
model = combo.model()
for i in range(model.rowCount()):
item = model.item(i)
if item.text() in ("<None Selected>", "Toggle Select All"):
continue
if item.checkState() == Qt.Checked:
checked.append(item.text())
return checked
def update_dropdown_label(self, param_name):
widget_info = self.widgets.get(param_name)
if not widget_info:
print(f"[ParamSection] No widget found for param '{param_name}'")
return
widget = widget_info["widget"]
if not isinstance(widget, FullClickComboBox):
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
return
selected = self._get_checked_items(widget)
if not selected:
widget.lineEdit().setText("<None Selected>")
else:
# You can customize how you display selected items here:
widget.lineEdit().setText(", ".join(selected))
def update_annotation_dropdown_from_folder(self, folder_path):
"""
Reads all EEG files in the given folder, extracts annotations using MNE,
and updates the dropdown for the `target_param` with the set of common annotations.
Args:
folder_param (str): The name of the parameter holding the folder path.
target_param (str): The name of the multi-select dropdown to update.
"""
# folder_path_widget = self.widgets.get(folder_param, {}).get("widget")
# if not folder_path_widget:
# print(f"[ParamSection] Folder path param '{folder_param}' not found.")
# return
# folder_path = folder_path_widget.text().strip()
# if not os.path.isdir(folder_path):
# print(f"[ParamSection] '{folder_path}' is not a valid directory.")
# return
annotation_sets = []
for filename in os.listdir(folder_path):
full_path = os.path.join(folder_path, filename)
try:
raw = read_raw_snirf(full_path, preload=False, verbose="ERROR")
annotations = raw.annotations
if annotations is not None:
labels = set(annotations.description)
annotation_sets.append(labels)
except Exception as e:
print(f"[ParamSection] Skipping file '{filename}' due to error: {e}")
if not annotation_sets:
print(f"[ParamSection] No annotations found in folder '{folder_path}'")
return
# Get common annotations
common_annotations = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0]
common_annotations = sorted(list(common_annotations)) # for consistent order
print(f"[ParamSection] Common annotations: {common_annotations}")
# Update the dropdown
self.update_dropdown_items("REMOVE_EVENTS", common_annotations)
class FullClickLineEdit(QLineEdit): class FullClickLineEdit(QLineEdit):
def mousePressEvent(self, event): def mousePressEvent(self, event):
@@ -1865,6 +2156,202 @@ class ParticipantBrainViewerWidget(QWidget):
class ParticipantFoldChannelsWidget(QWidget):
def __init__(self, haemo_dict, cha_dict):
super().__init__()
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(QLabel("This will cause the app to hang for ~30s!"))
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 _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...")
dummy_item = QStandardItem("<None Selected>")
dummy_item.setFlags(Qt.ItemIsEnabled)
model.appendRow(dummy_item)
toggle_item = QStandardItem("Toggle Select All")
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(toggle_item)
for item in items:
standard_item = QStandardItem(item)
standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
standard_item.setData(Qt.Unchecked, Qt.CheckStateRole)
model.appendRow(standard_item)
combo.setInsertPolicy(QComboBox.NoInsert)
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)
self._updating_checkstates = False
def on_item_changed(item):
if self._updating_checkstates:
return
self._updating_checkstates = True
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle
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)
elif item == dummy_item:
pass
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)
# Update label text immediately after change
if combo == self.participant_dropdown:
self.update_participant_dropdown_label()
elif combo == self.image_index_dropdown:
self.update_image_index_dropdown_label()
self._updating_checkstates = False
model.itemChanged.connect(on_item_changed)
combo.setInsertPolicy(QComboBox.NoInsert)
return combo
def _get_checked_items(self, combo):
checked = []
model = combo.model()
for i in range(model.rowCount()):
item = model.item(i)
# Skip dummy and toggle items:
if item.text() in ("<None Selected>", "Toggle Select All"):
continue
if item.checkState() == Qt.Checked:
checked.append(item.text())
return checked
def update_participant_dropdown_label(self):
selected = self._get_checked_items(self.participant_dropdown)
if not selected:
self.participant_dropdown.lineEdit().setText("<None Selected>")
else:
# Extract just "Participant N" from "Participant N (filename)"
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
self.participant_dropdown.lineEdit().setText(", ".join(selected_short))
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("<None Selected>")
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 show_fold_images(self):
import flares
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)
]
# Pass the necessary arguments to each method
for file_path in selected_file_paths:
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:
if idx == 0:
flares.fold_channels(haemo_obj)
else:
print(f"No method defined for index {idx}")
class ClickableLabel(QLabel): class ClickableLabel(QLabel):
def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap):
super().__init__() super().__init__()
@@ -2070,7 +2557,7 @@ class GroupViewerWidget(QWidget):
self.index_texts = [ self.index_texts = [
"0 (GLM Results)", "0 (GLM Results)",
"1 (Significance)", "1 (Significance)",
# "2 (third_image)", "2 (Brain Activity Visualization)",
# "3 (fourth image)", # "3 (fourth image)",
] ]
@@ -2373,6 +2860,32 @@ class GroupViewerWidget(QWidget):
"type": float, "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 # Inject full_text from index_texts
@@ -2395,8 +2908,13 @@ class GroupViewerWidget(QWidget):
all_cha = pd.DataFrame() all_cha = pd.DataFrame()
for fp in selected_file_paths: for file_path in selected_file_paths:
cha_df = self.cha.get(fp) haemo_obj = self.haemo_dict.get(file_path)
if haemo_obj is None:
continue
cha_df = self.cha.get(file_path)
if cha_df is not None: if cha_df is not None:
all_cha = pd.concat([all_cha, cha_df], ignore_index=True) all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
@@ -2451,6 +2969,20 @@ class GroupViewerWidget(QWidget):
df_contrasts = pd.concat(all_contrasts, ignore_index=True) df_contrasts = pd.concat(all_contrasts, ignore_index=True)
flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds) 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
flares.brain_3d_visualization(haemo_obj, 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: elif idx == 3:
pass pass
@@ -3017,18 +3549,22 @@ class ViewerLauncherWidget(QWidget):
btn2 = QPushButton("Open Participant Brain Viewer") btn2 = QPushButton("Open Participant Brain Viewer")
btn2.clicked.connect(lambda: self.open_participant_brain_viewer(haemo_dict, cha_dict)) btn2.clicked.connect(lambda: self.open_participant_brain_viewer(haemo_dict, cha_dict))
btn3 = QPushButton("Open Participant Fold Channels Viewer")
btn3.clicked.connect(lambda: self.open_participant_fold_channels_viewer(haemo_dict, cha_dict))
btn3 = QPushButton("Open Inter-Group Viewer") btn4 = QPushButton("Open Inter-Group Viewer")
btn3.clicked.connect(lambda: self.open_group_viewer(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group)) btn4.clicked.connect(lambda: self.open_group_viewer(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group))
btn4 = QPushButton("Open Cross Group Brain Viewer") btn5 = QPushButton("Open Cross Group Brain Viewer")
btn4.clicked.connect(lambda: self.open_group_brain_viewer(haemo_dict, df_ind, design_matrix, group, contrast_results_dict)) btn5.clicked.connect(lambda: self.open_group_brain_viewer(haemo_dict, df_ind, design_matrix, group, contrast_results_dict))
layout.addWidget(btn1) layout.addWidget(btn1)
layout.addWidget(btn2) layout.addWidget(btn2)
layout.addWidget(btn3) layout.addWidget(btn3)
layout.addWidget(btn4) layout.addWidget(btn4)
layout.addWidget(btn5)
def open_participant_viewer(self, haemo_dict, fig_bytes_dict): def open_participant_viewer(self, haemo_dict, fig_bytes_dict):
self.participant_viewer = ParticipantViewerWidget(haemo_dict, fig_bytes_dict) self.participant_viewer = ParticipantViewerWidget(haemo_dict, fig_bytes_dict)
@@ -3037,6 +3573,10 @@ class ViewerLauncherWidget(QWidget):
def open_participant_brain_viewer(self, haemo_dict, cha_dict): def open_participant_brain_viewer(self, haemo_dict, cha_dict):
self.participant_brain_viewer = ParticipantBrainViewerWidget(haemo_dict, cha_dict) self.participant_brain_viewer = ParticipantBrainViewerWidget(haemo_dict, cha_dict)
self.participant_brain_viewer.show() 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_group_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group): 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 = GroupViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group)
@@ -3063,9 +3603,12 @@ class MainApplication(QMainWindow):
self.help = None self.help = None
self.optodes = None self.optodes = None
self.events = None self.events = None
self.terminal = None
self.bubble_widgets = {} self.bubble_widgets = {}
self.param_sections = [] self.param_sections = []
self.folder_paths = [] self.folder_paths = []
self.section_widget = None
self.first_run = True
self.init_ui() self.init_ui()
self.create_menu_bar() self.create_menu_bar()
@@ -3294,8 +3837,8 @@ class MainApplication(QMainWindow):
options_actions = [ options_actions = [
("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")), ("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")),
("Check for Updates", "F5", self.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")), ("Check for Updates", "F5", self.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
("Update optodes in snirf file...", "F6", self.update_optode_positions, resource_path("icons/update_24dp_1F1F1F.svg")), ("Update optodes in snirf file...", "F6", self.update_optode_positions, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
("Update events in snirf file...", "F7", self.update_event_markers, resource_path("icons/update_24dp_1F1F1F.svg")), ("Update events in snirf file...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg")) ("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg"))
] ]
@@ -3304,6 +3847,13 @@ class MainApplication(QMainWindow):
if i == 1 or i == 3: # after the first 2 actions (0,1) if i == 1 or i == 3: # after the first 2 actions (0,1)
options_menu.addSeparator() options_menu.addSeparator()
terminal_menu = menu_bar.addMenu("Terminal")
terminal_actions = [
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
]
for name, shortcut, slot, icon in terminal_actions:
terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon))
self.statusbar = self.statusBar() self.statusbar = self.statusBar()
self.statusbar.showMessage("Ready") self.statusbar.showMessage("Ready")
@@ -3318,14 +3868,16 @@ class MainApplication(QMainWindow):
# Add ParamSection widgets from SECTIONS # Add ParamSection widgets from SECTIONS
for section in SECTIONS: for section in SECTIONS:
section_widget = ParamSection(section) self.section_widget = ParamSection(section)
self.rows_layout.addWidget(section_widget) self.rows_layout.addWidget(self.section_widget)
self.param_sections.append(section_widget) self.param_sections.append(self.section_widget)
def clear_all(self): def clear_all(self):
self.cancel_task()
self.right_column_widget.hide() self.right_column_widget.hide()
# Clear the bubble layout # Clear the bubble layout
@@ -3385,6 +3937,11 @@ class MainApplication(QMainWindow):
if self.help is None or not self.help.isVisible(): if self.help is None or not self.help.isVisible():
self.help = UserGuideWindow(self) self.help = UserGuideWindow(self)
self.help.show() self.help.show()
def terminal_gui(self):
if self.terminal is None or not self.terminal.isVisible():
self.terminal = TerminalWindow(self)
self.terminal.show()
def update_optode_positions(self): def update_optode_positions(self):
if self.optodes is None or not self.optodes.isVisible(): if self.optodes is None or not self.optodes.isVisible():
@@ -3415,6 +3972,12 @@ class MainApplication(QMainWindow):
if folder_path: if folder_path:
self.selected_path = folder_path # store the folder path self.selected_path = folder_path # store the folder path
self.show_files_as_bubbles(folder_path) self.show_files_as_bubbles(folder_path)
for section_widget in self.param_sections:
if "REMOVE_EVENTS" in section_widget.widgets:
section_widget.update_annotation_dropdown_from_folder(folder_path)
break
else:
print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget")
self.button1.setVisible(True) self.button1.setVisible(True)
@@ -3724,8 +4287,52 @@ class MainApplication(QMainWindow):
return self.file_metadata return self.file_metadata
def cancel_task(self):
self.button1.clicked.disconnect(self.cancel_task)
self.button1.setText("Stopping...")
if hasattr(self, "result_process") and self.result_process.is_alive():
parent = psutil.Process(self.result_process.pid)
children = parent.children(recursive=True)
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
self.result_process.terminate()
self.result_process.join()
if hasattr(self, "result_timer") and self.result_timer.isActive():
self.result_timer.stop()
# if hasattr(self, "result_process") and self.result_process.is_alive():
# self.result_process.terminate() # Forcefully terminate the process
# self.result_process.join() # Wait for it to properly close
# # Stop the QTimer if running
# if hasattr(self, "result_timer") and self.result_timer.isActive():
# self.result_timer.stop()
for bubble in self.bubble_widgets.values():
bubble.mark_cancelled()
self.statusbar.showMessage("Processing cancelled.")
self.button1.clicked.connect(self.on_run_task)
self.button1.setText("Process")
'''MODULE FILE''' '''MODULE FILE'''
def on_run_task(self): def on_run_task(self):
self.button1.clicked.disconnect(self.on_run_task)
self.button1.setText("Cancel")
self.button1.clicked.connect(self.cancel_task)
if not self.first_run:
for bubble in self.bubble_widgets.values():
bubble.mark_cancelled()
self.first_run = False
# Collect all selected snirf files in a flat list # Collect all selected snirf files in a flat list
snirf_files = [] snirf_files = []
@@ -3767,7 +4374,7 @@ class MainApplication(QMainWindow):
self.manager = Manager() self.manager = Manager()
self.result_queue = self.manager.Queue() self.result_queue = self.manager.Queue()
self.progress_queue = self.manager.Queue() self.progress_queue = self.manager.Queue()
self.result_process = Process( self.result_process = Process(
target=run_gui_entry_wrapper, target=run_gui_entry_wrapper,
args=(collected_data, self.result_queue, self.progress_queue) args=(collected_data, self.result_queue, self.progress_queue)
@@ -4253,7 +4860,7 @@ def resource_path(relative_path):
# PyInstaller bundle path # PyInstaller bundle path
base_path = sys._MEIPASS base_path = sys._MEIPASS
else: else:
base_path = os.path.abspath(".") base_path = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_path, relative_path) return os.path.join(base_path, relative_path)