diff --git a/changelog.md b/changelog.md index efbc77d..2874d6c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,13 @@ +# 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 +- Added 2 new parameters - TIME_WINDOW_START and TIME_WINDOW_END +- 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 + + # 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) @@ -9,6 +19,7 @@ - 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 diff --git a/flares.py b/flares.py index 586cc01..edb7f6d 100644 --- a/flares.py +++ b/flares.py @@ -134,6 +134,9 @@ SHORT_CHANNEL: bool REMOVE_EVENTS: list +TIME_WINDOW_START: int +TIME_WINDOW_END: int + VERBOSITY = True # FIXME: Shouldn't need each ordering - just order it before checking @@ -182,6 +185,8 @@ REQUIRED_KEYS: dict[str, Any] = { "SHORT_CHANNEL": bool, "REMOVE_EVENTS": list, + "TIME_WINDOW_START": int, + "TIME_WINDOW_END": int # "REJECT_PAIRS": bool, # "FORCE_DROP_ANNOTATIONS": list, # "FILTER_LOW_PASS": float, @@ -3031,7 +3036,11 @@ def process_participant(file_path, progress_callback=None): contrast_dict = {} 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: continue # skip if no columns found (shouldn't happen?) diff --git a/main.py b/main.py index 2714f5d..08627ed 100644 --- a/main.py +++ b/main.py @@ -45,7 +45,7 @@ from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleVa 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_SECONDARY = "https://git.research2.dezeeuw.ca/api/v1/repos/tyler/flares/releases" @@ -155,6 +155,8 @@ SECTIONS = [ { "title": "General Linear Model", "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."}, ] }, @@ -1016,64 +1018,94 @@ class UpdateEventsWindow(QWidget): try: raw = read_raw_snirf(snirf_path, preload=True) - # Build new Annotations from shifted BORIS events onsets = [] durations = [] descriptions = [] + open_events = {} # label -> list of start times label_counts = {} used_times = set() - sfreq = raw.info['sfreq'] # sampling frequency in Hz min_shift = 1.0 / sfreq - max_attempts = 10 - + for event in boris_events: if not isinstance(event, list) or len(event) < 3: continue - orig_time = event[0] - desc = event[2] + event_time = event[0] + label = event[2] - # Count occurrences per event label - count = label_counts.get(desc, 0) - label_counts[desc] = count + 1 + count = label_counts.get(label, 0) + 1 + label_counts[label] = count - # Only use 1st, 3rd, 5th... (odd occurrences) - if (count % 2) == 0: - shifted_time = orig_time + time_shift + if label not in open_events: + open_events[label] = [] - # Ensure unique timestamp by checking and adjusting slightly - adjusted_time = shifted_time + if count % 2 == 1: + # 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 + 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 {label} @ {matched_start}s. Skipping.") + continue + + adjusted_time = round(adjusted_time, 6) + used_times.add(adjusted_time) + + 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: Could not find unique timestamp for event '{desc}' at original time {orig_time:.3f}s. Skipping.") - continue # Skip problematic event + 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"Applying event: {desc} @ {adjusted_time:.3f}s (original: {orig_time:.3f}s)") - + print(f"Warning: Unmatched start for label '{label}' at {start_time:.3f}s. Adding with duration 0.") onsets.append(adjusted_time) durations.append(0.0) - descriptions.append(desc) + descriptions.append(label) new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions) - # Replace annotations in raw object raw.set_annotations(new_annotations) - - # Write a new SNIRF file - write_raw_snirf(raw, suggested_name) + write_raw_snirf(raw, save_path) QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.") @@ -2197,7 +2229,7 @@ class ParticipantFoldChannelsWidget(QWidget): 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(QLabel("This will cause the app to hang for ~30s/Participant!")) self.top_bar.addWidget(self.submit_button) self.scroll = QScrollArea() @@ -2352,6 +2384,226 @@ class ParticipantFoldChannelsWidget(QWidget): +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.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("") + 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 ("", "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("") + 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("") + 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 generate_and_save_csv(self): + + 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: + try: + suggested_name = f"{file_path}.csv" + + # Open save dialog + save_path, _ = QFileDialog.getSaveFileName( + self, + "Save SNIRF File As", + suggested_name, + "CSV Files (*.csv)" + ) + + if not save_path: + print("Save cancelled.") + 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}") + + + else: + print(f"No method defined for index {idx}") + + class ClickableLabel(QLabel): def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap): super().__init__() @@ -3558,6 +3810,9 @@ class ViewerLauncherWidget(QWidget): 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)) + + 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) @@ -3565,6 +3820,7 @@ class ViewerLauncherWidget(QWidget): layout.addWidget(btn3) layout.addWidget(btn4) layout.addWidget(btn5) + layout.addWidget(btn6) def open_participant_viewer(self, haemo_dict, fig_bytes_dict): self.participant_viewer = ParticipantViewerWidget(haemo_dict, fig_bytes_dict) @@ -3586,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.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): """ @@ -4655,6 +4915,16 @@ class MainApplication(QMainWindow): else: 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 except: return None