From 0f6434121f86942068718aaedb68bc72c16500c6 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 15 Apr 2026 16:15:15 -0700 Subject: [PATCH] qol improvements + optode location fix --- changelog.md | 12 ++- flares.py | 69 +++++++++++++++- main.py | 224 ++++++++++++++++++++++++--------------------------- 3 files changed, 185 insertions(+), 120 deletions(-) diff --git a/changelog.md b/changelog.md index d60845a..b0a4457 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,16 @@ +# Version 1.4.2 + +- Fixed AGE, GENDER, GROUP not visually appearing on a bubble after the metadata has been set. Fixes [Issue 42](https://git.research.dezeeuw.ca/tyler/flares/issues/42) +- Fixed first stage of progress bar going yellow after loading from an unprocessed save +- Fixed AGE, GENDER, GROUP not visually appearing on a bubble when loading from a save +- Group images involving an optode template will now be the average of all participants selected in the group and not the last processed participant. Fixes [Issue 62](https://git.research.dezeeuw.ca/tyler/flares/issues/62) +- Group images will no longer crash if being made with participants that have a different number of channels +- Changed CSV files to now save to the same folder rather than asking each time for each file. Fixes [Issue 39](https://git.research.dezeeuw.ca/tyler/flares/issues/39) + + # Version 1.4.1 -- Fixed a recursive child loop +- Hotfix to fix a recursive child loop that would cause the MacOS version to not open # Version 1.4.0 diff --git a/flares.py b/flares.py index a39a8f3..a7762e7 100644 --- a/flares.py +++ b/flares.py @@ -2403,6 +2403,71 @@ def plot_3d_evoked_array( return brain +def aggregate_fnirs_group_geometry(raw_list): + """ + Averages fNIRS geometry across participants in two tiers: + 1. Average by Channel Pairing (S_D). + 2. Average by Individual Optode (S, D) across all averaged pairings. + Returns a unified MNE Raw object with exactly one dot per optode. + """ + import mne + import numpy as np + + channel_locs = {} + all_ch_names = [] + + for raw in raw_list: + if raw is None: continue + raw_hbo = raw.copy().pick(picks="hbo") + + for i, ch_name in enumerate(raw_hbo.ch_names): + if ch_name not in channel_locs: + channel_locs[ch_name] = [] + all_ch_names.append(ch_name) + + channel_locs[ch_name].append(raw_hbo.info['chs'][i]['loc']) + + avg_pairings = {name: np.nanmean(locs, axis=0) for name, locs in channel_locs.items()} + + optode_collections = {'sources': {}, 'detectors': {}} + + for ch_name, loc in avg_pairings.items(): + parts = ch_name.split()[0].split('_') + s_name, d_name = parts[0], parts[1] + + optode_collections['sources'].setdefault(s_name, []).append(loc[3:6]) + optode_collections['detectors'].setdefault(d_name, []).append(loc[6:9]) + + final_sources = {s: np.nanmean(coords, axis=0) for s, coords in optode_collections['sources'].items()} + final_detectors = {d: np.nanmean(coords, axis=0) for d, coords in optode_collections['detectors'].items()} + + ref_raw = raw_list[0].copy().pick(picks="hbo") + template_lookup = {ch['ch_name']: ch for ch in ref_raw.info['chs']} + final_chs = [] + + for ch_name in all_ch_names: + unified_loc = avg_pairings[ch_name].copy() + parts = ch_name.split()[0].split('_') + s_name, d_name = parts[0], parts[1] + + unified_loc[3:6] = final_sources[s_name] + unified_loc[6:9] = final_detectors[d_name] + unified_loc[0:3] = (final_sources[s_name] + final_detectors[d_name]) / 2.0 + + # Create the new channel object + new_ch = template_lookup.get(ch_name, ref_raw.info['chs'][0]).copy() + new_ch['ch_name'] = ch_name + new_ch['loc'] = unified_loc + final_chs.append(new_ch) + + # Create the final MNE Info + fake_info = mne.create_info(ch_names=all_ch_names, sfreq=ref_raw.info['sfreq'], ch_types='hbo') + with fake_info._unlock(): + fake_info['chs'] = final_chs + + return mne.io.RawArray(np.zeros((len(all_ch_names), 1)), fake_info) + + def brain_3d_visualization(raw_haemo, df_cha, selected_event, t_or_theta: Literal['t', 'theta'] = 'theta', show_optodes: Literal['sensors', 'labels', 'none', 'all'] = 'all', show_text: bool = True, brain_bounds: float = 1.0) -> None: @@ -2446,7 +2511,7 @@ def brain_3d_visualization(raw_haemo, df_cha, selected_event, t_or_theta: Litera brain = plot_3d_evoked_array(raw_for_plot.pick(picks="hbo"), model_df, view="dorsal", distance=0.02, colorbar=True, clim=clim, mode="weighted", size=(800, 700)) # type: ignore if show_optodes == 'all' or show_optodes == 'sensors': - brain.add_sensors(getattr(raw_for_plot, "info"), trans=Transform('head', 'mri', np.eye(4)), fnirs=["channels", "pairs", "sources", "detectors"], verbose=False) # type: ignore + brain.add_sensors(raw_for_plot.pick(picks="hbo").info, trans=Transform('head', 'mri', np.eye(4)), fnirs=["channels", "pairs", "sources", "detectors"], verbose=False) # type: ignore if True: display_text = ('Folder: ' + '\nGroup: ' + '\nCondition: '+ cond + '\nShort Channel Regression: ' @@ -4025,7 +4090,7 @@ def process_participant(file_path, progress_callback=None): if num_bad > MAX_BAD_CHANNELS: raise Exception( f"Data Quality Error: {num_bad} channels flagged for removal, " - f"which exceeds the limit of {MAX_BAD_CHANNELS}. To avoid this," + f"which exceeds the limit of {MAX_BAD_CHANNELS}. To avoid this, " f"either lower your filtering parameters or increase MAX_BAD_CHANNELS." ) diff --git a/main.py b/main.py index c956df3..7695605 100644 --- a/main.py +++ b/main.py @@ -1685,13 +1685,14 @@ class ProgressBubble(QWidget): # Transition to a green checkmark self.setSuffixText(" ") - def update_progress(self, step_index): + def update_progress(self, step_index, active=True): self.current_step = step_index for i, rect in enumerate(self.rects): if i < step_index: rect.setStyleSheet("background-color: green; border: 1px solid gray;") elif i == step_index: - rect.setStyleSheet("background-color: yellow; border: 1px solid gray;") + color = "yellow" if active else "white" + rect.setStyleSheet(f"background-color: {color}; border: 1px solid gray;") else: rect.setStyleSheet("background-color: white; border: 1px solid gray;") @@ -3682,8 +3683,22 @@ class ExportDataAsCSVViewerWidget(FlaresBaseWidget): int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) ] + if not selected_file_paths or not selected_indexes: + QMessageBox.warning(self, "Selection Missing", "Please select at least one participant and one export type.") + return + + # 2. ASK ONCE: Select Output Directory + output_dir = QFileDialog.getExistingDirectory(self, "Select Output Folder for CSV Exports") + + if not output_dir: + print("Export cancelled: No folder selected.") + return + + success_count = 0 + # Pass the necessary arguments to each method for file_path in selected_file_paths: + base_filename = os.path.splitext(os.path.basename(file_path))[0] haemo_obj = self.haemo_dict.get(file_path) if haemo_obj is None: continue @@ -3691,90 +3706,57 @@ class ExportDataAsCSVViewerWidget(FlaresBaseWidget): cha = self.cha_dict.get(file_path) for idx in selected_indexes: - if idx == 0: - try: - suggested_name = f"{file_path}.csv" + try: + if idx == 0: + save_path = os.path.join(output_dir, f"{base_filename}_exported.csv") + if cha is not None: + cha.to_csv(save_path) + success_count += 1 - # Open save dialog - save_path, _ = QFileDialog.getSaveFileName( - self, - "Save CSV File As", - suggested_name, - "CSV Files (*.csv)" - ) - if not save_path: - print("Save cancelled.") - return + elif idx == 1: + # SPARKS Export + save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv") + if haemo_obj is not None: + raw = haemo_obj + data, times = raw.get_data(return_times=True) + ann_col = np.full(times.shape, "", dtype=object) - if not save_path.lower().endswith(".csv"): - save_path += ".csv" - # Save the CSV here + if raw.annotations is not None and len(raw.annotations) > 0: + for onset, duration, desc in zip( + raw.annotations.onset, + raw.annotations.duration, + raw.annotations.description + ): + mask = (times >= onset) & (times < onset + duration) + ann_col[mask] = desc + + df = pd.DataFrame(data.T, columns=raw.ch_names) + df.insert(0, "annotation", ann_col) + df.insert(0, "time", times) + df.to_csv(save_path, index=False) + success_count += 1 - cha.to_csv(save_path) - QMessageBox.information(self, "Success", "CSV file has been saved.") + else: + print(f"No method defined for index {idx}") + + except Exception as e: + print(f"Failed to export {file_path} (Type {idx}): {e}") - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to update CSV file:\n{e}") + # 4. Final Notification + if success_count > 0: + QMessageBox.information(self, "Export Complete", f"Successfully saved {success_count} CSV files to:\n{output_dir}") + + # # If SPARKS export was included, show the Event Window once at the end + # if 1 in selected_indexes: + # win = UpdateEventsWindow( + # parent=self, + # mode=EventUpdateMode.WRITE_JSON, + # caller="Video Alignment Tool" + # ) + # win.show() - elif idx == 1: - try: - suggested_name = f"{file_path}_sparks.csv" - - # Open save dialog - save_path, _ = QFileDialog.getSaveFileName( - self, - "Save CSV 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 - - raw = haemo_obj - - data, times = raw.get_data(return_times=True) - - - ann_col = np.full(times.shape, "", dtype=object) - - if raw.annotations is not None and len(raw.annotations) > 0: - for onset, duration, desc in zip( - raw.annotations.onset, - raw.annotations.duration, - raw.annotations.description - ): - mask = (times >= onset) & (times < onset + duration) - ann_col[mask] = desc - - df = pd.DataFrame(data.T, columns=raw.ch_names) - df.insert(0, "annotation", ann_col) - - df.insert(0, "time", times) - df.to_csv(save_path, index=False) - QMessageBox.information(self, "Success", "CSV file has been saved.") - - win = UpdateEventsWindow( - parent=self, - mode=EventUpdateMode.WRITE_JSON, - caller="Video Alignment Tool" - ) - win.show() - - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to update CSV 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__() @@ -4204,9 +4186,16 @@ class GroupViewerWidget(FlaresBaseWidget): 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) - + raw_list = [self.haemo_dict.get(fp) for fp in selected_file_paths] + if len(selected_file_paths) > 1: + print(f"Aggregating geometry for {len(selected_file_paths)} participants...") + processed_raw = flares.aggregate_fnirs_group_geometry(raw_list) + else: + processed_raw = raw_list[0].copy().pick(picks="hbo") + + flares.brain_3d_visualization(processed_raw, 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: pass @@ -4383,6 +4372,11 @@ class GroupBrainViewerWidget(FlaresBaseWidget): int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown) ] + all_selected_paths = list(set(file_paths_a + file_paths_b)) + + if not all_selected_paths: + print("No participants selected.") + return parameterized_indexes = { 0: [ @@ -4463,14 +4457,12 @@ class GroupBrainViewerWidget(FlaresBaseWidget): print("contrast_df_a empty?", contrast_df_a.empty) print("contrast_df_b empty?", contrast_df_b.empty) - # Get one person for their layout - rep_raw = None - for fp in file_paths_a + file_paths_b: - rep_raw = self.haemo_dict.get(fp) - if rep_raw: - break - - print(rep_raw) + all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)] + + if len(all_raw_objs) > 1: + processed_raw = flares.aggregate_fnirs_group_geometry(all_raw_objs) + else: + processed_raw = all_raw_objs[0].copy().pick(picks="hbo") # Visualizations for idx in selected_indexes: @@ -4486,12 +4478,12 @@ class GroupBrainViewerWidget(FlaresBaseWidget): print(f"Missing parameters for index {idx}, skipping.") continue - if not contrast_df_a.empty and not contrast_df_b.empty and rep_raw: + if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw: flares.plot_2d_3d_contrasts_between_groups( contrast_df_a, contrast_df_b, - raw_haemo=rep_raw, + raw_haemo=processed_raw, group_a_name=self.group_a_dropdown.currentText(), group_b_name=self.group_b_dropdown.currentText(), is_3d=is_3d, @@ -4713,6 +4705,7 @@ class MainApplication(QMainWindow): label = QLabel(key.capitalize()) right_column_layout.addWidget(label) right_column_layout.addWidget(field) + field.textChanged.connect(self.sync_bubble_data) label_desc = QLabel('Why are these useful?') label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) @@ -5405,8 +5398,6 @@ class MainApplication(QMainWindow): for rel_path, step in raw_progress.items() } - self.show_files_as_bubbles_from_list(file_list, progress_states, filename) - for rel_path in data["file_list"]: abs_path = str((project_dir / Path(rel_path)).resolve()) @@ -5425,14 +5416,7 @@ class MainApplication(QMainWindow): # Scenario C: Empty default self.file_metadata[abs_path] = {"AGE": "", "GENDER": "", "GROUP": ""} - if file_list: - self.current_file = file_list[0] - self.right_column_widget.show() - - # Update Metadata fields (Age/Gender/Group) for the selected file - curr_meta = self.file_metadata.get(self.current_file, {"AGE": "", "GENDER": "", "GROUP": ""}) - for key, field in self.meta_fields.items(): - field.setText(curr_meta.get(key, "")) + self.show_files_as_bubbles_from_list(file_list, progress_states, filename) if "current_ui_params" in data: self.restore_sections_from_config(data["current_ui_params"]) @@ -5589,6 +5573,18 @@ class MainApplication(QMainWindow): bubble.clicked.connect(self.on_bubble_clicked) bubble.rightClicked.connect(self.on_bubble_right_clicked) + if hasattr(self, 'file_metadata') and file_path in self.file_metadata: + meta = self.file_metadata[file_path] + + parts = [] + for key in ["AGE", "GENDER", "GROUP"]: + value = meta.get(key, "").strip() + if value: + parts.append(f"{key}: {value}") + + suffix = f"{', '.join(parts)}" if parts else "" + bubble.setSuffixText(suffix) + # Track it self.bubble_widgets[file_path] = bubble if file_path not in self.selected_paths: @@ -5596,7 +5592,7 @@ class MainApplication(QMainWindow): # Restore saved progress but keep loading state active step = progress_states.get(file_path, 0) - bubble.update_progress(step) + bubble.update_progress(step, active=False) # Add to layout self.bubble_layout.addWidget(bubble, index, 1) @@ -5757,21 +5753,15 @@ class MainApplication(QMainWindow): if getattr(self, 'last_clicked_bubble', None) is bubble: self.last_clicked_bubble = None - def eventFilter(self, watched, event): - if event.type() == QEvent.Type.MouseButtonPress: - widget = self.childAt(event.pos()) - if isinstance(widget, ProgressBubble): - pass - else: - if self.last_clicked_bubble: - if not self.last_clicked_bubble.isAncestorOf(widget): - if self.current_file: - self.save_metadata(self.current_file) - suffix = self.get_suffix_from_meta_fields() - self.last_clicked_bubble.setSuffixText(suffix) - self.last_clicked_bubble = None - - return super().eventFilter(watched, event) + def sync_bubble_data(self): + """Refreshes the bubble and saves data in real-time.""" + if self.current_file and self.last_clicked_bubble: + # Save the current state of all fields + self.save_metadata(self.current_file) + + # Grab the updated suffix and apply it immediately + suffix = self.get_suffix_from_meta_fields() + self.last_clicked_bubble.setSuffixText(suffix) def placeholder(self): QMessageBox.information(self, "Placeholder", "This feature is not implemented yet.")