diff --git a/.gitignore b/.gitignore index 36b13f1..29464dc 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ cython_debug/ # PyPI configuration file .pypirc +/individual_images \ No newline at end of file diff --git a/changelog.md b/changelog.md index b30b513..e5819f9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,15 @@ +# Version 1.2.0 +- Added new parameters to the right side of the screen +- These parameters include SHOW_OPTODE_NAMES, SECONDS_TO_STRIP_HR, MAX_LOW_HR, MAX_HIGH_HR, SMOTHING_WINDOW_HR, HEART_RATE_WINDOW, BAD_CHANNELS_HANDLING, MAX_DIST, MIN_NEIGHBORS, L_TRANS_BANDWIDTH, H_TRANS_BANDWIDTH, RESAMPLE, RESAMPLE_FREQ, STIM_DUR, HRF_MODEL, HIGH_PASS, DRIFT_ORDER, FIR_DELAYS, MIN_ONSET, OVERSAMPLING, SHORT_CHANNEL_REGRESSION, NOISE_MODEL, and BINS. +- All the new parameters have default values matching the underlying values in version 1.1.7 +- The order of the parameters have changed to match the order that the code runs when the Process button is clicked +- Moved TIME_WINDOW_START and TIME_WINDOW_END to the 'Other' category +- Fixed a bug causing SCI to not work when HEART_RATE was set to False +- Bad channels can now be dealt with by taking no action, removing them completely, or interpolating them based on their neighbours. Interpolation remains the default option +- Fixed an underlying deprecation warning +- Fixed an issue causing some overlay elements to not render on the brain for certain devices + + # Version 1.1.7 - Fixed a bug where having both a L_FREQ and H_FREQ would cause only the L_FREQ to be used diff --git a/flares.py b/flares.py index 135cb68..c2b97c8 100644 --- a/flares.py +++ b/flares.py @@ -21,6 +21,7 @@ import os.path as op import re import traceback from concurrent.futures import ProcessPoolExecutor, as_completed +from queue import Empty # External library imports import matplotlib.pyplot as plt @@ -53,7 +54,7 @@ from scipy.signal import welch, butter, filtfilt # type: ignore import pywt # type: ignore import neurokit2 as nk # type: ignore -# Backen visualization needed to be defined for pyinstaller +# Backend visualization needed to be defined for pyinstaller import pyvistaqt # type: ignore import vtkmodules.util.data_model import vtkmodules.util.execution_model @@ -89,9 +90,10 @@ from mne_nirs.io.fold import fold_channel_specificity # type: ignore from mne_nirs.preprocessing import peak_power # type: ignore from mne_nirs.statistics._glm_level_first import RegressionResults # type: ignore - +# Needs to be set for men os.environ["SUBJECTS_DIR"] = str(data_path()) + "/subjects" # type: ignore +# TODO: Tidy this up FIXED_CATEGORY_COLORS = { "SCI only": "skyblue", "PSP only": "salmon", @@ -112,10 +114,6 @@ FIXED_CATEGORY_COLORS = { } -AGE: float -GENDER: str - -# SECONDS_TO_STRIP: int DOWNSAMPLE: bool DOWNSAMPLE_FREQUENCY: int @@ -123,21 +121,37 @@ TRIM: bool SECONDS_TO_KEEP: float OPTODE_PLACEMENT: bool +SHOW_OPTODE_NAMES: bool HEART_RATE: bool +SHORT_CHANNEL: bool +SHORT_CHANNEL_THRESH: float +LONG_CHANNEL_THRESH: float + +HEART_RATE: bool +SECONDS_TO_STRIP_HR: int +MAX_LOW_HR: int +MAX_HIGH_HR: int +SMOOTHING_WINDOW_HR: int +HEART_RATE_WINDOW: int + SCI: bool SCI_TIME_WINDOW: int SCI_THRESHOLD: float SNR: bool -# SNR_TIME_WINDOW : int +# SNR_TIME_WINDOW : int #TODO: is this needed? SNR_THRESHOLD: float PSP: bool PSP_TIME_WINDOW: int PSP_THRESHOLD: float +BAD_CHANNELS_HANDLING: str +MAX_DIST: float +MIN_NEIGHBORS: int + TDDR: bool WAVELET: bool @@ -145,57 +159,39 @@ IQR: float WAVELET_TYPE: str WAVELET_LEVEL: int -HEART_RATE = True # True if heart rate should be calculated. This helps the SCI, PSP, and SNR methods to be more accurate. -SECONDS_TO_STRIP_HR =5 # Amount of seconds to temporarily strip from the data to calculate heart rate more effectively. Useful if participant removed cap while still recording. -MAX_LOW_HR = 40 # Any heart rate values lower than this will be set to this value. -MAX_HIGH_HR = 200 # Any heart rate values higher than this will be set to this value. -SMOOTHING_WINDOW_HR = 100 # Heart rate will be calculated as a rolling average over this many amount of samples. -HEART_RATE_WINDOW = 25 # Amount of BPM above and below the calculated average to use for a range of resting BPM. - ENHANCE_NEGATIVE_CORRELATION: bool FILTER: bool L_FREQ: float H_FREQ: float +L_TRANS_BANDWIDTH: float +H_TRANS_BANDWIDTH: float -SHORT_CHANNEL: bool -SHORT_CHANNEL_THRESH: float -LONG_CHANNEL_THRESH: float - +STIM_DUR: float +HRF_MODEL: str +DRIFT_MODEL: str +HIGH_PASS: float +DRIFT_ORDER: int +FIR_DELAYS: range +MIN_ONSET: int +OVERSAMPLING: int REMOVE_EVENTS: list +SHORT_CHANNEL_REGRESSION: bool + +NOISE_MODEL: str +BINS: int +N_JOBS: int TIME_WINDOW_START: int TIME_WINDOW_END: int -DRIFT_MODEL: str - VERBOSITY = True -# FIXME: Shouldn't need each ordering - just order it before checking -FIXED_CATEGORY_COLORS = { - "SCI only": "skyblue", - "PSP only": "salmon", - "SNR only": "lightgreen", - "PSP + SCI": "orange", - "SCI + SNR": "violet", - "PSP + SNR": "gold", - "SCI + PSP": "orange", - "SNR + SCI": "violet", - "SNR + PSP": "gold", - "PSP + SNR + SCI": "gray", - "SCI + PSP + SNR": "gray", - "SCI + SNR + PSP": "gray", - "PSP + SCI + SNR": "gray", - "PSP + SNR + SCI": "gray", - "SNR + SCI + PSP": "gray", - "SNR + PSP + SCI": "gray", -} - - -AGE = 25 +AGE = 25 # Assume 25 if not set from the GUI. This will result in a reasonable PPF GENDER = "" GROUP = "Default" +# These are parameters that are required for the analysis REQUIRED_KEYS: dict[str, Any] = { # "SECONDS_TO_STRIP": int, @@ -262,7 +258,7 @@ PLATFORM_NAME = platform.system().lower() # Configure logging to file with timestamps and realtime flush if PLATFORM_NAME == 'darwin': logging.basicConfig( - filename=os.path.join(os.path.dirname(sys.executable), "../../../fnirs_analysis.log"), + filename=os.path.join(os.path.dirname(sys.executable), "../../../fnirs_analysis.log"), # Needed to get out of the bundled application level=logging.INFO, format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S', @@ -320,8 +316,6 @@ def set_metadata(file_path, metadata: dict[str, Any]) -> None: val = file_metadata.get(key, None) if val not in (None, '', [], {}, ()): # check for "empty" values globals()[key] = val -from queue import Empty # This works with multiprocessing.Manager().Queue() - def gui_entry(config: dict[str, Any], gui_queue: Queue, progress_queue: Queue) -> None: def forward_progress(): @@ -825,7 +819,7 @@ def get_hbo_hbr_picks(raw): return hbo_picks, hbr_picks, hbo_wl, hbr_wl -def interpolate_fNIRS_bads_weighted_average(raw, bad_channels, max_dist=0.03, min_neighbors=2): +def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2): """ Interpolate bad fNIRS channels using a distance-weighted average of nearby good channels. @@ -1117,17 +1111,17 @@ def mark_bads(raw, bad_sci, bad_snr, bad_psp): def filter_the_data(raw_haemo): - # --- STEP 5: Filtering (0.01–0.2 Hz bandpass) --- + # --- STEP 5: Filtering (0.01-0.2 Hz bandpass) --- fig_filter = raw_haemo.compute_psd(fmax=3).plot( average=True, color="r", show=False, amplitude=True ) if L_FREQ == 0 and H_FREQ != 0: - raw_haemo = raw_haemo.filter(l_freq=None, h_freq=H_FREQ, h_trans_bandwidth=0.02) + raw_haemo = raw_haemo.filter(l_freq=None, h_freq=H_FREQ, h_trans_bandwidth=H_TRANS_BANDWIDTH) elif L_FREQ != 0 and H_FREQ == 0: - raw_haemo = raw_haemo.filter(l_freq=L_FREQ, h_freq=None, l_trans_bandwidth=0.002) + raw_haemo = raw_haemo.filter(l_freq=L_FREQ, h_freq=None, l_trans_bandwidth=L_TRANS_BANDWIDTH) elif L_FREQ != 0 and H_FREQ != 0: - raw_haemo = raw_haemo.filter(l_freq=L_FREQ, h_freq=H_FREQ, l_trans_bandwidth=0.002, h_trans_bandwidth=0.02) + raw_haemo = raw_haemo.filter(l_freq=L_FREQ, h_freq=H_FREQ, l_trans_bandwidth=L_TRANS_BANDWIDTH, h_trans_bandwidth=H_TRANS_BANDWIDTH) else: print("No filter") #raw_haemo = raw_haemo.filter(l_freq=None, h_freq=0.4, h_trans_bandwidth=0.2) @@ -1307,6 +1301,19 @@ def epochs_calculations(raw_haemo, events, event_dict): def make_design_matrix(raw_haemo, short_chans): + events_to_remove = REMOVE_EVENTS + + filtered_annotations = [ann for ann in raw_haemo.annotations if ann['description'] not in events_to_remove] + + new_annot = Annotations( + onset=[ann['onset'] for ann in filtered_annotations], + duration=[ann['duration'] for ann in filtered_annotations], + description=[ann['description'] for ann in filtered_annotations] + ) + + # Set the new annotations + raw_haemo.set_annotations(new_annot) + raw_haemo.resample(1, npad="auto") raw_haemo._data = raw_haemo._data * 1e6 # 2) Create design matrix @@ -1314,26 +1321,28 @@ def make_design_matrix(raw_haemo, short_chans): short_chans.resample(1) design_matrix = make_first_level_design_matrix( raw=raw_haemo, - hrf_model='fir', - stim_dur=0.5, - fir_delays=range(15), + stim_dur=STIM_DUR, + hrf_model=HRF_MODEL, drift_model=DRIFT_MODEL, - high_pass=0.01, - oversampling=1, - min_onset=-125, + high_pass=HIGH_PASS, + drift_order=DRIFT_ORDER, + fir_delays=range(15), add_regs=short_chans.get_data().T, - add_reg_names=short_chans.ch_names + add_reg_names=short_chans.ch_names, + min_onset=MIN_ONSET, + oversampling=OVERSAMPLING ) else: design_matrix = make_first_level_design_matrix( raw=raw_haemo, - hrf_model='fir', - stim_dur=0.5, - fir_delays=range(15), + stim_dur=STIM_DUR, + hrf_model=HRF_MODEL, drift_model=DRIFT_MODEL, - high_pass=0.01, - oversampling=1, - min_onset=-125, + high_pass=HIGH_PASS, + drift_order=DRIFT_ORDER, + fir_delays=range(15), + min_onset=MIN_ONSET, + oversampling=OVERSAMPLING ) print(design_matrix.head()) @@ -3310,18 +3319,20 @@ def hr_calc(raw): return fig, hr1, hr2, low, high + + def process_participant(file_path, progress_callback=None): fig_individual: dict[str, Figure] = {} - # Step 1: Load + # Step 1: Preprocessing raw = load_snirf(file_path) fig_raw = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Loaded Raw", show=False) fig_individual["Loaded Raw"] = fig_raw if progress_callback: progress_callback(1) - logger.info("1") - + logger.info("Step 1 Completed.") + # Step 2: Trimming if TRIM: if hasattr(raw, 'annotations') and len(raw.annotations) > 0: # Get time of first event @@ -3329,17 +3340,16 @@ def process_participant(file_path, progress_callback=None): trim_time = max(0, first_event_time - SECONDS_TO_KEEP) # Ensure we don't go negative raw.crop(tmin=trim_time) # Shift annotation onsets to match new t=0 - import mne ann = raw.annotations - ann_shifted = mne.Annotations( + ann_shifted = Annotations( onset=ann.onset - trim_time, # shift to start at zero duration=ann.duration, description=ann.description ) data = raw.get_data() info = raw.info.copy() - raw = mne.io.RawArray(data, info) + raw = RawArray(data, info) raw.set_annotations(ann_shifted) logger.info(f"Trimmed raw data: start at {trim_time}s (5s before first event), t=0 at new start") @@ -3349,169 +3359,162 @@ def process_participant(file_path, progress_callback=None): fig_trimmed = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Trimmed Raw", show=False) fig_individual["Trimmed Raw"] = fig_trimmed if progress_callback: progress_callback(2) - logger.info("2") + logger.info("Step 2 Completed.") - # Step 1.5: Verify optode positions + # Step 3: Verify Optode Placement if OPTODE_PLACEMENT: - fig_optodes = raw.plot_sensors(show_names=True, to_sphere=True, show=False) # type: ignore + fig_optodes = raw.plot_sensors(show_names=SHOW_OPTODE_NAMES, to_sphere=True, show=False) # type: ignore fig_individual["Plot Sensors"] = fig_optodes if progress_callback: progress_callback(3) - logger.info("3") + logger.info("Step 3 Completed.") - # Step 2: Bad from SCI + # Step 4: Short/Long Channels + if SHORT_CHANNEL: + short_chans = get_short_channels(raw, max_dist=SHORT_CHANNEL_THRESH) + fig_short_chans = short_chans.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Short Channels Only", show=False) + fig_individual["short"] = fig_short_chans + else: + short_chans = None + get_long_channels(raw, min_dist=SHORT_CHANNEL_THRESH, max_dist=LONG_CHANNEL_THRESH) # Don't update the existing raw + if progress_callback: progress_callback(4) + logger.info("Step 4 Completed.") + + # Step 5: Heart Rate if HEART_RATE: fig, hr1, hr2, low, high = hr_calc(raw) fig_individual["PSD"] = fig fig_individual['HeartRate_PSD'] = hr1 fig_individual['HeartRate_Time'] = hr2 - if progress_callback: progress_callback(4) - logger.info("4") + if progress_callback: progress_callback(5) + logger.info("Step 5 Completed.") + # Step 6: Scalp Coupling Index bad_sci = [] if SCI: - bad_sci, fig_sci_1, fig_sci_2 = calculate_scalp_coupling(raw, low, high) + if HEART_RATE: + bad_sci, fig_sci_1, fig_sci_2 = calculate_scalp_coupling(raw, low, high) + else: + bad_sci, fig_sci_1, fig_sci_2 = calculate_scalp_coupling(raw) fig_individual["SCI1"] = fig_sci_1 fig_individual["SCI2"] = fig_sci_2 - if progress_callback: progress_callback(5) - logger.info("5") + if progress_callback: progress_callback(6) + logger.info("Step 6 Completed.") - # Step 2: Bad from SNR + # Step 7: Signal to Noise Ratio bad_snr = [] if SNR: bad_snr, fig_snr = calculate_signal_noise_ratio(raw) fig_individual["SNR1"] = fig_snr - if progress_callback: progress_callback(6) - logger.info("6") + if progress_callback: progress_callback(7) + logger.info("Step 7 Completed.") - # Step 3: Bad from PSP + # Step 8: Peak Spectral Power bad_psp = [] if PSP: bad_psp, fig_psp1, fig_psp2 = calculate_peak_power(raw) fig_individual["PSP1"] = fig_psp1 fig_individual["PSP2"] = fig_psp2 - if progress_callback: progress_callback(7) - logger.info("7") - - # Step 4: Mark the bad channels - raw, fig_dropped, fig_raw_before, bad_channels = mark_bads(raw, bad_sci, bad_snr, bad_psp) - if fig_dropped and fig_raw_before is not None: - fig_individual["fig2"] = fig_dropped - fig_individual["fig3"] = fig_raw_before if progress_callback: progress_callback(8) - logger.info("8") + logger.info("Step 8 Completed.") + + # Step 9: Bad Channels Handling + if BAD_CHANNELS_HANDLING != "None": + raw, fig_dropped, fig_raw_before, bad_channels = mark_bads(raw, bad_sci, bad_snr, bad_psp) + if fig_dropped and fig_raw_before is not None: + fig_individual["fig2"] = fig_dropped + fig_individual["fig3"] = fig_raw_before + if bad_channels: + if BAD_CHANNELS_HANDLING == "Interpolate": + raw, fig_raw_after = interpolate_fNIRS_bads_weighted_average(raw, max_dist=MAX_DIST, min_neighbors=MIN_NEIGHBORS) + fig_individual["fig4"] = fig_raw_after + elif BAD_CHANNELS_HANDLING == "Remove": + pass + #TODO: Is there more needed here? - # Step 5: Interpolate the bad channels - if bad_channels: - raw, fig_raw_after = interpolate_fNIRS_bads_weighted_average(raw, bad_channels) - fig_individual["fig4"] = fig_raw_after if progress_callback: progress_callback(9) - logger.info("9") + logger.info("Step 9 Completed.") - # Step 6: Optical Density + # Step 10: Optical Density raw_od = optical_density(raw) fig_raw_od = raw_od.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Optical Density", show=False) fig_individual["Optical Density"] = fig_raw_od if progress_callback: progress_callback(10) - logger.info("10") + logger.info("Step 10 Completed.") - # Step 7: TDDR + # Step 11: Temporal Derivative Distribution Repair Filtering if TDDR: raw_od = temporal_derivative_distribution_repair(raw_od) fig_raw_od_tddr = raw_od.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After TDDR (Motion Correction)", show=False) fig_individual["TDDR"] = fig_raw_od_tddr if progress_callback: progress_callback(11) - logger.info("11") - + logger.info("Step 11 Completed.") + # Step 12: Wavelet Filtering if WAVELET: raw_od, fig = calculate_and_apply_wavelet(raw_od) fig_individual["Wavelet"] = fig if progress_callback: progress_callback(12) - logger.info("12") + logger.info("Step 12 Completed.") - - # Step 8: BLL + # Step 13: Haemoglobin Concentration raw_haemo = beer_lambert_law(raw_od, ppf=calculate_dpf(file_path)) fig_raw_haemo_bll = raw_haemo.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="HbO and HbR Signals", show=False) fig_individual["BLL"] = fig_raw_haemo_bll if progress_callback: progress_callback(13) - logger.info("13") + logger.info("Step 13 Completed.") - # Step 9: ENC + # Step 14: Enhance Negative Correlation if ENHANCE_NEGATIVE_CORRELATION: raw_haemo = enhance_negative_correlation(raw_haemo) - fig_raw_haemo_enc = raw_haemo.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="HbO and HbR Signals", show=False) + fig_raw_haemo_enc = raw_haemo.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Enhance Negative Correlation", show=False) fig_individual["ENC"] = fig_raw_haemo_enc if progress_callback: progress_callback(14) - logger.info("14") + logger.info("Step 14 Completed.") - # Step 10: Filter + # Step 15: Filter if FILTER: raw_haemo, fig_filter, fig_raw_haemo_filter = filter_the_data(raw_haemo) fig_individual["filter1"] = fig_filter fig_individual["filter2"] = fig_raw_haemo_filter if progress_callback: progress_callback(15) - logger.info("15") + logger.info("Step 15 Completed.") - # Step 11: Get short / long channels - if SHORT_CHANNEL: - short_chans = get_short_channels(raw_haemo, max_dist=SHORT_CHANNEL_THRESH) - fig_short_chans = short_chans.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Short Channels Only", show=False) - fig_individual["short"] = fig_short_chans - else: - short_chans = None - raw_haemo = get_long_channels(raw_haemo, min_dist=SHORT_CHANNEL_THRESH, max_dist=LONG_CHANNEL_THRESH) - if progress_callback: progress_callback(16) - logger.info("16") - - # Step 12: Events from annotations + # Step 16: Extracting Events events, event_dict = events_from_annotations(raw_haemo) fig_events = plot_events(events, event_id=event_dict, sfreq=raw_haemo.info["sfreq"], show=False) fig_individual["events"] = fig_events - if progress_callback: progress_callback(17) - logger.info("17") + if progress_callback: progress_callback(16) + logger.info("Step 16 Completed.") - # Step 13: Epoch calculations + # Step 17: Epoch Calculations epochs, fig_epochs = epochs_calculations(raw_haemo, events, event_dict) - for name, fig in fig_epochs: # Unpack the tuple here - fig_individual[f"epochs_{name}"] = fig # Store only the figure, not the name - if progress_callback: progress_callback(18) - logger.info("18") - - # Step 14: Design Matrix - events_to_remove = REMOVE_EVENTS - - filtered_annotations = [ann for ann in raw.annotations if ann['description'] not in events_to_remove] - - new_annot = Annotations( - onset=[ann['onset'] for ann in filtered_annotations], - duration=[ann['duration'] for ann in filtered_annotations], - description=[ann['description'] for ann in filtered_annotations] - ) - - # Set the new annotations - raw_haemo.set_annotations(new_annot) + for name, fig in fig_epochs: + fig_individual[f"epochs_{name}"] = fig + if progress_callback: progress_callback(17) + logger.info("Step 17 Completed.") + # Step 18: Design Matrix design_matrix, fig_design_matrix = make_design_matrix(raw_haemo, short_chans) fig_individual["Design Matrix"] = fig_design_matrix - if progress_callback: progress_callback(19) - logger.info("19") + if progress_callback: progress_callback(18) + logger.info("Step 18 Completed.") - # Step 15: Run GLM - glm_est = run_glm(raw_haemo, design_matrix) + + # Step 19: Run GLM + glm_est = run_glm(raw_haemo, design_matrix, noise_model=NOISE_MODEL, bins=BINS, n_jobs=N_JOBS, verbose=VERBOSITY) # Not used AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\nilearn\glm\contrasts.py # Yes used AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\mne_nirs\utils\_io.py - # The p-value is calculated from this t-statistic using the Student’s t-distribution with appropriate degrees of freedom. + # The p-value is calculated from this t-statistic using the Student's t-distribution with appropriate degrees of freedom. # p_value = 2 * stats.t.cdf(-abs(t_statistic), df) # It is a two-tailed p-value. # It says how likely it is to observe the effect you did (or something more extreme) if the true effect was zero (null hypothesis). - # A small p-value (e.g., < 0.05) suggests the effect is unlikely to be zero — it’s "statistically significant." + # A small p-value (e.g., < 0.05) suggests the effect is unlikely to be zero — it's "statistically significant." # A large p-value means the data do not provide strong evidence that the effect is different from zero. - if progress_callback: progress_callback(20) - logger.info("20") + if progress_callback: progress_callback(19) + logger.info("19") # Step 16: Plot GLM results fig_glm_result = plot_glm_results(file_path, raw_haemo, glm_est, design_matrix) diff --git a/main.py b/main.py index a5ef4df..1a6c8ac 100644 --- a/main.py +++ b/main.py @@ -23,10 +23,9 @@ from pathlib import Path, PurePosixPath from datetime import datetime from multiprocessing import Process, current_process, freeze_support, Manager +# External library imports import numpy as np import pandas as pd - -# External library imports import psutil import requests @@ -46,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.0" +CURRENT_VERSION = "1.2.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" @@ -58,7 +57,6 @@ SECTIONS = [ { "title": "Preprocessing", "params": [ - # {"name": "SECONDS_TO_STRIP", "default": 0, "type": int, "help": "Seconds to remove from beginning of all loaded snirf files. Setting this to 0 will remove nothing from the files."}, {"name": "DOWNSAMPLE", "default": True, "type": bool, "help": "Should the snirf files be downsampled? If this is set to True, DOWNSAMPLE_FREQUENCY will be used as the target frequency to downsample to."}, {"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated. Only used if DOWNSAMPLE is set to True"}, ] @@ -74,12 +72,26 @@ SECTIONS = [ "title": "Verify Optode Placement", "params": [ {"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "help": "Generate an image for each participant outlining their optode placement."}, + {"name": "SHOW_OPTODE_NAMES", "default": True, "type": bool, "help": "Should the optode names be written next to their location or not."}, + ] + }, + { + "title": "Short/Long Channels", + "params": [ + {"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data."}, + {"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "help": "The maximum distance the short channel can be in metres."}, + {"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance the long channel can be in metres."}, ] }, { "title": "Heart Rate", "params": [ {"name": "HEART_RATE", "default": True, "type": bool, "help": "Attempt to calculate the participants heart rate."}, + {"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "help": "Will remove this many seconds from the start and end of the file. Useful if recording before cap is firmly placed, or participant removes cap while still recording."}, + {"name": "MAX_LOW_HR", "default": 40, "type": int, "help": "Any heart rate windows that average below this value will be rounded up to this value."}, + {"name": "MAX_HIGH_HR", "default": 200, "type": int, "help": "Any heart rate windows that average above this value will be rounded down to this value."}, + {"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "help": "How many individual data points to smooth into a single window."}, + {"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "help": "Used for visualization. Shows the range of the calculated heart rate +- this value."}, ] }, { @@ -94,14 +106,12 @@ SECTIONS = [ "title": "Signal to Noise Ratio", "params": [ {"name": "SNR", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Signal to Noise Ratio. This metric calculates how much of the observed signal was noise versus how much of it was a useful signal."}, - # {"name": "SNR_TIME_WINDOW", "default": -1, "type": int, "help": "SNR time window."}, {"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "help": "SNR threshold (dB). A typical scale would be 0-25, but it is possible for values to be both above and below this range. Higher values correspond to a better signal. If SNR is True, any channels lower than this value will be marked as bad."}, ] }, { "title": "Peak Spectral Power", "params": [ - {"name": "PSP", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of a frequency component that is most prominent in a particular frequency range or spectrum."}, {"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "help": "Independent PSP calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."}, {"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "help": "PSP threshold. A typical scale would be 0-0.5, but it is possible for values to be above this range. Higher values correspond to a better signal. If PSP is True, any channels lower than this value will be marked as bad."}, @@ -110,15 +120,15 @@ SECTIONS = [ { "title": "Bad Channels Handling", "params": [ - # {"name": "NOT_IMPLEMENTED", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."}, - # {"name": "NOT_IMPLEMENTED", "default": 3, "type": int, "help": "PSP time window."}, - # {"name": "NOT_IMPLEMENTED", "default": 0.1, "type": float, "help": "PSP threshold."}, + {"name": "BAD_CHANNELS_HANDLING", "default": [], "type": list, "options": ["Interpolate", "Remove", "None"], "exclusive": True, "help": "How should we deal with the bad channels that occurred? Note: Some analysis options will only work when this is set to 'Interpolate'."}, + {"name": "MAX_DIST", "default": 0.03, "type": float, "help": "The maximum distance to look for neighbours when interpolating. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."}, + {"name": "MIN_NEIGHBORS", "default": 2, "type": int, "help": "The minimumn amount of neighbours needed within the MAX_DIST parameter. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."}, ] }, { "title": "Optical Density", "params": [ - # Intentionally empty (TODO) + # NOTE: Intentionally empty ] }, { @@ -139,7 +149,7 @@ SECTIONS = [ { "title": "Haemoglobin Concentration", "params": [ - # Intentionally empty (TODO) + # NOTE: Intentionally empty ] }, { @@ -154,24 +164,18 @@ SECTIONS = [ {"name": "FILTER", "default": True, "type": bool, "help": "Filter the data."}, {"name": "L_FREQ", "default": 0.005, "type": float, "help": "Any frequencies lower than this value will be removed."}, {"name": "H_FREQ", "default": 0.3, "type": float, "help": "Any frequencies higher than this value will be removed."}, + {"name": "L_TRANS_BANDWIDTH", "default": 0.002, "type": float, "help": "How wide the transitional period should be so the data doesn't just drop off on the lower bound."}, + {"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "help": "How wide the transitional period should be so the data doesn't just drop off on the upper bound."}, ] }, { - "title": "Short/Long Channels", - "params": [ - {"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data."}, - {"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "help": "The maximum distance the short channel can be in metres."}, - {"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance the long channel can be in metres."}, - ] - }, - { - "title": "Extracting Events", + "title": "Extracting Events*", "params": [ #{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."}, ] }, { - "title": "Epoch Calculations", + "title": "Epoch Calculations*", "params": [ #{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."}, ] @@ -179,18 +183,27 @@ SECTIONS = [ { "title": "Design Matrix", "params": [ + {"name": "RESAMPLE", "default": True, "type": bool, "help": "The length of your stimulus."}, + {"name": "RESAMPLE_FREQ", "default": 1, "type": int, "help": "The length of your stimulus."}, + + {"name": "STIM_DUR", "default": 0.5, "type": float, "help": "The length of your stimulus."}, + {"name": "HRF_MODEL", "default": "fir", "type": str, "help": "Specifies the hemodynamic response function."}, + {"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Specifies the desired drift model."}, + {"name": "HIGH_PASS", "default": 0.01, "type": float, "help": "High-pass frequency in case of a cosine model (in Hz)."}, + {"name": "DRIFT_ORDER", "default": 1, "type": int, "help": "Order of the drift model (in case it is polynomial)"}, + {"name": "FIR_DELAYS", "default": "None", "type": range, "help": "In case of FIR design, yields the array of delays used in the FIR model (in scans)."}, + {"name": "MIN_ONSET", "default": -24, "type": int, "help": "Minimal onset relative to frame times (in seconds)"}, + {"name": "OVERSAMPLING", "default": 50, "type": int, "help": "Oversampling factor used in temporal convolutions."}, {"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"}, - {"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Drift model for GLM."}, - # {"name": "DURATION_BETWEEN_ACTIVITIES", "default": 35, "type": int, "help": "Time between activities (s)."}, - # {"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "help": "Use short channel regression."}, + {"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "help": "Whether to use short channel regression and regress out the short channels. Requires SHORT_CHANNELS to be True and at least one short channel to be found."}, ] }, { "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."}, + {"name": "NOISE_MODEL", "default": "ar1", "type": str, "help": "Number of jobs for GLM processing."}, + {"name": "BINS", "default": 0, "type": int, "help": "Number of jobs for GLM processing."}, + {"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for GLM processing."}, ] }, { @@ -202,6 +215,8 @@ SECTIONS = [ { "title": "Other", "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": "MAX_WORKERS", "default": 4, "type": int, "help": "Number of files to be processed at once. Lowering this value may help on underpowered systems."}, ] }, @@ -485,6 +500,7 @@ class UserGuideWindow(QWidget): label = QLabel("Progress Bar Stages:", self) label2 = QLabel("Stage 1: Load the snirf file\n" "Stage 2: Check the optode positions\n" + "Stage 12: Get Short/Long Channels\n" "Stage 3: Scalp Coupling Index\n" "Stage 4: Signal to Noise Ratio\n" "Stage 5: Peak Spectral Power\n" @@ -494,7 +510,6 @@ class UserGuideWindow(QWidget): "Stage 9: Temporal Derivative Distribution Repair\n" "Stage 10: Beer Lambert Law\n" "Stage 11: Heart Rate Filtering\n" - "Stage 12: Get Short/Long Channels\n" "Stage 13: Calculate Events from Annotations\n" "Stage 14: Epoch Calculations\n" "Stage 15: Design Matrix\n" @@ -1358,7 +1373,12 @@ class ParamSection(QWidget): widget.setValidator(QDoubleValidator()) widget.setText(str(param["default"])) elif param["type"] == list: - widget = self._create_multiselect_dropdown(None) + if param.get("exclusive", True): + widget = QComboBox() + widget.addItems(param.get("options", [])) + widget.setCurrentText(str(param.get("default", ""))) + else: + widget = self._create_multiselect_dropdown(None) else: widget = QLineEdit() widget.setText(str(param["default"])) @@ -1466,7 +1486,10 @@ class ParamSection(QWidget): if expected_type == bool: values[name] = widget.currentText() == "True" elif expected_type == list: - values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()] + if isinstance(widget, FullClickComboBox): + values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()] + elif isinstance(widget, QComboBox): + values[name] = widget.currentText() else: raw_text = widget.text() try: diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 7f8fb98..d5491ee 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -1025,7 +1025,7 @@ def _handle_sensor_types(meg, eeg, fnirs): fnirs=dict(channels="fnirs", pairs="fnirs_pairs"), ) sensor_alpha = { - key: dict(meg_helmet=0.25, meg=0.25).get(key, 0.8) + key: dict(meg_helmet=0.25, meg=0.25).get(key, 1.0) for ch_dict in alpha_map.values() for key in ch_dict.values() } diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 620793c..2434017 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -586,7 +586,7 @@ class _PyVistaRenderer(_AbstractRenderer): color = None else: scalars = None - tube = line.tube(radius, n_sides=self.tube_n_sides) + tube = line.tube(radius=radius, n_sides=self.tube_n_sides) actor = _add_mesh( plotter=self.plotter, mesh=tube, diff --git a/version_main.txt b/version_main.txt index 440967e..a3b7606 100644 --- a/version_main.txt +++ b/version_main.txt @@ -18,7 +18,7 @@ VSVersionInfo( StringStruct('FileDescription', 'FLARES main application'), StringStruct('FileVersion', '1.0.0.0'), StringStruct('InternalName', 'flares.exe'), - StringStruct('LegalCopyright', '© 2025 Tyler de Zeeuw'), + StringStruct('LegalCopyright', '© 2025-2026 Tyler de Zeeuw'), StringStruct('OriginalFilename', 'flares.exe'), StringStruct('ProductName', 'FLARES'), StringStruct('ProductVersion', '1.0.0.0')]) diff --git a/version_updater.txt b/version_updater.txt index 4302aa4..66f52b2 100644 --- a/version_updater.txt +++ b/version_updater.txt @@ -18,7 +18,7 @@ VSVersionInfo( StringStruct('FileDescription', 'FLARES updater application'), StringStruct('FileVersion', '1.0.0.0'), StringStruct('InternalName', 'main.exe'), - StringStruct('LegalCopyright', '© 2025 Tyler de Zeeuw'), + StringStruct('LegalCopyright', '© 2025-2026 Tyler de Zeeuw'), StringStruct('OriginalFilename', 'flares_updater.exe'), StringStruct('ProductName', 'FLARES Updater'), StringStruct('ProductVersion', '1.0.0.0')])