Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b017005c5 | ||
|
|
2b019c1bc0 | ||
|
|
0680718398 | ||
|
|
2ff7cda93a | ||
|
|
d300dfb7f7 | ||
|
|
12afc5d3bc | ||
|
|
ffa14693b3 |
@@ -1,3 +1,40 @@
|
|||||||
|
# Version 1.5.1
|
||||||
|
|
||||||
|
- Open a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
|
||||||
|
- Renamed options in the Viewer Launcher window to better denote what actions they can perform
|
||||||
|
- 2 new analysis options have been added in the viewer launcher window: Inter-Group Stats and Cross-Group Stats
|
||||||
|
- Each of these analysis options have lots of text explaining what they do when their respective windows are opened
|
||||||
|
- The parameter input dialog in the analysis windows will now be able to scroll down when lots of parameters are required
|
||||||
|
- When a project is saved, it automatically will populate into the Recent Projects menu. Fixes [Issue 86](https://git.research.dezeeuw.ca/tyler/flares/issues/86)
|
||||||
|
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
|
||||||
|
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
|
||||||
|
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
|
||||||
|
- Fixed participant metadata remaing in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
|
||||||
|
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
|
||||||
|
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
|
||||||
|
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
|
||||||
|
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
|
||||||
|
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
|
||||||
|
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
|
||||||
|
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
|
||||||
|
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
|
||||||
|
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
|
||||||
|
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
|
||||||
|
- Changed the messaged displayed when the application crashes to better reflect what occured
|
||||||
|
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perforn
|
||||||
|
- Changed stage 25 from "Generate Channel Significance" to "Generate Channel Results"
|
||||||
|
- Changed stage 26 from "Generate Channel, Region of Interest, and Contrast Results" to "Generate Region of Interest Results"
|
||||||
|
- Changed stage 27 from "Compute Contrast Results" to "Generate Contrast Results"
|
||||||
|
- Changed backend code to only load required methods and not load all methods every time
|
||||||
|
- Fixed string parameters not going blue and bold when their value was different than default
|
||||||
|
- Fixed all parameters not going blue and bold when a save file is loaded containing values that differ from default
|
||||||
|
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
|
||||||
|
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
|
||||||
|
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
|
||||||
|
- Decreased unnecessary processing time when fOLDing channels by an order of magnitude
|
||||||
|
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands
|
||||||
|
|
||||||
|
|
||||||
# Version 1.5.0
|
# Version 1.5.0
|
||||||
|
|
||||||
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
|
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
|
||||||
|
|||||||
@@ -1,3 +1,39 @@
|
|||||||
|
# Version 1.5.1
|
||||||
|
|
||||||
|
- Open a folder is now minimally BIDS compatible. It will recursively go through all folders, but not load external metadata
|
||||||
|
- Renamed options in the Viewer Launcher window to better denote what actions they can perform
|
||||||
|
- 2 new analysis options have been added in the viewer launcher window: Inter-Group Stats and Cross-Group Stats
|
||||||
|
- Each of these analysis options have lots of text explaining what they do when their respective windows are opened
|
||||||
|
- The parameter input dialog in the analysis windows will now be able to scroll down when lots of parameters are required
|
||||||
|
- When a project is saved, it automatically will populate into the Recent Projects menu. Fixes [Issue 86](https://git.research.dezeeuw.ca/tyler/flares/issues/86)
|
||||||
|
- The description when clicking "Why are these useful?" underneath the per-file parameters is no longer placeholder text. Fixes [Issue 85](https://git.research.dezeeuw.ca/tyler/flares/issues/85)
|
||||||
|
- Added a new parameter section "Region of Interest". It has one parameter of "JSON_LOCATION", a json file containing Region of Interest data
|
||||||
|
- Temporarily removed the parameter REMOVE_EVENTS due to its functionality being removed because of a memory leak
|
||||||
|
- Fixed participant metadata remaing in the background when the participant was removed. Fixes [Issue 82](https://git.research.dezeeuw.ca/tyler/flares/issues/82)
|
||||||
|
- Fixed processing remaining active hidden in the background when the "Clear" button was pushed. Fixes [Issue 81](https://git.research.dezeeuw.ca/tyler/flares/issues/81)
|
||||||
|
- Now when "Clear" is pushed while data is processing, a popup will appear ensuring that the user understands that pushing "Clear" will stop processing
|
||||||
|
- Reset to Default Configuration will now properly reset all of the parameters to their default values. [Issue 90](https://git.research.dezeeuw.ca/tyler/flares/issues/90)
|
||||||
|
- A confirmation popup will now display once the application has been reset to default in addition to the status bar message
|
||||||
|
- Changed the improper display text of "Cross Validation" to now properly read as "Coefficient of Variation"
|
||||||
|
- Changed the parameters "CV" and "CV_THRESHOLD" to now be "COEFF_VAR and COEFF_VAR_THRESHOLD"
|
||||||
|
- Changed the improper display text of "Mean Absolute Deviation" to now properly read as "Median Absolute Deviation"
|
||||||
|
- Changed the parameters "SHORT_CHANNEL", "SHORT_CHANNEL_THRESH", and "LONG_CHANNEL_THRESH" to now be "SHORT_CHANNELS", "SHORT_CHANNELS_THRESHOLD", and "LONG_CHANNELS_THRESHOLD"
|
||||||
|
- Changed the parameter section "Channel Variance" to now be "Sensor Dropout" to better reflect the action it performs, not the method employed to calculate it
|
||||||
|
- Changed the parameters "CHANNEL_VAR" and "CHANNEL_THRESH" to now be "SENSOR_DROPOUT" and "SENSOR_DROPOUT_VARIANCE_THRESHOLD"
|
||||||
|
- Changed the messaged displayed when the application crashes to better reflect what occured
|
||||||
|
- Changed the processing stages 25-27 and updates their messages to better reflect their actions that they now perforn
|
||||||
|
- Changed stage 25 from "Generate Channel Significance" to "Generate Channel Results"
|
||||||
|
- Changed stage 26 from "Generate Channel, Region of Interest, and Contrast Results" to "Generate Region of Interest Results"
|
||||||
|
- Changed stage 27 from "Compute Contrast Results" to "Generate Contrast Results"
|
||||||
|
- Changed backend code to only load required methods and not load all methods every time
|
||||||
|
- Fixed string parameters not going blue and bold when their value was different than default
|
||||||
|
- Fixed all parameters not going blue and bold when a save file is loaded containing values that differ from default
|
||||||
|
- Fixed a crucial bug where short channels were not being processed and filtered the same way as long channels before being used as regressors
|
||||||
|
- Fixed a crucial bug where short channels were being presented to the design matrix as normal long channels
|
||||||
|
- Fixed a crucial bug where long channels could be interpolated from short channels. Short channels are still potentially interpolated from long channels. See [this link](https://git.research.dezeeuw.ca/tyler/flares/issues/80) for more information regarding this issue.
|
||||||
|
- Added a welcome message when the terminal is opened, resized the terminal, and added more commands
|
||||||
|
|
||||||
|
|
||||||
# Version 1.5.0
|
# Version 1.5.0
|
||||||
|
|
||||||
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
|
- This release introduces a new configuration file that may break existing installs. If your application does not update correctly, please download fresh from [this link.](https://git.research.dezeeuw.ca/tyler/flares/releases/)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from src.window.updateoptodes import UpdateOptodesWindow
|
|||||||
from src.window.userguide import UserGuideWindow
|
from src.window.userguide import UserGuideWindow
|
||||||
from src.window.viewerlauncher import ViewerLauncherWidget
|
from src.window.viewerlauncher import ViewerLauncherWidget
|
||||||
from src.window.welcome import WelcomeDialog
|
from src.window.welcome import WelcomeDialog
|
||||||
from src.shared.flaresbasewidget import ParamSection
|
from src.shared.flaresbasewidget import FilePickerWidget, ParamSection
|
||||||
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PIPELINE_STAGES, PLATFORM_NAME
|
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PIPELINE_STAGES, PLATFORM_NAME
|
||||||
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
|
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ SECTIONS = [
|
|||||||
"title": "Trimming",
|
"title": "Trimming",
|
||||||
"params": [
|
"params": [
|
||||||
{"name": "TRIM", "default": True, "type": bool, "help": "Should the start of the files be trimmed?"},
|
{"name": "TRIM", "default": True, "type": bool, "help": "Should the start of the files be trimmed?"},
|
||||||
{"name": "SECONDS_TO_KEEP", "default": 5, "type": float, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
|
{"name": "SECONDS_TO_KEEP", "default": 5.0, "type": float, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,9 +104,9 @@ SECTIONS = [
|
|||||||
{
|
{
|
||||||
"title": "Short/Long Channels",
|
"title": "Short/Long Channels",
|
||||||
"params": [
|
"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. For more information about short channels, please visit the Wiki."},
|
{"name": "SHORT_CHANNELS", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."},
|
||||||
{"name": "SHORT_CHANNEL_THRESH", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNEL", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
|
{"name": "SHORT_CHANNELS_THRESHOLD", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNEL", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
|
||||||
{"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
|
{"name": "LONG_CHANNELS_THRESHOLD", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -144,16 +144,16 @@ SECTIONS = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Cross Validation",
|
"title": "Coefficient of Variation",
|
||||||
"params": [
|
"params": [
|
||||||
{"name": "CV", "default": True, "type": bool, "help": "Identifies bad channels using the Coefficient of Variation."},
|
{"name": "COEFF_VAR", "default": True, "type": bool, "help": "Identifies bad channels using the Coefficient of Variation."},
|
||||||
{"name": "CV_THRESHOLD", "default": 20, "type": int, "depends_on": "CV", "help": "Noise threshold (%)."},
|
{"name": "COEFF_VAR_THRESHOLD", "default": 20, "type": int, "depends_on": "COEFF_VAR", "help": "Noise threshold (%)."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Median Absolute Deviation",
|
"title": "Median Absolute Deviation",
|
||||||
"params": [
|
"params": [
|
||||||
{"name": "MAD", "default": True, "type": bool, "help": "Identifies bad channels using Mean Absolute Deviation."},
|
{"name": "MAD", "default": True, "type": bool, "help": "Identifies bad channels using Median Absolute Deviation."},
|
||||||
{"name": "MAD_THRESHOLD", "default": 4, "type": int, "depends_on": "MAD", "help": "Amount of deviations before the channel is flagged bad."},
|
{"name": "MAD_THRESHOLD", "default": 4, "type": int, "depends_on": "MAD", "help": "Amount of deviations before the channel is flagged bad."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -166,10 +166,10 @@ SECTIONS = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Channel Variance",
|
"title": "Sensor Dropout",
|
||||||
"params": [
|
"params": [
|
||||||
{"name": "CHANNEL_VAR", "default": True, "type": bool, "help": "Identifies bad channels based on comparing the variance of the first 25% of the data to the last 25%."},
|
{"name": "SENSOR_DROPOUT", "default": True, "type": bool, "help": "Identifies bad channels based on comparing the variance of the first 25% of the data to the last 25%."},
|
||||||
{"name": "CHANNEL_THRESH", "default": 0.05, "type": float, "depends_on": "CHANNEL_VAR", "help": "If the end variance is less than this % of the start variance, the channel will be marked as bad."},
|
{"name": "SENSOR_DROPOUT_VARIANCE_THRESHOLD", "default": 0.05, "type": float, "depends_on": "SENSOR_DROPOUT", "help": "If the end variance is less than this % of the start variance, the channel will be marked as bad."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -206,6 +206,7 @@ SECTIONS = [
|
|||||||
"title": "Haemoglobin Concentration",
|
"title": "Haemoglobin Concentration",
|
||||||
"params": [
|
"params": [
|
||||||
# NOTE: Intentionally empty
|
# NOTE: Intentionally empty
|
||||||
|
# TODO: Manual override of PPF?
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -256,7 +257,8 @@ SECTIONS = [
|
|||||||
{"name": "FIR_DELAYS", "default": 15, "type": range, "depends_on": "HRF_MODEL", "depends_value": "fir", "help": "In case of FIR design, yields the array of delays used in the FIR model (in scans)."},
|
{"name": "FIR_DELAYS", "default": 15, "type": range, "depends_on": "HRF_MODEL", "depends_value": "fir", "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": "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": "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"},
|
# TODO: Re-implement this without causing a memory leak
|
||||||
|
# {"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
|
||||||
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNEL", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
|
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNEL", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -268,7 +270,13 @@ SECTIONS = [
|
|||||||
{"name": "N_JOBS", "default": 1, "type": int, "help": "The number of CPUs to use to do the GLM computation. -1 means 'all CPUs'."},
|
{"name": "N_JOBS", "default": 1, "type": int, "help": "The number of CPUs to use to do the GLM computation. -1 means 'all CPUs'."},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"title": "Region of Interest",
|
||||||
|
"params": [
|
||||||
|
{"name": "JSON_LOCATION", "default": "", "type": "json_file", "help": "Location of the JSON file containing region of interest results for significance calculations."},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
"title": "Finishing Touches",
|
"title": "Finishing Touches",
|
||||||
"params": [
|
"params": [
|
||||||
# Intentionally empty (TODO)
|
# Intentionally empty (TODO)
|
||||||
@@ -287,6 +295,19 @@ SECTIONS = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DATA_SCHEMA = [
|
||||||
|
{"key": "raw_haemo_dict", "help": "Dict[file_path, MNE RawArray]: Haemodynamic raw data"},
|
||||||
|
{"key": "epochs_dict", "help": "Dict[file_path, MNE Epochs]: Time-locked epoch data"},
|
||||||
|
{"key": "cha_dict", "help": "Dict[file_path, DataFrame]: Channel analysis results"},
|
||||||
|
{"key": "df_ind_dict", "help": "Dict[file_path, DataFrame]: Individual-level data/ROI results"},
|
||||||
|
{"key": "design_matrix_dict", "help": "Dict[file_path, DataFrame]: GLM design matrices"},
|
||||||
|
{"key": "config_dict", "help": "Dict[file_path, dict]: Processing configuration parameters"},
|
||||||
|
{"key": "fig_bytes_dict", "help": "Dict[file_path, dict]: Serialized figure data"},
|
||||||
|
{"key": "contrast_results_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
|
||||||
|
{"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -485,18 +506,13 @@ class MainApplication(QMainWindow):
|
|||||||
self.missing_events_bypass = False
|
self.missing_events_bypass = False
|
||||||
self.analysis_clearing_bypass = False
|
self.analysis_clearing_bypass = False
|
||||||
self.folding_bypass = False
|
self.folding_bypass = False
|
||||||
|
self.json_location = ""
|
||||||
|
|
||||||
|
|
||||||
# Initialization to ensure that saving can occur
|
# Initialization to ensure that saving can occur
|
||||||
self.raw_haemo_dict = {} # Processed Hemodynamic data
|
for item in DATA_SCHEMA:
|
||||||
self.config_dict = {} # Analysis parameters/settings
|
setattr(self, item["key"], {})
|
||||||
self.epochs_dict = {} # Timing/Event data
|
|
||||||
self.cha_dict = {} # Channel configurations
|
|
||||||
self.contrast_results_dict = {} # Statistical results
|
|
||||||
self.df_ind_dict = {} # Individual dataframes
|
|
||||||
self.design_matrix_dict = {} # GLM Design matrices
|
|
||||||
self.valid_dict = {} # Quality control/Validity flags
|
|
||||||
self.fig_bytes_dict = {} # Cached plot images (serialized)
|
|
||||||
self.file_metadata = {} # AGE, GENDER, GROUP
|
self.file_metadata = {} # AGE, GENDER, GROUP
|
||||||
self.metadata_cache = {} # Internal file/path information metadata cache
|
self.metadata_cache = {} # Internal file/path information metadata cache
|
||||||
self.bubble_widgets = {} # References to the UI "Bubble" objects
|
self.bubble_widgets = {} # References to the UI "Bubble" objects
|
||||||
@@ -510,15 +526,13 @@ class MainApplication(QMainWindow):
|
|||||||
self.files_failed = set() # set of failed file paths
|
self.files_failed = set() # set of failed file paths
|
||||||
self.files_results = {} # dict for successful results (if needed)
|
self.files_results = {} # dict for successful results (if needed)
|
||||||
|
|
||||||
self.platform_suffix = "-" + PLATFORM_NAME
|
|
||||||
|
|
||||||
self.updater = UpdateManager(
|
self.updater = UpdateManager(
|
||||||
main_window=self,
|
main_window=self,
|
||||||
api_url=API_URL,
|
api_url=API_URL,
|
||||||
api_url_sec=API_URL_SECONDARY,
|
api_url_sec=API_URL_SECONDARY,
|
||||||
current_version=CURRENT_VERSION,
|
current_version=CURRENT_VERSION,
|
||||||
platform_name=PLATFORM_NAME,
|
platform_name=PLATFORM_NAME,
|
||||||
platform_suffix=self.platform_suffix,
|
platform_suffix="-" + PLATFORM_NAME,
|
||||||
app_name=APP_NAME
|
app_name=APP_NAME
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -531,7 +545,7 @@ class MainApplication(QMainWindow):
|
|||||||
self.installEventFilter(self)
|
self.installEventFilter(self)
|
||||||
|
|
||||||
# Start local pending update check thread
|
# Start local pending update check thread
|
||||||
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix, PLATFORM_NAME, APP_NAME)
|
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, "-" + PLATFORM_NAME, PLATFORM_NAME, APP_NAME)
|
||||||
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
|
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
|
||||||
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
|
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
|
||||||
self.local_check_thread.start()
|
self.local_check_thread.start()
|
||||||
@@ -602,7 +616,7 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
label_desc = QLabel('<a href="#">Why are these useful?</a>')
|
label_desc = QLabel('<a href="#">Why are these useful?</a>')
|
||||||
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
label_desc.linkActivated.connect(lambda: QMessageBox.information(None, "Info", "Parameter Info..."))
|
label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF.\nGender: Not currently used or implemented.\nGroup: Used to split participants into groups for comparisons between them."))
|
||||||
right_column_layout.addWidget(label_desc)
|
right_column_layout.addWidget(label_desc)
|
||||||
right_column_layout.addStretch()
|
right_column_layout.addStretch()
|
||||||
self.right_column_widget.hide()
|
self.right_column_widget.hide()
|
||||||
@@ -820,6 +834,23 @@ class MainApplication(QMainWindow):
|
|||||||
and resets the memory heap.
|
and resets the memory heap.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if hasattr(self, "result_process") and self.result_process and self.result_process.is_alive():
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle(f"Confirm Clear - {APP_NAME.upper()}")
|
||||||
|
msg.setText("Data processing is currently active in the background. "
|
||||||
|
"Clearing now will forcefully kill all tasks and lose current progress.\n\n"
|
||||||
|
"Are you sure you want to proceed?")
|
||||||
|
|
||||||
|
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
|
||||||
|
msg.setDefaultButton(QMessageBox.StandardButton.Cancel)
|
||||||
|
|
||||||
|
response = msg.exec()
|
||||||
|
|
||||||
|
if response == QMessageBox.StandardButton.Ok:
|
||||||
|
self.cancel_task()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
self.top_left_widget.clear()
|
self.top_left_widget.clear()
|
||||||
|
|
||||||
if hasattr(self, "last_clicked_bubble"):
|
if hasattr(self, "last_clicked_bubble"):
|
||||||
@@ -878,18 +909,21 @@ class MainApplication(QMainWindow):
|
|||||||
self.files_done = set()
|
self.files_done = set()
|
||||||
self.files_failed = set()
|
self.files_failed = set()
|
||||||
|
|
||||||
self.raw_haemo_dict = {}
|
for item in DATA_SCHEMA:
|
||||||
self.config_dict = {}
|
setattr(self, item["key"], {})
|
||||||
self.epochs_dict = {}
|
|
||||||
self.fig_bytes_dict = {}
|
|
||||||
self.cha_dict = {}
|
|
||||||
self.contrast_results_dict = {}
|
|
||||||
self.df_ind_dict = {}
|
|
||||||
self.design_matrix_dict = {}
|
|
||||||
self.valid_dict = {}
|
|
||||||
|
|
||||||
self.metadata_cache = {}
|
self.metadata_cache = {}
|
||||||
|
|
||||||
|
self.file_metadata = {}
|
||||||
|
|
||||||
|
if hasattr(self, "meta_fields"):
|
||||||
|
for field in self.meta_fields.values():
|
||||||
|
field.blockSignals(True)
|
||||||
|
field.clear()
|
||||||
|
field.blockSignals(False)
|
||||||
|
|
||||||
|
self.current_file = None
|
||||||
|
|
||||||
if hasattr(self, "selected_paths"): self.selected_paths = []
|
if hasattr(self, "selected_paths"): self.selected_paths = []
|
||||||
if hasattr(self, "selected_path"): self.selected_path = None
|
if hasattr(self, "selected_path"): self.selected_path = None
|
||||||
|
|
||||||
@@ -1045,7 +1079,23 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
|
|
||||||
def open_launcher_window(self):
|
def open_launcher_window(self):
|
||||||
self.launcher_window = ViewerLauncherWidget(self.raw_haemo_dict, self.config_dict, self.fig_bytes_dict, self.cha_dict, self.contrast_results_dict, self.df_ind_dict, self.design_matrix_dict, self.epochs_dict, self.folding_bypass)
|
data_map = {item["key"]: getattr(self, item["key"]) for item in DATA_SCHEMA}
|
||||||
|
|
||||||
|
# 2. Extract values in the specific order the widget constructor expects
|
||||||
|
args = [
|
||||||
|
data_map["raw_haemo_dict"],
|
||||||
|
data_map["epochs_dict"],
|
||||||
|
data_map["cha_dict"],
|
||||||
|
data_map["df_ind_dict"],
|
||||||
|
data_map["design_matrix_dict"],
|
||||||
|
data_map["config_dict"],
|
||||||
|
data_map["fig_bytes_dict"],
|
||||||
|
data_map["contrast_results_dict"],
|
||||||
|
self.folding_bypass,
|
||||||
|
self.json_location
|
||||||
|
]
|
||||||
|
|
||||||
|
self.launcher_window = ViewerLauncherWidget(*args)
|
||||||
self.launcher_window.show()
|
self.launcher_window.show()
|
||||||
|
|
||||||
def copy_text(self):
|
def copy_text(self):
|
||||||
@@ -1152,8 +1202,19 @@ class MainApplication(QMainWindow):
|
|||||||
file_cfg.read_string(DEFAULT_CONFIG)
|
file_cfg.read_string(DEFAULT_CONFIG)
|
||||||
|
|
||||||
self.sync_app_with_config()
|
self.sync_app_with_config()
|
||||||
|
self.update_sections(0)
|
||||||
|
|
||||||
self.statusbar.showMessage("All settings have been reset to their default values.", 5000)
|
QTimer.singleShot(100, self._show_reset_success_dialog)
|
||||||
|
|
||||||
|
|
||||||
|
def _show_reset_success_dialog(self):
|
||||||
|
"""Helper method triggered after the UI has completely finished redrawing."""
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Reset Successful",
|
||||||
|
"All application settings have been successfully restored to their default values."
|
||||||
|
)
|
||||||
|
self.statusbar.showMessage("All settings have been reset to their default values.", 5000)
|
||||||
|
|
||||||
|
|
||||||
def sync_app_with_config(self):
|
def sync_app_with_config(self):
|
||||||
@@ -1188,7 +1249,7 @@ class MainApplication(QMainWindow):
|
|||||||
def open_folder_dialog(self):
|
def open_folder_dialog(self):
|
||||||
folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", "")
|
folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", "")
|
||||||
if folder_path:
|
if folder_path:
|
||||||
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).glob("*.snirf")]
|
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
|
||||||
self._load_files_into_pipeline(snirf_files)
|
self._load_files_into_pipeline(snirf_files)
|
||||||
|
|
||||||
|
|
||||||
@@ -1297,7 +1358,10 @@ class MainApplication(QMainWindow):
|
|||||||
has_param_changes = any(section.has_any_changes() for section in self.param_sections)
|
has_param_changes = any(section.has_any_changes() for section in self.param_sections)
|
||||||
|
|
||||||
# Check if there is processed data
|
# Check if there is processed data
|
||||||
has_processed_data = bool(getattr(self, 'raw_haemo_dict', None))
|
has_processed_data = any(
|
||||||
|
len(getattr(self, item["key"], {})) > 0
|
||||||
|
for item in DATA_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
if not (has_processed_data or has_metadata or has_param_changes):
|
if not (has_processed_data or has_metadata or has_param_changes):
|
||||||
if not onCrash: # Don't show popups during a crash/autosave
|
if not onCrash: # Don't show popups during a crash/autosave
|
||||||
@@ -1368,23 +1432,17 @@ class MainApplication(QMainWindow):
|
|||||||
current_params = self.config_dict[first_file]
|
current_params = self.config_dict[first_file]
|
||||||
|
|
||||||
version = CURRENT_VERSION
|
version = CURRENT_VERSION
|
||||||
project_data = {
|
|
||||||
|
project_data = {item["key"]: getattr(self, item["key"]) for item in DATA_SCHEMA}
|
||||||
|
|
||||||
|
project_data.update({
|
||||||
"version": version,
|
"version": version,
|
||||||
"file_list": file_list,
|
"file_list": file_list,
|
||||||
"progress_states": progress_states,
|
"progress_states": progress_states,
|
||||||
"raw_haemo_dict": self.raw_haemo_dict,
|
|
||||||
"file_metadata": rel_metadata,
|
"file_metadata": rel_metadata,
|
||||||
"file_parameters": rel_file_params,
|
"file_parameters": rel_file_params,
|
||||||
"config_dict": self.config_dict,
|
|
||||||
"epochs_dict": self.epochs_dict,
|
|
||||||
"fig_bytes_dict": self.fig_bytes_dict,
|
|
||||||
"cha_dict": self.cha_dict,
|
|
||||||
"current_ui_params": current_params,
|
"current_ui_params": current_params,
|
||||||
"contrast_results_dict": self.contrast_results_dict,
|
})
|
||||||
"df_ind_dict": self.df_ind_dict,
|
|
||||||
"design_matrix_dict": self.design_matrix_dict,
|
|
||||||
"valid_dict": self.valid_dict,
|
|
||||||
}
|
|
||||||
|
|
||||||
def sanitize(obj):
|
def sanitize(obj):
|
||||||
if isinstance(obj, Path):
|
if isinstance(obj, Path):
|
||||||
@@ -1397,6 +1455,8 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
project_data = sanitize(project_data)
|
project_data = sanitize(project_data)
|
||||||
|
|
||||||
|
self.add_to_recent_projects(os.path.normpath(filename))
|
||||||
|
|
||||||
self.saving_overlay = SavingOverlay(self)
|
self.saving_overlay = SavingOverlay(self)
|
||||||
self.saving_overlay.resize(self.size()) # Cover the main window
|
self.saving_overlay.resize(self.size()) # Cover the main window
|
||||||
self.saving_overlay.show()
|
self.saving_overlay.show()
|
||||||
@@ -1472,15 +1532,9 @@ class MainApplication(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
self.raw_haemo_dict = data.get("raw_haemo_dict", {})
|
for item in DATA_SCHEMA:
|
||||||
self.config_dict = data.get("config_dict", {})
|
key = item["key"]
|
||||||
self.epochs_dict = data.get("epochs_dict", {})
|
setattr(self, key, data.get(key, {}))
|
||||||
self.fig_bytes_dict = data.get("fig_bytes_dict", {})
|
|
||||||
self.cha_dict = data.get("cha_dict", {})
|
|
||||||
self.contrast_results_dict = data.get("contrast_results_dict", {})
|
|
||||||
self.df_ind_dict = data.get("df_ind_dict", {})
|
|
||||||
self.design_matrix_dict = data.get("design_matrix_dict", {})
|
|
||||||
self.valid_dict = data.get("valid_dict", {})
|
|
||||||
|
|
||||||
project_dir = Path(filename).parent
|
project_dir = Path(filename).parent
|
||||||
|
|
||||||
@@ -1530,7 +1584,7 @@ class MainApplication(QMainWindow):
|
|||||||
first_file = next(iter(self.config_dict.keys()))
|
first_file = next(iter(self.config_dict.keys()))
|
||||||
self.restore_sections_from_config(self.config_dict[first_file])
|
self.restore_sections_from_config(self.config_dict[first_file])
|
||||||
|
|
||||||
has_data = bool(self.raw_haemo_dict)
|
has_data = any(len(getattr(self, item["key"], {})) > 0 for item in DATA_SCHEMA)
|
||||||
self.button1.setVisible(not has_data)
|
self.button1.setVisible(not has_data)
|
||||||
self.button3.setVisible(has_data)
|
self.button3.setVisible(has_data)
|
||||||
|
|
||||||
@@ -1567,6 +1621,12 @@ class MainApplication(QMainWindow):
|
|||||||
widget.blockSignals(False)
|
widget.blockSignals(False)
|
||||||
widget.update()
|
widget.update()
|
||||||
|
|
||||||
|
elif isinstance(widget, FilePickerWidget):
|
||||||
|
widget.blockSignals(True)
|
||||||
|
widget.setText(str(value)) # Updates the internal QLineEdit text safely
|
||||||
|
widget.blockSignals(False)
|
||||||
|
widget.update()
|
||||||
|
|
||||||
# QComboBox (bool, list)
|
# QComboBox (bool, list)
|
||||||
elif isinstance(widget, QComboBox):
|
elif isinstance(widget, QComboBox):
|
||||||
widget.blockSignals(True)
|
widget.blockSignals(True)
|
||||||
@@ -1584,10 +1644,19 @@ class MainApplication(QMainWindow):
|
|||||||
widget.blockSignals(False)
|
widget.blockSignals(False)
|
||||||
widget.update()
|
widget.update()
|
||||||
|
|
||||||
|
if hasattr(section_widget, 'check_if_changed'):
|
||||||
|
if isinstance(widget, (QLineEdit, FilePickerWidget)):
|
||||||
|
section_widget.check_if_changed(name, widget.text())
|
||||||
|
elif isinstance(widget, QComboBox):
|
||||||
|
section_widget.check_if_changed(name, widget.currentText())
|
||||||
|
elif isinstance(widget, QSpinBox):
|
||||||
|
section_widget.check_if_changed(name, widget.value())
|
||||||
|
|
||||||
# After restoring, make sure dependencies are updated
|
# After restoring, make sure dependencies are updated
|
||||||
if hasattr(section_widget, 'update_dependencies'):
|
if hasattr(section_widget, 'update_dependencies'):
|
||||||
section_widget.update_dependencies()
|
section_widget.update_dependencies()
|
||||||
|
|
||||||
|
#TODO: Update blue bold text too
|
||||||
|
|
||||||
# def show_files_as_bubbles(self, folder_paths):
|
# def show_files_as_bubbles(self, folder_paths):
|
||||||
|
|
||||||
@@ -1644,7 +1713,6 @@ class MainApplication(QMainWindow):
|
|||||||
current_ui_config = {}
|
current_ui_config = {}
|
||||||
try:
|
try:
|
||||||
for section in self.param_sections:
|
for section in self.param_sections:
|
||||||
# This calls the get_param_values() method you shared earlier
|
|
||||||
section_values = section.get_param_values()
|
section_values = section.get_param_values()
|
||||||
current_ui_config.update(section_values)
|
current_ui_config.update(section_values)
|
||||||
return current_ui_config
|
return current_ui_config
|
||||||
@@ -1824,6 +1892,19 @@ class MainApplication(QMainWindow):
|
|||||||
self.top_left_widget.clear()
|
self.top_left_widget.clear()
|
||||||
self.right_column_widget.hide()
|
self.right_column_widget.hide()
|
||||||
|
|
||||||
|
target_path = bubble.file_path
|
||||||
|
|
||||||
|
if hasattr(self, 'file_metadata'):
|
||||||
|
self.file_metadata.pop(target_path, None)
|
||||||
|
|
||||||
|
if getattr(self, 'current_file', None) == target_path:
|
||||||
|
self.current_file = None
|
||||||
|
if hasattr(self, 'meta_fields'):
|
||||||
|
for field in self.meta_fields.values():
|
||||||
|
field.blockSignals(True)
|
||||||
|
field.clear()
|
||||||
|
field.blockSignals(False)
|
||||||
|
|
||||||
parent_layout = bubble.parent().layout()
|
parent_layout = bubble.parent().layout()
|
||||||
if parent_layout is not None:
|
if parent_layout is not None:
|
||||||
parent_layout.removeWidget(bubble)
|
parent_layout.removeWidget(bubble)
|
||||||
@@ -1955,15 +2036,8 @@ class MainApplication(QMainWindow):
|
|||||||
|
|
||||||
self.button3.setVisible(False)
|
self.button3.setVisible(False)
|
||||||
|
|
||||||
self.raw_haemo_dict = {}
|
for item in DATA_SCHEMA:
|
||||||
self.config_dict = {}
|
setattr(self, item["key"], {})
|
||||||
self.epochs_dict = {}
|
|
||||||
self.fig_bytes_dict = {}
|
|
||||||
self.cha_dict = {}
|
|
||||||
self.contrast_results_dict = {}
|
|
||||||
self.df_ind_dict = {}
|
|
||||||
self.design_matrix_dict = {}
|
|
||||||
self.valid_dict = {}
|
|
||||||
|
|
||||||
self.button1.clicked.disconnect(self.on_run_task)
|
self.button1.clicked.disconnect(self.on_run_task)
|
||||||
self.button1.setText("Cancel")
|
self.button1.setText("Cancel")
|
||||||
@@ -2047,11 +2121,14 @@ class MainApplication(QMainWindow):
|
|||||||
if self.folding_bypass:
|
if self.folding_bypass:
|
||||||
all_params['FOLDING_BYP'] = True
|
all_params['FOLDING_BYP'] = True
|
||||||
|
|
||||||
|
self.json_location = all_params['JSON_LOCATION']
|
||||||
|
|
||||||
collected_data = {
|
collected_data = {
|
||||||
"SNIRF_FILES": snirf_files,
|
"SNIRF_FILES": snirf_files,
|
||||||
"PARAMS": all_params, # add this line
|
"PARAMS": all_params, # add this line
|
||||||
"METADATA": self.get_all_metadata(), # optionally add metadata if needed
|
"METADATA": self.get_all_metadata(), # optionally add metadata if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
# Start processing
|
# Start processing
|
||||||
if current_process().name == 'MainProcess':
|
if current_process().name == 'MainProcess':
|
||||||
self.result_queue = Queue()
|
self.result_queue = Queue()
|
||||||
@@ -2091,27 +2168,13 @@ class MainApplication(QMainWindow):
|
|||||||
# print(f"[DEBUG] Progress: {len(self.files_done)} / {self.files_total}")
|
# print(f"[DEBUG] Progress: {len(self.files_done)} / {self.files_total}")
|
||||||
|
|
||||||
if msg.get("success"):
|
if msg.get("success"):
|
||||||
# Unpack the massive tuple
|
|
||||||
raw_haemo, config, epochs, fig_bytes, cha, contrast, df_ind, design, valid = msg["result"]
|
|
||||||
|
|
||||||
# Initialize dictionaries once if needed
|
results = msg["result"]
|
||||||
if not hasattr(self, 'raw_haemo_dict') or self.raw_haemo_dict is None:
|
self.files_results[file_path] = results
|
||||||
attrs = ['raw_haemo_dict', 'config_dict', 'epochs_dict', 'fig_bytes_dict',
|
|
||||||
'cha_dict', 'contrast_results_dict', 'df_ind_dict',
|
|
||||||
'design_matrix_dict', 'valid_dict']
|
|
||||||
for attr in attrs:
|
|
||||||
setattr(self, attr, {})
|
|
||||||
|
|
||||||
self.files_results[file_path] = msg["result"]
|
# Simple, clean assignment
|
||||||
self.raw_haemo_dict[file_path] = raw_haemo
|
for item, value in zip(DATA_SCHEMA, results):
|
||||||
self.config_dict[file_path] = config
|
getattr(self, item["key"])[file_path] = value
|
||||||
self.epochs_dict[file_path] = epochs
|
|
||||||
self.fig_bytes_dict[file_path] = fig_bytes
|
|
||||||
self.cha_dict[file_path] = cha
|
|
||||||
self.contrast_results_dict[file_path] = contrast
|
|
||||||
self.df_ind_dict[file_path] = df_ind
|
|
||||||
self.design_matrix_dict[file_path] = design
|
|
||||||
self.valid_dict[file_path] = valid
|
|
||||||
|
|
||||||
self.statusbar.showMessage(f"Processed: {os.path.basename(file_path)}")
|
self.statusbar.showMessage(f"Processed: {os.path.basename(file_path)}")
|
||||||
|
|
||||||
@@ -2509,8 +2572,9 @@ def show_critical_error(error_msg):
|
|||||||
message = (
|
message = (
|
||||||
f"{APP_NAME.upper()} has encountered an unrecoverable error and needs to close.<br><br>"
|
f"{APP_NAME.upper()} has encountered an unrecoverable error and needs to close.<br><br>"
|
||||||
f"We are sorry for the inconvenience. An autosave was attempted to be saved to <a href='{autosave_link}'>{autosave_path}</a>, but it may not have been saved. "
|
f"We are sorry for the inconvenience. An autosave was attempted to be saved to <a href='{autosave_link}'>{autosave_path}</a>, but it may not have been saved. "
|
||||||
"If the file was saved, it still may not be intact, openable, or contain the correct data. Use the autosave at your discretion.<br><br>"
|
"If the file was saved, it still may not be intact, openable, or contain the correct data. Use the autosave at your own discretion.<br><br>"
|
||||||
f"This unrecoverable error was likely due to an error with {APP_NAME.upper()} and not your data.<br>"
|
f"This unrecoverable error was due to an error with {APP_NAME.upper()} and not your data.<br>"
|
||||||
|
f"If this crash occured inside a [BETA] branch, it is likely to eventually be fixed.<br>"
|
||||||
f"Please raise an issue <a href='https://git.research.dezeeuw.ca/tyler/{APP_NAME}/issues'>here</a> and attach the error file located at <a href='{log_link}'>{log_path2}</a><br><br>"
|
f"Please raise an issue <a href='https://git.research.dezeeuw.ca/tyler/{APP_NAME}/issues'>here</a> and attach the error file located at <a href='{log_link}'>{log_path2}</a><br><br>"
|
||||||
f"<pre>{error_msg}</pre>"
|
f"<pre>{error_msg}</pre>"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
Filename: crossgroupbrainimage.py
|
||||||
|
Description: Logic for the Cross-Group Brain & Image analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in Imports
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from flares import aggregate_fnirs_group_geometry, plot_2d_3d_contrasts_between_groups
|
||||||
|
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"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 about the contrast.",
|
||||||
|
"default": "True",
|
||||||
|
"type": bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "brain_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "1.0",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "is_3d",
|
||||||
|
"label": "Should we display the results in a 3D interactive window?",
|
||||||
|
"default": "True",
|
||||||
|
"type": bool,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CrossGroupBrainImageWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
df_ind_dict: dict[str, DataFrame],
|
||||||
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
|
group_dict: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("CrossGroupBrainImage")
|
||||||
|
self.setWindowTitle(f"Cross-Group Brain & Image Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.df_ind_dict = df_ind_dict
|
||||||
|
self.design_matrix_dict = design_matrix_dict
|
||||||
|
self.contrast_results_dict = contrast_results_dict
|
||||||
|
self.group_dict = group_dict
|
||||||
|
|
||||||
|
self.setup_cross_group_ui(["0 (Contrast Image)"])
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
|
||||||
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
(selected_event, file_paths_a, file_paths_b, all_selected_paths, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
|
# Build group-level contrast DataFrames
|
||||||
|
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
|
||||||
|
group_df = pd.DataFrame()
|
||||||
|
for fp in file_paths:
|
||||||
|
print(f"Looking up contrast for: {fp}")
|
||||||
|
event_con_dict = self.contrast_results_dict.get(fp, {})
|
||||||
|
print("Available events for this file:", list(event_con_dict.keys()))
|
||||||
|
if event and event in event_con_dict:
|
||||||
|
df = event_con_dict[event]
|
||||||
|
print(f"Appending contrast df for event: {event}")
|
||||||
|
group_df = pd.concat([group_df, df], ignore_index=True)
|
||||||
|
else:
|
||||||
|
print(f"Event '{event}' not found for {fp}")
|
||||||
|
return group_df
|
||||||
|
|
||||||
|
print("Selected event:", selected_event)
|
||||||
|
print("File paths A:", file_paths_a)
|
||||||
|
print("File paths B:", file_paths_b)
|
||||||
|
|
||||||
|
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
|
||||||
|
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
|
||||||
|
|
||||||
|
print("contrast_df_a empty?", contrast_df_a.empty)
|
||||||
|
print("contrast_df_b empty?", contrast_df_b.empty)
|
||||||
|
|
||||||
|
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 = aggregate_fnirs_group_geometry(all_raw_objs)
|
||||||
|
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
||||||
|
processed_raw = all_raw_objs[0].copy()
|
||||||
|
processed_raw.pick(picks="hbo") # type: ignore
|
||||||
|
else:
|
||||||
|
processed_raw = None
|
||||||
|
|
||||||
|
# Visualizations
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
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)
|
||||||
|
is_3d = params.get("is_3d", None)
|
||||||
|
|
||||||
|
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw:
|
||||||
|
|
||||||
|
plot_2d_3d_contrasts_between_groups(
|
||||||
|
contrast_df_a,
|
||||||
|
contrast_df_b,
|
||||||
|
raw_haemo=processed_raw,
|
||||||
|
group_a_name=self.group_a_dropdown.currentText(),
|
||||||
|
group_b_name=self.group_b_dropdown.currentText(),
|
||||||
|
is_3d=is_3d,
|
||||||
|
t_or_theta=t_or_theta,
|
||||||
|
show_optodes=show_optodes,
|
||||||
|
show_text=show_text,
|
||||||
|
brain_bounds=brain_bounds
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""
|
||||||
|
Filename: crossgroupstats.py
|
||||||
|
Description: Cross-Group stats analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import run_cross_group_contrast_analysis, run_cross_group_laterality_analysis, run_cross_group_second_level_analysis
|
||||||
|
from src.shared.flaresbasewidget import CrossGroupUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "p_threshold",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "3",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "fdr_bh",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "threshold_topo",
|
||||||
|
"label": "threshold_topo: TBD",
|
||||||
|
"default": False,
|
||||||
|
"type": bool,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "p_threshold",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "3",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "None",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "roi_a",
|
||||||
|
"label": "ROI A (e.g. contralateral region name from regions.json)",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "roi_b",
|
||||||
|
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{
|
||||||
|
"key": "p_value",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "3",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "fdr_bh",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "contrast_name",
|
||||||
|
"label": "Name of the contrast to use",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTION = """0. Raw ROI Comparison (run_cross_group_second_level_analysis)
|
||||||
|
\nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population.
|
||||||
|
\nIf you expected a group difference and didn't find one, the most common cause is within-group heterogeneity swallowing a real between-group difference - a "target" population (e.g. a clinical group) is often more variable than a tightly-screened control group, and that added within-group variance directly weakens a between-group t-test even if the group means truly differ. Small per-group sample sizes compound this. It's also possible the true difference between your groups isn't in raw magnitude at all, but in spatial specificity or task-differentiation - which is exactly why the laterality and contrast-comparison methods exist alongside this one; a null result here doesn't rule those out.
|
||||||
|
\n\n1. Laterality Comparison (run_cross_group_laterality_analysis)
|
||||||
|
\nComputes each subject's own contralateral-minus-ipsilateral laterality index first, then compares those indices between the two groups with Welch's t-test. A significant result means the degree of spatial specificity/lateralization differs between the two populations - a claim about lateralization itself, harder to explain away as a generic population confound since person-level differences in overall reactivity largely cancel before the group comparison happens. It says nothing about overall response magnitude between groups (a group could have identical laterality but very different raw amplitude), and it only uses subjects who have both the contra and ipsi ROI valid, so it can lose subjects the raw-ROI comparison would have kept.
|
||||||
|
\nNon-significance here has two likely sources, and it's worth distinguishing them. First, the same covariance issue from the within-group paired test applies across a whole group: if contra/ipsi responses aren't well-correlated within subjects, the laterality index itself is noisier than either ROI alone, and that added noise now has to clear a between-group test on top of it - a double power cost at small N. Second, and more informative if true: the groups may genuinely have similar lateralization but differ in overall magnitude instead, in which case this test correctly returns null while method 4 (raw comparison) should be the one to look at.
|
||||||
|
\n\n2. Contrast Comparison (run_cross_group_contrast_analysis)
|
||||||
|
\nCompares a jointly-fit task contrast (e.g. Task A minus Task B, estimated together within each subject's GLM), aggregated to ROI level, between two independent groups. A significant result means one group differentiates between the two tasks more or less than the other does, at this specific ROI - with systemic noise cancelled at the model-fitting stage, the same benefit that makes the within-group version of this method the strongest of that trio. As with the within-group version, it does not by itself say where a difference is localized unless you compare sign/pattern across multiple ROIs - opposite-signed group differences across regions point to something spatially specific, same-signed differences everywhere point to a diffuse/non-specific group difference (e.g. one group simply has stronger contrast responses across the whole head).
|
||||||
|
\nIf this comes back non-significant despite an expected group difference, check first whether the underlying single-subject contrast estimates are noisy for either group - small per-group N means the joint contrast's precision depends on the same limited subject count as everything else, and a noisy input propagates all the way through the ROI aggregation. It's also possible for a real, localized sub-regional effect to get washed out by ROI averaging itself: if only part of an ROI's channels actually show the group difference while others don't, the inverse-variance-weighted average can dilute it toward null - in that case, a finer-grained ROI definition (splitting the region further) may recover the effect that a coarser ROI averaged away. Finally, FDR correction across every ROI tested reduces power exactly as it does everywhere else in this framework - a real but modest effect can fail to survive correction even when the raw p-value would have looked convincing on its own.
|
||||||
|
\n\n
|
||||||
|
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
|
||||||
|
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
|
||||||
|
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
|
||||||
|
|
||||||
|
|
||||||
|
class CrossGroupStatsWidget(CrossGroupUIMixin, FlaresBaseWidget):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
cha_dict: dict[str, DataFrame],
|
||||||
|
df_ind_dict: dict[str, DataFrame],
|
||||||
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
|
group_dict: dict[str, str],
|
||||||
|
json_location: str | Path
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("CrossGroupStats")
|
||||||
|
self.setWindowTitle(f"Cross-Group Stats Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
self.df_ind_dict = df_ind_dict
|
||||||
|
self.design_matrix_dict = design_matrix_dict
|
||||||
|
self.contrast_results_dict = contrast_results_dict
|
||||||
|
# self.group_dict = group_dict
|
||||||
|
self.json_location = json_location
|
||||||
|
|
||||||
|
self.setup_cross_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], placeholder_text=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.json_location, self.contrast_results_dict)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
(selected_event, file_paths_a, file_paths_b, _, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
|
valid_dfs = [df for df in self.df_ind_dict.values() if not df.empty]
|
||||||
|
if valid_dfs:
|
||||||
|
df_ind_combined = pd.concat(valid_dfs, ignore_index=True)
|
||||||
|
else:
|
||||||
|
df_ind_combined = pd.DataFrame()
|
||||||
|
|
||||||
|
valid_chas = [df for df in self.cha_dict.values() if not df.empty]
|
||||||
|
cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame()
|
||||||
|
|
||||||
|
sample_path = file_paths_a[0]
|
||||||
|
p_haemo = self.haemo_dict.get(sample_path)
|
||||||
|
|
||||||
|
# Visualizations
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 3)
|
||||||
|
correction_method = params.get("correction_method", "fdr_bh")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
threshold_topo = params.get("threshold_topo", False)
|
||||||
|
|
||||||
|
run_cross_group_second_level_analysis(
|
||||||
|
df_roi_all=df_ind_combined, # Individual stats dataframe
|
||||||
|
file_paths_a=file_paths_a,
|
||||||
|
file_paths_b=file_paths_b,
|
||||||
|
group_a_name=self.group_a_dropdown.currentText(),
|
||||||
|
group_b_name=self.group_b_dropdown.currentText(),
|
||||||
|
df_cha_all=cha_combined,
|
||||||
|
raw_haemo=p_haemo,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
correction_method=correction_method,
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
selected_event=selected_event,
|
||||||
|
roi_config=self.json_location,
|
||||||
|
threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
|
||||||
|
)
|
||||||
|
elif idx == 1:
|
||||||
|
if not selected_event:
|
||||||
|
print("Laterality comparison requires a specific event/condition "
|
||||||
|
"to be selected first.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 3)
|
||||||
|
correction_method = params.get("correction_method", "None")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
roi_a: str = params.get("roi_a", "").strip()
|
||||||
|
roi_b: str = params.get("roi_b", "").strip()
|
||||||
|
|
||||||
|
if not roi_a or not roi_b:
|
||||||
|
print("Both a contralateral and ipsilateral ROI name must be specified.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if correction_method == "None":
|
||||||
|
correction_method = None
|
||||||
|
|
||||||
|
# Build each group's dataframe directly from the dict using
|
||||||
|
# the file-path lists as keys - no ID cleaning/matching needed.
|
||||||
|
def _build_group_df(
|
||||||
|
file_paths: list[str],
|
||||||
|
dict_source: dict[str, DataFrame]
|
||||||
|
) -> DataFrame:
|
||||||
|
|
||||||
|
valid_dfs = [
|
||||||
|
dict_source[fp] for fp in file_paths
|
||||||
|
if fp in dict_source and not dict_source[fp].empty
|
||||||
|
]
|
||||||
|
|
||||||
|
return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict)
|
||||||
|
df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict)
|
||||||
|
|
||||||
|
if df_roi_a.empty or df_roi_b.empty:
|
||||||
|
print("No ROI data (df_ind) found for one or both groups.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
run_cross_group_laterality_analysis(
|
||||||
|
df_roi_all_a=df_roi_a,
|
||||||
|
df_roi_all_b=df_roi_b,
|
||||||
|
roi_pairs=(roi_a, roi_b),
|
||||||
|
condition=selected_event,
|
||||||
|
group_a_name=self.group_a_dropdown.currentText(),
|
||||||
|
group_b_name=self.group_b_dropdown.currentText(),
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
correction_method=correction_method,
|
||||||
|
roi_contra_label=roi_a,
|
||||||
|
roi_ipsi_label=roi_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 2:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 3)
|
||||||
|
correction_method = params.get("correction_method", "fdr_bh")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
contrast_name = params.get("contrast_name", "")
|
||||||
|
|
||||||
|
if not contrast_name:
|
||||||
|
print("A contrast name must be specified.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build each group's channel-level contrast dataframe
|
||||||
|
# directly from contrast_results_dict, keyed by file path -
|
||||||
|
# same dict-key approach as the laterality patch, avoids
|
||||||
|
# any ID-string matching.
|
||||||
|
def _build_group_contrast_df(
|
||||||
|
file_paths: list[str],
|
||||||
|
contrast_dict: dict[str, dict[str, pd.DataFrame]],
|
||||||
|
name: str,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
|
||||||
|
all_rows: list[DataFrame] = []
|
||||||
|
for fp in file_paths:
|
||||||
|
condition_dfs = contrast_dict.get(fp)
|
||||||
|
if condition_dfs is None:
|
||||||
|
print(f" [MISSING] '{fp}' not found in contrast_results.")
|
||||||
|
continue
|
||||||
|
if name in condition_dfs:
|
||||||
|
df = condition_dfs[name].copy()
|
||||||
|
df["ID"] = fp
|
||||||
|
df["contrast_name"] = name
|
||||||
|
all_rows.append(df)
|
||||||
|
else:
|
||||||
|
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
|
||||||
|
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
|
||||||
|
|
||||||
|
df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name)
|
||||||
|
df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name)
|
||||||
|
|
||||||
|
if df_contrasts_a.empty or df_contrasts_b.empty:
|
||||||
|
print("No contrast data found for one or both groups.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
run_cross_group_contrast_analysis(
|
||||||
|
df_contrasts_a=df_contrasts_a,
|
||||||
|
df_contrasts_b=df_contrasts_b,
|
||||||
|
contrast_name=contrast_name,
|
||||||
|
roi_json_path=self.json_location,
|
||||||
|
group_a_name=self.group_a_dropdown.currentText(),
|
||||||
|
group_b_name=self.group_b_dropdown.currentText(),
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
correction_method=correction_method,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("no")
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""
|
|
||||||
Filename: exportcsv.py
|
|
||||||
Description: Export data as csv analysis window for FLARES
|
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
|
||||||
License: GPL-3.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QFileDialog, QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
|
||||||
from PySide6.QtCore import QSize
|
|
||||||
|
|
||||||
from src.shared.flaresbasewidget import FlaresBaseWidget
|
|
||||||
from src.shared.shareddata import APP_NAME
|
|
||||||
|
|
||||||
|
|
||||||
class ExportDataAsCSVViewerWidget(FlaresBaseWidget):
|
|
||||||
def __init__(self, haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict):
|
|
||||||
super().__init__("ExportDataAsCSVViewer")
|
|
||||||
self.setWindowTitle(f"Export Data As CSV Viewer - {APP_NAME.upper()}")
|
|
||||||
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 (CSV for SPARKS)",
|
|
||||||
# "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 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)
|
|
||||||
]
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
cha = self.cha_dict.get(file_path)
|
|
||||||
|
|
||||||
for idx in selected_indexes:
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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 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
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"No method defined for index {idx}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to export {file_path} (Type {idx}): {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()
|
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Filename: exporttocsv.py
|
||||||
|
Description: Logic for the Export To CSV analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||||
|
|
||||||
|
from flares import sparks_csv_export
|
||||||
|
from src.shared.flaresbasewidget import CSVUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class ExportToCSVWidget(CSVUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
cha_dict: dict[str, DataFrame],
|
||||||
|
df_ind_dict: dict[str, DataFrame],
|
||||||
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
|
group_dict: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("ExportToCSV")
|
||||||
|
self.setWindowTitle(f"Export To CSV Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
# self.df_ind = df_ind_dict
|
||||||
|
# self.design_matrix = design_matrix_dict
|
||||||
|
# self.contrast_results_dict = contrast_results_dict
|
||||||
|
# self.group = group_dict
|
||||||
|
|
||||||
|
self.setup_csv_ui(["0 (Export Data to CSV)", "1 (CSV for SPARKS)",])
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_file_paths: list[str] = []
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
cha = self.cha_dict.get(file_path)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
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
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
# SPARKS Export
|
||||||
|
save_path = os.path.join(output_dir, f"{base_filename}_sparks.csv")
|
||||||
|
sparks_csv_export(haemo_obj, save_path)
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to export {file_path} (Type {idx}): {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()
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
"""
|
|
||||||
Filename: group.py
|
|
||||||
Description: Group analysis window for FLARES
|
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
|
||||||
License: GPL-3.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
|
||||||
from PySide6.QtCore import QSize
|
|
||||||
|
|
||||||
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
|
||||||
from src.shared.shareddata import APP_NAME
|
|
||||||
|
|
||||||
|
|
||||||
class GroupViewerWidget(FlaresBaseWidget):
|
|
||||||
def __init__(self, haemo_dict, cha, df_ind, design_matrix, contrast_results, group):
|
|
||||||
super().__init__("GroupViewer")
|
|
||||||
self.setWindowTitle(f"Group Viewer - {APP_NAME.upper()}")
|
|
||||||
self.haemo_dict = haemo_dict
|
|
||||||
self.cha = cha
|
|
||||||
self.df_ind = df_ind
|
|
||||||
self.design_matrix = design_matrix
|
|
||||||
self.contrast_results = contrast_results
|
|
||||||
self.group = group
|
|
||||||
self.show_all_events = True
|
|
||||||
self._updating_checkstates = False
|
|
||||||
|
|
||||||
# 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.group_to_paths = {}
|
|
||||||
for file_path, group_name in self.group.items():
|
|
||||||
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
|
||||||
|
|
||||||
self.group_names = sorted(self.group_to_paths.keys())
|
|
||||||
|
|
||||||
self.group_dropdown = QComboBox()
|
|
||||||
self.group_dropdown.addItem("<None Selected>")
|
|
||||||
self.group_dropdown.addItems(self.group_names)
|
|
||||||
self.group_dropdown.setCurrentIndex(0)
|
|
||||||
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
|
|
||||||
|
|
||||||
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
|
||||||
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
|
||||||
self.participant_dropdown.setEnabled(False)
|
|
||||||
|
|
||||||
self.event_dropdown = QComboBox()
|
|
||||||
self.event_dropdown.addItem("<None Selected>")
|
|
||||||
|
|
||||||
self.index_texts = [
|
|
||||||
"0 (GLM Results)",
|
|
||||||
"1 (Significance)",
|
|
||||||
"2 (Brain Activity Visualization)",
|
|
||||||
# "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.show_brain_images)
|
|
||||||
|
|
||||||
self.top_bar.addWidget(QLabel("Group:"))
|
|
||||||
self.top_bar.addWidget(self.group_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Participants:"))
|
|
||||||
self.top_bar.addWidget(self.participant_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Event:"))
|
|
||||||
self.top_bar.addWidget(self.event_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
||||||
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 show_brain_images(self):
|
|
||||||
import flares as flares
|
|
||||||
|
|
||||||
selected_event = self.event_dropdown.currentText()
|
|
||||||
if selected_event == "<None Selected>":
|
|
||||||
selected_event = None
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if selected_event:
|
|
||||||
valid_paths = []
|
|
||||||
for fp in selected_file_paths:
|
|
||||||
raw = self.haemo_dict.get(fp)
|
|
||||||
# Check if this participant actually has the event in their annotations
|
|
||||||
if raw is not None and hasattr(raw, "annotations"):
|
|
||||||
if selected_event in raw.annotations.description:
|
|
||||||
valid_paths.append(fp)
|
|
||||||
|
|
||||||
selected_file_paths = valid_paths
|
|
||||||
|
|
||||||
selected_indexes = [
|
|
||||||
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
|
||||||
]
|
|
||||||
|
|
||||||
if not selected_file_paths:
|
|
||||||
print("No participants selected.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Only keep indexes 0 and 1 that need parameters
|
|
||||||
parameterized_indexes = {
|
|
||||||
0: [
|
|
||||||
{
|
|
||||||
"key": "lower_bound",
|
|
||||||
"label": "Lower bound + <description>",
|
|
||||||
"default": "-0.3",
|
|
||||||
"type": float, # specify int here
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "upper_bound",
|
|
||||||
"label": "Upper bound + <description>",
|
|
||||||
"default": "0.8",
|
|
||||||
"type": float, # specify int here
|
|
||||||
}
|
|
||||||
],
|
|
||||||
1: [
|
|
||||||
{
|
|
||||||
"key": "p_value",
|
|
||||||
"label": "Significance threshold P-value (e.g. 0.05)",
|
|
||||||
"default": "0.05",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "graph_bounds",
|
|
||||||
"label": "Graph Upper/Lower Limit",
|
|
||||||
"default": "3.0",
|
|
||||||
"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
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
all_cha = pd.DataFrame()
|
|
||||||
for file_path in selected_file_paths:
|
|
||||||
haemo_obj = self.haemo_dict.get(file_path)
|
|
||||||
|
|
||||||
if selected_event:
|
|
||||||
participant_events = set(haemo_obj.annotations.description)
|
|
||||||
if selected_event not in participant_events:
|
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if haemo_obj is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
cha_df = self.cha.get(file_path)
|
|
||||||
if cha_df is not None:
|
|
||||||
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
|
||||||
|
|
||||||
# Pass the necessary arguments to each method
|
|
||||||
file_path = selected_file_paths[0]
|
|
||||||
p_haemo = self.haemo_dict.get(file_path)
|
|
||||||
p_design_matrix = self.design_matrix.get(file_path)
|
|
||||||
|
|
||||||
df_group = pd.DataFrame()
|
|
||||||
|
|
||||||
if selected_file_paths:
|
|
||||||
for file_path in selected_file_paths:
|
|
||||||
df = self.df_ind.get(file_path)
|
|
||||||
if df is not None:
|
|
||||||
df_group = pd.concat([df_group, df], ignore_index=True)
|
|
||||||
|
|
||||||
|
|
||||||
for idx in selected_indexes:
|
|
||||||
if idx == 0:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
lower_bound = params.get("lower_bound", None)
|
|
||||||
upper_bound = params.get("upper_bound", None)
|
|
||||||
|
|
||||||
if lower_bound is None or upper_bound is None:
|
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
flares.plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
|
|
||||||
|
|
||||||
elif idx == 1:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
p_val = params.get("p_value", None)
|
|
||||||
graph_bounds = params.get("graph_bounds", None)
|
|
||||||
|
|
||||||
if p_val is None or graph_bounds is None:
|
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
all_contrasts = []
|
|
||||||
for fp in selected_file_paths:
|
|
||||||
condition_dfs = self.contrast_results.get(fp, {})
|
|
||||||
if selected_event in condition_dfs:
|
|
||||||
df = condition_dfs[selected_event].copy()
|
|
||||||
df["ID"] = fp
|
|
||||||
all_contrasts.append(df)
|
|
||||||
|
|
||||||
if not all_contrasts:
|
|
||||||
print("No contrast data found for selected participants and event.")
|
|
||||||
return
|
|
||||||
|
|
||||||
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"No method defined for index {idx}")
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
"""
|
|
||||||
Filename: groupbrain.py
|
|
||||||
Description: Group brain analysis window for FLARES
|
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
|
||||||
License: GPL-3.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
|
||||||
from PySide6.QtCore import QSize
|
|
||||||
|
|
||||||
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
|
||||||
from src.shared.shareddata import APP_NAME
|
|
||||||
|
|
||||||
|
|
||||||
class GroupBrainViewerWidget(FlaresBaseWidget):
|
|
||||||
def __init__(self, haemo_dict, df_ind, design_matrix, group, contrast_results_dict):
|
|
||||||
super().__init__("GroupBrainViewer")
|
|
||||||
self.setWindowTitle(f"Group Brain Viewer - {APP_NAME.upper()}")
|
|
||||||
self.haemo_dict = haemo_dict
|
|
||||||
self.df_ind = df_ind
|
|
||||||
self.design_matrix = design_matrix
|
|
||||||
self.group = group
|
|
||||||
self.contrast_results_dict = contrast_results_dict
|
|
||||||
|
|
||||||
self.group_to_paths = {}
|
|
||||||
for file_path, group_name in self.group.items():
|
|
||||||
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
|
||||||
|
|
||||||
self.group_names = sorted(self.group_to_paths.keys())
|
|
||||||
|
|
||||||
self.layout = QVBoxLayout(self)
|
|
||||||
self.top_bar = QHBoxLayout()
|
|
||||||
self.layout.addLayout(self.top_bar)
|
|
||||||
|
|
||||||
|
|
||||||
self.group_a_dropdown = QComboBox()
|
|
||||||
self.group_a_dropdown.addItem("<None Selected>")
|
|
||||||
self.group_a_dropdown.addItems(self.group_names)
|
|
||||||
self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options)
|
|
||||||
|
|
||||||
|
|
||||||
self.group_b_dropdown = QComboBox()
|
|
||||||
self.group_b_dropdown.addItem("<None Selected>")
|
|
||||||
self.group_b_dropdown.addItems(self.group_names)
|
|
||||||
self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options)
|
|
||||||
|
|
||||||
|
|
||||||
self.event_dropdown = QComboBox()
|
|
||||||
self.event_dropdown.addItem("<None Selected>")
|
|
||||||
|
|
||||||
self.participant_dropdown_a = self._create_multiselect_dropdown([])
|
|
||||||
self.participant_dropdown_a.lineEdit().setPlaceholderText("Select participants (Group A)")
|
|
||||||
self.participant_dropdown_a.model().itemChanged.connect(self._on_participants_changed)
|
|
||||||
|
|
||||||
|
|
||||||
self.participant_dropdown_b = self._create_multiselect_dropdown([])
|
|
||||||
self.participant_dropdown_b.lineEdit().setPlaceholderText("Select participants (Group B)")
|
|
||||||
self.participant_dropdown_b.model().itemChanged.connect(self._on_participants_changed)
|
|
||||||
|
|
||||||
|
|
||||||
self.index_texts = [
|
|
||||||
"0 (Contrast Image)",
|
|
||||||
# "1 (3D Brain Contrast)",
|
|
||||||
# "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.show_brain_images)
|
|
||||||
|
|
||||||
|
|
||||||
self.top_bar.addWidget(QLabel("Group A:"))
|
|
||||||
self.top_bar.addWidget(self.group_a_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Participants (Group A):"))
|
|
||||||
self.top_bar.addWidget(self.participant_dropdown_a)
|
|
||||||
self.top_bar.addWidget(QLabel("Group B:"))
|
|
||||||
self.top_bar.addWidget(self.group_b_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Participants (Group B):"))
|
|
||||||
self.top_bar.addWidget(self.participant_dropdown_b)
|
|
||||||
self.top_bar.addWidget(QLabel("Event:"))
|
|
||||||
self.top_bar.addWidget(self.event_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
||||||
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 _update_group_b_options(self):
|
|
||||||
"""Triggered when Group B changes: Update Group A to exclude B's choice"""
|
|
||||||
selected_b = self.group_b_dropdown.currentText()
|
|
||||||
|
|
||||||
# Refresh Group A and exclude what was just picked in Group B
|
|
||||||
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
|
|
||||||
|
|
||||||
# Update the participants for Group B
|
|
||||||
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
|
|
||||||
self._update_event_dropdown()
|
|
||||||
|
|
||||||
def _update_group_a_options(self):
|
|
||||||
"""Triggered when Group A changes: Update Group B to exclude A's choice"""
|
|
||||||
selected_a = self.group_a_dropdown.currentText()
|
|
||||||
|
|
||||||
# Refresh Group B and exclude what was just picked in Group A
|
|
||||||
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
|
|
||||||
|
|
||||||
# Update the participants for Group A
|
|
||||||
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
|
|
||||||
self._update_event_dropdown()
|
|
||||||
|
|
||||||
def _on_participants_changed(self, item=None):
|
|
||||||
self._update_event_dropdown()
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_group_dropdown(self, dropdown, exclude):
|
|
||||||
current = dropdown.currentText()
|
|
||||||
dropdown.blockSignals(True)
|
|
||||||
dropdown.clear()
|
|
||||||
dropdown.addItem("<None Selected>")
|
|
||||||
for group in self.group_names:
|
|
||||||
if group != exclude:
|
|
||||||
dropdown.addItem(group)
|
|
||||||
# Restore previous selection if still valid
|
|
||||||
if current != "<None Selected>" and current != exclude and dropdown.findText(current) != -1:
|
|
||||||
dropdown.setCurrentText(current)
|
|
||||||
else:
|
|
||||||
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
|
|
||||||
dropdown.blockSignals(False)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_file_paths_from_labels(self, labels, group_name):
|
|
||||||
file_paths = []
|
|
||||||
|
|
||||||
if group_name == self.group_a_dropdown.currentText():
|
|
||||||
participant_map = self.participant_map_a
|
|
||||||
elif group_name == self.group_b_dropdown.currentText():
|
|
||||||
participant_map = self.participant_map_b
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Reverse map: display label -> file path
|
|
||||||
reverse_map = {
|
|
||||||
f"{label} ({os.path.basename(fp)})": fp
|
|
||||||
for fp, label in participant_map.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
for label in labels:
|
|
||||||
file_path = reverse_map.get(label)
|
|
||||||
if file_path:
|
|
||||||
file_paths.append(file_path)
|
|
||||||
|
|
||||||
return file_paths
|
|
||||||
|
|
||||||
def show_brain_images(self):
|
|
||||||
import flares as flares
|
|
||||||
|
|
||||||
selected_event = self.event_dropdown.currentText()
|
|
||||||
if selected_event == "<None Selected>":
|
|
||||||
selected_event = None
|
|
||||||
|
|
||||||
# Group A
|
|
||||||
participants_a = self._get_checked_items(self.participant_dropdown_a)
|
|
||||||
file_paths_a = self._get_file_paths_from_labels(participants_a, self.group_a_dropdown.currentText())
|
|
||||||
|
|
||||||
# Group B
|
|
||||||
participants_b = self._get_checked_items(self.participant_dropdown_b)
|
|
||||||
file_paths_b = self._get_file_paths_from_labels(participants_b, self.group_b_dropdown.currentText())
|
|
||||||
|
|
||||||
selected_indexes = [
|
|
||||||
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: [
|
|
||||||
{
|
|
||||||
"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 about the contrast.",
|
|
||||||
"default": "True",
|
|
||||||
"type": bool,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "brain_bounds",
|
|
||||||
"label": "Graph Upper/Lower Limit",
|
|
||||||
"default": "1.0",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "is_3d",
|
|
||||||
"label": "Should we display the results in a 3D interactive window?",
|
|
||||||
"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
|
|
||||||
|
|
||||||
# Build group-level contrast DataFrames
|
|
||||||
def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame:
|
|
||||||
group_df = pd.DataFrame()
|
|
||||||
for fp in file_paths:
|
|
||||||
print(f"Looking up contrast for: {fp}")
|
|
||||||
event_con_dict = self.contrast_results_dict.get(fp, {})
|
|
||||||
print("Available events for this file:", list(event_con_dict.keys()))
|
|
||||||
if event and event in event_con_dict:
|
|
||||||
df = event_con_dict[event]
|
|
||||||
print(f"Appending contrast df for event: {event}")
|
|
||||||
group_df = pd.concat([group_df, df], ignore_index=True)
|
|
||||||
else:
|
|
||||||
print(f"Event '{event}' not found for {fp}")
|
|
||||||
return group_df
|
|
||||||
|
|
||||||
print("Selected event:", selected_event)
|
|
||||||
print("File paths A:", file_paths_a)
|
|
||||||
print("File paths B:", file_paths_b)
|
|
||||||
|
|
||||||
contrast_df_a = concat_group_contrasts(file_paths_a, selected_event)
|
|
||||||
contrast_df_b = concat_group_contrasts(file_paths_b, selected_event)
|
|
||||||
|
|
||||||
print("contrast_df_a empty?", contrast_df_a.empty)
|
|
||||||
print("contrast_df_b empty?", contrast_df_b.empty)
|
|
||||||
|
|
||||||
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:
|
|
||||||
if idx == 0:
|
|
||||||
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)
|
|
||||||
is_3d = params.get("is_3d", None)
|
|
||||||
|
|
||||||
if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None:
|
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
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=processed_raw,
|
|
||||||
group_a_name=self.group_a_dropdown.currentText(),
|
|
||||||
group_b_name=self.group_b_dropdown.currentText(),
|
|
||||||
is_3d=is_3d,
|
|
||||||
t_or_theta=t_or_theta,
|
|
||||||
show_optodes=show_optodes,
|
|
||||||
show_text=show_text,
|
|
||||||
brain_bounds=brain_bounds
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("no")
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
"""
|
|
||||||
Filename: groupfunctionalconnectivity.py
|
|
||||||
Description: Group functional connectivity analysis window for FLARES
|
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
|
||||||
License: GPL-3.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel, QMessageBox
|
|
||||||
from PySide6.QtCore import QSize
|
|
||||||
|
|
||||||
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
|
||||||
from src.shared.shareddata import APP_NAME
|
|
||||||
|
|
||||||
|
|
||||||
class GroupFunctionalConnectivityWidget(FlaresBaseWidget):
|
|
||||||
def __init__(self, haemo_dict, group, config_dict):
|
|
||||||
super().__init__("GroupFunctionalConnectivityWidget")
|
|
||||||
self.setWindowTitle(f"Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
|
||||||
self.haemo_dict = haemo_dict
|
|
||||||
self.group = group
|
|
||||||
self.config_dict = config_dict
|
|
||||||
self.show_all_events = True
|
|
||||||
self._updating_checkstates = False
|
|
||||||
|
|
||||||
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
|
||||||
"By clicking OK, you accept that the images generated may not be factual.")
|
|
||||||
|
|
||||||
|
|
||||||
# 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.group_to_paths = {}
|
|
||||||
for file_path, group_name in self.group.items():
|
|
||||||
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
|
||||||
|
|
||||||
self.group_names = sorted(self.group_to_paths.keys())
|
|
||||||
|
|
||||||
self.group_dropdown = QComboBox()
|
|
||||||
self.group_dropdown.addItem("<None Selected>")
|
|
||||||
self.group_dropdown.addItems(self.group_names)
|
|
||||||
self.group_dropdown.setCurrentIndex(0)
|
|
||||||
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
|
|
||||||
|
|
||||||
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
|
||||||
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
|
||||||
self.participant_dropdown.setEnabled(False)
|
|
||||||
|
|
||||||
self.event_dropdown = QComboBox()
|
|
||||||
self.event_dropdown.addItem("<None Selected>")
|
|
||||||
|
|
||||||
self.index_texts = [
|
|
||||||
"0 (Betas)",
|
|
||||||
#"1 (Significance)",
|
|
||||||
#"2 (Brain Activity Visualization)",
|
|
||||||
# "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.show_brain_images)
|
|
||||||
|
|
||||||
self.top_bar.addWidget(QLabel("Group:"))
|
|
||||||
self.top_bar.addWidget(self.group_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Participants:"))
|
|
||||||
self.top_bar.addWidget(self.participant_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Event:"))
|
|
||||||
self.top_bar.addWidget(self.event_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
||||||
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 show_brain_images(self):
|
|
||||||
import flares as flares
|
|
||||||
|
|
||||||
selected_event = self.event_dropdown.currentText()
|
|
||||||
if selected_event == "<None Selected>":
|
|
||||||
selected_event = None
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if selected_event:
|
|
||||||
valid_paths = []
|
|
||||||
for fp in selected_file_paths:
|
|
||||||
raw = self.haemo_dict.get(fp)
|
|
||||||
# Check if this participant actually has the event in their annotations
|
|
||||||
if raw is not None and hasattr(raw, "annotations"):
|
|
||||||
if selected_event in raw.annotations.description:
|
|
||||||
valid_paths.append(fp)
|
|
||||||
|
|
||||||
selected_file_paths = valid_paths
|
|
||||||
|
|
||||||
selected_indexes = [
|
|
||||||
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
|
||||||
]
|
|
||||||
|
|
||||||
if not selected_file_paths:
|
|
||||||
print("No participants selected.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Only keep indexes 0 and 1 that need parameters
|
|
||||||
parameterized_indexes = {
|
|
||||||
0: [
|
|
||||||
{
|
|
||||||
"key": "n_lines",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "20",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
for idx in selected_indexes:
|
|
||||||
if idx == 0:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
n_lines = params.get("n_lines", None)
|
|
||||||
vmin = params.get("vmin", None)
|
|
||||||
|
|
||||||
if n_lines is None or vmin is None:
|
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
|
||||||
continue
|
|
||||||
flares.run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
|
|
||||||
elif idx == 1:
|
|
||||||
pass
|
|
||||||
elif idx == 2:
|
|
||||||
pass
|
|
||||||
elif idx == 3:
|
|
||||||
pass
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"No method defined for index {idx}")
|
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
Filename: intergroupbrainimage.py
|
||||||
|
Description: Logic for the Inter-Group Brain & Image analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in Imports
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from mne import Annotations
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import aggregate_fnirs_group_geometry, plot_fir_model_results, brain_3d_visualization
|
||||||
|
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
from mne.io import BaseRaw
|
||||||
|
|
||||||
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "lower_bound",
|
||||||
|
"label": "Lower bound + <description>",
|
||||||
|
"default": "-0.3",
|
||||||
|
"type": float, # specify int here
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "upper_bound",
|
||||||
|
"label": "Upper bound + <description>",
|
||||||
|
"default": "0.8",
|
||||||
|
"type": float, # specify int here
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "p_value",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "graph_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "3.0",
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
cha_dict: dict[str, DataFrame],
|
||||||
|
df_ind_dict: dict[str, DataFrame],
|
||||||
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
|
group_dict: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("InterGroupBrainImage")
|
||||||
|
self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
self.df_ind_dict = df_ind_dict
|
||||||
|
self.design_matrix_dict = design_matrix_dict
|
||||||
|
self.contrast_results_dict = contrast_results_dict
|
||||||
|
# self.group_dict = group_dict
|
||||||
|
|
||||||
|
self.setup_inter_group_ui(["0 (GLM Results)", "1 (Significance)", "2 (Brain Activity Visualization)",])
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
|
all_cha = pd.DataFrame()
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
|
if raw_annotations is not None:
|
||||||
|
annotations = cast(Annotations, raw_annotations)
|
||||||
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
|
participant_events: set[str] = set(descriptions)
|
||||||
|
else:
|
||||||
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
cha_df = self.cha_dict.get(file_path)
|
||||||
|
if cha_df is not None:
|
||||||
|
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||||
|
|
||||||
|
# Pass the necessary arguments to each method
|
||||||
|
file_path = selected_file_paths[0]
|
||||||
|
p_haemo = self.haemo_dict.get(file_path)
|
||||||
|
p_design_matrix = self.design_matrix_dict.get(file_path)
|
||||||
|
|
||||||
|
df_group = pd.DataFrame()
|
||||||
|
|
||||||
|
if selected_file_paths:
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
df = self.df_ind_dict.get(file_path)
|
||||||
|
if df is not None:
|
||||||
|
df_group = pd.concat([df_group, df], ignore_index=True)
|
||||||
|
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
lower_bound = params.get("lower_bound", None)
|
||||||
|
upper_bound = params.get("upper_bound", None)
|
||||||
|
|
||||||
|
if lower_bound is None or upper_bound is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_val = params.get("p_value", None)
|
||||||
|
graph_bounds = params.get("graph_bounds", None)
|
||||||
|
|
||||||
|
if p_val is None or graph_bounds is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_contrasts: list[DataFrame] = []
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
condition_dfs = self.contrast_results_dict.get(fp, {})
|
||||||
|
if selected_event in condition_dfs:
|
||||||
|
df = condition_dfs[selected_event].copy()
|
||||||
|
df["ID"] = fp
|
||||||
|
all_contrasts.append(df)
|
||||||
|
|
||||||
|
if not all_contrasts:
|
||||||
|
print("No contrast data found for selected participants and event.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: look at intergroupstats and figure out what to do
|
||||||
|
_ = pd.concat(all_contrasts, ignore_index=True)
|
||||||
|
#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
|
||||||
|
|
||||||
|
all_raw_objs = [self.haemo_dict.get(fp) for fp in selected_file_paths if self.haemo_dict.get(fp)]
|
||||||
|
|
||||||
|
if len(all_raw_objs) > 1:
|
||||||
|
processed_raw = aggregate_fnirs_group_geometry(all_raw_objs)
|
||||||
|
elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None:
|
||||||
|
processed_raw = all_raw_objs[0].copy()
|
||||||
|
processed_raw.pick(picks="hbo") # type: ignore
|
||||||
|
else:
|
||||||
|
processed_raw = None
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
Filename: intergroupfunctionalconnectivity.py
|
||||||
|
Description: Logic for the Inter-Group Functional Connectivity analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import run_group_functional_connectivity
|
||||||
|
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class InterGroupFunctionalConnectivityWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
group_dict: dict[str, str],
|
||||||
|
config_dict: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("InterGroupFunctionalConnectivity")
|
||||||
|
self.setWindowTitle(f"Inter-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
#self.group_dict = group_dict
|
||||||
|
self.config_dict = config_dict
|
||||||
|
|
||||||
|
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
||||||
|
"By clicking OK, you accept that the images generated may not be factual.")
|
||||||
|
|
||||||
|
self.setup_inter_group_ui(["0 (Betas)",])
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
n_lines = params.get("n_lines", None)
|
||||||
|
vmin = params.get("vmin", None)
|
||||||
|
|
||||||
|
if n_lines is None or vmin is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"""
|
||||||
|
Filename: intergroupstats.py
|
||||||
|
Description: Logic for the Inter-Group Stats analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import pandas as pd
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from mne import Annotations
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import run_roi_paired_contrast_analysis, run_roi_second_level_analysis, aggregate_channel_contrasts_to_roi
|
||||||
|
from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
|
0: [
|
||||||
|
{
|
||||||
|
"key": "p_threshold",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "5",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "fdr_bh",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "graph_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "0.0",
|
||||||
|
"type": float,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "p_threshold",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "5",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "None",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "roi_a",
|
||||||
|
"label": "ROI A (e.g. contralateral region name from regions.json)",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "roi_b",
|
||||||
|
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{
|
||||||
|
"key": "p_value",
|
||||||
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||||
|
"default": "0.05",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "min_subjects",
|
||||||
|
"label": "Minimum number of participants to process",
|
||||||
|
"default": "5",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "correction_method",
|
||||||
|
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||||
|
"default": "fdr_bh",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target_chroma",
|
||||||
|
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||||
|
"default": "hbo",
|
||||||
|
"type": str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "contrast_name",
|
||||||
|
"label": "Name of the contrast to use",
|
||||||
|
"default": [],
|
||||||
|
"type": list,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weighted",
|
||||||
|
"label": "Use inverse-variance weighting to minimize noisy channels",
|
||||||
|
"default": True,
|
||||||
|
"type": bool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "graph_bounds",
|
||||||
|
"label": "Graph Upper/Lower Limit",
|
||||||
|
"default": "0.0",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
|
||||||
|
\nTests whether one ROI's response during one condition reliably differs from zero across subjects - a one-sample t-test on each subject's ROI-averaged theta. A significant result means the region's signal during this condition is consistently non-zero across your sample, not just noise. It does not tell you whether the response is localized/specific to this region, or whether it reflects real neural activity versus systemic physiology (blood pressure, arousal) shared across the whole head during any active task - a single-condition "vs. zero" test can't distinguish those two explanations on its own.
|
||||||
|
\nIf you expected significance here and didn't get it, likely causes include: the sample size is simply small relative to between-subject variability in true response magnitude or HRF shape (individual differences in timing/amplitude inflate the variance a t-test divides by); the ROI's channel composition differs slightly across subjects (missing channels get down-weighted or excluded from the inverse-variance average, diluting a real signal); FDR correction across many ROIs is suppressing a modest true effect that would clear an uncorrected threshold; or the condition itself may not reliably engage this region the way you assumed (worth checking the single-subject/individual-level results for this ROI before concluding the group effect isn't there).
|
||||||
|
\n\n1. Paired ROI Contrast (run_roi_paired_contrast_analysis)
|
||||||
|
\nFor one condition, subtracts each subject's ROI_A response from their ROI_B response, then tests whether that per-subject difference is reliably non-zero. A significant result is a genuine spatial contrast - the two regions respond differently from each other during this specific condition, with shared systemic noise partially cancelling in the subtraction. It says nothing about whether the condition produced meaningful activity at all (only a relative difference between two places), and its power depends entirely on ROI_A and ROI_B varying together across subjects - an assumption that isn't guaranteed.
|
||||||
|
\nIf this test underperforms a plain ROI-vs-zero result, which can occur, the most likely explanation is that ROI_A and ROI_B's noise isn't well-correlated across your subjects. The math is variance(A - B) = variance(A) + variance(B) - 2·covariance(A,B): subtraction only helps when the shared/systemic component is large relative to independent noise in each region. If the two regions are picking up largely independent noise sources (motion artifact affecting one side more, different channel quality, etc.), subtracting adds variance rather than removing it, and can turn a detectable single-ROI effect into an underpowered paired one. Small sample size makes this worse, since the covariance itself is poorly estimated with few subjects.
|
||||||
|
\n\n2. Joint Contrast, ROI-Aggregated (aggregate_channel_contrasts_to_roi + one-sample test)
|
||||||
|
\nUses a contrast fit jointly within each subject's GLM (Condition A minus Condition B, estimated together), then aggregates that per-channel contrast to ROI level using inverse-variance weighting, and tests it against zero across subjects. A significant result means the two conditions produce reliably different responses at this ROI, with systemic noise largely cancelled at the model-fitting stage itself - the most statistically efficient of the three within-group methods, since the correlation between conditions is handled natively rather than inferred afterward. It does not tell you where the difference is localized on its own - for that, compare the sign/pattern across multiple ROIs: opposite signs across regions indicates a real, spatially-specific effect, while the same sign everywhere suggests diffuse/systemic noise rather than localized activity (as seen when comparing a real task-vs-task contrast against a task-vs-inert-marker contrast).
|
||||||
|
\nIf this comes back non-significant despite expecting an effect, first check whether the two conditions are actually similar enough in their neural engagement of this ROI that a small or genuinely near-zero contrast is the correct answer - not every ROI should differentiate every pair of tasks, and a null result here can be the right result. Beyond that: FDR correction across every ROI in your regions file can suppress a real but modest contrast; the inverse-variance weighting can be destabilized if a few channels within the ROI have very noisy or near-zero t-statistics (their standard error estimate becomes huge or unstable); and - as always - small subject counts limit the achievable degrees of freedom regardless of how clean the underlying per-channel estimates are.
|
||||||
|
\n\n
|
||||||
|
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
|
||||||
|
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
|
||||||
|
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
|
||||||
|
|
||||||
|
|
||||||
|
class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
cha_dict: dict[str, DataFrame],
|
||||||
|
df_ind_dict: dict[str, DataFrame],
|
||||||
|
design_matrix_dict: dict[str, DataFrame],
|
||||||
|
contrast_results_dict: dict[str, dict[str, Any]],
|
||||||
|
group_dict: dict[str, str],
|
||||||
|
json_location: str | Path
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("InterGroupStats")
|
||||||
|
self.setWindowTitle(f"Inter-Group Stats Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_dict
|
||||||
|
self.df_ind_dict = df_ind_dict
|
||||||
|
self.design_matrix_dict = design_matrix_dict
|
||||||
|
self.contrast_results_dict = contrast_results_dict
|
||||||
|
self.group_dict = group_dict
|
||||||
|
self.json_location = json_location
|
||||||
|
|
||||||
|
self.setup_inter_group_ui(["0 (ROI vs. Zero)", "1 (Paired ROI Contrast)", "2 (Joint Contrast, ROI-Aggregated)"], placeholder_text=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
|
def process_request(self):
|
||||||
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.json_location, self.contrast_results_dict)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
|
|
||||||
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
|
|
||||||
|
all_cha = DataFrame()
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
|
if raw_annotations is not None:
|
||||||
|
annotations = cast(Annotations, raw_annotations)
|
||||||
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
|
participant_events: set[str] = set(descriptions)
|
||||||
|
else:
|
||||||
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
|
if selected_event not in participant_events:
|
||||||
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
cha_df = self.cha_dict.get(file_path)
|
||||||
|
if cha_df is not None:
|
||||||
|
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||||
|
|
||||||
|
file_path = selected_file_paths[0]
|
||||||
|
p_haemo = self.haemo_dict.get(file_path)
|
||||||
|
|
||||||
|
# Concatenate individual ROI stats (df_ind) for all chosen subjects
|
||||||
|
df_group = DataFrame()
|
||||||
|
if selected_file_paths:
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
df = self.df_ind_dict.get(file_path)
|
||||||
|
if df is not None:
|
||||||
|
df_group = pd.concat([df_group, df], ignore_index=True)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 5)
|
||||||
|
correction_method = params.get("correction_method", "fdr_bh")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
graph_bounds = params.get("graph_bounds", 0.0)
|
||||||
|
|
||||||
|
if correction_method == "None":
|
||||||
|
correction_method = None
|
||||||
|
|
||||||
|
if df_group.empty:
|
||||||
|
print("No ROI data (df_ind) found for selected participants.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter down to the selected experimental event/condition
|
||||||
|
if selected_event:
|
||||||
|
if 'Condition' in df_group.columns:
|
||||||
|
df_filtered = df_group[df_group['Condition'] == selected_event]
|
||||||
|
else:
|
||||||
|
print("Warning: 'Condition' column not found in ROI data.")
|
||||||
|
df_filtered = df_group
|
||||||
|
else:
|
||||||
|
df_filtered = df_group
|
||||||
|
|
||||||
|
if df_filtered.empty:
|
||||||
|
print(f"No ROI data matches the condition '{selected_event}'.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_cha_filtered = DataFrame()
|
||||||
|
if not all_cha.empty:
|
||||||
|
if selected_event and 'Condition' in all_cha.columns:
|
||||||
|
all_cha_filtered = all_cha[all_cha['Condition'] == selected_event]
|
||||||
|
else:
|
||||||
|
all_cha_filtered = all_cha
|
||||||
|
|
||||||
|
|
||||||
|
run_roi_second_level_analysis(
|
||||||
|
df_roi_all=df_filtered,
|
||||||
|
df_cha_all=all_cha_filtered,
|
||||||
|
raw_haemo=p_haemo,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
correction_method=correction_method,
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
||||||
|
roi_config=self.json_location
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 5)
|
||||||
|
correction_method = params.get("correction_method", "None")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
roi_a = params.get("roi_a", "").strip()
|
||||||
|
roi_b = params.get("roi_b", "").strip()
|
||||||
|
|
||||||
|
if not selected_event:
|
||||||
|
print("Paired ROI contrast requires a specific event/condition "
|
||||||
|
"to be selected - pick one from the Event dropdown first.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if df_group.empty:
|
||||||
|
print("No ROI data (df_ind) found for selected participants.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if correction_method == "None":
|
||||||
|
correction_method = None
|
||||||
|
|
||||||
|
if not roi_a or not roi_b:
|
||||||
|
print("Both ROI A and ROI B must be specified.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(min_subjects)
|
||||||
|
run_roi_paired_contrast_analysis(
|
||||||
|
df_roi_all=df_group,
|
||||||
|
roi_pairs=(roi_a, roi_b),
|
||||||
|
condition=selected_event,
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
correction_method=correction_method,
|
||||||
|
roi_a_label=roi_a,
|
||||||
|
roi_b_label=roi_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif idx == 2:
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
p_threshold = params.get("p_threshold", 0.05)
|
||||||
|
min_subjects = params.get("min_subjects", 5)
|
||||||
|
correction_method = params.get("correction_method", "fdr_bh")
|
||||||
|
target_chroma = params.get("target_chroma", "hbo")
|
||||||
|
contrast_name = params.get("contrast_name", "")
|
||||||
|
weighted = params.get("weighted", True)
|
||||||
|
graph_bounds = params.get("graph_bounds", 0.0)
|
||||||
|
|
||||||
|
if not selected_event:
|
||||||
|
print("Joint contrast ROI analysis requires a specific contrast "
|
||||||
|
"to be selected from the Event dropdown first.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not contrast_name:
|
||||||
|
print("Contrast name must be specified.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
all_contrasts: list[DataFrame] = []
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
condition_dfs = self.contrast_results_dict.get(fp)
|
||||||
|
if condition_dfs is None:
|
||||||
|
print(f" [MISSING] '{fp}' not found in contrast_results.")
|
||||||
|
continue
|
||||||
|
if contrast_name in condition_dfs:
|
||||||
|
df = condition_dfs[contrast_name].copy()
|
||||||
|
df["ID"] = fp
|
||||||
|
df["contrast_name"] = contrast_name
|
||||||
|
all_contrasts.append(df)
|
||||||
|
else:
|
||||||
|
print(f" [MISSING CONTRAST] '{contrast_name}' not "
|
||||||
|
f"available for {self.participant_map.get(fp, fp)}.")
|
||||||
|
|
||||||
|
if not all_contrasts:
|
||||||
|
print(f"No contrast data found for '{contrast_name}' "
|
||||||
|
f"across selected participants.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
roi_theta = aggregate_channel_contrasts_to_roi(
|
||||||
|
df_contrasts,
|
||||||
|
roi_json_path=self.json_location,
|
||||||
|
weighted=weighted,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to aggregate contrasts to ROI: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if roi_theta.empty:
|
||||||
|
print("No ROI-level contrast values could be computed "
|
||||||
|
"(check regions.json channel names against this montage).")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# TODO: Come back to this
|
||||||
|
# df_cha_all intentionally omitted (None): the topography
|
||||||
|
# section of run_roi_second_level_analysis expects
|
||||||
|
# single-condition Condition values in df_cha_all, which
|
||||||
|
# doesn't semantically match a contrast name - skip it here
|
||||||
|
# rather than pass mismatched data.
|
||||||
|
run_roi_second_level_analysis(
|
||||||
|
df_roi_all=roi_theta,
|
||||||
|
df_cha_all=None,
|
||||||
|
raw_haemo=p_haemo,
|
||||||
|
p_threshold=p_threshold,
|
||||||
|
min_subjects=min_subjects,
|
||||||
|
correction_method=correction_method,
|
||||||
|
target_chroma=target_chroma,
|
||||||
|
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
@@ -1,175 +1,117 @@
|
|||||||
"""
|
"""
|
||||||
Filename: participantbrain.py
|
Filename: participantbrain.py
|
||||||
Description: Participant brain analysis window for FLARES
|
Description: Logic for the Participant Brain analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
# Built-in imports
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
# External library imports
|
||||||
from PySide6.QtCore import QSize
|
from mne import Annotations
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import brain_3d_visualization, brain_landmarks_3d
|
||||||
|
from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
|
||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
class ParticipantBrainViewerWidget(FlaresBaseWidget):
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
def __init__(self, haemo_dict, cha_dict):
|
0: [
|
||||||
super().__init__("ParticipantBrainViewer")
|
{
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantBrainViewerWidget(ParticipantUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
cha_dict: dict[str, DataFrame],
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("ParticipantBrain")
|
||||||
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
self.cha_dict = cha_dict
|
self.cha_dict = cha_dict
|
||||||
|
|
||||||
# Create mappings: file_path -> participant label and dropdown display text
|
self.setup_participant_ui(["0 (Brain Landmarks)", "1 (Brain Activity Visualization)",])
|
||||||
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.event_dropdown = QComboBox()
|
|
||||||
self.event_dropdown.addItem("<None Selected>")
|
|
||||||
|
|
||||||
|
|
||||||
self.index_texts = [
|
def process_request(self):
|
||||||
"0 (Brain Landmarks)",
|
|
||||||
"1 (Brain Activity Visualization)",
|
|
||||||
# "2 (third image)",
|
|
||||||
# "3 (fourth image)",
|
|
||||||
]
|
|
||||||
|
|
||||||
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
self.submit_button = QPushButton("Submit")
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
self.submit_button.clicked.connect(self.show_brain_images)
|
|
||||||
|
|
||||||
self.top_bar.addWidget(QLabel("Participants:"))
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
self.top_bar.addWidget(self.participant_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Event:"))
|
|
||||||
self.top_bar.addWidget(self.event_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
||||||
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 show_brain_images(self):
|
|
||||||
import flares as flares
|
|
||||||
|
|
||||||
selected_event = self.event_dropdown.currentText()
|
|
||||||
if selected_event == "<None Selected>":
|
|
||||||
selected_event = None
|
|
||||||
|
|
||||||
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)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
1: [
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
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)
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
if selected_event:
|
if selected_event:
|
||||||
participant_events = set(haemo_obj.annotations.description)
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
|
if raw_annotations is not None:
|
||||||
|
annotations = cast(Annotations, raw_annotations)
|
||||||
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
|
participant_events: set[str] = set(descriptions)
|
||||||
|
else:
|
||||||
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
if selected_event not in participant_events:
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if haemo_obj is None:
|
|
||||||
raise Exception("How did we get here?")
|
|
||||||
|
|
||||||
cha = self.cha_dict.get(file_path)
|
cha = self.cha_dict.get(file_path)
|
||||||
|
|
||||||
for idx in selected_indexes:
|
for idx in selected_indexes:
|
||||||
@@ -183,7 +125,7 @@ class ParticipantBrainViewerWidget(FlaresBaseWidget):
|
|||||||
print(f"Missing parameters for index {idx}, skipping.")
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
|
brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
|
||||||
|
|
||||||
elif idx == 1:
|
elif idx == 1:
|
||||||
params = param_values.get(idx, {})
|
params = param_values.get(idx, {})
|
||||||
@@ -196,7 +138,7 @@ class ParticipantBrainViewerWidget(FlaresBaseWidget):
|
|||||||
print(f"Missing parameters for index {idx}, skipping.")
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
flares.brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
|
brain_3d_visualization(haemo_obj, cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Filename: participantfoldchannels.py
|
Filename: participantfoldchannels.py
|
||||||
Description: Participant fOLD channels analysis window for FLARES
|
Description: Logic for the Participant fOLD Channels analysis window
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
@@ -69,9 +69,9 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue)
|
|||||||
""" Runs inside its own dedicated process """
|
""" Runs inside its own dedicated process """
|
||||||
p_name = os.path.basename(file_path)
|
p_name = os.path.basename(file_path)
|
||||||
try:
|
try:
|
||||||
import flares as flares
|
from flares import fold_channels
|
||||||
# Perform the heavy fold_channels logic
|
# Perform the heavy fold_channels logic
|
||||||
channel_results = flares.fold_channels(raw_data, p_name, progress_queue)
|
channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue)
|
||||||
|
|
||||||
# Hand back results and signal completion
|
# Hand back results and signal completion
|
||||||
result_queue.put({file_path: channel_results})
|
result_queue.put({file_path: channel_results})
|
||||||
@@ -736,13 +736,13 @@ class ProcessOrchestrator(QObject):
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
# 🟢 [Delay 1 Fix] Instantiate Manager completely off the main thread
|
# Instantiate Manager completely off the main thread
|
||||||
manager = Manager()
|
manager = Manager()
|
||||||
result_queue = manager.Queue()
|
result_queue = manager.Queue()
|
||||||
progress_queue = manager.Queue()
|
progress_queue = manager.Queue()
|
||||||
active_processes = []
|
active_processes = []
|
||||||
|
|
||||||
# 🟢 [Delay 2 Fix] Perform heavy pickling loop safely in the background
|
# Perform heavy pickling loop safely in the background
|
||||||
for file_path in self.selected_files:
|
for file_path in self.selected_files:
|
||||||
p = Process(
|
p = Process(
|
||||||
target=self.worker_func,
|
target=self.worker_func,
|
||||||
@@ -896,7 +896,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
|
|||||||
self.progress_queue = progress_queue
|
self.progress_queue = progress_queue
|
||||||
self.active_processes = active_processes
|
self.active_processes = active_processes
|
||||||
|
|
||||||
# 🟢 Safely initialize and trigger your polling listener
|
# Safely initialize and trigger the polling listener
|
||||||
self.completed_count = 0
|
self.completed_count = 0
|
||||||
self.result_timer = QTimer()
|
self.result_timer = QTimer()
|
||||||
self.result_timer.timeout.connect(self.check_parallel_results)
|
self.result_timer.timeout.connect(self.check_parallel_results)
|
||||||
|
|||||||
@@ -1,198 +1,143 @@
|
|||||||
"""
|
"""
|
||||||
Filename: participantfunctionalconnectivity.py
|
Filename: participantfunctionalconnectivity.py
|
||||||
Description: Participant functional connectivity analysis window for FLARES
|
Description: Logic for the Participant Functional Connectivity analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
# Built-in Imports
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from PySide6.QtWidgets import QComboBox, QDialog, QGridLayout, QHBoxLayout, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel, QMessageBox
|
# External library imports
|
||||||
from PySide6.QtCore import QSize
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from mne import Annotations
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
|
from flares import functional_connectivity_betas, functional_connectivity_envelope, functional_connectivity_spectral_epochs, functional_connectivity_spectral_time
|
||||||
|
from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
|
||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
|
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||||
def __init__(self, haemo_dict, epochs_dict):
|
0: [
|
||||||
super().__init__("FunctionalConnectivityWidget")
|
{
|
||||||
self.setWindowTitle(f"Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
1: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
{
|
||||||
|
"key": "n_lines",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "20",
|
||||||
|
"type": int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vmin",
|
||||||
|
"label": "<Description>",
|
||||||
|
"default": "0.9",
|
||||||
|
"type": float,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str | Path, BaseRaw],
|
||||||
|
epochs_dict: dict[str, DataFrame],
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("ParticipantFunctionalConnectivity")
|
||||||
|
self.setWindowTitle(f"Participant Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
self.epochs_dict = epochs_dict
|
self.epochs_dict = epochs_dict
|
||||||
|
|
||||||
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
|
||||||
"By clicking OK, you accept that the images generated may not be factual.")
|
"By clicking OK, you accept that the images generated may not be factual.")
|
||||||
|
|
||||||
# Create mappings: file_path -> participant label and dropdown display text
|
self.setup_participant_ui(["0 (Spectral Connectivity Epochs)", "1 (Envelope Correlation)", "2 (Betas)", "3 (Spectral Connectivity Epochs)",])
|
||||||
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.event_dropdown = QComboBox()
|
|
||||||
self.event_dropdown.addItem("<None Selected>")
|
|
||||||
|
|
||||||
|
|
||||||
self.index_texts = [
|
|
||||||
"0 (Spectral Connectivity Epochs)",
|
|
||||||
"1 (Envelope Correlation)",
|
|
||||||
"2 (Betas)",
|
|
||||||
"3 (Spectral Connectivity Epochs)",
|
|
||||||
]
|
|
||||||
|
|
||||||
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
def process_request(self):
|
||||||
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
|
|
||||||
self.submit_button = QPushButton("Submit")
|
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||||
self.submit_button.clicked.connect(self.show_brain_images)
|
|
||||||
|
|
||||||
self.top_bar.addWidget(QLabel("Participants:"))
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||||
self.top_bar.addWidget(self.participant_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Event:"))
|
|
||||||
self.top_bar.addWidget(self.event_dropdown)
|
|
||||||
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
||||||
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 show_brain_images(self):
|
|
||||||
import flares as flares
|
|
||||||
|
|
||||||
selected_event = self.event_dropdown.currentText()
|
|
||||||
if selected_event == "<None Selected>":
|
|
||||||
selected_event = None
|
|
||||||
|
|
||||||
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)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
parameterized_indexes = {
|
|
||||||
0: [
|
|
||||||
{
|
|
||||||
"key": "n_lines",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "20",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
1: [
|
|
||||||
{
|
|
||||||
"key": "n_lines",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "20",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
|
|
||||||
],
|
|
||||||
2: [
|
|
||||||
{
|
|
||||||
"key": "n_lines",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "20",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
|
|
||||||
],
|
|
||||||
3: [
|
|
||||||
{
|
|
||||||
"key": "n_lines",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "20",
|
|
||||||
"type": int,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vmin",
|
|
||||||
"label": "<Description>",
|
|
||||||
"default": "0.9",
|
|
||||||
"type": float,
|
|
||||||
},
|
|
||||||
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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)
|
||||||
epochs_obj = self.epochs_dict.get(file_path)
|
epochs_obj = self.epochs_dict.get(file_path)
|
||||||
|
|
||||||
|
if haemo_obj is None:
|
||||||
|
continue
|
||||||
|
|
||||||
if selected_event:
|
if selected_event:
|
||||||
participant_events = set(haemo_obj.annotations.description)
|
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||||
|
|
||||||
|
if raw_annotations is not None:
|
||||||
|
annotations = cast(Annotations, raw_annotations)
|
||||||
|
descriptions = cast(list[str], list(annotations.description))
|
||||||
|
participant_events: set[str] = set(descriptions)
|
||||||
|
else:
|
||||||
|
participant_events: set[str] = set()
|
||||||
|
|
||||||
if selected_event not in participant_events:
|
if selected_event not in participant_events:
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if haemo_obj is None:
|
|
||||||
raise Exception("How did we get here?")
|
|
||||||
|
|
||||||
|
|
||||||
for idx in selected_indexes:
|
for idx in selected_indexes:
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
@@ -204,7 +149,7 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
|
|||||||
if n_lines is None or vmin is None:
|
if n_lines is None or vmin is None:
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
continue
|
continue
|
||||||
flares.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
|
functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
elif idx == 1:
|
elif idx == 1:
|
||||||
params = param_values.get(idx, {})
|
params = param_values.get(idx, {})
|
||||||
@@ -214,7 +159,7 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
|
|||||||
if n_lines is None or vmin is None:
|
if n_lines is None or vmin is None:
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
continue
|
continue
|
||||||
flares.functional_connectivity_envelope(epochs_obj, n_lines, vmin)
|
functional_connectivity_envelope(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
elif idx == 2:
|
elif idx == 2:
|
||||||
params = param_values.get(idx, {})
|
params = param_values.get(idx, {})
|
||||||
@@ -224,7 +169,7 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
|
|||||||
if n_lines is None or vmin is None:
|
if n_lines is None or vmin is None:
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
continue
|
continue
|
||||||
flares.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
|
functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
|
||||||
|
|
||||||
elif idx == 3:
|
elif idx == 3:
|
||||||
params = param_values.get(idx, {})
|
params = param_values.get(idx, {})
|
||||||
@@ -234,7 +179,7 @@ class ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
|
|||||||
if n_lines is None or vmin is None:
|
if n_lines is None or vmin is None:
|
||||||
print(f"Missing parameters for index {idx}, skipping.")
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
continue
|
continue
|
||||||
flares.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
|
functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print(f"No method defined for index {idx}")
|
print(f"No method defined for index {idx}")
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Filename: participant.py
|
Filename: participantimage.py
|
||||||
Description: Participant analysis window for FLARES
|
Description: Logic for the Participant Image analysis window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
# Built-in Imports
|
||||||
|
import os.path as op
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
from mne.io.base import BaseRaw
|
||||||
|
|
||||||
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
||||||
from PySide6.QtCore import Qt, QSize
|
from PySide6.QtCore import Qt, QSize
|
||||||
from PySide6.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
@@ -18,27 +23,33 @@ from src.shared.flaresbasewidget import ClickableLabel, FlaresBaseWidget
|
|||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
class ParticipantViewerWidget(FlaresBaseWidget):
|
class ParticipantImageViewerWidget(FlaresBaseWidget):
|
||||||
def __init__(self, haemo_dict, fig_bytes_dict):
|
|
||||||
super().__init__("ParticipantViewer")
|
def __init__(
|
||||||
|
self,
|
||||||
|
haemo_dict: dict[str, BaseRaw],
|
||||||
|
fig_bytes_dict: dict[str, dict[str, bytes]]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__("ParticipantImage")
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
self.setWindowTitle(f"Participant Viewer - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Participant Image Viewer - {APP_NAME.upper()}")
|
||||||
self.haemo_dict = haemo_dict
|
self.haemo_dict = haemo_dict
|
||||||
self.fig_bytes_dict = fig_bytes_dict
|
self.fig_bytes_dict = fig_bytes_dict
|
||||||
|
|
||||||
# Create mappings: file_path -> participant label and dropdown display text
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
self.participant_map = {} # file_path -> "Participant 1"
|
self.participant_map: dict[str, str] = {}
|
||||||
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
self.participant_dropdown_items: list[str] = []
|
||||||
|
|
||||||
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
||||||
short_label = f"Participant {i}"
|
short_label = f"Participant {i}"
|
||||||
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
display_label = f"{short_label} ({op.basename(file_path)})"
|
||||||
self.participant_map[file_path] = short_label
|
self.participant_map[file_path] = short_label
|
||||||
self.participant_dropdown_items.append(display_label)
|
self.participant_dropdown_items.append(display_label)
|
||||||
|
|
||||||
self.layout = QVBoxLayout(self)
|
self.main_layout = QVBoxLayout(self)
|
||||||
self.top_bar = QHBoxLayout()
|
self.top_bar = QHBoxLayout()
|
||||||
self.layout.addLayout(self.top_bar)
|
self.main_layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
@@ -58,12 +69,12 @@ class ParticipantViewerWidget(FlaresBaseWidget):
|
|||||||
self.top_bar.addWidget(self.image_index_dropdown)
|
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_area = QScrollArea()
|
||||||
self.scroll.setWidgetResizable(True)
|
self.scroll_area.setWidgetResizable(True)
|
||||||
self.scroll_content = QWidget()
|
self.scroll_content = QWidget()
|
||||||
self.grid_layout = QGridLayout(self.scroll_content)
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
self.scroll.setWidget(self.scroll_content)
|
self.scroll_area.setWidget(self.scroll_content)
|
||||||
self.layout.addWidget(self.scroll)
|
self.main_layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
self.thumb_size = QSize(280, 180)
|
self.thumb_size = QSize(280, 180)
|
||||||
|
|
||||||
@@ -85,23 +96,24 @@ class ParticipantViewerWidget(FlaresBaseWidget):
|
|||||||
|
|
||||||
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
# Map from display names back to file paths
|
# Map from display names back to file paths
|
||||||
selected_file_paths = []
|
selected_file_paths: list[str] = []
|
||||||
for display_name in selected_display_names:
|
for display_name in selected_display_names:
|
||||||
# Find file_path by matching display name
|
# Find file_path by matching display name
|
||||||
for fp, short_label in self.participant_map.items():
|
for fp, short_label in self.participant_map.items():
|
||||||
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
expected_display = f"{short_label} ({Path(fp).name})"
|
||||||
if display_name == expected_display:
|
if display_name == expected_display:
|
||||||
selected_file_paths.append(fp)
|
selected_file_paths.append(str(fp))
|
||||||
break
|
break
|
||||||
|
|
||||||
selected_labels = self._get_checked_items(self.image_index_dropdown)
|
selected_labels = self._get_checked_items(self.image_index_dropdown)
|
||||||
|
|
||||||
row, col = 0, 0
|
row, col = 0, 0
|
||||||
for file_path in selected_file_paths:
|
for file_path in selected_file_paths:
|
||||||
fig_list = self.fig_bytes_dict.get(file_path, [])
|
fig_map: dict[str, bytes] = self.fig_bytes_dict.get(file_path, {})
|
||||||
participant_label = self.participant_map[file_path]
|
participant_label: str = self.participant_map.get(file_path, "Unknown")
|
||||||
|
|
||||||
for label in selected_labels:
|
for label in selected_labels:
|
||||||
fig_bytes = fig_list.get(label)
|
fig_bytes: bytes | None = fig_map.get(label)
|
||||||
if not fig_bytes:
|
if not fig_bytes:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -147,7 +159,7 @@ class ParticipantViewerWidget(FlaresBaseWidget):
|
|||||||
for display_name in selected_display_names:
|
for display_name in selected_display_names:
|
||||||
# Match display name to file path
|
# Match display name to file path
|
||||||
for file_path, short_label in self.participant_map.items():
|
for file_path, short_label in self.participant_map.items():
|
||||||
expected_display = f"{short_label} ({os.path.basename(file_path)})"
|
expected_display = f"{short_label} ({op.basename(file_path)})"
|
||||||
if display_name == expected_display:
|
if display_name == expected_display:
|
||||||
fig_dict = self.fig_bytes_dict.get(file_path, {})
|
fig_dict = self.fig_bytes_dict.get(file_path, {})
|
||||||
for label in selected_image_labels:
|
for label in selected_image_labels:
|
||||||
@@ -155,7 +167,7 @@ class ParticipantViewerWidget(FlaresBaseWidget):
|
|||||||
continue
|
continue
|
||||||
fig_bytes = fig_dict[label]
|
fig_bytes = fig_dict[label]
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png"
|
filename = f"{op.basename(file_path)}_{label}_{timestamp}.png"
|
||||||
output_path = save_dir / filename
|
output_path = save_dir / filename
|
||||||
with open(output_path, "wb") as f:
|
with open(output_path, "wb") as f:
|
||||||
f.write(fig_bytes)
|
f.write(fig_bytes)
|
||||||
+757
-20
@@ -7,10 +7,13 @@ License: GPL-3.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QVBoxLayout, QWidget, QFrame, QSpinBox
|
from pathlib import Path
|
||||||
|
from typing import Sequence, Any
|
||||||
|
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget, QFrame, QSpinBox
|
||||||
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
||||||
from PySide6.QtCore import QEvent, Qt
|
from PySide6.QtCore import QEvent, QSize, Qt
|
||||||
|
|
||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
@@ -84,28 +87,53 @@ class ParameterInputDialog(QDialog):
|
|||||||
self.params_dict = params_dict
|
self.params_dict = params_dict
|
||||||
self.inputs = {} # {(idx, param_key): QLineEdit}
|
self.inputs = {} # {(idx, param_key): QLineEdit}
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
main_layout = QVBoxLayout(self)
|
||||||
intro_label = QLabel(
|
intro_label = QLabel(
|
||||||
"Some methods require parameters to continue:\n"
|
"Some methods require parameters to continue:\n"
|
||||||
"Clicking OK will simply use default values if input is left empty."
|
"Clicking OK will simply use default values if input is left empty."
|
||||||
)
|
)
|
||||||
layout.addWidget(intro_label)
|
main_layout.addWidget(intro_label)
|
||||||
|
self.setMinimumWidth(400)
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll.setMaximumHeight(800)
|
||||||
|
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.scroll_layout = QVBoxLayout(self.scroll_content)
|
||||||
|
self.scroll_layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
self.scroll.setWidget(self.scroll_content)
|
||||||
|
|
||||||
|
main_layout.addWidget(self.scroll)
|
||||||
|
|
||||||
for idx, param_list in params_dict.items():
|
for idx, param_list in params_dict.items():
|
||||||
full_text = param_list[0].get('full_text', f"Index [{idx}]")
|
full_text = param_list[0].get('full_text', f"Index [{idx}]")
|
||||||
group_label = QLabel(f"{full_text} requires parameters:")
|
group_label = QLabel(f"{full_text} requires parameters:")
|
||||||
group_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
group_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
||||||
layout.addWidget(group_label)
|
self.scroll_layout.addWidget(group_label)
|
||||||
|
|
||||||
for param_info in param_list:
|
for param_info in param_list:
|
||||||
label = QLabel(param_info["label"])
|
label = QLabel(param_info["label"])
|
||||||
layout.addWidget(label)
|
self.scroll_layout.addWidget(label)
|
||||||
|
|
||||||
line_edit = QLineEdit(self)
|
if param_info.get("type") == list:
|
||||||
line_edit.setPlaceholderText(str(param_info.get("default", "")))
|
widget = QComboBox(self)
|
||||||
layout.addWidget(line_edit)
|
# Convert options to string just in case they aren't
|
||||||
|
options = [str(opt) for opt in param_info.get("options", [])]
|
||||||
|
widget.addItems(options)
|
||||||
|
|
||||||
self.inputs[(idx, param_info["key"])] = line_edit
|
# Set default choice if it exists in the options list
|
||||||
|
default_val = str(param_info.get("default", ""))
|
||||||
|
if default_val in options:
|
||||||
|
widget.setCurrentText(default_val)
|
||||||
|
else:
|
||||||
|
widget = QLineEdit(self)
|
||||||
|
widget.setPlaceholderText(str(param_info.get("default", "")))
|
||||||
|
|
||||||
|
self.scroll_layout.addWidget(widget)
|
||||||
|
|
||||||
|
self.inputs[(idx, param_info["key"])] = widget
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
btn_layout = QHBoxLayout()
|
btn_layout = QHBoxLayout()
|
||||||
@@ -113,7 +141,7 @@ class ParameterInputDialog(QDialog):
|
|||||||
cancel_btn = QPushButton("Cancel", self)
|
cancel_btn = QPushButton("Cancel", self)
|
||||||
btn_layout.addWidget(ok_btn)
|
btn_layout.addWidget(ok_btn)
|
||||||
btn_layout.addWidget(cancel_btn)
|
btn_layout.addWidget(cancel_btn)
|
||||||
layout.addLayout(btn_layout)
|
main_layout.addLayout(btn_layout)
|
||||||
|
|
||||||
ok_btn.clicked.connect(self.accept)
|
ok_btn.clicked.connect(self.accept)
|
||||||
cancel_btn.clicked.connect(self.reject)
|
cancel_btn.clicked.connect(self.reject)
|
||||||
@@ -131,8 +159,11 @@ class ParameterInputDialog(QDialog):
|
|||||||
Returns None if validation fails (error dialog shown).
|
Returns None if validation fails (error dialog shown).
|
||||||
"""
|
"""
|
||||||
values = {}
|
values = {}
|
||||||
for (idx, param_key), line_edit in self.inputs.items():
|
for (idx, param_key), widget in self.inputs.items():
|
||||||
text = line_edit.text().strip()
|
if isinstance(widget, QComboBox):
|
||||||
|
text = widget.currentText().strip()
|
||||||
|
else:
|
||||||
|
text = widget.text().strip()
|
||||||
|
|
||||||
# Find param info dict
|
# Find param info dict
|
||||||
param_info = None
|
param_info = None
|
||||||
@@ -164,14 +195,15 @@ class ParameterInputDialog(QDialog):
|
|||||||
val = False
|
val = False
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid bool value: {text}")
|
raise ValueError(f"Invalid bool value: {text}")
|
||||||
elif param_type == str:
|
elif param_type in (str, list):
|
||||||
val = text
|
val = text
|
||||||
else:
|
else:
|
||||||
val = text # fallback
|
val = text # fallback
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
type_name = "list option" if param_type == list else param_type.__name__
|
||||||
self._show_error(
|
self._show_error(
|
||||||
f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n"
|
f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n"
|
||||||
f"Expected type: {param_type.__name__}"
|
f"Expected type: {type_name}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -209,6 +241,55 @@ class FullClickComboBox(QComboBox):
|
|||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton, QFileDialog
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
|
||||||
|
class FilePickerWidget(QWidget):
|
||||||
|
# This custom signal lets our container mimic a standard QLineEdit
|
||||||
|
textChanged = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, default_val="", file_filter="JSON Files (*.json);;All Files (*)", parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
# Inline layout to hold line edit and button side-by-side
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(5)
|
||||||
|
|
||||||
|
self.line_edit = QLineEdit()
|
||||||
|
self.line_edit.setText(str(default_val))
|
||||||
|
self.line_edit.setPlaceholderText("Select file path...")
|
||||||
|
# Forward internal text updates out through our custom component signal
|
||||||
|
self.line_edit.textChanged.connect(self.textChanged.emit)
|
||||||
|
|
||||||
|
self.browse_btn = QPushButton("Browse...")
|
||||||
|
self.browse_btn.clicked.connect(self.open_file_dialog)
|
||||||
|
|
||||||
|
layout.addWidget(self.line_edit)
|
||||||
|
layout.addWidget(self.browse_btn)
|
||||||
|
|
||||||
|
self.file_filter = file_filter
|
||||||
|
|
||||||
|
def open_file_dialog(self):
|
||||||
|
# Open PySide6 native file browser
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Select Configuration File",
|
||||||
|
self.line_edit.text(),
|
||||||
|
self.file_filter
|
||||||
|
)
|
||||||
|
if file_path:
|
||||||
|
self.line_edit.setText(file_path)
|
||||||
|
|
||||||
|
# Mimic standard text getter/setter behaviors so parent systems remain unbothered
|
||||||
|
def text(self):
|
||||||
|
return self.line_edit.text()
|
||||||
|
|
||||||
|
def setText(self, text):
|
||||||
|
self.line_edit.setText(text)
|
||||||
|
|
||||||
|
|
||||||
class ParamSection(QWidget):
|
class ParamSection(QWidget):
|
||||||
"""
|
"""
|
||||||
A widget section that dynamically creates labeled input fields from parameter metadata.
|
A widget section that dynamically creates labeled input fields from parameter metadata.
|
||||||
@@ -309,9 +390,15 @@ class ParamSection(QWidget):
|
|||||||
else:
|
else:
|
||||||
widget.setValue(15) # Default fallback
|
widget.setValue(15) # Default fallback
|
||||||
widget.valueChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
widget.valueChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||||
|
elif param["type"] == "json_file":
|
||||||
|
# Create our custom dual-element compound layout widget
|
||||||
|
widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)")
|
||||||
|
# Your existing tracking signals connect seamlessly!
|
||||||
|
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||||
else:
|
else:
|
||||||
widget = QLineEdit()
|
widget = QLineEdit()
|
||||||
widget.setText(str(default_val))
|
widget.setText(str(default_val))
|
||||||
|
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||||
|
|
||||||
if "depends_on" in param:
|
if "depends_on" in param:
|
||||||
self.dependencies.append({
|
self.dependencies.append({
|
||||||
@@ -537,6 +624,8 @@ class ParamSection(QWidget):
|
|||||||
values[name] = range(widget.value())
|
values[name] = range(widget.value())
|
||||||
else:
|
else:
|
||||||
values[name] = range(15) # Fallback
|
values[name] = range(15) # Fallback
|
||||||
|
elif expected_type == "json_file":
|
||||||
|
values[name] = widget.text()
|
||||||
else:
|
else:
|
||||||
raw_text = widget.text()
|
raw_text = widget.text()
|
||||||
try:
|
try:
|
||||||
@@ -720,7 +809,11 @@ class FlaresBaseWidget(QWidget):
|
|||||||
self.image_index_dropdown = None
|
self.image_index_dropdown = None
|
||||||
|
|
||||||
|
|
||||||
def _create_multiselect_dropdown(self, items):
|
def _create_multiselect_dropdown(
|
||||||
|
self,
|
||||||
|
items: Sequence[str]
|
||||||
|
) -> FullClickComboBox:
|
||||||
|
|
||||||
combo = FullClickComboBox()
|
combo = FullClickComboBox()
|
||||||
combo.setView(QListView())
|
combo.setView(QListView())
|
||||||
model = QStandardItemModel()
|
model = QStandardItemModel()
|
||||||
@@ -787,7 +880,11 @@ class FlaresBaseWidget(QWidget):
|
|||||||
# checked.append(item.text())
|
# checked.append(item.text())
|
||||||
# return checked
|
# return checked
|
||||||
|
|
||||||
def _get_checked_items(self, combo=None):
|
def _get_checked_items(
|
||||||
|
self,
|
||||||
|
combo: QComboBox | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
|
||||||
target = combo if combo is not None else getattr(self, 'participant_dropdown', None)
|
target = combo if combo is not None else getattr(self, 'participant_dropdown', None)
|
||||||
|
|
||||||
if target is None or target.model() is None:
|
if target is None or target.model() is None:
|
||||||
@@ -810,7 +907,10 @@ class FlaresBaseWidget(QWidget):
|
|||||||
return checked_items
|
return checked_items
|
||||||
|
|
||||||
|
|
||||||
def update_participant_dropdown_label(self, combo=None):
|
def update_participant_dropdown_label(
|
||||||
|
self,
|
||||||
|
combo: QComboBox | int | None = None
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Handles label updates for ANY participant dropdown.
|
Handles label updates for ANY participant dropdown.
|
||||||
If 'combo' is None, it defaults to the standard self.participant_dropdown.
|
If 'combo' is None, it defaults to the standard self.participant_dropdown.
|
||||||
@@ -836,9 +936,9 @@ class FlaresBaseWidget(QWidget):
|
|||||||
# 3. Conditional trigger for event updates
|
# 3. Conditional trigger for event updates
|
||||||
# We only update events if we aren't in one of the excluded viewers
|
# We only update events if we aren't in one of the excluded viewers
|
||||||
excluded_viewers = {
|
excluded_viewers = {
|
||||||
"ParticipantViewer",
|
"ParticipantImage",
|
||||||
"ParticipantFoldChannels",
|
"ParticipantFoldChannels",
|
||||||
"ExportDataAsCSVViewer",
|
"ExportToCSV",
|
||||||
}
|
}
|
||||||
|
|
||||||
if getattr(self, "caller", None) not in excluded_viewers:
|
if getattr(self, "caller", None) not in excluded_viewers:
|
||||||
@@ -1051,3 +1151,640 @@ class FlaresBaseWidget(QWidget):
|
|||||||
|
|
||||||
self._connect_select_all_toggle(toggle_ref, model)
|
self._connect_select_all_toggle(toggle_ref, model)
|
||||||
self.update_participant_dropdown_label(combo=target_combo)
|
self.update_participant_dropdown_label(combo=target_combo)
|
||||||
|
|
||||||
|
|
||||||
|
class CrossGroupUIMixin:
|
||||||
|
|
||||||
|
participant_map: dict[str, str]
|
||||||
|
|
||||||
|
def setup_cross_group_ui(
|
||||||
|
self,
|
||||||
|
index_texts: Sequence[str],
|
||||||
|
placeholder_text: str = ""
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
self.group_to_paths = {}
|
||||||
|
for file_path, group_name in self.group_dict.items():
|
||||||
|
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
||||||
|
|
||||||
|
self.group_names = sorted(self.group_to_paths.keys())
|
||||||
|
|
||||||
|
self.main_layout = QVBoxLayout(self)
|
||||||
|
self.top_bar = QHBoxLayout()
|
||||||
|
self.main_layout.addLayout(self.top_bar)
|
||||||
|
|
||||||
|
|
||||||
|
self.group_a_dropdown = QComboBox()
|
||||||
|
self.group_a_dropdown.addItem("<None Selected>")
|
||||||
|
self.group_a_dropdown.addItems(self.group_names)
|
||||||
|
self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options)
|
||||||
|
|
||||||
|
|
||||||
|
self.group_b_dropdown = QComboBox()
|
||||||
|
self.group_b_dropdown.addItem("<None Selected>")
|
||||||
|
self.group_b_dropdown.addItems(self.group_names)
|
||||||
|
self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options)
|
||||||
|
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
|
self.participant_dropdown_a = self._create_multiselect_dropdown([])
|
||||||
|
line_edit = self.participant_dropdown_a.lineEdit()
|
||||||
|
assert line_edit is not None, "Dropdown A must be editable to have a lineEdit"
|
||||||
|
line_edit.setPlaceholderText("Select participants (Group A)")
|
||||||
|
model = self.participant_dropdown_a.model()
|
||||||
|
assert isinstance(model, QStandardItemModel), "Model must be QStandardItemModel"
|
||||||
|
model.itemChanged.connect(self._on_participants_changed)
|
||||||
|
|
||||||
|
|
||||||
|
self.participant_dropdown_b = self._create_multiselect_dropdown([])
|
||||||
|
line_edit = self.participant_dropdown_b.lineEdit()
|
||||||
|
assert line_edit is not None, "Dropdown B must be editable to have a lineEdit"
|
||||||
|
line_edit.setPlaceholderText("Select participants (Group B)")
|
||||||
|
model = self.participant_dropdown_b.model()
|
||||||
|
assert isinstance(model, QStandardItemModel), "Model must be QStandardItemModel"
|
||||||
|
model.itemChanged.connect(self._on_participants_changed)
|
||||||
|
|
||||||
|
|
||||||
|
self.index_texts = index_texts
|
||||||
|
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.process_request)
|
||||||
|
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Group A:"))
|
||||||
|
self.top_bar.addWidget(self.group_a_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Participants (Group A):"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown_a)
|
||||||
|
self.top_bar.addWidget(QLabel("Group B:"))
|
||||||
|
self.top_bar.addWidget(self.group_b_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Participants (Group B):"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown_b)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
self.top_bar.addWidget(self.image_index_dropdown)
|
||||||
|
self.top_bar.addWidget(self.submit_button)
|
||||||
|
|
||||||
|
self.scroll_area = QScrollArea()
|
||||||
|
self.scroll_area.setWidgetResizable(True)
|
||||||
|
self.scroll_content = QWidget()
|
||||||
|
self.grid_layout = QGridLayout(self.scroll_content)
|
||||||
|
self.scroll_area.setWidget(self.scroll_content)
|
||||||
|
self.placeholder_label = QLabel(placeholder_text)
|
||||||
|
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
|
||||||
|
self.placeholder_label.setWordWrap(True)
|
||||||
|
self.placeholder_label.setScaledContents(True)
|
||||||
|
self.main_layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
def _update_group_b_options(self):
|
||||||
|
"""Triggered when Group B changes: Update Group A to exclude B's choice"""
|
||||||
|
selected_b = self.group_b_dropdown.currentText()
|
||||||
|
|
||||||
|
# Refresh Group A and exclude what was just picked in Group B
|
||||||
|
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
|
||||||
|
|
||||||
|
# Update the participants for Group B
|
||||||
|
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
|
||||||
|
self._update_event_dropdown()
|
||||||
|
|
||||||
|
def _update_group_a_options(self):
|
||||||
|
"""Triggered when Group A changes: Update Group B to exclude A's choice"""
|
||||||
|
selected_a = self.group_a_dropdown.currentText()
|
||||||
|
|
||||||
|
# Refresh Group B and exclude what was just picked in Group A
|
||||||
|
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
|
||||||
|
|
||||||
|
# Update the participants for Group A
|
||||||
|
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
|
||||||
|
self._update_event_dropdown()
|
||||||
|
|
||||||
|
def _on_participants_changed(self, item=None):
|
||||||
|
self._update_event_dropdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_group_dropdown(self, dropdown, exclude):
|
||||||
|
current = dropdown.currentText()
|
||||||
|
dropdown.blockSignals(True)
|
||||||
|
dropdown.clear()
|
||||||
|
dropdown.addItem("<None Selected>")
|
||||||
|
for group in self.group_names:
|
||||||
|
if group != exclude:
|
||||||
|
dropdown.addItem(group)
|
||||||
|
# Restore previous selection if still valid
|
||||||
|
if current != "<None Selected>" and current != exclude and dropdown.findText(current) != -1:
|
||||||
|
dropdown.setCurrentText(current)
|
||||||
|
else:
|
||||||
|
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
|
||||||
|
dropdown.blockSignals(False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_file_paths_from_labels(self, labels, group_name):
|
||||||
|
file_paths = []
|
||||||
|
|
||||||
|
if group_name == self.group_a_dropdown.currentText():
|
||||||
|
participant_map = self.participant_map_a
|
||||||
|
elif group_name == self.group_b_dropdown.currentText():
|
||||||
|
participant_map = self.participant_map_b
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Reverse map: display label -> file path
|
||||||
|
reverse_map = {
|
||||||
|
f"{label} ({os.path.basename(fp)})": fp
|
||||||
|
for fp, label in participant_map.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for label in labels:
|
||||||
|
file_path = reverse_map.get(label)
|
||||||
|
if file_path:
|
||||||
|
file_paths.append(file_path)
|
||||||
|
|
||||||
|
return file_paths
|
||||||
|
|
||||||
|
def get_common_request_data(
|
||||||
|
self,
|
||||||
|
parameterized_indexes: dict[int, list[dict[str, Any]]],
|
||||||
|
json_location: str | Path | None = None,
|
||||||
|
contrast_dfs: dict[str, dict[str, Any]] | None = None,
|
||||||
|
) -> tuple[str | None, list[str], list[str], list[str], list[int], dict[str, Any]] | None:
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
participants_a = self._get_checked_items(self.participant_dropdown_a)
|
||||||
|
file_paths_a = self._get_file_paths_from_labels(
|
||||||
|
participants_a, self.group_a_dropdown.currentText()
|
||||||
|
)
|
||||||
|
|
||||||
|
participants_b = self._get_checked_items(self.participant_dropdown_b)
|
||||||
|
file_paths_b = self._get_file_paths_from_labels(
|
||||||
|
participants_b, self.group_b_dropdown.currentText()
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
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 None
|
||||||
|
|
||||||
|
# Inject full_text
|
||||||
|
for idx, params_list in parameterized_indexes.items():
|
||||||
|
full_text = self.index_texts[idx]
|
||||||
|
for param in params_list:
|
||||||
|
param["full_text"] = full_text
|
||||||
|
|
||||||
|
indexes_needing_params = {
|
||||||
|
idx: parameterized_indexes[idx]
|
||||||
|
for idx in selected_indexes
|
||||||
|
if idx in parameterized_indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic_rois = []
|
||||||
|
|
||||||
|
# 1. Check for the JSON file and parse ROI names
|
||||||
|
if os.path.exists(json_location):
|
||||||
|
try:
|
||||||
|
with open(json_location, 'r', encoding='utf-8') as f:
|
||||||
|
regions_data = json.load(f)
|
||||||
|
|
||||||
|
# Extract "name" from each region under "regions_of_interest"
|
||||||
|
regions_list = regions_data.get("regions_of_interest", [])
|
||||||
|
dynamic_rois = [region["name"] for region in regions_list if "name" in region]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Safe log if JSON is corrupted or unreadable
|
||||||
|
print(f"Error reading ROI configurations from {json_location}: {e}")
|
||||||
|
|
||||||
|
# Fallback to prevent UI crashes if JSON file doesn't exist or is empty
|
||||||
|
if not dynamic_rois:
|
||||||
|
dynamic_rois = ["Option 1", "Option 2"]
|
||||||
|
|
||||||
|
dynamic_contrasts = []
|
||||||
|
if contrast_dfs:
|
||||||
|
contrast_set = set()
|
||||||
|
for fp in all_selected_paths:
|
||||||
|
# Get the contrasts dictionary associated with this file path
|
||||||
|
file_contrasts = contrast_dfs.get(fp, {})
|
||||||
|
for contrast_name in file_contrasts.keys():
|
||||||
|
# If no event is selected, display all contrasts.
|
||||||
|
# If an event is selected, only keep contrasts containing the event name as a substring.
|
||||||
|
if selected_event is None or selected_event in contrast_name:
|
||||||
|
contrast_set.add(contrast_name)
|
||||||
|
|
||||||
|
# Sort them cleanly for the UI
|
||||||
|
dynamic_contrasts = sorted(list(contrast_set))
|
||||||
|
|
||||||
|
# 2. Loop through the active parameters needing input and intercept 'roi_a' and 'roi_b'
|
||||||
|
for idx, params_list in indexes_needing_params.items():
|
||||||
|
for param_info in params_list:
|
||||||
|
if param_info["key"] == "roi_a":
|
||||||
|
# Inject options list dynamically
|
||||||
|
param_info["options"] = dynamic_rois
|
||||||
|
# Default to the very first item
|
||||||
|
param_info["default"] = dynamic_rois[0] if dynamic_rois else ""
|
||||||
|
|
||||||
|
elif param_info["key"] == "roi_b":
|
||||||
|
# Inject the same options list
|
||||||
|
param_info["options"] = dynamic_rois
|
||||||
|
# Default to the first item not taken (index 1), with safety fallbacks
|
||||||
|
if len(dynamic_rois) > 1:
|
||||||
|
param_info["default"] = dynamic_rois[1]
|
||||||
|
elif len(dynamic_rois) == 1:
|
||||||
|
param_info["default"] = dynamic_rois[0]
|
||||||
|
else:
|
||||||
|
param_info["default"] = ""
|
||||||
|
|
||||||
|
elif param_info["key"] == "contrast_name":
|
||||||
|
param_info["options"] = dynamic_contrasts
|
||||||
|
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
||||||
|
|
||||||
|
param_values = {}
|
||||||
|
if indexes_needing_params:
|
||||||
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
||||||
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
|
return None
|
||||||
|
|
||||||
|
param_values = dialog.get_values()
|
||||||
|
if param_values is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (
|
||||||
|
selected_event,
|
||||||
|
file_paths_a,
|
||||||
|
file_paths_b,
|
||||||
|
all_selected_paths,
|
||||||
|
selected_indexes,
|
||||||
|
param_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CSVUIMixin:
|
||||||
|
|
||||||
|
def setup_csv_ui(
|
||||||
|
self,
|
||||||
|
index_texts: Sequence[str]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
||||||
|
self.participant_dropdown_items: list[str] = [] # "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: FullClickComboBox = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
|
||||||
|
self.index_texts = index_texts
|
||||||
|
|
||||||
|
self.image_index_dropdown: FullClickComboBox = 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.process_request)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class InterGroupUIMixin:
|
||||||
|
|
||||||
|
def setup_inter_group_ui(
|
||||||
|
self,
|
||||||
|
index_texts: Sequence[str],
|
||||||
|
placeholder_text: str = ""
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
self.show_all_events = True
|
||||||
|
self._updating_checkstates = False
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map: dict[str, str] = {} # 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.group_to_paths = {}
|
||||||
|
for file_path, group_name in self.group.items():
|
||||||
|
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
||||||
|
|
||||||
|
self.group_names = sorted(self.group_to_paths.keys())
|
||||||
|
|
||||||
|
self.group_dropdown = QComboBox()
|
||||||
|
self.group_dropdown.addItem("<None Selected>")
|
||||||
|
self.group_dropdown.addItems(self.group_names)
|
||||||
|
self.group_dropdown.setCurrentIndex(0)
|
||||||
|
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
|
||||||
|
|
||||||
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
||||||
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
||||||
|
self.participant_dropdown.setEnabled(False)
|
||||||
|
|
||||||
|
self.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
self.index_texts = index_texts
|
||||||
|
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.process_request)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Group:"))
|
||||||
|
self.top_bar.addWidget(self.group_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
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.placeholder_label = QLabel(placeholder_text)
|
||||||
|
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
|
||||||
|
self.placeholder_label.setWordWrap(True)
|
||||||
|
self.placeholder_label.setScaledContents(True)
|
||||||
|
self.layout.addWidget(self.scroll)
|
||||||
|
|
||||||
|
self.thumb_size = QSize(280, 180)
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
def get_common_request_data(
|
||||||
|
self,
|
||||||
|
parameterized_indexes: dict[int, list[dict[str, Any]]],
|
||||||
|
json_location: str | Path | None = None,
|
||||||
|
contrast_dfs: dict[str, dict[str, Any]] | None = None,
|
||||||
|
) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None:
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if selected_event:
|
||||||
|
valid_paths = []
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
raw = self.haemo_dict.get(fp)
|
||||||
|
# Check if this participant actually has the event in their annotations
|
||||||
|
if raw is not None and hasattr(raw, "annotations"):
|
||||||
|
if selected_event in raw.annotations.description:
|
||||||
|
valid_paths.append(fp)
|
||||||
|
|
||||||
|
selected_file_paths = valid_paths
|
||||||
|
|
||||||
|
selected_indexes = [
|
||||||
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not selected_file_paths:
|
||||||
|
print("No participants selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
|
||||||
|
dynamic_rois = []
|
||||||
|
|
||||||
|
# 1. Check for the JSON file and parse ROI names
|
||||||
|
if json_location is not None and os.path.exists(json_location):
|
||||||
|
try:
|
||||||
|
with open(json_location, 'r', encoding='utf-8') as f:
|
||||||
|
regions_data = json.load(f)
|
||||||
|
|
||||||
|
# Extract "name" from each region under "regions_of_interest"
|
||||||
|
regions_list = regions_data.get("regions_of_interest", [])
|
||||||
|
dynamic_rois = [region["name"] for region in regions_list if "name" in region]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Safe log if JSON is corrupted or unreadable
|
||||||
|
print(f"Error reading ROI configurations from {json_location}: {e}")
|
||||||
|
|
||||||
|
# Fallback to prevent UI crashes if JSON file doesn't exist or is empty
|
||||||
|
if not dynamic_rois:
|
||||||
|
dynamic_rois = ["Option 1", "Option 2"]
|
||||||
|
|
||||||
|
dynamic_contrasts = []
|
||||||
|
if contrast_dfs:
|
||||||
|
contrast_set = set()
|
||||||
|
for fp in selected_file_paths:
|
||||||
|
# Get the contrasts dictionary associated with this file path
|
||||||
|
file_contrasts = contrast_dfs.get(fp, {})
|
||||||
|
for contrast_name in file_contrasts.keys():
|
||||||
|
# If no event is selected, display all contrasts.
|
||||||
|
# If an event is selected, only keep contrasts containing the event name as a substring.
|
||||||
|
if selected_event is None or selected_event in contrast_name:
|
||||||
|
contrast_set.add(contrast_name)
|
||||||
|
|
||||||
|
# Sort them cleanly for the UI
|
||||||
|
dynamic_contrasts = sorted(list(contrast_set))
|
||||||
|
|
||||||
|
# 2. Loop through the active parameters needing input and intercept 'roi_a' and 'roi_b'
|
||||||
|
for idx, params_list in indexes_needing_params.items():
|
||||||
|
for param_info in params_list:
|
||||||
|
if param_info["key"] == "roi_a":
|
||||||
|
# Inject options list dynamically
|
||||||
|
param_info["options"] = dynamic_rois
|
||||||
|
# Default to the very first item
|
||||||
|
param_info["default"] = dynamic_rois[0] if dynamic_rois else ""
|
||||||
|
|
||||||
|
elif param_info["key"] == "roi_b":
|
||||||
|
# Inject the same options list
|
||||||
|
param_info["options"] = dynamic_rois
|
||||||
|
# Default to the first item not taken (index 1), with safety fallbacks
|
||||||
|
if len(dynamic_rois) > 1:
|
||||||
|
param_info["default"] = dynamic_rois[1]
|
||||||
|
elif len(dynamic_rois) == 1:
|
||||||
|
param_info["default"] = dynamic_rois[0]
|
||||||
|
else:
|
||||||
|
param_info["default"] = ""
|
||||||
|
|
||||||
|
elif param_info["key"] == "contrast_name":
|
||||||
|
param_info["options"] = dynamic_contrasts
|
||||||
|
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return (
|
||||||
|
selected_event,
|
||||||
|
selected_file_paths,
|
||||||
|
selected_indexes,
|
||||||
|
param_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ParticipantUIMixin:
|
||||||
|
def setup_participant_ui(
|
||||||
|
self,
|
||||||
|
index_texts: Sequence[str]
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# Create mappings: file_path -> participant label and dropdown display text
|
||||||
|
self.participant_map: dict[str, str] = {} # 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.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
|
self.index_texts = index_texts
|
||||||
|
|
||||||
|
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.process_request)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Event:"))
|
||||||
|
self.top_bar.addWidget(self.event_dropdown)
|
||||||
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
||||||
|
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 get_common_request_data(
|
||||||
|
self,
|
||||||
|
parameterized_indexes: dict[int, list[dict[str, Any]]]
|
||||||
|
) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None:
|
||||||
|
|
||||||
|
selected_event = self.event_dropdown.currentText()
|
||||||
|
if selected_event == "<None Selected>":
|
||||||
|
selected_event = None
|
||||||
|
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
return (
|
||||||
|
selected_event,
|
||||||
|
selected_file_paths,
|
||||||
|
selected_indexes,
|
||||||
|
param_values,
|
||||||
|
)
|
||||||
+15
-14
@@ -1,22 +1,27 @@
|
|||||||
"""
|
"""
|
||||||
Filename: shareddata.py
|
Filename: shareddata.py
|
||||||
Description: Shared constants and methods for FLARES
|
Description: Shared constants and methods other files depend on
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
# Built-in imports
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
|
|
||||||
CURRENT_VERSION = "1.5.0"
|
CURRENT_VERSION = "1.5.0"
|
||||||
APP_NAME = "flares"
|
APP_NAME = "flares"
|
||||||
|
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
|
||||||
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||||
API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||||
PLATFORM_NAME = platform.system().lower()
|
PLATFORM_NAME = platform.system().lower()
|
||||||
CHANGELOG_URL = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md"
|
CHANGELOG_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/raw/branch/main/changelog_major.md"
|
||||||
WIKI_URL = "https://git.research.dezeeuw.ca/tyler/flares/wiki"
|
WIKI_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/wiki"
|
||||||
|
|
||||||
|
|
||||||
PIPELINE_STAGES = [
|
PIPELINE_STAGES = [
|
||||||
"Preprocessing",
|
"Preprocessing",
|
||||||
@@ -43,21 +48,17 @@ PIPELINE_STAGES = [
|
|||||||
"Design Matrix",
|
"Design Matrix",
|
||||||
"General Linear Model",
|
"General Linear Model",
|
||||||
"Generate GLM Results",
|
"Generate GLM Results",
|
||||||
"Generate Channel Significance",
|
"Generate Channel Results",
|
||||||
"Generate Channel, Region of Interest, and Contrast Results",
|
"Generate Region of Interest Results",
|
||||||
"Compute Contrast Results",
|
"Generate Contrast Results",
|
||||||
"Finishing Up"
|
"Finishing Up"
|
||||||
]
|
]
|
||||||
|
|
||||||
def resource_path(relative_path):
|
|
||||||
|
def resource_path(relative_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if hasattr(sys, '_MEIPASS'):
|
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
|
||||||
# PyInstaller bundle path
|
|
||||||
base_path = sys._MEIPASS
|
|
||||||
else:
|
|
||||||
base_path = os.path.abspath(".")
|
|
||||||
|
|
||||||
return os.path.join(base_path, relative_path)
|
return os.path.join(base_path, relative_path)
|
||||||
+5
-4
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Filename: about.py
|
Filename: about.py
|
||||||
Description: About window for FLARES
|
Description: About window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
@@ -9,7 +10,7 @@ License: GPL-3.0
|
|||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
|
from src.shared.shareddata import APP_NAME, APP_NAME_EXPANDED, CURRENT_VERSION
|
||||||
|
|
||||||
class AboutWindow(QWidget):
|
class AboutWindow(QWidget):
|
||||||
"""
|
"""
|
||||||
@@ -19,14 +20,14 @@ class AboutWindow(QWidget):
|
|||||||
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent, Qt.WindowType.Window)
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
self.setWindowTitle(f"About {APP_NAME.upper()}")
|
self.setWindowTitle(f"About {APP_NAME.upper()}")
|
||||||
self.resize(250, 100)
|
self.resize(250, 100)
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
label = QLabel(f"About {APP_NAME.upper()}", self)
|
label = QLabel(f"About {APP_NAME.upper()}", self)
|
||||||
label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", self)
|
label2 = QLabel(f"{APP_NAME_EXPANDED}", self)
|
||||||
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
|
label3 = QLabel(f"{APP_NAME.upper()} is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
|
||||||
label4 = QLabel(f"Version v{CURRENT_VERSION}")
|
label4 = QLabel(f"Version v{CURRENT_VERSION}")
|
||||||
|
|
||||||
|
|||||||
+45
-9
@@ -1,22 +1,27 @@
|
|||||||
"""
|
"""
|
||||||
Filename: terminal.py
|
Filename: terminal.py
|
||||||
Description: Terminal window for FLARES
|
Description: Terminal window
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
|
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME
|
||||||
|
from src.window.about import AboutWindow
|
||||||
|
from updater import UpdateManager
|
||||||
|
|
||||||
|
|
||||||
class TerminalWindow(QWidget):
|
class TerminalWindow(QWidget):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent, Qt.WindowType.Window)
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
|
||||||
|
self.resize(320, 180)
|
||||||
self.output_area = QTextEdit()
|
self.output_area = QTextEdit()
|
||||||
self.output_area.setReadOnly(True)
|
self.output_area.setReadOnly(True)
|
||||||
|
|
||||||
@@ -28,12 +33,20 @@ class TerminalWindow(QWidget):
|
|||||||
layout.addWidget(self.input_line)
|
layout.addWidget(self.input_line)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
self.commands = {
|
self.commands: dict[str, Callable[..., Any]] = {
|
||||||
"hello": self.cmd_hello,
|
"hello": self.cmd_hello,
|
||||||
"help": self.cmd_help,
|
"help": self.cmd_help,
|
||||||
"version": self.cmd_version,
|
"version": self.cmd_version,
|
||||||
|
"about": self.cmd_about,
|
||||||
|
"update": self.cmd_update,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.output_area.append(f"Welcome to {APP_NAME.upper()}. You are running version {CURRENT_VERSION}.")
|
||||||
|
self.output_area.append("Type 'help' for a list of available commands.\n")
|
||||||
|
|
||||||
|
self.input_line.setFocus()
|
||||||
|
|
||||||
|
|
||||||
def handle_command(self):
|
def handle_command(self):
|
||||||
command_text = self.input_line.text()
|
command_text = self.input_line.text()
|
||||||
self.input_line.clear()
|
self.input_line.clear()
|
||||||
@@ -58,11 +71,34 @@ class TerminalWindow(QWidget):
|
|||||||
self.output_area.append(f"[Unknown command] '{command_name}'")
|
self.output_area.append(f"[Unknown command] '{command_name}'")
|
||||||
|
|
||||||
|
|
||||||
def cmd_hello(self, *args):
|
def cmd_hello(self, *args: Any) -> str:
|
||||||
return "Hello from the terminal!"
|
return "Hello from the terminal!"
|
||||||
|
|
||||||
def cmd_help(self, *args):
|
def cmd_help(self, *args: Any) -> str:
|
||||||
return f"Available commands: {', '.join(self.commands.keys())}"
|
return f"Available commands: {', '.join(self.commands.keys())}"
|
||||||
|
|
||||||
def cmd_version(self, *args):
|
def cmd_version(self, *args: Any) -> str:
|
||||||
return f"{CURRENT_VERSION}"
|
return f"{APP_NAME.upper()} is running version {CURRENT_VERSION}."
|
||||||
|
|
||||||
|
def cmd_about(self, *args: Any) -> None:
|
||||||
|
self.about = AboutWindow(self)
|
||||||
|
self.about.show()
|
||||||
|
|
||||||
|
def cmd_update(self, *args: Any) -> str:
|
||||||
|
main_win = self.parent()
|
||||||
|
if not isinstance(main_win, QWidget):
|
||||||
|
return "[Error] Main window context not found."
|
||||||
|
|
||||||
|
self.updater = UpdateManager(
|
||||||
|
main_window=main_win,
|
||||||
|
api_url=API_URL,
|
||||||
|
api_url_sec=API_URL_SECONDARY,
|
||||||
|
current_version=CURRENT_VERSION,
|
||||||
|
platform_name=PLATFORM_NAME,
|
||||||
|
platform_suffix="-" + PLATFORM_NAME,
|
||||||
|
app_name=APP_NAME
|
||||||
|
)
|
||||||
|
self.output_area.append("Checking for updates...")
|
||||||
|
|
||||||
|
self.updater.manual_check_for_updates()
|
||||||
|
return "See status bar for update information."
|
||||||
@@ -15,9 +15,9 @@ import numpy as np
|
|||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
from mne.io import read_raw_snirf
|
from mne.io import read_raw_snirf #type: ignore
|
||||||
from mne_nirs.io import write_raw_snirf
|
from mne_nirs.io import write_raw_snirf #type: ignore
|
||||||
from mne.channels import make_dig_montage
|
from mne.channels import make_dig_montage #type: ignore
|
||||||
|
|
||||||
from src.shared.shareddata import APP_NAME
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class UserGuideWindow(QWidget):
|
|||||||
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent, Qt.WindowType.Window)
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
self.setWindowTitle(f"User Guide - {APP_NAME.upper()}")
|
self.setWindowTitle(f"User Guide - {APP_NAME.upper()}")
|
||||||
self.resize(250, 100)
|
self.resize(250, 100)
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Filename: viewerlauncher.py
|
Filename: viewerlauncher.py
|
||||||
Description: Analysis options launcher for FLARES
|
Description: Viewer launcher window
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# External library imports
|
||||||
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
|
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
|
|
||||||
from src.analysis.exportcsv import ExportDataAsCSVViewerWidget
|
from src.analysis.exporttocsv import ExportToCSVWidget
|
||||||
from src.analysis.group import GroupViewerWidget
|
from src.analysis.intergroupbrainimage import InterGroupBrainImageWidget
|
||||||
from src.analysis.groupbrain import GroupBrainViewerWidget
|
from src.analysis.crossgroupbrainimage import CrossGroupBrainImageWidget
|
||||||
from src.analysis.groupfunctionalconnectivity import GroupFunctionalConnectivityWidget
|
from src.analysis.intergroupfunctionalconnectivity import InterGroupFunctionalConnectivityWidget
|
||||||
from src.analysis.participant import ParticipantViewerWidget
|
from src.analysis.intergroupstats import InterGroupStatsWidget
|
||||||
|
from src.analysis.crossgroupstats import CrossGroupStatsWidget
|
||||||
|
from src.analysis.participantimage import ParticipantImageViewerWidget
|
||||||
from src.analysis.participantbrain import ParticipantBrainViewerWidget
|
from src.analysis.participantbrain import ParticipantBrainViewerWidget
|
||||||
from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget
|
from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget
|
||||||
from src.analysis.participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget
|
from src.analysis.participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget
|
||||||
@@ -21,92 +24,42 @@ from src.shared.shareddata import APP_NAME
|
|||||||
|
|
||||||
|
|
||||||
class ViewerLauncherWidget(QWidget):
|
class ViewerLauncherWidget(QWidget):
|
||||||
def __init__(self, haemo_dict, config_dict, fig_bytes_dict, cha_dict, contrast_results_dict, df_ind, design_matrix, epochs_dict, folding_bypass):
|
def __init__(self, haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, config_dict, fig_bytes_dict, contrast_results_dict, folding_bypass, json_location):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
|
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
|
||||||
|
|
||||||
group_dict = {
|
group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()}
|
||||||
file_path: config.get("GROUP", "Unknown")
|
|
||||||
for file_path, config in config_dict.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def launch(func, btn, *args):
|
btn_data = [
|
||||||
func(*args)
|
("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True),
|
||||||
self._trigger_success(btn)
|
("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True),
|
||||||
|
("Participant Fold Channels Viewer", ParticipantFoldChannelsWidget, [haemo_dict, cha_dict], False),
|
||||||
|
("Participant Functional Connectivity Viewer [BETA]", ParticipantFunctionalConnectivityWidget, [haemo_dict, epochs_dict], True),
|
||||||
|
("Inter-Group Functional Connectivity Viewer [BETA]", InterGroupFunctionalConnectivityWidget, [haemo_dict, group_dict, config_dict], True),
|
||||||
|
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, json_location], True),
|
||||||
|
("Cross-Group Stats Viewer", CrossGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict, json_location], True),
|
||||||
|
("Inter-Group Brain and Image Viewer", InterGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
||||||
|
("Cross-Group Brain and Image Viewer", CrossGroupBrainImageWidget, [haemo_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
|
||||||
|
("Export To CSV Viewer", ExportToCSVWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True)
|
||||||
|
]
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
for label, widget_class, args, requires_bypass in btn_data:
|
||||||
|
btn = QPushButton(f"Open {label}")
|
||||||
|
# Connect directly to the generic opener
|
||||||
|
btn.clicked.connect(lambda _, c=widget_class, b=btn, a=args: self._open_viewer(c, b, *a))
|
||||||
|
btn.setEnabled(not (requires_bypass and folding_bypass))
|
||||||
|
layout.addWidget(btn)
|
||||||
|
|
||||||
btn1 = QPushButton("Open Participant Viewer")
|
def _open_viewer(self, widget_class, btn, *args):
|
||||||
btn1.clicked.connect(lambda: launch(self.open_participant_viewer, btn1, haemo_dict, fig_bytes_dict))
|
# Instantiate and show dynamically
|
||||||
btn1.setEnabled(not folding_bypass)
|
self.active_viewer = widget_class(*args)
|
||||||
|
self.active_viewer.show()
|
||||||
|
self._trigger_success(btn)
|
||||||
|
|
||||||
btn2 = QPushButton("Open Participant Brain Viewer")
|
def _launch(self, func, btn, *args):
|
||||||
btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict))
|
func(*args)
|
||||||
btn2.setEnabled(not folding_bypass)
|
self._trigger_success(btn)
|
||||||
|
|
||||||
btn3 = QPushButton("Open Participant Fold Channels Viewer")
|
|
||||||
btn3.clicked.connect(lambda: launch(self.open_participant_fold_channels_viewer, btn3, haemo_dict, cha_dict))
|
|
||||||
|
|
||||||
btn7 = QPushButton("Open Functional Connectivity Viewer [BETA]")
|
|
||||||
btn7.clicked.connect(lambda: launch(self.open_participant_functional_connectivity_viewer, btn7, haemo_dict, epochs_dict))
|
|
||||||
btn7.setEnabled(not folding_bypass)
|
|
||||||
|
|
||||||
btn8 = QPushButton("Open Group Functional Connectivity Viewer [BETA]")
|
|
||||||
btn8.clicked.connect(lambda: launch(self.open_group_functional_connectivity_viewer, btn8, haemo_dict, group_dict, config_dict))
|
|
||||||
btn8.setEnabled(not folding_bypass)
|
|
||||||
|
|
||||||
btn4 = QPushButton("Open Inter-Group Viewer")
|
|
||||||
btn4.clicked.connect(lambda: launch(self.open_group_viewer, btn4, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict))
|
|
||||||
btn4.setEnabled(not folding_bypass)
|
|
||||||
|
|
||||||
btn5 = QPushButton("Open Cross Group Brain Viewer")
|
|
||||||
btn5.clicked.connect(lambda: launch(self.open_group_brain_viewer, btn5, haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict))
|
|
||||||
btn5.setEnabled(not folding_bypass)
|
|
||||||
|
|
||||||
btn6 = QPushButton("Open Export Data As CSV Viewer")
|
|
||||||
btn6.clicked.connect(lambda: launch(self.open_export_data_as_csv_viewer, btn6, haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict))
|
|
||||||
btn6.setEnabled(not folding_bypass)
|
|
||||||
|
|
||||||
layout.addWidget(btn1)
|
|
||||||
layout.addWidget(btn2)
|
|
||||||
layout.addWidget(btn3)
|
|
||||||
layout.addWidget(btn7)
|
|
||||||
layout.addWidget(btn8)
|
|
||||||
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)
|
|
||||||
self.participant_viewer.show()
|
|
||||||
|
|
||||||
def open_participant_brain_viewer(self, haemo_dict, cha_dict):
|
|
||||||
self.participant_brain_viewer = ParticipantBrainViewerWidget(haemo_dict, cha_dict)
|
|
||||||
self.participant_brain_viewer.show()
|
|
||||||
|
|
||||||
def open_participant_fold_channels_viewer(self, haemo_dict, cha_dict):
|
|
||||||
self.participant_fold_channels_viewer = ParticipantFoldChannelsWidget(haemo_dict, cha_dict)
|
|
||||||
self.participant_fold_channels_viewer.show()
|
|
||||||
|
|
||||||
def open_participant_functional_connectivity_viewer(self, haemo_dict, epochs_dict):
|
|
||||||
self.participant_brain_viewer = ParticipantFunctionalConnectivityWidget(haemo_dict, epochs_dict)
|
|
||||||
self.participant_brain_viewer.show()
|
|
||||||
|
|
||||||
def open_group_functional_connectivity_viewer(self, haemo_dict, group, config_dict):
|
|
||||||
self.participant_brain_viewer = GroupFunctionalConnectivityWidget(haemo_dict, group, config_dict)
|
|
||||||
self.participant_brain_viewer.show()
|
|
||||||
|
|
||||||
def open_group_viewer(self, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group):
|
|
||||||
self.participant_brain_viewer = GroupViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group)
|
|
||||||
self.participant_brain_viewer.show()
|
|
||||||
|
|
||||||
def open_group_brain_viewer(self, 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()
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
def _trigger_success(self, button):
|
def _trigger_success(self, button):
|
||||||
"""Temporarily adds a green checkmark to the button text."""
|
"""Temporarily adds a green checkmark to the button text."""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Filename: welcome.py
|
Filename: welcome.py
|
||||||
Description: Welcome dialog for FLARES
|
Description: Welcome dialog for FLARES
|
||||||
|
Note: Compliant with pylance strict type checking
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
Author: Tyler de Zeeuw
|
||||||
License: GPL-3.0
|
License: GPL-3.0
|
||||||
@@ -9,13 +10,13 @@ License: GPL-3.0
|
|||||||
from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton
|
from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton
|
||||||
from PySide6.QtGui import QDesktopServices, QIcon
|
from PySide6.QtGui import QDesktopServices, QIcon
|
||||||
from PySide6.QtCore import QUrl
|
from PySide6.QtCore import QUrl
|
||||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest
|
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||||
|
|
||||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path
|
||||||
|
|
||||||
|
|
||||||
class WelcomeDialog(QDialog):
|
class WelcomeDialog(QDialog):
|
||||||
def __init__(self, parent=None, direct=True, first=False):
|
def __init__(self, parent: QDialog | None = None, direct: bool = True, first: bool = False):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
|
self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
|
||||||
self.setMinimumSize(550, 450)
|
self.setMinimumSize(550, 450)
|
||||||
@@ -64,10 +65,10 @@ class WelcomeDialog(QDialog):
|
|||||||
self.network_manager.get(QNetworkRequest(QUrl(CHANGELOG_URL)))
|
self.network_manager.get(QNetworkRequest(QUrl(CHANGELOG_URL)))
|
||||||
|
|
||||||
|
|
||||||
def _on_download_complete(self, reply):
|
def _on_download_complete(self, reply: QNetworkReply) -> None:
|
||||||
"""Processes the downloaded markdown and drops it into the view frame."""
|
"""Processes the downloaded markdown and drops it into the view frame."""
|
||||||
if reply.error() == reply.NetworkError.NoError:
|
if reply.error() == reply.NetworkError.NoError:
|
||||||
raw_bytes = reply.readAll()
|
raw_bytes = reply.readAll().data()
|
||||||
|
|
||||||
# Convert raw bytes to standard text string
|
# Convert raw bytes to standard text string
|
||||||
markdown_text = str(raw_bytes, encoding='utf-8')
|
markdown_text = str(raw_bytes, encoding='utf-8')
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
update
|
||||||
Reference in New Issue
Block a user