2 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
5 changed files with 141 additions and 73 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

@@ -1077,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

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

191
main.py
View File

@@ -42,6 +42,7 @@ 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"
@@ -173,10 +174,10 @@ SECTIONS = [
class CommandConsole(QWidget): class TerminalWindow(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("Custom Console") self.setWindowTitle("Terminal - FLARES")
self.output_area = QTextEdit() self.output_area = QTextEdit()
self.output_area.setReadOnly(True) self.output_area.setReadOnly(True)
@@ -189,10 +190,8 @@ class CommandConsole(QWidget):
layout.addWidget(self.input_line) layout.addWidget(self.input_line)
self.setLayout(layout) self.setLayout(layout)
# Define your commands
self.commands = { self.commands = {
"hello": self.cmd_hello, "hello": self.cmd_hello,
"add": self.cmd_add,
"help": self.cmd_help "help": self.cmd_help
} }
@@ -219,12 +218,9 @@ class CommandConsole(QWidget):
else: else:
self.output_area.append(f"[Unknown command] '{command_name}'") self.output_area.append(f"[Unknown command] '{command_name}'")
# Example commands
def cmd_hello(self, *args):
return "Hello from the console!"
def cmd_add(self, a, b): def cmd_hello(self, *args):
return f"Result: {int(a) + int(b)}" return "Hello from the terminal!"
def cmd_help(self, *args): def cmd_help(self, *args):
return f"Available commands: {', '.join(self.commands.keys())}" return f"Available commands: {', '.join(self.commands.keys())}"
@@ -734,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)
@@ -1178,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)
@@ -2191,12 +2191,13 @@ class ParticipantFoldChannelsWidget(QWidget):
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label) self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
self.submit_button = QPushButton("Submit") self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.show_brain_images) self.submit_button.clicked.connect(self.show_fold_images)
self.top_bar.addWidget(QLabel("Participants:")) self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown) self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Fold Type:")) self.top_bar.addWidget(QLabel("Fold Type:"))
self.top_bar.addWidget(self.image_index_dropdown) 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.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea() self.scroll = QScrollArea()
@@ -2316,7 +2317,7 @@ class ParticipantFoldChannelsWidget(QWidget):
index_labels = [s.split(" ")[0] for s in selected] index_labels = [s.split(" ")[0] for s in selected]
self.image_index_dropdown.lineEdit().setText(", ".join(index_labels)) self.image_index_dropdown.lineEdit().setText(", ".join(index_labels))
def show_brain_images(self): def show_fold_images(self):
import flares import flares
selected_display_names = self._get_checked_items(self.participant_dropdown) selected_display_names = self._get_checked_items(self.participant_dropdown)
@@ -2332,42 +2333,6 @@ class ParticipantFoldChannelsWidget(QWidget):
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
] ]
parameterized_indexes = {
0: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
"default": "all",
"type": str,
},
{
"key": "show_brodmann",
"label": "Show common brodmann areas on the brain.",
"default": "True",
"type": bool,
}
],
}
# Inject full_text from index_texts
for idx, params_list in parameterized_indexes.items():
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
for param_info in params_list:
param_info["full_text"] = full_text
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
param_values = {}
if indexes_needing_params:
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
if dialog.exec_() == QDialog.Accepted:
param_values = dialog.get_values()
if param_values is None:
return
else:
return
# Pass the necessary arguments to each method # Pass the necessary arguments to each method
for file_path in selected_file_paths: for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path) haemo_obj = self.haemo_dict.get(file_path)
@@ -2380,14 +2345,6 @@ class ParticipantFoldChannelsWidget(QWidget):
for idx in selected_indexes: for idx in selected_indexes:
if idx == 0: if idx == 0:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
show_brodmann = params.get("show_brodmann", None)
if show_optodes is None or show_brodmann is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
flares.fold_channels(haemo_obj) flares.fold_channels(haemo_obj)
else: else:
@@ -2600,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)",
] ]
@@ -2903,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
@@ -2925,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)
@@ -2981,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
@@ -3606,6 +3608,7 @@ class MainApplication(QMainWindow):
self.param_sections = [] self.param_sections = []
self.folder_paths = [] self.folder_paths = []
self.section_widget = None self.section_widget = None
self.first_run = True
self.init_ui() self.init_ui()
self.create_menu_bar() self.create_menu_bar()
@@ -3834,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"))
] ]
@@ -3846,9 +3849,7 @@ class MainApplication(QMainWindow):
terminal_menu = menu_bar.addMenu("Terminal") terminal_menu = menu_bar.addMenu("Terminal")
terminal_actions = [ terminal_actions = [
("Cut", "Ctrl+X", self.terminal_gui, resource_path("icons/content_cut_24dp_1F1F1F.svg")), ("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
("Copy", "Ctrl+C", self.terminal_gui, resource_path("icons/content_copy_24dp_1F1F1F.svg")),
("Paste", "Ctrl+V", self.terminal_gui, resource_path("icons/content_paste_24dp_1F1F1F.svg"))
] ]
for name, shortcut, slot, icon in terminal_actions: for name, shortcut, slot, icon in terminal_actions:
terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon)) terminal_menu.addAction(make_action(name, shortcut, slot, icon=icon))
@@ -3875,6 +3876,8 @@ class MainApplication(QMainWindow):
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
@@ -3937,7 +3940,7 @@ class MainApplication(QMainWindow):
def terminal_gui(self): def terminal_gui(self):
if self.terminal is None or not self.terminal.isVisible(): if self.terminal is None or not self.terminal.isVisible():
self.terminal = CommandConsole(self) self.terminal = TerminalWindow(self)
self.terminal.show() self.terminal.show()
def update_optode_positions(self): def update_optode_positions(self):
@@ -4284,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 = []
@@ -4327,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)
@@ -4813,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)