diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..5cc220c --- /dev/null +++ b/changelog.md @@ -0,0 +1,16 @@ +# Version 1.0.1 + +- Two new options have been added when clicking on a participant's file. +- First new option is Age which allows the option to specify age on a per-file basis to calculate DPF by using the Scholkmann & Wolf calculation instead of having one DPF for all files. Fixes [Issue 1](https://git.research.dezeeuw.ca/tyler/flares/issues/1). This also allows for proper calculation of DPF for the two seperate wavelengths. +- If no age is set by the user, the code will fall back to the age of 25 +- Second new option is Gender to be used in future analysis options (currently not implemented). +- UI elements have been shuffled to make room for the new parameters. +- Removed the process button from being available until a file or project is opened. Fixes [Issue 2](https://git.research.dezeeuw.ca/tyler/flares/issues/2). +- Removed the option for PPF under Haemoglobin Concentration as it is no longer used. +- Added a new category in the options called Other +- Added MAX_WORKERS to the Other category. This is how many files should be processed at once. Fixes [Issue 13](https://git.research.dezeeuw.ca/tyler/flares/issues/13). + + +# Version 1.0.0 + +- Initial release. \ No newline at end of file diff --git a/fNIRS_module.py b/fNIRS_module.py index c9ce53a..a56826a 100644 --- a/fNIRS_module.py +++ b/fNIRS_module.py @@ -42,14 +42,14 @@ from pandas import DataFrame import matplotlib.pyplot as plt from matplotlib.axes import Axes from numpy.typing import NDArray -import vtkmodules.util.data_model +#import vtkmodules.util.data_model from numpy import floating, float64 from matplotlib.lines import Line2D import matplotlib.colors as mcolors from scipy.stats import ttest_1samp # type: ignore from matplotlib.figure import Figure import statsmodels.formula.api as smf # type: ignore -import vtkmodules.util.execution_model +#import vtkmodules.util.execution_model from nilearn.plotting import plot_design_matrix # type: ignore from scipy.signal import welch, butter, filtfilt # type: ignore from matplotlib.colors import LinearSegmentedColormap @@ -127,7 +127,7 @@ EXCLUDE_CHANNELS: bool MAX_BAD_CHANNELS: int LONG_CHANNEL_THRESH: float -PPF: float +METADATA: dict DRIFT_MODEL: str DURATION_BETWEEN_ACTIVITIES: int @@ -223,7 +223,7 @@ REQUIRED_KEYS: dict[str, Any] = { "EXCLUDE_CHANNELS": bool, "MAX_BAD_CHANNELS": int, "LONG_CHANNEL_THRESH": float, - "PPF": float, + "METADATA": dict, "DRIFT_MODEL": str, "DURATION_BETWEEN_ACTIVITIES": int, "HRF_MODEL": str, @@ -284,8 +284,6 @@ else: logger = logging.getLogger() - - class ProcessingError(Exception): def __init__(self, message: str = "Something went wrong!"): self.message = message @@ -1615,7 +1613,7 @@ def calculate_optical_density(data: BaseRaw, ID: str) -> tuple[BaseRaw, Figure]: # STEP 9: Haemoglobin concentration -def calculate_haemoglobin_concentration(optical_density_data: BaseRaw, ID: str) -> tuple[BaseRaw, Figure]: +def calculate_haemoglobin_concentration(optical_density_data: BaseRaw, ID: str, file_path: str) -> tuple[BaseRaw, Figure]: """ Calculates haemoglobin concentration from optical density data using the Beer-Lambert law and generates a plot. @@ -1625,7 +1623,9 @@ def calculate_haemoglobin_concentration(optical_density_data: BaseRaw, ID: str) The data in optical density format. ID : str File name of the the snirf file that was loaded. - + file_path : str + Entire file path if snirf file that was loaded. + Returns ------- tuple[BaseRaw, Figure] @@ -1636,7 +1636,7 @@ def calculate_haemoglobin_concentration(optical_density_data: BaseRaw, ID: str) logger.info("Calculating haemoglobin concentration data...") # Get the haemoglobin concentration using beer lambert law - haemoglobin_concentration_data = beer_lambert_law(optical_density_data, PPF) + haemoglobin_concentration_data = beer_lambert_law(optical_density_data, ppf=calculate_dpf(file_path)) logger.info("Creating the figure...") fig = cast(Figure, optical_density_data.plot(show=False, n_channels=len(getattr(optical_density_data, "ch_names")), duration=getattr(optical_density_data, "times")[-1]).figure) # type: ignore @@ -1720,10 +1720,10 @@ def calculate_and_apply_negative_correlation_enhancement(haemoglobin_concentrati -def calculate_and_apply_short_channel_correction(optical_density_data: BaseRaw) -> dict[str, list[Any] | mne.evoked.EvokedArray]: +def calculate_and_apply_short_channel_correction(optical_density_data: BaseRaw, file_path: str) -> dict[str, list[Any] | mne.evoked.EvokedArray]: od_corrected = short_channel_regression(optical_density_data, SHORT_CHANNEL_THRESH) - haemoglobin_concentration_data = beer_lambert_law(od_corrected, PPF) + haemoglobin_concentration_data = beer_lambert_law(od_corrected, ppf=calculate_dpf(file_path)) events, _ = mne.events_from_annotations(haemoglobin_concentration_data, event_id={"Reach": 1, "Start of Rest": 2}, verbose=VERBOSITY) # type: ignore event_dict = {"Reach": 1, "Start of Rest": 2} @@ -1917,6 +1917,33 @@ def run_GLM_analysis(data: BaseRaw, design_matrix: DataFrame) -> RegressionResul return glm_est +def calculate_dpf(file_path): + # order is hbo / hbr + import h5py + with h5py.File(file_path, 'r') as f: + wavelengths = f['/nirs/probe/wavelengths'][:] + print("Wavelengths (nm):", wavelengths) + wavelengths = sorted(wavelengths, reverse=True) + data = METADATA.get(file_path) + if data is None: + age = 25 + else: + age = data['Age'] + logger.info(age) + age = float(age) + a = 223.3 + b = 0.05624 + c = 0.8493 + d = -5.723e-7 + e = 0.001245 + f = -0.9025 + dpf = [] + for w in wavelengths: + logger.info(w) + dpf.append(a + b * (age**c) + d* (w**3) + e * (w**2) + f*w) + logger.info(dpf) + return dpf + def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0, progress_callback=None) -> tuple[BaseRaw, BaseRaw, DataFrame, DataFrame, DataFrame, DataFrame, dict[str, Figure], str, bool, bool]: """ @@ -1971,6 +1998,12 @@ def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0, # Initalize the participants full layout to be the current data regardless if it will be updated later raw_full_layout = data + + logger.info(file_path) + logger.info(ID) + logger.info(METADATA.get(file_path)) + calculate_dpf(file_path) + try: # Did the user want to load new channel positions from an optode file? # STEP 2 @@ -1985,7 +2018,7 @@ def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0, # but i shouldnt need to do od and bll just check the last three numbers temp = data.copy() temp_od = cast(BaseRaw, optical_density(temp, verbose=VERBOSITY)) - raw_full_layout = beer_lambert_law(temp_od, ppf=PPF) + raw_full_layout = beer_lambert_law(temp_od, ppf=calculate_dpf(file_path)) # If specified, apply TDDR to the data # STEP 3 @@ -2082,11 +2115,11 @@ def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0, if SHORT_CHANNEL: short_chans_od = cast(BaseRaw, optical_density(short_chans)) data_recombined = cast(BaseRaw, data.copy().add_channels([short_chans_od])) # type: ignore - evoked_dict_corr = calculate_and_apply_short_channel_correction(data_recombined.copy()) + evoked_dict_corr = calculate_and_apply_short_channel_correction(data_recombined.copy(), file_path) # Calculate the haemoglobin concentration # STEP 9 - data, fig = calculate_haemoglobin_concentration(data, ID) + data, fig = calculate_haemoglobin_concentration(data, ID, file_path) order_of_operations += " + Haemoglobin Concentration" fig_dict['HaemoglobinConcentration'] = fig if progress_callback: progress_callback(9) diff --git a/main.py b/main.py index b552668..680a891 100644 --- a/main.py +++ b/main.py @@ -27,7 +27,7 @@ import psutil import requests from PySide6.QtWidgets import ( QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, - QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy + QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox ) from PySide6.QtCore import QThread, Signal, Qt, QTimer from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator @@ -114,7 +114,7 @@ SECTIONS = [ { "title": "Haemoglobin Concentration", "params": [ - {"name": "PPF", "default": 0.1, "type": float, "help": "Partial Pathlength Factor."}, + # Intentionally empty (TODO) ] }, { @@ -128,7 +128,7 @@ SECTIONS = [ { "title": "General Linear Model", "params": [ - {"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for processing."}, + {"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for GLM processing."}, ] }, { @@ -137,6 +137,12 @@ SECTIONS = [ # Intentionally empty (TODO) ] }, + { + "title": "Other", + "params": [ + {"name": "MAX_WORKERS", "default": 4, "type": int, "help": "Number of files to process at once."}, + ] + }, ] @@ -403,7 +409,7 @@ class ProgressBubble(QWidget): self.setLayout(self.layout) # Store the file path - self.file_path = file_path + self.file_path = os.path.normpath(file_path) self.current_step = 0 # Make the bubble clickable @@ -619,6 +625,10 @@ class MainApplication(QMainWindow): self.platform_suffix = "-" + PLATFORM_NAME self.pending_update_version = None self.pending_update_path = None + + self.file_metadata = {} + self.current_file = None + # Start local pending update check thread self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix) @@ -642,11 +652,51 @@ class MainApplication(QMainWindow): left_container.setLayout(left_layout) left_container.setMinimumWidth(300) - # Top left widget (for demo, use QTextEdit) + top_left_container = QGroupBox() # Optional: add a title inside parentheses + top_left_container.setTitle("File information") # Optional visible title + top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }") # Style if needed + top_left_container.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + + top_left_layout = QHBoxLayout() + + top_left_container.setLayout(top_left_layout) + + # QTextEdit with fixed height, but only 80% width self.top_left_widget = QTextEdit() - self.top_left_widget.setReadOnly(True) + self.top_left_widget.setReadOnly(True) self.top_left_widget.setPlaceholderText("Click a file below to get started! No files below? Open one using File > Open!") - self.top_left_widget.setFixedHeight(250) + + # Add QTextEdit to the layout with a stretch factor + top_left_layout.addWidget(self.top_left_widget, stretch=4) # 80% + + # Create a vertical box layout for the right 20% + self.right_column_widget = QWidget() + right_column_layout = QVBoxLayout() + self.right_column_widget.setLayout(right_column_layout) + + + self.meta_fields = { + "Age": QLineEdit(), + "Gender": QLineEdit(), + } + + + # Inside your top-left container's right column layout: + for key, field in self.meta_fields.items(): + label = QLabel(key.capitalize()) + field.setPlaceholderText(f"Enter {key}") + right_column_layout.addWidget(label) + right_column_layout.addWidget(field) + + right_column_layout.addStretch() # Push fields to top + + self.right_column_widget.hide() + + # Add right column widget to the top-left layout (takes 20% width) + top_left_layout.addWidget(self.right_column_widget, stretch=1) + + # Add top_left_container to the main left_layout + left_layout.addWidget(top_left_container) # Bottom left: the bubbles inside a scroll area self.bubble_container = QWidget() @@ -660,7 +710,6 @@ class MainApplication(QMainWindow): self.scroll_area.setMinimumHeight(300) # Add top left and bottom left to left layout - left_layout.addWidget(self.top_left_widget) left_layout.addWidget(self.scroll_area) self.progress_update_signal.connect(self.update_file_progress) @@ -713,6 +762,7 @@ class MainApplication(QMainWindow): self.button2.setMinimumSize(100, 40) self.button3.setMinimumSize(100, 40) + self.button1.setVisible(False) self.button3.setVisible(False) self.button1.clicked.connect(self.on_run_task) @@ -828,6 +878,8 @@ class MainApplication(QMainWindow): def clear_all(self): + self.right_column_widget.hide() + # Clear the bubble layout while self.bubble_layout.count(): item = self.bubble_layout.takeAt(0) @@ -846,6 +898,7 @@ class MainApplication(QMainWindow): self.all_figures = None # Reset any visible UI elements + self.button1.setVisible(False) self.button3.setVisible(False) self.top_left_widget.clear() @@ -889,6 +942,8 @@ class MainApplication(QMainWindow): if file_path: self.selected_path = file_path # store the file path self.show_files_as_bubbles(file_path) + + self.button1.setVisible(True) def open_folder_dialog(self): folder_path = QFileDialog.getExistingDirectory( @@ -897,6 +952,8 @@ class MainApplication(QMainWindow): if folder_path: self.selected_path = folder_path # store the folder path self.show_files_as_bubbles(folder_path) + + self.button1.setVisible(True) def open_multiple_folders_dialog(self): @@ -921,6 +978,8 @@ class MainApplication(QMainWindow): ) if more == QMessageBox.StandardButton.No: break + + self.button1.setVisible(True) def save_project(self): @@ -990,7 +1049,8 @@ class MainApplication(QMainWindow): self.show_files_as_bubbles_from_list(data["file_list"], data.get("progress_states", {}), filename) - # Re-enable the "Viewer" button + # Re-enable the buttons + self.button1.setVisible(True) self.button3.setVisible(True) QMessageBox.information(self, "Loaded", f"Project loaded from:\n{filename}") @@ -1089,6 +1149,10 @@ class MainApplication(QMainWindow): def on_bubble_clicked(self, bubble): + + # show age / gender + self.right_column_widget.show() + file_path = bubble.file_path if not os.path.exists(file_path): self.top_left_widget.setText("File not found.") @@ -1121,15 +1185,51 @@ class MainApplication(QMainWindow): # info += f" {k}: {v}\n" self.top_left_widget.setText(info) + + clicked_bubble = self.sender() + file_path = clicked_bubble.file_path + + # Save current file's metadata + if self.current_file: + self.save_metadata(self.current_file) + + # Update current file + self.current_file = file_path + + # Load new file's metadata into the fields + metadata = self.file_metadata.get(file_path, {}) + for key, field in self.meta_fields.items(): + field.blockSignals(True) + field.setText(metadata.get(key, "")) + field.blockSignals(False) def placeholder(self): QMessageBox.information(self, "Placeholder", "This feature is not implemented yet.") + def save_metadata(self, file_path): + if not file_path: + return + self.file_metadata[file_path] = { + key: field.text() + for key, field in self.meta_fields.items() + } + + def get_all_metadata(self): + # First, make sure current file's edits are saved + if self.current_file: + self.save_metadata(self.current_file) + + return self.file_metadata '''MODULE FILE''' def on_run_task(self): + + all_metadata = self.get_all_metadata() + + for file_path, data in all_metadata.items(): + print(f"{file_path}: {data}") collected_data = {} @@ -1164,7 +1264,6 @@ class MainApplication(QMainWindow): collected_data["SNIRF_SUBFOLDERS"] = [Path(p).name for p in self.selected_paths] collected_data["STIM_DURATION"] = [0 for _ in self.selected_paths] - elif hasattr(self, "selected_path") and self.selected_path: # Handle single folder selected_path = Path(self.selected_path) @@ -1176,10 +1275,9 @@ class MainApplication(QMainWindow): # No folder selected - handle gracefully or raise error raise ValueError("No folder(s) selected") - + collected_data["METADATA"] = all_metadata collected_data["HRF_MODEL"] = 'fir' - collected_data["MAX_WORKERS"] = 12 collected_data["FORCE_DROP_CHANNELS"] = [] collected_data["TARGET_ACTIVITY"] = "Reach"