6 Commits

Author SHA1 Message Date
5361f6ea21 changed the changelog 2025-10-15 16:12:51 -07:00
ee023c26c1 changelog fix 2025-10-15 16:10:48 -07:00
06c9ff0ecf update changelog 2025-10-15 15:59:26 -07:00
542dd85a78 general fixes 2025-10-15 15:51:02 -07:00
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 461 additions and 102 deletions

View File

@@ -1,3 +1,34 @@
# Version 1.1.4
- Fixed some display text to now display the correct information
- A new option under Analysis has been added to export the data from a specified participant as a csv file. Fixes [Issue 19](https://git.research.dezeeuw.ca/tyler/flares/issues/19), [Issue 27](https://git.research.dezeeuw.ca/tyler/flares/issues/27)
- Added 2 new parameters - TIME_WINDOW_START and TIME_WINDOW_END. Fixes [Issue 29](https://git.research.dezeeuw.ca/tyler/flares/issues/29)
- These parameters affect the visualization of the significance and contrast images but do not change the total time modeled underneath
- Fixed the duration of annotations edited from a BORIS file from 0 seconds to their proper duration
- Added the annotation information to each participant under their "File information" window
- Fixed Macs not being able to save snirfs attempting to be updated from BORIS files, and in general the updated files not respecting the path chosen by the user
# 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)
- Fixed the description under "Update events in snirf file..."
# 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

@@ -134,6 +134,9 @@ SHORT_CHANNEL: bool
REMOVE_EVENTS: list REMOVE_EVENTS: list
TIME_WINDOW_START: int
TIME_WINDOW_END: int
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
@@ -182,6 +185,8 @@ REQUIRED_KEYS: dict[str, Any] = {
"SHORT_CHANNEL": bool, "SHORT_CHANNEL": bool,
"REMOVE_EVENTS": list, "REMOVE_EVENTS": list,
"TIME_WINDOW_START": int,
"TIME_WINDOW_END": int
# "REJECT_PAIRS": bool, # "REJECT_PAIRS": bool,
# "FORCE_DROP_ANNOTATIONS": list, # "FORCE_DROP_ANNOTATIONS": list,
# "FILTER_LOW_PASS": float, # "FILTER_LOW_PASS": float,
@@ -1077,7 +1082,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
@@ -3031,7 +3036,11 @@ def process_participant(file_path, progress_callback=None):
contrast_dict = {} contrast_dict = {}
for condition in all_conditions: for condition in all_conditions:
delay_cols = [col for col in all_delay_cols if col.startswith(f"{condition}_delay_")] delay_cols = [
col for col in all_delay_cols
if col.startswith(f"{condition}_delay_") and
TIME_WINDOW_START <= int(col.split("_delay_")[-1]) <= TIME_WINDOW_END
]
if not delay_cols: if not delay_cols:
continue # skip if no columns found (shouldn't happen?) continue # skip if no columns found (shouldn't happen?)

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

509
main.py
View File

@@ -42,9 +42,10 @@ 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.0"
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" API_URL_SECONDARY = "https://git.research2.dezeeuw.ca/api/v1/repos/tyler/flares/releases"
@@ -154,6 +155,8 @@ SECTIONS = [
{ {
"title": "General Linear Model", "title": "General Linear Model",
"params": [ "params": [
{"name": "TIME_WINDOW_START", "default": "0", "type": int, "help": "Where to start averaging the fir model bins. Only affects the significance and contrast images."},
{"name": "TIME_WINDOW_END", "default": "15", "type": int, "help": "Where to end averaging the fir model bins. Only affects the significance and contrast images."},
#{"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for GLM processing."}, #{"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for GLM processing."},
] ]
}, },
@@ -173,10 +176,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 +192,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 +220,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 +732,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)
@@ -1021,64 +1018,94 @@ class UpdateEventsWindow(QWidget):
try: try:
raw = read_raw_snirf(snirf_path, preload=True) raw = read_raw_snirf(snirf_path, preload=True)
# Build new Annotations from shifted BORIS events
onsets = [] onsets = []
durations = [] durations = []
descriptions = [] descriptions = []
open_events = {} # label -> list of start times
label_counts = {} label_counts = {}
used_times = set() used_times = set()
sfreq = raw.info['sfreq'] # sampling frequency in Hz sfreq = raw.info['sfreq'] # sampling frequency in Hz
min_shift = 1.0 / sfreq min_shift = 1.0 / sfreq
max_attempts = 10 max_attempts = 10
for event in boris_events: for event in boris_events:
if not isinstance(event, list) or len(event) < 3: if not isinstance(event, list) or len(event) < 3:
continue continue
orig_time = event[0] event_time = event[0]
desc = event[2] label = event[2]
# Count occurrences per event label count = label_counts.get(label, 0) + 1
count = label_counts.get(desc, 0) label_counts[label] = count
label_counts[desc] = count + 1
# Only use 1st, 3rd, 5th... (odd occurrences) if label not in open_events:
if (count % 2) == 0: open_events[label] = []
shifted_time = orig_time + time_shift
# Ensure unique timestamp by checking and adjusting slightly if count % 2 == 1:
adjusted_time = shifted_time # Odd occurrence = start event
open_events[label].append(event_time)
else:
# Even occurrence = end event
if open_events[label]:
matched_start = open_events[label].pop(0)
duration = event_time - matched_start
# Try to find a unique timestamp if duration <= 0:
print(f"Warning: Duration for {label} is non-positive ({duration}). Skipping.")
continue
shifted_start = matched_start + time_shift
adjusted_time = shifted_start
attempts = 0 attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts: while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift adjusted_time += min_shift
attempts += 1 attempts += 1
if attempts == max_attempts: if attempts == max_attempts:
print(f"Warning: Could not find unique timestamp for event '{desc}' at original time {orig_time:.3f}s. Skipping.") print(f"Warning: Couldn't find unique time for {label} @ {matched_start}s. Skipping.")
continue # Skip problematic event continue
adjusted_time = round(adjusted_time, 6) adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time) used_times.add(adjusted_time)
print(f"Applying event: {desc} @ {adjusted_time:.3f}s (original: {orig_time:.3f}s)") print(f"Adding event: {label} @ {adjusted_time:.3f}s for {duration:.3f}s")
onsets.append(adjusted_time)
durations.append(duration)
descriptions.append(label)
else:
print(f"Warning: Unmatched end for label '{label}' at {event_time:.3f}s. Skipping.")
# Optionally warn about any unmatched starts left open
for label, starts in open_events.items():
for start_time in starts:
shifted_start = start_time + time_shift
adjusted_time = shifted_start
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
print(f"Warning: Couldn't find unique time for unmatched start {label} @ {start_time}s. Skipping.")
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
print(f"Warning: Unmatched start for label '{label}' at {start_time:.3f}s. Adding with duration 0.")
onsets.append(adjusted_time) onsets.append(adjusted_time)
durations.append(0.0) durations.append(0.0)
descriptions.append(desc) descriptions.append(label)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions) new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
# Replace annotations in raw object
raw.set_annotations(new_annotations) raw.set_annotations(new_annotations)
write_raw_snirf(raw, save_path)
# Write a new SNIRF file
write_raw_snirf(raw, suggested_name)
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.") QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
@@ -1179,6 +1206,11 @@ class ProgressBubble(QWidget):
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)
super().mousePressEvent(event) super().mousePressEvent(event)
@@ -2191,12 +2223,212 @@ 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/Participant!"))
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 ExportDataAsCSVViewerWidget(QWidget):
def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
super().__init__()
self.setWindowTitle("FLARES Export Data As CSV Viewer")
self.haemo_dict = haemo_dict
self.cha_dict = cha_dict
self.df_ind = df_ind
self.design_matrix = design_matrix
self.group = group
self.contrast_results_dict = contrast_results_dict
# Create mappings: file_path -> participant label and dropdown display text
self.participant_map = {} # file_path -> "Participant 1"
self.participant_dropdown_items = [] # "Participant 1 (filename)"
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
short_label = f"Participant {i}"
display_label = f"{short_label} ({os.path.basename(file_path)})"
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
self.index_texts = [
"0 (Export Data to CSV)",
# "1 (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.generate_and_save_csv)
self.top_bar.addWidget(QLabel("Participants:"))
self.top_bar.addWidget(self.participant_dropdown)
self.top_bar.addWidget(QLabel("Export Type:"))
self.top_bar.addWidget(self.image_index_dropdown)
self.top_bar.addWidget(self.submit_button) self.top_bar.addWidget(self.submit_button)
self.scroll = QScrollArea() self.scroll = QScrollArea()
@@ -2316,8 +2548,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 generate_and_save_csv(self):
import flares
selected_display_names = self._get_checked_items(self.participant_dropdown) selected_display_names = self._get_checked_items(self.participant_dropdown)
selected_file_paths = [] selected_file_paths = []
@@ -2332,42 +2563,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)
@@ -2375,26 +2570,40 @@ class ParticipantFoldChannelsWidget(QWidget):
if haemo_obj is None: if haemo_obj is None:
continue continue
#cha = self.cha_dict.get(file_path) cha = self.cha_dict.get(file_path)
for idx in selected_indexes: for idx in selected_indexes:
if idx == 0: if idx == 0:
try:
suggested_name = f"{file_path}.csv"
params = param_values.get(idx, {}) # Open save dialog
show_optodes = params.get("show_optodes", None) save_path, _ = QFileDialog.getSaveFileName(
show_brodmann = params.get("show_brodmann", None) self,
"Save SNIRF File As",
suggested_name,
"CSV Files (*.csv)"
)
if show_optodes is None or show_brodmann is None: if not save_path:
print(f"Missing parameters for index {idx}, skipping.") print("Save cancelled.")
continue return
if not save_path.lower().endswith(".csv"):
save_path += ".csv"
# Save the CSV here
cha.to_csv(save_path)
QMessageBox.information(self, "Success", "CSV file has been saved.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
flares.fold_channels(haemo_obj)
else: else:
print(f"No method defined for index {idx}") 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__()
@@ -2600,7 +2809,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 +3112,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 +3160,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 +3221,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
@@ -3557,12 +3811,16 @@ class ViewerLauncherWidget(QWidget):
btn5 = QPushButton("Open Cross Group Brain Viewer") btn5 = QPushButton("Open Cross Group Brain Viewer")
btn5.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))
btn6 = QPushButton("Open Export Data As CSV Viewer")
btn6.clicked.connect(lambda: self.open_export_data_as_csv_viewer(haemo_dict, cha_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) layout.addWidget(btn5)
layout.addWidget(btn6)
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)
@@ -3584,6 +3842,10 @@ class ViewerLauncherWidget(QWidget):
self.participant_brain_viewer = GroupBrainViewerWidget(haemo_dict, df_ind, design_matrix, group, contrast_results_dict) self.participant_brain_viewer = GroupBrainViewerWidget(haemo_dict, df_ind, design_matrix, group, contrast_results_dict)
self.participant_brain_viewer.show() self.participant_brain_viewer.show()
def open_export_data_as_csv_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
self.export_data_as_csv_viewer = ExportDataAsCSVViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict)
self.export_data_as_csv_viewer.show()
class MainApplication(QMainWindow): class MainApplication(QMainWindow):
""" """
@@ -3606,6 +3868,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 +4097,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 +4109,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 +4136,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 +4200,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 +4547,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 = []
@@ -4608,6 +4915,16 @@ class MainApplication(QMainWindow):
else: else:
snirf_info['Digitization Points'] = "Not found" snirf_info['Digitization Points'] = "Not found"
if raw.annotations is not None and len(raw.annotations) > 0:
annot_info = []
for onset, duration, desc in zip(raw.annotations.onset,
raw.annotations.duration,
raw.annotations.description):
annot_info.append(f"Onset: {onset:.2f}s, Duration: {duration:.2f}s, Description: {desc}")
snirf_info['Annotations'] = annot_info
else:
snirf_info['Annotations'] = "No annotations found"
return snirf_info return snirf_info
except: except:
return None return None
@@ -4813,7 +5130,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)