Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afde775b17 | ||
|
|
2708c78f8e | ||
|
|
b5de8709de | ||
|
|
ddb30d98f2 | ||
|
|
f10eb64332 | ||
|
|
617daa0e9b | ||
|
|
766bf75dd7 | ||
|
|
57b7082564 | ||
|
|
e1c7e1a0e6 | ||
|
|
69f3df8921 |
@@ -181,3 +181,4 @@ cython_debug/
|
|||||||
*.json
|
*.json
|
||||||
flares-*
|
flares-*
|
||||||
*.flare
|
*.flare
|
||||||
|
*.cfg
|
||||||
@@ -1,3 +1,28 @@
|
|||||||
|
# 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 features an almost complete rewrite of backend files. If you encounter any problems, please raise an issue at [this link.](https://git.research.dezeeuw.ca/tyler/flares/issues/new)
|
||||||
|
- New configuration file has been added! Now your choices of preferences will be saved when the application is closed and re-opened. If the configuration file is missing, a new one will be generated
|
||||||
|
- Recent files and recent projects are now saved and appear under the File menu for quick resuming
|
||||||
|
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to it's default values and remove any recent files
|
||||||
|
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "Show Update Changelog"
|
||||||
|
- Changed the hotkey for "Update optodes in snirf file..." to be F9 instead of F6
|
||||||
|
- Revamped the fOLD channels window. Images containing the pie charts are now interactable! Click whitespace to expand the whole image and click a chart to expand it.
|
||||||
|
- fOLD progress bar when processing now updates the percentages live. Fixes [Issue 76](https://git.research.dezeeuw.ca/tyler/flares/issues/76)
|
||||||
|
- Overall pie charts on an individal and global basis are now genereted. Fixes [Issue 78](https://git.research.dezeeuw.ca/tyler/flares/issues/78)
|
||||||
|
- Brodmann images are now available when examining a pie chart to understand which area is being reported. Fixes [Issue 77](https://git.research.dezeeuw.ca/tyler/flares/issues/77)
|
||||||
|
- Added a new option 'Folding Bypass' to the Preferences Menu. This skips most processing steps and the only analysis option available will be to fold. Parameters on the right will be ignored. Fixes [Issue 75](https://git.research.dezeeuw.ca/tyler/flares/issues/75)
|
||||||
|
- Fixed an issue where the fOLD analysis progress window would go unresponsive before processing participants. Fixes [Issue 45](https://git.research.dezeeuw.ca/tyler/flares/issues/45), Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34)
|
||||||
|
- Added a feature to hover over the 28 stage progress bar and see which state the progress bar is at. Fixes [Issue 74](https://git.research.dezeeuw.ca/tyler/flares/issues/74)
|
||||||
|
- Loading a broken snirf file no longer hangs its processing and can now be removed from the list. Fixes [Issue 73](https://git.research.dezeeuw.ca/tyler/flares/issues/73)
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.4.3
|
||||||
|
|
||||||
|
- Fixed an issue where the fOLD files could not be located
|
||||||
|
- Added better support for updating events from external software
|
||||||
|
|
||||||
|
|
||||||
# Version 1.4.2
|
# Version 1.4.2
|
||||||
|
|
||||||
- Fixed AGE, GENDER, GROUP not visually appearing on a bubble after the metadata has been set. Fixes [Issue 42](https://git.research.dezeeuw.ca/tyler/flares/issues/42)
|
- Fixed AGE, GENDER, GROUP not visually appearing on a bubble after the metadata has been set. Fixes [Issue 42](https://git.research.dezeeuw.ca/tyler/flares/issues/42)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 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 features an almost complete rewrite of backend files. If you encounter any problems, please raise an issue at [this link.](https://git.research.dezeeuw.ca/tyler/flares/issues/new)
|
||||||
|
- New configuration file has been added! Now your choices of preferences will be saved when the application is closed and re-opened. If the configuration file is missing, a new one will be generated
|
||||||
|
- Recent files and recent projects are now saved and appear under the File menu for quick resuming
|
||||||
|
- The new option "Reset to Default Configuration" under the Options menu will reset the configuration file back to it's default values and remove any recent files
|
||||||
|
- A welcome dialog will now display the changelog on first startup and after every update. This popup will only appear once but can be reopened under the Options menu through the button "Show Update Changelog"
|
||||||
|
- Changed the hotkey for "Update optodes in snirf file..." to be F9 instead of F6
|
||||||
|
- Revamped the fOLD channels window. Images containing the pie charts are now interactable! Click whitespace to expand the whole image and click a chart to expand it.
|
||||||
|
- fOLD progress bar when processing now updates the percentages live. Fixes [Issue 76](https://git.research.dezeeuw.ca/tyler/flares/issues/76)
|
||||||
|
- Overall pie charts on an individal and global basis are now genereted. Fixes [Issue 78](https://git.research.dezeeuw.ca/tyler/flares/issues/78)
|
||||||
|
- Brodmann images are now available when examining a pie chart to understand which area is being reported. Fixes [Issue 77](https://git.research.dezeeuw.ca/tyler/flares/issues/77)
|
||||||
|
- Added a new option 'Folding Bypass' to the Preferences Menu. This skips most processing steps and the only analysis option available will be to fold. Parameters on the right will be ignored. Fixes [Issue 75](https://git.research.dezeeuw.ca/tyler/flares/issues/75)
|
||||||
|
- Fixed an issue where the fOLD analysis progress window would go unresponsive before processing participants. Fixes [Issue 45](https://git.research.dezeeuw.ca/tyler/flares/issues/45), Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34)
|
||||||
|
- Added a feature to hover over the 28 stage progress bar and see which state the progress bar is at. Fixes [Issue 74](https://git.research.dezeeuw.ca/tyler/flares/issues/74)
|
||||||
|
- Loading a broken snirf file no longer hangs its processing and can now be removed from the list. Fixes [Issue 73](https://git.research.dezeeuw.ca/tyler/flares/issues/73)
|
||||||
@@ -213,6 +213,8 @@ AGE: int = 25 # Assume 25 if not set from the GUI. This will result in a reason
|
|||||||
GENDER: str = ""
|
GENDER: str = ""
|
||||||
GROUP: str = "Default"
|
GROUP: str = "Default"
|
||||||
|
|
||||||
|
FOLDING_BYP: bool = False
|
||||||
|
|
||||||
# These are parameters that are required for the analysis
|
# These are parameters that are required for the analysis
|
||||||
REQUIRED_KEYS: dict[str, Any] = {
|
REQUIRED_KEYS: dict[str, Any] = {
|
||||||
|
|
||||||
@@ -1487,7 +1489,7 @@ def make_design_matrix(raw_haemo, short_chans):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# 2) Create design matrix
|
# 2) Create design matrix
|
||||||
if SHORT_CHANNEL_REGRESSION:
|
if SHORT_CHANNEL_REGRESSION and not FOLDING_BYP:
|
||||||
design_matrix = make_first_level_design_matrix(
|
design_matrix = make_first_level_design_matrix(
|
||||||
raw=raw_haemo,
|
raw=raw_haemo,
|
||||||
stim_dur=STIM_DUR,
|
stim_dur=STIM_DUR,
|
||||||
@@ -1739,160 +1741,196 @@ def resource_path(relative_path):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def fold_channels(raw: BaseRaw) -> None:
|
# def fold_channels(raw: BaseRaw) -> None:
|
||||||
|
|
||||||
# Locate the fOLD excel files
|
# # Locate the fOLD excel files
|
||||||
|
# if getattr(sys, 'frozen', False):
|
||||||
|
# set_config('MNE_NIRS_FOLD_PATH', resource_path("./mne_data/fOLD/fOLD-public-master/Supplementary")) # type: ignore
|
||||||
|
# else:
|
||||||
|
# path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary"
|
||||||
|
# set_config('MNE_NIRS_FOLD_PATH', resource_path(path)) # type: ignore
|
||||||
|
|
||||||
|
# output = None
|
||||||
|
|
||||||
|
# # List to store the results
|
||||||
|
# landmark_specificity_data: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# # Filter the data to only what we want
|
||||||
|
# hbo_channel_names = cast(list[str], getattr(raw.copy().pick(picks='hbo'), "ch_names")) # type: ignore
|
||||||
|
|
||||||
|
# # Format the output to make it slightly easier to read
|
||||||
|
|
||||||
|
# if True:
|
||||||
|
# num_channels = len(hbo_channel_names)
|
||||||
|
# rows, cols = 4, 7 # 6 rows and 4 columns of pie charts
|
||||||
|
# fig, axes = plt.subplots(rows, cols, figsize=(16, 10), constrained_layout=True)
|
||||||
|
# axes = axes.flatten() # Flatten the axes array for easier indexing
|
||||||
|
|
||||||
|
# # If more pie charts than subplots, create extra subplots
|
||||||
|
# if num_channels > rows * cols:
|
||||||
|
# fig, axes = plt.subplots((num_channels // cols) + 1, cols, figsize=(16, 10), constrained_layout=True)
|
||||||
|
# axes = axes.flatten()
|
||||||
|
|
||||||
|
# # Create a list for consistent color mapping
|
||||||
|
# landmarks = [
|
||||||
|
# "1 - Primary Somatosensory Cortex",
|
||||||
|
# "2 - Primary Somatosensory Cortex",
|
||||||
|
# "3 - Primary Somatosensory Cortex",
|
||||||
|
# "4 - Primary Motor Cortex",
|
||||||
|
# "5 - Somatosensory Association Cortex",
|
||||||
|
# "6 - Pre-Motor and Supplementary Motor Cortex",
|
||||||
|
# "7 - Somatosensory Association Cortex",
|
||||||
|
# "8 - Includes Frontal eye fields",
|
||||||
|
# "9 - Dorsolateral prefrontal cortex",
|
||||||
|
# "10 - Frontopolar area",
|
||||||
|
# "11 - Orbitofrontal area",
|
||||||
|
# "17 - Primary Visual Cortex (V1)",
|
||||||
|
# "18 - Visual Association Cortex (V2)",
|
||||||
|
# "19 - V3",
|
||||||
|
# "20 - Inferior Temporal gyrus",
|
||||||
|
# "21 - Middle Temporal gyrus",
|
||||||
|
# "22 - Superior Temporal Gyrus",
|
||||||
|
# "23 - Ventral Posterior cingulate cortex",
|
||||||
|
# "24 - Ventral Anterior cingulate cortex",
|
||||||
|
# "25 - Subgenual cortex",
|
||||||
|
# "32 - Dorsal anterior cingulate cortex",
|
||||||
|
# "37 - Fusiform gyrus",
|
||||||
|
# "38 - Temporopolar area",
|
||||||
|
# "39 - Angular gyrus, part of Wernicke's area",
|
||||||
|
# "40 - Supramarginal gyrus part of Wernicke's area",
|
||||||
|
# "41 - Primary and Auditory Association Cortex",
|
||||||
|
# "42 - Primary and Auditory Association Cortex",
|
||||||
|
# "43 - Subcentral area",
|
||||||
|
# "44 - pars opercularis, part of Broca's area",
|
||||||
|
# "45 - pars triangularis Broca's area",
|
||||||
|
# "46 - Dorsolateral prefrontal cortex",
|
||||||
|
# "47 - Inferior prefrontal gyrus",
|
||||||
|
# "48 - Retrosubicular area",
|
||||||
|
# "Brain_Outside",
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# cmap1 = plt.get_cmap('tab20') # First 20 colors
|
||||||
|
# cmap2 = plt.get_cmap('tab20b') # Next 20 colors
|
||||||
|
|
||||||
|
# # Combine the colors from both colormaps
|
||||||
|
# colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] # Total 40 colors
|
||||||
|
|
||||||
|
# landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf')))
|
||||||
|
|
||||||
|
# landmark_color_map = {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)}
|
||||||
|
|
||||||
|
# # Iterate over each channel
|
||||||
|
# print(len(hbo_channel_names))
|
||||||
|
|
||||||
|
# for idx, channel_name in enumerate(hbo_channel_names):
|
||||||
|
|
||||||
|
# print(idx, channel_name)
|
||||||
|
# # Run the fOLD on the selected channel
|
||||||
|
# channel_data = raw.copy().pick(picks=channel_name) # type: ignore
|
||||||
|
|
||||||
|
# output = cast(list[DataFrame], fold_channel_specificity_normal(channel_data, interpolate=True, atlas='Brodmann'))
|
||||||
|
|
||||||
|
# # Process each DataFrame that fold_channel_specificity returns
|
||||||
|
# for df_data in output:
|
||||||
|
|
||||||
|
# # Extract the relevant columns
|
||||||
|
# useful_data = df_data[['Landmark', 'Specificity']]
|
||||||
|
|
||||||
|
# # Store the results
|
||||||
|
# landmark_specificity_data.append({
|
||||||
|
# 'Channel': channel_name,
|
||||||
|
# 'Data': useful_data,
|
||||||
|
# })
|
||||||
|
|
||||||
|
|
||||||
|
# # Plot the results
|
||||||
|
# # TODO: Fix this
|
||||||
|
# if True:
|
||||||
|
# unique_landmarks = sorted(useful_data['Landmark'].unique())
|
||||||
|
# color_list = [landmark_color_map[landmark] for landmark in useful_data['Landmark']]
|
||||||
|
|
||||||
|
# # Plot specificity for each channel
|
||||||
|
# ax = axes[idx]
|
||||||
|
|
||||||
|
# labels = [f'{landmark.split(" - ")[0]}' if landmark != 'Brain_Outside' else 'B' for landmark in useful_data['Landmark']]
|
||||||
|
|
||||||
|
# wedges, texts, autotexts = ax.pie(
|
||||||
|
# useful_data['Specificity'],
|
||||||
|
# autopct='%1.1f%%',
|
||||||
|
# startangle=90,
|
||||||
|
# labels=labels,
|
||||||
|
# labeldistance=1.05,
|
||||||
|
# colors=color_list)
|
||||||
|
|
||||||
|
# ax.set_title(f'{channel_name}')
|
||||||
|
# ax.axis('equal')
|
||||||
|
|
||||||
|
# landmark_specificity_data = []
|
||||||
|
|
||||||
|
|
||||||
|
# # TODO: Fix this
|
||||||
|
# if True:
|
||||||
|
# handles = [
|
||||||
|
# plt.Line2D([0], [0], marker='o', color='w', label=landmark, markersize=10,
|
||||||
|
# markerfacecolor=landmark_color_map[landmark])
|
||||||
|
# for landmark in landmarks
|
||||||
|
# ]
|
||||||
|
# n_landmarks = len(landmarks)
|
||||||
|
|
||||||
|
# # Calculate the figure size based on number of rows and columns
|
||||||
|
# fig_width = 5
|
||||||
|
# fig_height = n_landmarks / 4
|
||||||
|
|
||||||
|
# # Create a new figure window for the legend
|
||||||
|
# legend_fig = plt.figure(figsize=(fig_width, fig_height))
|
||||||
|
# legend_axes = legend_fig.add_subplot(111)
|
||||||
|
# legend_axes.axis('off') # Turn off axis for the legend window
|
||||||
|
# legend_axes.legend(handles=handles, loc='center', fontsize=10, title="Landmarks")
|
||||||
|
|
||||||
|
# for ax in axes[len(hbo_channel_names):]:
|
||||||
|
# ax.axis('off')
|
||||||
|
|
||||||
|
# #plt.show()
|
||||||
|
# fig_dict = {"main": fig, "legend": legend_fig}
|
||||||
|
# return convert_fig_dict_to_png_bytes(fig_dict)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def fold_channels(raw: BaseRaw, p_name: str, progress_queue=None) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Runs in background process.
|
||||||
|
Does only heavy math/lookup. Returns data instead of a static image.
|
||||||
|
"""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
set_config('MNE_NIRS_FOLD_PATH', resource_path("/mne_data/fOLD/fOLD-public-master/Supplementary")) # type: ignore
|
set_config('MNE_NIRS_FOLD_PATH', resource_path("./mne_data/fOLD/fOLD-public-master/Supplementary"))
|
||||||
else:
|
else:
|
||||||
path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary"
|
path = os.path.expanduser("~") + "/mne_data/fOLD/fOLD-public-master/Supplementary"
|
||||||
set_config('MNE_NIRS_FOLD_PATH', resource_path(path)) # type: ignore
|
set_config('MNE_NIRS_FOLD_PATH', resource_path(path))
|
||||||
|
|
||||||
output = None
|
hbo_channel_names = cast(list[str], getattr(raw.copy().pick(picks='hbo'), "ch_names"))
|
||||||
|
|
||||||
# List to store the results
|
# Store clean, picklable data lists instead of complex DataFrames
|
||||||
landmark_specificity_data: list[dict[str, Any]] = []
|
channel_results = {}
|
||||||
|
|
||||||
# Filter the data to only what we want
|
step_idx = 0
|
||||||
hbo_channel_names = cast(list[str], getattr(raw.copy().pick(picks='hbo'), "ch_names")) # type: ignore
|
|
||||||
|
|
||||||
# Format the output to make it slightly easier to read
|
|
||||||
|
|
||||||
if True:
|
|
||||||
num_channels = len(hbo_channel_names)
|
|
||||||
rows, cols = 4, 7 # 6 rows and 4 columns of pie charts
|
|
||||||
fig, axes = plt.subplots(rows, cols, figsize=(16, 10), constrained_layout=True)
|
|
||||||
axes = axes.flatten() # Flatten the axes array for easier indexing
|
|
||||||
|
|
||||||
# If more pie charts than subplots, create extra subplots
|
|
||||||
if num_channels > rows * cols:
|
|
||||||
fig, axes = plt.subplots((num_channels // cols) + 1, cols, figsize=(16, 10), constrained_layout=True)
|
|
||||||
axes = axes.flatten()
|
|
||||||
|
|
||||||
# Create a list for consistent color mapping
|
|
||||||
landmarks = [
|
|
||||||
"1 - Primary Somatosensory Cortex",
|
|
||||||
"2 - Primary Somatosensory Cortex",
|
|
||||||
"3 - Primary Somatosensory Cortex",
|
|
||||||
"4 - Primary Motor Cortex",
|
|
||||||
"5 - Somatosensory Association Cortex",
|
|
||||||
"6 - Pre-Motor and Supplementary Motor Cortex",
|
|
||||||
"7 - Somatosensory Association Cortex",
|
|
||||||
"8 - Includes Frontal eye fields",
|
|
||||||
"9 - Dorsolateral prefrontal cortex",
|
|
||||||
"10 - Frontopolar area",
|
|
||||||
"11 - Orbitofrontal area",
|
|
||||||
"17 - Primary Visual Cortex (V1)",
|
|
||||||
"18 - Visual Association Cortex (V2)",
|
|
||||||
"19 - V3",
|
|
||||||
"20 - Inferior Temporal gyrus",
|
|
||||||
"21 - Middle Temporal gyrus",
|
|
||||||
"22 - Superior Temporal Gyrus",
|
|
||||||
"23 - Ventral Posterior cingulate cortex",
|
|
||||||
"24 - Ventral Anterior cingulate cortex",
|
|
||||||
"25 - Subgenual cortex",
|
|
||||||
"32 - Dorsal anterior cingulate cortex",
|
|
||||||
"37 - Fusiform gyrus",
|
|
||||||
"38 - Temporopolar area",
|
|
||||||
"39 - Angular gyrus, part of Wernicke's area",
|
|
||||||
"40 - Supramarginal gyrus part of Wernicke's area",
|
|
||||||
"41 - Primary and Auditory Association Cortex",
|
|
||||||
"42 - Primary and Auditory Association Cortex",
|
|
||||||
"43 - Subcentral area",
|
|
||||||
"44 - pars opercularis, part of Broca's area",
|
|
||||||
"45 - pars triangularis Broca's area",
|
|
||||||
"46 - Dorsolateral prefrontal cortex",
|
|
||||||
"47 - Inferior prefrontal gyrus",
|
|
||||||
"48 - Retrosubicular area",
|
|
||||||
"Brain_Outside",
|
|
||||||
]
|
|
||||||
|
|
||||||
cmap1 = plt.get_cmap('tab20') # First 20 colors
|
|
||||||
cmap2 = plt.get_cmap('tab20b') # Next 20 colors
|
|
||||||
|
|
||||||
# Combine the colors from both colormaps
|
|
||||||
colors = [cmap1(i) for i in range(20)] + [cmap2(i) for i in range(20)] # Total 40 colors
|
|
||||||
|
|
||||||
landmarks.sort(key=lambda x: (int(x.split(" - ")[0]) if x.split(" - ")[0].isdigit() else float('inf')))
|
|
||||||
|
|
||||||
landmark_color_map = {landmark: colors[i % len(colors)] for i, landmark in enumerate(landmarks)}
|
|
||||||
|
|
||||||
# Iterate over each channel
|
|
||||||
print(len(hbo_channel_names))
|
|
||||||
|
|
||||||
for idx, channel_name in enumerate(hbo_channel_names):
|
|
||||||
|
|
||||||
print(idx, channel_name)
|
|
||||||
# Run the fOLD on the selected channel
|
|
||||||
channel_data = raw.copy().pick(picks=channel_name) # type: ignore
|
|
||||||
|
|
||||||
|
for channel_name in hbo_channel_names:
|
||||||
|
channel_data = raw.copy().pick(picks=channel_name)
|
||||||
output = cast(list[DataFrame], fold_channel_specificity_normal(channel_data, interpolate=True, atlas='Brodmann'))
|
output = cast(list[DataFrame], fold_channel_specificity_normal(channel_data, interpolate=True, atlas='Brodmann'))
|
||||||
|
|
||||||
# Process each DataFrame that fold_channel_specificity returns
|
channel_results[channel_name] = []
|
||||||
for df_data in output:
|
for df_data in output:
|
||||||
|
# Extract just raw primitive types so they transfer over process channels flawlessly
|
||||||
|
for _, row in df_data.iterrows():
|
||||||
|
channel_results[channel_name].append({
|
||||||
|
'Landmark': str(row['Landmark']),
|
||||||
|
'Specificity': float(row['Specificity'])
|
||||||
|
})
|
||||||
|
step_idx += 1
|
||||||
|
if progress_queue is not None:
|
||||||
|
progress_queue.put((p_name, step_idx))
|
||||||
|
|
||||||
# Extract the relevant columns
|
# Return raw data dictionary to the result_queue
|
||||||
useful_data = df_data[['Landmark', 'Specificity']]
|
return channel_results
|
||||||
|
|
||||||
# Store the results
|
|
||||||
landmark_specificity_data.append({
|
|
||||||
'Channel': channel_name,
|
|
||||||
'Data': useful_data,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# Plot the results
|
|
||||||
# TODO: Fix this
|
|
||||||
if True:
|
|
||||||
unique_landmarks = sorted(useful_data['Landmark'].unique())
|
|
||||||
color_list = [landmark_color_map[landmark] for landmark in useful_data['Landmark']]
|
|
||||||
|
|
||||||
# Plot specificity for each channel
|
|
||||||
ax = axes[idx]
|
|
||||||
|
|
||||||
labels = [f'{landmark.split(" - ")[0]}' if landmark != 'Brain_Outside' else 'B' for landmark in useful_data['Landmark']]
|
|
||||||
|
|
||||||
wedges, texts, autotexts = ax.pie(
|
|
||||||
useful_data['Specificity'],
|
|
||||||
autopct='%1.1f%%',
|
|
||||||
startangle=90,
|
|
||||||
labels=labels,
|
|
||||||
labeldistance=1.05,
|
|
||||||
colors=color_list)
|
|
||||||
|
|
||||||
ax.set_title(f'{channel_name}')
|
|
||||||
ax.axis('equal')
|
|
||||||
|
|
||||||
landmark_specificity_data = []
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: Fix this
|
|
||||||
if True:
|
|
||||||
handles = [
|
|
||||||
plt.Line2D([0], [0], marker='o', color='w', label=landmark, markersize=10,
|
|
||||||
markerfacecolor=landmark_color_map[landmark])
|
|
||||||
for landmark in landmarks
|
|
||||||
]
|
|
||||||
n_landmarks = len(landmarks)
|
|
||||||
|
|
||||||
# Calculate the figure size based on number of rows and columns
|
|
||||||
fig_width = 5
|
|
||||||
fig_height = n_landmarks / 4
|
|
||||||
|
|
||||||
# Create a new figure window for the legend
|
|
||||||
legend_fig = plt.figure(figsize=(fig_width, fig_height))
|
|
||||||
legend_axes = legend_fig.add_subplot(111)
|
|
||||||
legend_axes.axis('off') # Turn off axis for the legend window
|
|
||||||
legend_axes.legend(handles=handles, loc='center', fontsize=10, title="Landmarks")
|
|
||||||
|
|
||||||
for ax in axes[len(hbo_channel_names):]:
|
|
||||||
ax.axis('off')
|
|
||||||
|
|
||||||
#plt.show()
|
|
||||||
fig_dict = {"main": fig, "legend": legend_fig}
|
|
||||||
return convert_fig_dict_to_png_bytes(fig_dict)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def individual_significance(raw_haemo, glm_est):
|
def individual_significance(raw_haemo, glm_est):
|
||||||
@@ -3939,6 +3977,7 @@ def hr_calc(raw):
|
|||||||
def process_participant(file_path, progress_callback=None):
|
def process_participant(file_path, progress_callback=None):
|
||||||
|
|
||||||
fig_individual: dict[str, Figure] = {}
|
fig_individual: dict[str, Figure] = {}
|
||||||
|
logger.info(f"Folding Bypass: {FOLDING_BYP}")
|
||||||
|
|
||||||
# Step 1: Preprocessing
|
# Step 1: Preprocessing
|
||||||
raw = load_snirf(file_path)
|
raw = load_snirf(file_path)
|
||||||
@@ -3949,7 +3988,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
|
|
||||||
# Step 2: Trimming
|
# Step 2: Trimming
|
||||||
# TODO: Clean this into a method
|
# TODO: Clean this into a method
|
||||||
if TRIM:
|
if TRIM and not FOLDING_BYP:
|
||||||
if hasattr(raw, 'annotations') and len(raw.annotations) > 0:
|
if hasattr(raw, 'annotations') and len(raw.annotations) > 0:
|
||||||
# Get time of first event
|
# Get time of first event
|
||||||
first_event_time = raw.annotations.onset[0]
|
first_event_time = raw.annotations.onset[0]
|
||||||
@@ -3985,7 +4024,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
logger.info("Step 3 Completed.")
|
logger.info("Step 3 Completed.")
|
||||||
|
|
||||||
# Step 4: Short/Long Channels
|
# Step 4: Short/Long Channels
|
||||||
if SHORT_CHANNEL:
|
if SHORT_CHANNEL and not FOLDING_BYP:
|
||||||
short_chans = get_short_channels(raw, max_dist=SHORT_CHANNEL_THRESH)
|
short_chans = get_short_channels(raw, max_dist=SHORT_CHANNEL_THRESH)
|
||||||
fig_short_chans = short_chans.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Short Channels Only", show=False)
|
fig_short_chans = short_chans.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Short Channels Only", show=False)
|
||||||
fig_individual["short"] = fig_short_chans
|
fig_individual["short"] = fig_short_chans
|
||||||
@@ -3996,7 +4035,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
logger.info("Step 4 Completed.")
|
logger.info("Step 4 Completed.")
|
||||||
|
|
||||||
# Step 5: Heart Rate
|
# Step 5: Heart Rate
|
||||||
if HEART_RATE:
|
if HEART_RATE and not FOLDING_BYP:
|
||||||
fig, hr1, hr2, low, high = hr_calc(raw)
|
fig, hr1, hr2, low, high = hr_calc(raw)
|
||||||
fig_individual["PSD"] = fig
|
fig_individual["PSD"] = fig
|
||||||
fig_individual['HeartRate_PSD'] = hr1
|
fig_individual['HeartRate_PSD'] = hr1
|
||||||
@@ -4017,7 +4056,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
|
|
||||||
# Step 6: Scalp Coupling Index
|
# Step 6: Scalp Coupling Index
|
||||||
bad_sci = []
|
bad_sci = []
|
||||||
if SCI:
|
if SCI and not FOLDING_BYP:
|
||||||
if HEART_RATE:
|
if HEART_RATE:
|
||||||
bad_sci, fig_sci_1, fig_sci_2 = calculate_scalp_coupling(raw, low, high)
|
bad_sci, fig_sci_1, fig_sci_2 = calculate_scalp_coupling(raw, low, high)
|
||||||
else:
|
else:
|
||||||
@@ -4029,7 +4068,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
|
|
||||||
# Step 7: Signal to Noise Ratio
|
# Step 7: Signal to Noise Ratio
|
||||||
bad_snr = []
|
bad_snr = []
|
||||||
if SNR:
|
if SNR and not FOLDING_BYP:
|
||||||
bad_snr, fig_snr = calculate_signal_noise_ratio(raw)
|
bad_snr, fig_snr = calculate_signal_noise_ratio(raw)
|
||||||
fig_individual["SNR1"] = fig_snr
|
fig_individual["SNR1"] = fig_snr
|
||||||
if progress_callback: progress_callback(7)
|
if progress_callback: progress_callback(7)
|
||||||
@@ -4037,7 +4076,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
|
|
||||||
# Step 8: Peak Spectral Power
|
# Step 8: Peak Spectral Power
|
||||||
bad_psp = []
|
bad_psp = []
|
||||||
if PSP:
|
if PSP and not FOLDING_BYP:
|
||||||
bad_psp, fig_psp1, fig_psp2 = calculate_peak_power(raw)
|
bad_psp, fig_psp1, fig_psp2 = calculate_peak_power(raw)
|
||||||
fig_individual["PSP1"] = fig_psp1
|
fig_individual["PSP1"] = fig_psp1
|
||||||
fig_individual["PSP2"] = fig_psp2
|
fig_individual["PSP2"] = fig_psp2
|
||||||
@@ -4045,35 +4084,35 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
logger.info("Step 8 Completed.")
|
logger.info("Step 8 Completed.")
|
||||||
|
|
||||||
bad_cv = []
|
bad_cv = []
|
||||||
if CV:
|
if CV and not FOLDING_BYP:
|
||||||
bad_cv, fig_cv = find_bad_channels_cv(raw, cv_threshold=CV_THRESHOLD)
|
bad_cv, fig_cv = find_bad_channels_cv(raw, cv_threshold=CV_THRESHOLD)
|
||||||
fig_individual['cv'] = fig_cv
|
fig_individual['cv'] = fig_cv
|
||||||
if progress_callback: progress_callback(9)
|
if progress_callback: progress_callback(9)
|
||||||
logger.info("Step 9 Completed.")
|
logger.info("Step 9 Completed.")
|
||||||
|
|
||||||
bad_range = []
|
bad_range = []
|
||||||
if MAD:
|
if MAD and not FOLDING_BYP:
|
||||||
bad_range, fig_range = find_bad_channels_range(raw, threshold=MAD_THRESHOLD)
|
bad_range, fig_range = find_bad_channels_range(raw, threshold=MAD_THRESHOLD)
|
||||||
fig_individual['range'] = fig_range
|
fig_individual['range'] = fig_range
|
||||||
if progress_callback: progress_callback(10)
|
if progress_callback: progress_callback(10)
|
||||||
logger.info("Step 10 Completed.")
|
logger.info("Step 10 Completed.")
|
||||||
|
|
||||||
bad_noise = []
|
bad_noise = []
|
||||||
if PSD_NOISE:
|
if PSD_NOISE and not FOLDING_BYP:
|
||||||
bad_noise, fig_noise = detect_high_freq_noise(raw, db_limit=DB_LIMIT, freq_div=TARGET_FREQ_DIV)
|
bad_noise, fig_noise = detect_high_freq_noise(raw, db_limit=DB_LIMIT, freq_div=TARGET_FREQ_DIV)
|
||||||
fig_individual['psd_noise'] = fig_noise
|
fig_individual['psd_noise'] = fig_noise
|
||||||
if progress_callback: progress_callback(11)
|
if progress_callback: progress_callback(11)
|
||||||
logger.info("Step 11 Completed.")
|
logger.info("Step 11 Completed.")
|
||||||
|
|
||||||
bad_disp = []
|
bad_disp = []
|
||||||
if CHANNEL_VAR:
|
if CHANNEL_VAR and not FOLDING_BYP:
|
||||||
bad_disp, fig_disp = detect_sensor_displacement(raw, threshold_ratio=CHANNEL_THRESH)
|
bad_disp, fig_disp = detect_sensor_displacement(raw, threshold_ratio=CHANNEL_THRESH)
|
||||||
fig_individual['displacement'] = fig_disp
|
fig_individual['displacement'] = fig_disp
|
||||||
if progress_callback: progress_callback(12)
|
if progress_callback: progress_callback(12)
|
||||||
logger.info("Step 12 Completed.")
|
logger.info("Step 12 Completed.")
|
||||||
|
|
||||||
# Step 9: Bad Channels Handling
|
# Step 9: Bad Channels Handling
|
||||||
if BAD_CHANNELS_HANDLING != "None":
|
if BAD_CHANNELS_HANDLING != "None" and not FOLDING_BYP:
|
||||||
raw, fig_dropped, fig_raw_before, bad_channels = mark_bads(raw, bad_sci, bad_snr, bad_psp, bad_cv, bad_range, bad_noise, bad_disp)
|
raw, fig_dropped, fig_raw_before, bad_channels = mark_bads(raw, bad_sci, bad_snr, bad_psp, bad_cv, bad_range, bad_noise, bad_disp)
|
||||||
if fig_dropped and fig_raw_before is not None:
|
if fig_dropped and fig_raw_before is not None:
|
||||||
fig_individual["fig2"] = fig_dropped
|
fig_individual["fig2"] = fig_dropped
|
||||||
@@ -4108,7 +4147,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
logger.info("Step 14 Completed.")
|
logger.info("Step 14 Completed.")
|
||||||
|
|
||||||
# Step 11: Temporal Derivative Distribution Repair Filtering
|
# Step 11: Temporal Derivative Distribution Repair Filtering
|
||||||
if TDDR:
|
if TDDR and not FOLDING_BYP:
|
||||||
raw_od = temporal_derivative_distribution_repair(raw_od)
|
raw_od = temporal_derivative_distribution_repair(raw_od)
|
||||||
fig_raw_od_tddr = raw_od.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After TDDR (Motion Correction)", show=False)
|
fig_raw_od_tddr = raw_od.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After TDDR (Motion Correction)", show=False)
|
||||||
fig_individual["TDDR"] = fig_raw_od_tddr
|
fig_individual["TDDR"] = fig_raw_od_tddr
|
||||||
@@ -4116,7 +4155,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
logger.info("Step 15 Completed.")
|
logger.info("Step 15 Completed.")
|
||||||
|
|
||||||
# Step 12: Wavelet Filtering
|
# Step 12: Wavelet Filtering
|
||||||
if WAVELET:
|
if WAVELET and not FOLDING_BYP:
|
||||||
raw_od, fig = calculate_and_apply_wavelet(raw_od)
|
raw_od, fig = calculate_and_apply_wavelet(raw_od)
|
||||||
fig_individual["Wavelet"] = fig
|
fig_individual["Wavelet"] = fig
|
||||||
if progress_callback: progress_callback(16)
|
if progress_callback: progress_callback(16)
|
||||||
@@ -4130,7 +4169,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
logger.info("Step 17 Completed.")
|
logger.info("Step 17 Completed.")
|
||||||
|
|
||||||
# Step 14: Enhance Negative Correlation
|
# Step 14: Enhance Negative Correlation
|
||||||
if ENHANCE_NEGATIVE_CORRELATION:
|
if ENHANCE_NEGATIVE_CORRELATION and not FOLDING_BYP:
|
||||||
raw_haemo = enhance_negative_correlation(raw_haemo)
|
raw_haemo = enhance_negative_correlation(raw_haemo)
|
||||||
fig_raw_haemo_enc = raw_haemo.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Enhance Negative Correlation", show=False)
|
fig_raw_haemo_enc = raw_haemo.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="Enhance Negative Correlation", show=False)
|
||||||
fig_individual["ENC"] = fig_raw_haemo_enc
|
fig_individual["ENC"] = fig_raw_haemo_enc
|
||||||
@@ -4138,7 +4177,7 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
logger.info("Step 18 Completed.")
|
logger.info("Step 18 Completed.")
|
||||||
|
|
||||||
# Step 15: Filter
|
# Step 15: Filter
|
||||||
if FILTER:
|
if FILTER and not FOLDING_BYP:
|
||||||
raw_haemo, fig_filter, fig_raw_haemo_filter = filter_the_data(raw_haemo)
|
raw_haemo, fig_filter, fig_raw_haemo_filter = filter_the_data(raw_haemo)
|
||||||
fig_individual["filter1"] = fig_filter
|
fig_individual["filter1"] = fig_filter
|
||||||
fig_individual["filter2"] = fig_raw_haemo_filter
|
fig_individual["filter2"] = fig_raw_haemo_filter
|
||||||
@@ -4146,16 +4185,18 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
logger.info("Step 19 Completed.")
|
logger.info("Step 19 Completed.")
|
||||||
|
|
||||||
# Step 16: Extracting Events
|
# Step 16: Extracting Events
|
||||||
events, event_dict = events_from_annotations(raw_haemo)
|
if not FOLDING_BYP:
|
||||||
fig_events = plot_events(events, event_id=event_dict, sfreq=raw_haemo.info["sfreq"], show=False)
|
events, event_dict = events_from_annotations(raw_haemo)
|
||||||
fig_individual["events"] = fig_events
|
fig_events = plot_events(events, event_id=event_dict, sfreq=raw_haemo.info["sfreq"], show=False)
|
||||||
|
fig_individual["events"] = fig_events
|
||||||
if progress_callback: progress_callback(20)
|
if progress_callback: progress_callback(20)
|
||||||
logger.info("Step 20 Completed.")
|
logger.info("Step 20 Completed.")
|
||||||
|
|
||||||
# Step 17: Epoch Calculations
|
# Step 17: Epoch Calculations
|
||||||
epochs, fig_epochs = epochs_calculations(raw_haemo, events, event_dict)
|
if not FOLDING_BYP:
|
||||||
for name, fig in fig_epochs:
|
epochs, fig_epochs = epochs_calculations(raw_haemo, events, event_dict)
|
||||||
fig_individual[f"epochs_{name}"] = fig
|
for name, fig in fig_epochs:
|
||||||
|
fig_individual[f"epochs_{name}"] = fig
|
||||||
if progress_callback: progress_callback(21)
|
if progress_callback: progress_callback(21)
|
||||||
logger.info("Step 21 Completed.")
|
logger.info("Step 21 Completed.")
|
||||||
|
|
||||||
@@ -4274,6 +4315,8 @@ def process_participant(file_path, progress_callback=None):
|
|||||||
# Step 24: Finishing Up
|
# Step 24: Finishing Up
|
||||||
fig_bytes = convert_fig_dict_to_png_bytes(fig_individual)
|
fig_bytes = convert_fig_dict_to_png_bytes(fig_individual)
|
||||||
|
|
||||||
|
if FOLDING_BYP:
|
||||||
|
epochs = None
|
||||||
sanitize_paths_for_pickle(raw_haemo, epochs)
|
sanitize_paths_for_pickle(raw_haemo, epochs)
|
||||||
|
|
||||||
if progress_callback: progress_callback(28)
|
if progress_callback: progress_callback(28)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M400-280h160v-80H400v80Zm0-160h280v-80H400v80ZM280-600h400v-80H280v80Zm200 120ZM265-80q-79 0-134.5-55.5T75-270q0-57 29.5-102t77.5-68H80v-80h240v240h-80v-97q-37 8-61 38t-24 69q0 46 32.5 78t77.5 32v80Zm135-40v-80h360v-560H200v160h-80v-160q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H400Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-80q-155 0-269-103T82-440h81q15 121 105.5 200.5T480-160q134 0 227-93t93-227q0-134-93-227t-227-93q-86 0-159.5 42.5T204-640h116v80H88q29-140 139-230t253-90q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm112-232L440-464v-216h80v184l128 128-56 56Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M520-330v-60h160v60H520Zm60 210v-50h-60v-60h60v-50h60v160h-60Zm100-50v-60h160v60H680Zm40-110v-160h60v50h60v60h-60v50h-60Zm111-280h-83q-26-88-99-144t-169-56q-117 0-198.5 81.5T200-480q0 72 32.5 132t87.5 98v-110h80v240H160v-80h94q-62-50-98-122.5T120-480q0-75 28.5-140.5t77-114q48.5-48.5 114-77T480-840q129 0 226.5 79.5T831-560Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 449 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M451.5-251.5Q440-263 440-280t11.5-28.5Q463-320 480-320t28.5 11.5Q520-297 520-280t-11.5 28.5Q497-240 480-240t-28.5-11.5ZM440-360v-161l80 80v81h-80Zm433 158L655-419 480-720l-47 80-58-58 105-182 393 678Zm-695 2h469L350-497 178-200ZM819-28l-92-92H40l252-435L27-820l57-57L876-85l-57 57ZM499-348Zm45-181Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 423 B |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
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,306 @@
|
|||||||
|
"""
|
||||||
|
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}")
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""
|
||||||
|
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,164 @@
|
|||||||
|
"""
|
||||||
|
Filename: participant.py
|
||||||
|
Description: Participant analysis window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMessageBox, QPushButton, QScrollArea, QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import Qt, QSize
|
||||||
|
from PySide6.QtGui import QPixmap
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import ClickableLabel, FlaresBaseWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, fig_bytes_dict):
|
||||||
|
super().__init__("ParticipantViewer")
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
|
self.setWindowTitle(f"Participant Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.fig_bytes_dict = fig_bytes_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)
|
||||||
|
|
||||||
|
first_fig_dict = next(iter(self.fig_bytes_dict.values()))
|
||||||
|
image_label_items = list(first_fig_dict.keys())
|
||||||
|
|
||||||
|
self.image_index_dropdown = self._create_multiselect_dropdown(image_label_items)
|
||||||
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
||||||
|
|
||||||
|
self.submit_button = QPushButton("Submit")
|
||||||
|
self.submit_button.clicked.connect(self.show_selected_images)
|
||||||
|
|
||||||
|
self.top_bar.addWidget(QLabel("Participants:"))
|
||||||
|
self.top_bar.addWidget(self.participant_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.save_button = QPushButton("Save Displayed Images")
|
||||||
|
self.save_button.clicked.connect(self.save_displayed_images)
|
||||||
|
self.top_bar.addWidget(self.save_button)
|
||||||
|
|
||||||
|
self.showMaximized()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def show_selected_images(self):
|
||||||
|
# Clear previous images
|
||||||
|
while self.grid_layout.count():
|
||||||
|
item = self.grid_layout.takeAt(0)
|
||||||
|
widget = item.widget()
|
||||||
|
if widget:
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
# Map from display names back to file paths
|
||||||
|
selected_file_paths = []
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
# Find file_path by matching display name
|
||||||
|
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_labels = self._get_checked_items(self.image_index_dropdown)
|
||||||
|
|
||||||
|
row, col = 0, 0
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
fig_list = self.fig_bytes_dict.get(file_path, [])
|
||||||
|
participant_label = self.participant_map[file_path]
|
||||||
|
for label in selected_labels:
|
||||||
|
fig_bytes = fig_list.get(label)
|
||||||
|
if not fig_bytes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
full_pixmap = QPixmap()
|
||||||
|
full_pixmap.loadFromData(fig_bytes)
|
||||||
|
|
||||||
|
thumbnail_pixmap = full_pixmap.scaled(
|
||||||
|
self.thumb_size,
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation
|
||||||
|
)
|
||||||
|
|
||||||
|
container = QWidget()
|
||||||
|
hlayout = QHBoxLayout(container)
|
||||||
|
hlayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
hlayout.setSpacing(0)
|
||||||
|
hlayout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
image_label = ClickableLabel(full_pixmap, thumbnail_pixmap)
|
||||||
|
image_label.setToolTip(f"{participant_label}\n{label}")
|
||||||
|
hlayout.addWidget(image_label)
|
||||||
|
|
||||||
|
self.grid_layout.addWidget(container, row, col)
|
||||||
|
|
||||||
|
col += 1
|
||||||
|
if col >= 6:
|
||||||
|
col = 0
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Update dropdown labels after display
|
||||||
|
self.update_participant_dropdown_label()
|
||||||
|
self.update_image_index_dropdown_label()
|
||||||
|
|
||||||
|
|
||||||
|
def save_displayed_images(self):
|
||||||
|
# Ensure the folder exists
|
||||||
|
save_dir = Path("individual_images")
|
||||||
|
save_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
||||||
|
selected_image_labels = self._get_checked_items(self.image_index_dropdown)
|
||||||
|
|
||||||
|
for display_name in selected_display_names:
|
||||||
|
# Match display name to file path
|
||||||
|
for file_path, short_label in self.participant_map.items():
|
||||||
|
expected_display = f"{short_label} ({os.path.basename(file_path)})"
|
||||||
|
if display_name == expected_display:
|
||||||
|
fig_dict = self.fig_bytes_dict.get(file_path, {})
|
||||||
|
for label in selected_image_labels:
|
||||||
|
if label not in fig_dict:
|
||||||
|
continue
|
||||||
|
fig_bytes = fig_dict[label]
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"{os.path.basename(file_path)}_{label}_{timestamp}.png"
|
||||||
|
output_path = save_dir / filename
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(fig_bytes)
|
||||||
|
break # file_path matched; stop loop
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Save Complete", f"Images saved to {save_dir.resolve()}")
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Filename: participantbrain.py
|
||||||
|
Description: Participant brain 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
|
||||||
|
from PySide6.QtCore import QSize
|
||||||
|
|
||||||
|
from src.shared.flaresbasewidget import FlaresBaseWidget, ParameterInputDialog
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantBrainViewerWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, cha_dict):
|
||||||
|
super().__init__("ParticipantBrainViewer")
|
||||||
|
self.setWindowTitle(f"Participant Brain Viewer - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_dict
|
||||||
|
self.cha_dict = cha_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.event_dropdown = QComboBox()
|
||||||
|
self.event_dropdown.addItem("<None Selected>")
|
||||||
|
|
||||||
|
|
||||||
|
self.index_texts = [
|
||||||
|
"0 (Brain Landmarks)",
|
||||||
|
"1 (Brain Activity Visualization)",
|
||||||
|
# "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("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
|
||||||
|
|
||||||
|
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
|
||||||
|
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:
|
||||||
|
raise Exception("How did we get here?")
|
||||||
|
|
||||||
|
cha = self.cha_dict.get(file_path)
|
||||||
|
|
||||||
|
for idx in selected_indexes:
|
||||||
|
if idx == 0:
|
||||||
|
|
||||||
|
params = param_values.get(idx, {})
|
||||||
|
show_optodes = params.get("show_optodes", None)
|
||||||
|
show_brodmann = params.get("show_brodmann", None)
|
||||||
|
|
||||||
|
if show_optodes is None or show_brodmann is None:
|
||||||
|
print(f"Missing parameters for index {idx}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
flares.brain_landmarks_3d(haemo_obj, show_optodes, show_brodmann)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Filename: participantfunctionalconnectivity.py
|
||||||
|
Description: Participant 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 ParticipantFunctionalConnectivityWidget(FlaresBaseWidget):
|
||||||
|
def __init__(self, haemo_dict, epochs_dict):
|
||||||
|
super().__init__("FunctionalConnectivityWidget")
|
||||||
|
self.setWindowTitle(f"Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
|
||||||
|
self.haemo_dict = haemo_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. "
|
||||||
|
"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.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)
|
||||||
|
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("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
|
||||||
|
|
||||||
|
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
|
||||||
|
for file_path in selected_file_paths:
|
||||||
|
haemo_obj = self.haemo_dict.get(file_path)
|
||||||
|
epochs_obj = self.epochs_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:
|
||||||
|
raise Exception("How did we get here?")
|
||||||
|
|
||||||
|
|
||||||
|
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.functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
elif idx == 1:
|
||||||
|
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.functional_connectivity_envelope(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
elif idx == 2:
|
||||||
|
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.functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
|
||||||
|
|
||||||
|
elif idx == 3:
|
||||||
|
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.functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"No method defined for index {idx}")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Filename: shareddata.py
|
||||||
|
Description: Shared constants and methods for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
CURRENT_VERSION = "1.5.0"
|
||||||
|
APP_NAME = "flares"
|
||||||
|
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"
|
||||||
|
PLATFORM_NAME = platform.system().lower()
|
||||||
|
CHANGELOG_URL = "https://git.research.dezeeuw.ca/tyler/flares/raw/branch/main/changelog_major.md"
|
||||||
|
WIKI_URL = "https://git.research.dezeeuw.ca/tyler/flares/wiki"
|
||||||
|
|
||||||
|
PIPELINE_STAGES = [
|
||||||
|
"Preprocessing",
|
||||||
|
"Trimming",
|
||||||
|
"Verify Optode Placement",
|
||||||
|
"Short/Long Channels",
|
||||||
|
"Heart Rate",
|
||||||
|
"Scalp Coupling Index",
|
||||||
|
"Signal to Noise Ratio",
|
||||||
|
"Peak Spectral Power",
|
||||||
|
"Cross Validation",
|
||||||
|
"Median Absolute Deviation",
|
||||||
|
"Power Spectral Density Noise",
|
||||||
|
"Channel Variance",
|
||||||
|
"Bad Channels Handling",
|
||||||
|
"Optical Density",
|
||||||
|
"Temporal Derivative Distribution Repair Filtering",
|
||||||
|
"Wavelet Filtering",
|
||||||
|
"Haemoglobin Concentration",
|
||||||
|
"Enhance Negative Correlation",
|
||||||
|
"Filter",
|
||||||
|
"Extracting Events",
|
||||||
|
"Epoch Calculations",
|
||||||
|
"Design Matrix",
|
||||||
|
"General Linear Model",
|
||||||
|
"Generate GLM Results",
|
||||||
|
"Generate Channel Significance",
|
||||||
|
"Generate Channel, Region of Interest, and Contrast Results",
|
||||||
|
"Compute Contrast Results",
|
||||||
|
"Finishing Up"
|
||||||
|
]
|
||||||
|
|
||||||
|
def resource_path(relative_path):
|
||||||
|
"""
|
||||||
|
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
||||||
|
"""
|
||||||
|
|
||||||
|
if hasattr(sys, '_MEIPASS'):
|
||||||
|
# PyInstaller bundle path
|
||||||
|
base_path = sys._MEIPASS
|
||||||
|
else:
|
||||||
|
base_path = os.path.abspath(".")
|
||||||
|
|
||||||
|
return os.path.join(base_path, relative_path)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Filename: about.py
|
||||||
|
Description: About window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
|
||||||
|
|
||||||
|
class AboutWindow(QWidget):
|
||||||
|
"""
|
||||||
|
Simple About window displaying basic application information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
self.setWindowTitle(f"About {APP_NAME.upper()}")
|
||||||
|
self.resize(250, 100)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
label = QLabel(f"About {APP_NAME.upper()}", self)
|
||||||
|
label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", 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}")
|
||||||
|
|
||||||
|
layout.addWidget(label)
|
||||||
|
layout.addWidget(label2)
|
||||||
|
layout.addWidget(label3)
|
||||||
|
layout.addWidget(label4)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Filename: terminal.py
|
||||||
|
Description: Terminal window for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalWindow(QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
|
||||||
|
|
||||||
|
self.output_area = QTextEdit()
|
||||||
|
self.output_area.setReadOnly(True)
|
||||||
|
|
||||||
|
self.input_line = QLineEdit()
|
||||||
|
self.input_line.returnPressed.connect(self.handle_command)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.addWidget(self.output_area)
|
||||||
|
layout.addWidget(self.input_line)
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
self.commands = {
|
||||||
|
"hello": self.cmd_hello,
|
||||||
|
"help": self.cmd_help,
|
||||||
|
"version": self.cmd_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_command(self):
|
||||||
|
command_text = self.input_line.text()
|
||||||
|
self.input_line.clear()
|
||||||
|
|
||||||
|
self.output_area.append(f"> {command_text}")
|
||||||
|
parts = command_text.strip().split()
|
||||||
|
if not parts:
|
||||||
|
return
|
||||||
|
|
||||||
|
command_name = parts[0]
|
||||||
|
args = parts[1:]
|
||||||
|
|
||||||
|
func = self.commands.get(command_name)
|
||||||
|
if func:
|
||||||
|
try:
|
||||||
|
result = func(*args)
|
||||||
|
if result:
|
||||||
|
self.output_area.append(str(result))
|
||||||
|
except Exception as e:
|
||||||
|
self.output_area.append(f"[Error] {e}")
|
||||||
|
else:
|
||||||
|
self.output_area.append(f"[Unknown command] '{command_name}'")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_hello(self, *args):
|
||||||
|
return "Hello from the terminal!"
|
||||||
|
|
||||||
|
def cmd_help(self, *args):
|
||||||
|
return f"Available commands: {', '.join(self.commands.keys())}"
|
||||||
|
|
||||||
|
def cmd_version(self, *args):
|
||||||
|
return f"{CURRENT_VERSION}"
|
||||||
@@ -0,0 +1,855 @@
|
|||||||
|
"""
|
||||||
|
Filename: updateevents.py
|
||||||
|
Description: Methods to update snirf events for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from enum import Enum, auto
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from mne import Annotations
|
||||||
|
from mne.io import read_raw_snirf
|
||||||
|
from mne_nirs.io import write_raw_snirf
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class EventUpdateMode(Enum):
|
||||||
|
WRITE_SNIRF = auto() # destructive
|
||||||
|
WRITE_JSON = auto() # non-destructive
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateEventsWindow(QWidget):
|
||||||
|
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
|
||||||
|
self.mode = mode
|
||||||
|
self.caller = caller or self.__class__.__name__
|
||||||
|
self.setWindowTitle(f"Update event markers - {APP_NAME.upper()}")
|
||||||
|
self.resize(760, 200)
|
||||||
|
|
||||||
|
print("INIT MODE:", mode)
|
||||||
|
|
||||||
|
self.label_file_a = QLabel("SNIRF file:")
|
||||||
|
self.line_edit_file_a = QLineEdit()
|
||||||
|
self.line_edit_file_a.setReadOnly(True)
|
||||||
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||||
|
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
||||||
|
|
||||||
|
self.label_file_b = QLabel("BORIS file:")
|
||||||
|
self.line_edit_file_b = QLineEdit()
|
||||||
|
self.line_edit_file_b.setReadOnly(True)
|
||||||
|
self.btn_browse_b = QPushButton("Browse .boris")
|
||||||
|
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
||||||
|
|
||||||
|
self.label_suffix = QLabel("Filename in BORIS project file:")
|
||||||
|
self.combo_suffix = QComboBox()
|
||||||
|
self.combo_suffix.setEditable(False)
|
||||||
|
self.combo_suffix.currentIndexChanged.connect(self.on_observation_selected)
|
||||||
|
|
||||||
|
self.label_events = QLabel("Events in selected observation:")
|
||||||
|
self.combo_events = QComboBox()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
||||||
|
self.combo_snirf_events = QComboBox()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
|
self.btn_clear = QPushButton("Clear")
|
||||||
|
self.btn_go = QPushButton("Go")
|
||||||
|
self.btn_clear.clicked.connect(self.clear_files)
|
||||||
|
self.btn_go.clicked.connect(self.go_action)
|
||||||
|
|
||||||
|
# ---
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
self.description = QLabel()
|
||||||
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
|
self.description.setOpenExternalLinks(True)
|
||||||
|
|
||||||
|
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
||||||
|
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||||
|
"The software <a href='https://www.boris.unito.it/'>BORIS</a> is used to add these events to video files, and these events can be applied to the snirf file <br>"
|
||||||
|
"selected below by selecting the correct BORIS observation and time syncing it to an event that it shares with the snirf file.")
|
||||||
|
|
||||||
|
layout.addWidget(self.description)
|
||||||
|
|
||||||
|
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
|
||||||
|
|
||||||
|
file_a_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# Help button on the left
|
||||||
|
help_btn_a = QPushButton("?")
|
||||||
|
help_btn_a.setFixedWidth(25)
|
||||||
|
help_btn_a.setToolTip(help_text_a)
|
||||||
|
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
||||||
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
|
# Container for label + line_edit + browse button with tooltip
|
||||||
|
file_a_container = QWidget()
|
||||||
|
file_a_container_layout = QHBoxLayout()
|
||||||
|
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_a_container_layout.addWidget(self.label_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.line_edit_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.btn_browse_a)
|
||||||
|
file_a_container.setLayout(file_a_container_layout)
|
||||||
|
file_a_container.setToolTip(help_text_a)
|
||||||
|
|
||||||
|
file_a_layout.addWidget(file_a_container)
|
||||||
|
layout.addLayout(file_a_layout)
|
||||||
|
|
||||||
|
help_text_b = "Provide a .boris project file that contains events for this participant."
|
||||||
|
|
||||||
|
file_b_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_b = QPushButton("?")
|
||||||
|
help_btn_b.setFixedWidth(25)
|
||||||
|
help_btn_b.setToolTip(help_text_b)
|
||||||
|
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
||||||
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
|
file_b_container = QWidget()
|
||||||
|
file_b_container_layout = QHBoxLayout()
|
||||||
|
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_b_container_layout.addWidget(self.label_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.line_edit_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.btn_browse_b)
|
||||||
|
file_b_container.setLayout(file_b_container_layout)
|
||||||
|
file_b_container.setToolTip(help_text_b)
|
||||||
|
|
||||||
|
file_b_layout.addWidget(file_b_container)
|
||||||
|
layout.addLayout(file_b_layout)
|
||||||
|
|
||||||
|
help_text_suffix = "This participant from the .boris project file matches the .snirf file."
|
||||||
|
|
||||||
|
suffix_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_suffix = QPushButton("?")
|
||||||
|
help_btn_suffix.setFixedWidth(25)
|
||||||
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||||
|
suffix_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
|
suffix_container = QWidget()
|
||||||
|
suffix_container_layout = QHBoxLayout()
|
||||||
|
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
suffix_container_layout.addWidget(self.label_suffix)
|
||||||
|
suffix_container_layout.addWidget(self.combo_suffix)
|
||||||
|
suffix_container.setLayout(suffix_container_layout)
|
||||||
|
suffix_container.setToolTip(help_text_suffix)
|
||||||
|
|
||||||
|
suffix_layout.addWidget(suffix_container)
|
||||||
|
layout.addLayout(suffix_layout)
|
||||||
|
|
||||||
|
help_text_suffix = "The events extracted from the BORIS project file for the selected observation."
|
||||||
|
|
||||||
|
suffix2_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_suffix = QPushButton("?")
|
||||||
|
help_btn_suffix.setFixedWidth(25)
|
||||||
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||||
|
suffix2_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
|
suffix2_container = QWidget()
|
||||||
|
suffix2_container_layout = QHBoxLayout()
|
||||||
|
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
suffix2_container_layout.addWidget(self.label_events)
|
||||||
|
suffix2_container_layout.addWidget(self.combo_events)
|
||||||
|
suffix2_container.setLayout(suffix2_container_layout)
|
||||||
|
suffix2_container.setToolTip(help_text_suffix)
|
||||||
|
|
||||||
|
suffix2_layout.addWidget(suffix2_container)
|
||||||
|
layout.addLayout(suffix2_layout)
|
||||||
|
|
||||||
|
snirf_events_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
||||||
|
help_btn_snirf_events = QPushButton("?")
|
||||||
|
help_btn_snirf_events.setFixedWidth(25)
|
||||||
|
help_btn_snirf_events.setToolTip(help_text_snirf_events)
|
||||||
|
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
|
||||||
|
snirf_events_layout.addWidget(help_btn_snirf_events)
|
||||||
|
|
||||||
|
snirf_events_container = QWidget()
|
||||||
|
snirf_events_container_layout = QHBoxLayout()
|
||||||
|
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
snirf_events_container_layout.addWidget(self.label_snirf_events)
|
||||||
|
snirf_events_container_layout.addWidget(self.combo_snirf_events)
|
||||||
|
snirf_events_container.setLayout(snirf_events_container_layout)
|
||||||
|
snirf_events_container.setToolTip(help_text_snirf_events)
|
||||||
|
|
||||||
|
snirf_events_layout.addWidget(snirf_events_container)
|
||||||
|
layout.addLayout(snirf_events_layout)
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
buttons_layout.addWidget(self.btn_clear)
|
||||||
|
buttons_layout.addWidget(self.btn_go)
|
||||||
|
layout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def show_help_popup(self, text):
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
|
msg.setText(text)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def browse_file_a(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_a.setText(file_path)
|
||||||
|
try:
|
||||||
|
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||||
|
raw = read_raw_snirf(file_path, preload=False)
|
||||||
|
annotations = raw.annotations
|
||||||
|
|
||||||
|
# Build individual event entries
|
||||||
|
event_entries = []
|
||||||
|
for onset, description in zip(annotations.onset, annotations.description):
|
||||||
|
event_str = f"{description} @ {onset:.3f}s"
|
||||||
|
event_entries.append(event_str)
|
||||||
|
|
||||||
|
if not event_entries:
|
||||||
|
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.addItems(event_entries)
|
||||||
|
self.combo_snirf_events.setEnabled(True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
|
def browse_file_b(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_b.setText(file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.boris_data = data
|
||||||
|
|
||||||
|
observation_keys = self.extract_boris_observation_keys(data)
|
||||||
|
self.combo_suffix.clear()
|
||||||
|
self.combo_suffix.addItems(observation_keys)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
|
||||||
|
|
||||||
|
def extract_boris_observation_keys(self, data):
|
||||||
|
if "observations" not in data:
|
||||||
|
raise KeyError("Missing 'observations' key in BORIS file.")
|
||||||
|
|
||||||
|
observations = data["observations"]
|
||||||
|
if not isinstance(observations, dict):
|
||||||
|
raise TypeError("'observations' must be a dictionary.")
|
||||||
|
|
||||||
|
return list(observations.keys())
|
||||||
|
|
||||||
|
def on_observation_selected(self):
|
||||||
|
selected_obs = self.combo_suffix.currentText()
|
||||||
|
if not selected_obs or not hasattr(self, 'boris_data'):
|
||||||
|
self.combo_events.clear()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
events = self.boris_data["observations"][selected_obs]["events"]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
self.combo_events.clear()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
event_entries = []
|
||||||
|
for event in events:
|
||||||
|
if isinstance(event, list) and len(event) >= 3:
|
||||||
|
timestamp = event[0]
|
||||||
|
label = event[2]
|
||||||
|
display = f"{label} @ {timestamp:.3f}"
|
||||||
|
event_entries.append(display)
|
||||||
|
|
||||||
|
self.combo_events.clear()
|
||||||
|
self.combo_events.addItems(event_entries)
|
||||||
|
self.combo_events.setEnabled(bool(event_entries))
|
||||||
|
|
||||||
|
def clear_files(self):
|
||||||
|
self.line_edit_file_a.clear()
|
||||||
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
|
def go_action(self):
|
||||||
|
|
||||||
|
file_a = self.line_edit_file_a.text()
|
||||||
|
suffix = "flare"
|
||||||
|
|
||||||
|
if not hasattr(self, "boris_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
||||||
|
QMessageBox.warning(self, "Missing data", "Please make sure a BORIS and SNIRF event are selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract BORIS anchor
|
||||||
|
try:
|
||||||
|
boris_label, boris_time_str = self.combo_events.currentText().split(" @ ")
|
||||||
|
boris_anchor_time = float(boris_time_str.replace("s", "").strip())
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "BORIS Event Error", f"Could not parse BORIS anchor event:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract SNIRF anchor
|
||||||
|
try:
|
||||||
|
snirf_label, snirf_time_str = self.combo_snirf_events.currentText().split(" @ ")
|
||||||
|
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor event:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
time_shift = snirf_anchor_time - boris_anchor_time
|
||||||
|
|
||||||
|
selected_obs = self.combo_suffix.currentText()
|
||||||
|
if not selected_obs or selected_obs not in self.boris_data["observations"]:
|
||||||
|
QMessageBox.warning(self, "Invalid selection", "Selected observation not found in BORIS file.")
|
||||||
|
return
|
||||||
|
|
||||||
|
boris_events = self.boris_data["observations"][selected_obs].get("events", [])
|
||||||
|
if not boris_events:
|
||||||
|
QMessageBox.warning(self, "No BORIS events", "No events found in selected BORIS observation.")
|
||||||
|
return
|
||||||
|
|
||||||
|
snirf_path = self.line_edit_file_a.text()
|
||||||
|
if not snirf_path:
|
||||||
|
QMessageBox.warning(self, "No SNIRF file", "Please select a SNIRF file.")
|
||||||
|
return
|
||||||
|
|
||||||
|
boris_obs = self.boris_data["observations"][selected_obs]
|
||||||
|
|
||||||
|
# --- Extract videos + delays ---
|
||||||
|
files = boris_obs.get("file", {})
|
||||||
|
offsets = boris_obs.get("media_info", {}).get("offset", {})
|
||||||
|
|
||||||
|
videos = {}
|
||||||
|
for key, path in files.items():
|
||||||
|
if path: # only include videos that exist
|
||||||
|
delay = offsets.get(key, 0.0) # default 0 if missing
|
||||||
|
videos[key] = {"file": path, "delay": delay}
|
||||||
|
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||||
|
|
||||||
|
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
||||||
|
# Open save dialog for SNIRF
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||||
|
suggested_name = f"{base_name}_{suffix}.snirf"
|
||||||
|
save_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save SNIRF File As",
|
||||||
|
suggested_name,
|
||||||
|
"SNIRF Files (*.snirf)"
|
||||||
|
)
|
||||||
|
if not save_path:
|
||||||
|
print("SNIRF save cancelled.")
|
||||||
|
return
|
||||||
|
if not save_path.lower().endswith(".snirf"):
|
||||||
|
save_path += ".snirf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = read_raw_snirf(file_a, preload=True)
|
||||||
|
|
||||||
|
# --- Align BORIS events to SNIRF ---
|
||||||
|
boris_events = boris_obs.get("events", [])
|
||||||
|
onsets, durations, descriptions = [], [], []
|
||||||
|
open_events = {} # label -> list of start times
|
||||||
|
label_counts = {}
|
||||||
|
used_times = set()
|
||||||
|
sfreq = raw.info['sfreq']
|
||||||
|
min_shift = 1.0 / sfreq
|
||||||
|
max_attempts = 10
|
||||||
|
|
||||||
|
for event in boris_events:
|
||||||
|
if not isinstance(event, list) or len(event) < 3:
|
||||||
|
continue
|
||||||
|
event_time = event[0]
|
||||||
|
label = event[2]
|
||||||
|
count = label_counts.get(label, 0) + 1
|
||||||
|
label_counts[label] = count
|
||||||
|
|
||||||
|
if label not in open_events:
|
||||||
|
open_events[label] = []
|
||||||
|
|
||||||
|
if count % 2 == 1:
|
||||||
|
open_events[label].append(event_time)
|
||||||
|
else:
|
||||||
|
if open_events[label]:
|
||||||
|
start_time = open_events[label].pop(0)
|
||||||
|
duration = event_time - start_time
|
||||||
|
if duration <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
adjusted_time = start_time + time_shift
|
||||||
|
attempts = 0
|
||||||
|
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||||
|
adjusted_time += min_shift
|
||||||
|
attempts += 1
|
||||||
|
if attempts == max_attempts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
adjusted_time = round(adjusted_time, 6)
|
||||||
|
used_times.add(adjusted_time)
|
||||||
|
onsets.append(adjusted_time)
|
||||||
|
durations.append(duration)
|
||||||
|
descriptions.append(label)
|
||||||
|
# Handle unmatched starts
|
||||||
|
for label, starts in open_events.items():
|
||||||
|
for start_time in starts:
|
||||||
|
adjusted_time = start_time + time_shift
|
||||||
|
attempts = 0
|
||||||
|
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
|
||||||
|
adjusted_time += min_shift
|
||||||
|
attempts += 1
|
||||||
|
if attempts == max_attempts:
|
||||||
|
continue
|
||||||
|
adjusted_time = round(adjusted_time, 6)
|
||||||
|
used_times.add(adjusted_time)
|
||||||
|
onsets.append(adjusted_time)
|
||||||
|
durations.append(0.0)
|
||||||
|
descriptions.append(label)
|
||||||
|
|
||||||
|
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
|
||||||
|
raw.set_annotations(new_annotations)
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
||||||
|
|
||||||
|
elif self.mode == EventUpdateMode.WRITE_JSON:
|
||||||
|
# Open save dialog for JSON
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||||
|
suggested_name = f"{base_name}_{suffix}_alignment.json"
|
||||||
|
save_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save Event Alignment JSON As",
|
||||||
|
suggested_name,
|
||||||
|
"JSON Files (*.json)"
|
||||||
|
)
|
||||||
|
if not save_path:
|
||||||
|
print("JSON save cancelled.")
|
||||||
|
return
|
||||||
|
if not save_path.lower().endswith(".json"):
|
||||||
|
save_path += ".json"
|
||||||
|
|
||||||
|
# Build JSON dict
|
||||||
|
json_data = {
|
||||||
|
"observation": selected_obs,
|
||||||
|
"snirf_anchor": {"label": snirf_label, "time": snirf_anchor_time},
|
||||||
|
"boris_anchor": {"label": boris_label, "time": boris_anchor_time},
|
||||||
|
"time_shift": time_shift,
|
||||||
|
"videos": videos
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write JSON
|
||||||
|
try:
|
||||||
|
with open(save_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(json_data, f, indent=4)
|
||||||
|
QMessageBox.information(self, "Success", f"Event alignment saved to:\n{save_path}")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_optode_positions(self, file_a, file_b, save_path):
|
||||||
|
|
||||||
|
fiducials = {}
|
||||||
|
ch_positions = {}
|
||||||
|
|
||||||
|
# Read the lines from the optode file
|
||||||
|
with open(file_b, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.strip():
|
||||||
|
# Split by the semicolon and convert to meters
|
||||||
|
ch_name, coords_str = line.split(":")
|
||||||
|
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
|
||||||
|
|
||||||
|
# The key we have is a fiducial
|
||||||
|
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
|
||||||
|
fiducials[ch_name.lower()] = coords
|
||||||
|
|
||||||
|
# The key we have is a source or detector
|
||||||
|
else:
|
||||||
|
ch_positions[ch_name.upper()] = coords
|
||||||
|
|
||||||
|
# Create montage with updated coords in head space
|
||||||
|
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
|
||||||
|
|
||||||
|
# Read the SNIRF file, set the montage, and write it back
|
||||||
|
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||||
|
raw = read_raw_snirf(file_a, preload=True)
|
||||||
|
raw.set_montage(initial_montage)
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_events_to_snirf(self, raw, new_annotations, save_path):
|
||||||
|
raw.set_annotations(new_annotations)
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
|
||||||
|
def _write_event_mapping_json(
|
||||||
|
self,
|
||||||
|
file_a,
|
||||||
|
file_b,
|
||||||
|
selected_obs,
|
||||||
|
snirf_anchor,
|
||||||
|
boris_anchor,
|
||||||
|
time_shift,
|
||||||
|
mapped_events,
|
||||||
|
save_path
|
||||||
|
):
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"source": {
|
||||||
|
"called_from": self.caller,
|
||||||
|
"snirf_file": os.path.basename(file_a),
|
||||||
|
"boris_file": os.path.basename(file_b),
|
||||||
|
"observation": selected_obs
|
||||||
|
},
|
||||||
|
"alignment": {
|
||||||
|
"snirf_anchor": snirf_anchor,
|
||||||
|
"boris_anchor": boris_anchor,
|
||||||
|
"time_shift_seconds": time_shift
|
||||||
|
},
|
||||||
|
"events": mapped_events,
|
||||||
|
"created_at": datetime.utcnow().isoformat() + "Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(save_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, indent=2)
|
||||||
|
|
||||||
|
return save_path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateEventsBlazesWindow(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
|
||||||
|
self.mode = mode
|
||||||
|
self.caller = caller or self.__class__.__name__
|
||||||
|
self.setWindowTitle("Update event markers (BLAZES)")
|
||||||
|
self.resize(760, 200)
|
||||||
|
|
||||||
|
self.label_file_a = QLabel("SNIRF file:")
|
||||||
|
self.line_edit_file_a = QLineEdit()
|
||||||
|
self.line_edit_file_a.setReadOnly(True)
|
||||||
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||||
|
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
||||||
|
|
||||||
|
self.label_file_b = QLabel("BLAZES file:")
|
||||||
|
self.line_edit_file_b = QLineEdit()
|
||||||
|
self.line_edit_file_b.setReadOnly(True)
|
||||||
|
self.btn_browse_b = QPushButton("Browse .blaze")
|
||||||
|
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
||||||
|
|
||||||
|
self.label_events = QLabel("Events in selected blazes file:")
|
||||||
|
self.combo_events = QComboBox()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
self.label_snirf_events = QLabel("Events in SNIRF file:")
|
||||||
|
self.combo_snirf_events = QComboBox()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
|
self.btn_clear = QPushButton("Clear")
|
||||||
|
self.btn_go = QPushButton("Go")
|
||||||
|
self.btn_clear.clicked.connect(self.clear_files)
|
||||||
|
self.btn_go.clicked.connect(self.go_action)
|
||||||
|
|
||||||
|
# ---
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
self.description = QLabel()
|
||||||
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
|
self.description.setOpenExternalLinks(True)
|
||||||
|
|
||||||
|
self.description.setText("The events that are present in a snirf file may not be the events that are to be studied and examined.<br>"
|
||||||
|
"Utilizing different software and video recordings, it is easy enough to see when an action actually occured in a file.<br>"
|
||||||
|
"The software <a href='https://git.research.dezeeuw.ca/tyler/blazes/'>BLAZES</a> is used to create these events in video files, and these events can be applied to the snirf file <br>"
|
||||||
|
"selected below by time syncing it to an event that it shares with the snirf file.")
|
||||||
|
|
||||||
|
layout.addWidget(self.description)
|
||||||
|
|
||||||
|
help_text_a = "Select the SNIRF (.snirf) file to update with new event markers."
|
||||||
|
|
||||||
|
file_a_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# Help button on the left
|
||||||
|
help_btn_a = QPushButton("?")
|
||||||
|
help_btn_a.setFixedWidth(25)
|
||||||
|
help_btn_a.setToolTip(help_text_a)
|
||||||
|
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
||||||
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
|
# Container for label + line_edit + browse button with tooltip
|
||||||
|
file_a_container = QWidget()
|
||||||
|
file_a_container_layout = QHBoxLayout()
|
||||||
|
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_a_container_layout.addWidget(self.label_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.line_edit_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.btn_browse_a)
|
||||||
|
file_a_container.setLayout(file_a_container_layout)
|
||||||
|
file_a_container.setToolTip(help_text_a)
|
||||||
|
|
||||||
|
file_a_layout.addWidget(file_a_container)
|
||||||
|
layout.addLayout(file_a_layout)
|
||||||
|
|
||||||
|
help_text_b = "Provide a .blaze output file that contains events for this participant."
|
||||||
|
|
||||||
|
file_b_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_b = QPushButton("?")
|
||||||
|
help_btn_b.setFixedWidth(25)
|
||||||
|
help_btn_b.setToolTip(help_text_b)
|
||||||
|
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
||||||
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
|
file_b_container = QWidget()
|
||||||
|
file_b_container_layout = QHBoxLayout()
|
||||||
|
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_b_container_layout.addWidget(self.label_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.line_edit_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.btn_browse_b)
|
||||||
|
file_b_container.setLayout(file_b_container_layout)
|
||||||
|
file_b_container.setToolTip(help_text_b)
|
||||||
|
|
||||||
|
file_b_layout.addWidget(file_b_container)
|
||||||
|
layout.addLayout(file_b_layout)
|
||||||
|
|
||||||
|
help_text_suffix = "The events extracted from the blaze file."
|
||||||
|
|
||||||
|
suffix2_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_suffix = QPushButton("?")
|
||||||
|
help_btn_suffix.setFixedWidth(25)
|
||||||
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||||
|
suffix2_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
|
suffix2_container = QWidget()
|
||||||
|
suffix2_container_layout = QHBoxLayout()
|
||||||
|
suffix2_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
suffix2_container_layout.addWidget(self.label_events)
|
||||||
|
suffix2_container_layout.addWidget(self.combo_events)
|
||||||
|
suffix2_container.setLayout(suffix2_container_layout)
|
||||||
|
suffix2_container.setToolTip(help_text_suffix)
|
||||||
|
|
||||||
|
suffix2_layout.addWidget(suffix2_container)
|
||||||
|
layout.addLayout(suffix2_layout)
|
||||||
|
|
||||||
|
snirf_events_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_text_snirf_events = "The event markers extracted from the SNIRF file."
|
||||||
|
help_btn_snirf_events = QPushButton("?")
|
||||||
|
help_btn_snirf_events.setFixedWidth(25)
|
||||||
|
help_btn_snirf_events.setToolTip(help_text_snirf_events)
|
||||||
|
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
|
||||||
|
snirf_events_layout.addWidget(help_btn_snirf_events)
|
||||||
|
|
||||||
|
snirf_events_container = QWidget()
|
||||||
|
snirf_events_container_layout = QHBoxLayout()
|
||||||
|
snirf_events_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
snirf_events_container_layout.addWidget(self.label_snirf_events)
|
||||||
|
snirf_events_container_layout.addWidget(self.combo_snirf_events)
|
||||||
|
snirf_events_container.setLayout(snirf_events_container_layout)
|
||||||
|
snirf_events_container.setToolTip(help_text_snirf_events)
|
||||||
|
|
||||||
|
snirf_events_layout.addWidget(snirf_events_container)
|
||||||
|
layout.addLayout(snirf_events_layout)
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
buttons_layout.addWidget(self.btn_clear)
|
||||||
|
buttons_layout.addWidget(self.btn_go)
|
||||||
|
layout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
||||||
|
def show_help_popup(self, text):
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
|
msg.setText(text)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def browse_file_a(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_a.setText(file_path)
|
||||||
|
try:
|
||||||
|
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
|
||||||
|
raw = read_raw_snirf(file_path, preload=False)
|
||||||
|
annotations = raw.annotations
|
||||||
|
|
||||||
|
# Build individual event entries
|
||||||
|
event_entries = []
|
||||||
|
for onset, description in zip(annotations.onset, annotations.description):
|
||||||
|
event_str = f"{description} @ {onset:.3f}s"
|
||||||
|
event_entries.append(event_str)
|
||||||
|
|
||||||
|
if not event_entries:
|
||||||
|
QMessageBox.information(self, "No Events", "No events found in SNIRF file.")
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.addItems(event_entries)
|
||||||
|
self.combo_snirf_events.setEnabled(True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Could not read SNIRF file with MNE:\n{str(e)}")
|
||||||
|
self.combo_snirf_events.clear()
|
||||||
|
self.combo_snirf_events.setEnabled(False)
|
||||||
|
|
||||||
|
def browse_file_b(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_b.setText(file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.json_data = data
|
||||||
|
|
||||||
|
obs_keys = self.extract_json_observation_strings(data)
|
||||||
|
self.combo_events.clear()
|
||||||
|
if obs_keys:
|
||||||
|
self.combo_events.addItems(obs_keys)
|
||||||
|
self.combo_events.setEnabled(True)
|
||||||
|
else:
|
||||||
|
QMessageBox.information(self, "No Events", "No events found in JSON file.")
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError, KeyError, TypeError) as e:
|
||||||
|
QMessageBox.warning(self, "Error", f"Failed to parse JSON file:\n{e}")
|
||||||
|
self.combo_events.clear()
|
||||||
|
self.combo_events.setEnabled(False)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json_observation_strings(self, data):
|
||||||
|
if "events" not in data:
|
||||||
|
raise KeyError("Missing 'events' key in JSON file.")
|
||||||
|
|
||||||
|
event_strings = []
|
||||||
|
|
||||||
|
# The new format is a flat list chronologically ordered
|
||||||
|
for event in data["events"]:
|
||||||
|
track_name = event.get("track_name", "Unknown")
|
||||||
|
onset = event.get("start_sec", 0.0)
|
||||||
|
|
||||||
|
# Formatting to match your SNIRF style: "Event Name @ 0.000s"
|
||||||
|
display_str = f"{track_name} @ {onset:.3f}s"
|
||||||
|
event_strings.append(display_str)
|
||||||
|
|
||||||
|
return event_strings
|
||||||
|
|
||||||
|
|
||||||
|
def clear_files(self):
|
||||||
|
self.line_edit_file_a.clear()
|
||||||
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def go_action(self):
|
||||||
|
file_a = self.line_edit_file_a.text()
|
||||||
|
file_b = self.line_edit_file_b.text()
|
||||||
|
suffix = APP_NAME
|
||||||
|
|
||||||
|
if not hasattr(self, "json_data") or self.combo_events.count() == 0 or self.combo_snirf_events.count() == 0:
|
||||||
|
QMessageBox.warning(self, "Missing data", "Please make sure a JSON and SNIRF event are selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_text = self.combo_events.currentText()
|
||||||
|
_, json_time_str = json_text.split(" @ ")
|
||||||
|
json_anchor_time = float(json_time_str.replace("s", "").strip())
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "JSON Event Error", f"Could not parse JSON anchor:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
snirf_text = self.combo_snirf_events.currentText()
|
||||||
|
_, snirf_time_str = snirf_text.split(" @ ")
|
||||||
|
snirf_anchor_time = float(snirf_time_str.replace("s", "").strip())
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "SNIRF Event Error", f"Could not parse SNIRF anchor:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
time_shift = snirf_anchor_time - json_anchor_time
|
||||||
|
|
||||||
|
onsets, durations, descriptions = [], [], []
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
events_list = self.json_data.get("events", [])
|
||||||
|
|
||||||
|
for event in events_list:
|
||||||
|
track_name = event.get("track_name", "Unknown")
|
||||||
|
clean_name = track_name.replace("AI: ", "").strip()
|
||||||
|
|
||||||
|
original_start = event.get("start_sec", 0.0)
|
||||||
|
original_end = event.get("end_sec", original_start)
|
||||||
|
duration = original_end - original_start
|
||||||
|
|
||||||
|
# FILTER: Minimum 0.1s duration
|
||||||
|
if duration < 0.1:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply shift
|
||||||
|
adjusted_onset = original_start + time_shift
|
||||||
|
|
||||||
|
onsets.append(round(adjusted_onset, 6))
|
||||||
|
durations.append(round(duration, 6))
|
||||||
|
descriptions.append(clean_name)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Track Error", f"Failed to process tracks: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not onsets:
|
||||||
|
QMessageBox.warning(self, "No Data", f"No events met the 0.1s threshold. (Skipped {skipped_count})")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.mode == EventUpdateMode.WRITE_SNIRF:
|
||||||
|
suggested_name = f"{os.path.splitext(os.path.basename(file_a))[0]}_{suffix}.snirf"
|
||||||
|
save_path, _ = QFileDialog.getSaveFileName(self, "Save SNIRF", suggested_name, "SNIRF Files (*.snirf)")
|
||||||
|
|
||||||
|
if not save_path: return
|
||||||
|
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = read_raw_snirf(file_a, preload=True)
|
||||||
|
|
||||||
|
# Create annotations
|
||||||
|
new_annotations = Annotations(
|
||||||
|
onset=onsets,
|
||||||
|
duration=durations,
|
||||||
|
description=descriptions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace existing annotations with the new aligned JSON tracks
|
||||||
|
raw.set_annotations(new_annotations)
|
||||||
|
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
|
QMessageBox.information(self, "Success",
|
||||||
|
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""
|
||||||
|
Filename: updateoptodes.py
|
||||||
|
Description: Methods to update optode locations for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from mne.io import read_raw_snirf
|
||||||
|
from mne_nirs.io import write_raw_snirf
|
||||||
|
from mne.channels import make_dig_montage
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateOptodesWindow(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}")
|
||||||
|
self.resize(760, 200)
|
||||||
|
|
||||||
|
self.label_file_a = QLabel("SNIRF file:")
|
||||||
|
self.line_edit_file_a = QLineEdit()
|
||||||
|
self.line_edit_file_a.setReadOnly(True)
|
||||||
|
self.btn_browse_a = QPushButton("Browse .snirf")
|
||||||
|
self.btn_browse_a.clicked.connect(self.browse_file_a)
|
||||||
|
|
||||||
|
self.label_file_b = QLabel("Text file:")
|
||||||
|
self.line_edit_file_b = QLineEdit()
|
||||||
|
self.line_edit_file_b.setReadOnly(True)
|
||||||
|
self.btn_browse_b = QPushButton("Browse .txt/.xlsx")
|
||||||
|
self.btn_browse_b.clicked.connect(self.browse_file_b)
|
||||||
|
|
||||||
|
self.label_suffix = QLabel("Suffix to append to filename:")
|
||||||
|
self.line_edit_suffix = QLineEdit()
|
||||||
|
self.line_edit_suffix.setText("flare")
|
||||||
|
|
||||||
|
self.btn_clear = QPushButton("Clear")
|
||||||
|
self.btn_go = QPushButton("Go")
|
||||||
|
self.btn_clear.clicked.connect(self.clear_files)
|
||||||
|
self.btn_go.clicked.connect(self.go_action)
|
||||||
|
|
||||||
|
# ---
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
self.description = QLabel()
|
||||||
|
self.description.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
|
self.description.setOpenExternalLinks(False) # Handle the click internally
|
||||||
|
|
||||||
|
self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.<br>"
|
||||||
|
"This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.<br>"
|
||||||
|
"Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>"
|
||||||
|
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>"
|
||||||
|
"An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>. Currently only .xlsx files directly exported from a<br>"
|
||||||
|
"Polhemus system are supported.")
|
||||||
|
|
||||||
|
self.description.linkActivated.connect(self.handle_link_click)
|
||||||
|
layout.addWidget(self.description)
|
||||||
|
|
||||||
|
help_text_a = "Select the SNIRF (.snirf) file to update with new optode positions."
|
||||||
|
|
||||||
|
file_a_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
# Help button on the left
|
||||||
|
help_btn_a = QPushButton("?")
|
||||||
|
help_btn_a.setFixedWidth(25)
|
||||||
|
help_btn_a.setToolTip(help_text_a)
|
||||||
|
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
|
||||||
|
file_a_layout.addWidget(help_btn_a)
|
||||||
|
|
||||||
|
# Container for label + line_edit + browse button with tooltip
|
||||||
|
file_a_container = QWidget()
|
||||||
|
file_a_container_layout = QHBoxLayout()
|
||||||
|
file_a_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_a_container_layout.addWidget(self.label_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.line_edit_file_a)
|
||||||
|
file_a_container_layout.addWidget(self.btn_browse_a)
|
||||||
|
file_a_container.setLayout(file_a_container_layout)
|
||||||
|
file_a_container.setToolTip(help_text_a)
|
||||||
|
|
||||||
|
file_a_layout.addWidget(file_a_container)
|
||||||
|
layout.addLayout(file_a_layout)
|
||||||
|
|
||||||
|
help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates, or a .xlsx file from a Polhemius system."
|
||||||
|
|
||||||
|
file_b_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_b = QPushButton("?")
|
||||||
|
help_btn_b.setFixedWidth(25)
|
||||||
|
help_btn_b.setToolTip(help_text_b)
|
||||||
|
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
|
||||||
|
file_b_layout.addWidget(help_btn_b)
|
||||||
|
|
||||||
|
file_b_container = QWidget()
|
||||||
|
file_b_container_layout = QHBoxLayout()
|
||||||
|
file_b_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
file_b_container_layout.addWidget(self.label_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.line_edit_file_b)
|
||||||
|
file_b_container_layout.addWidget(self.btn_browse_b)
|
||||||
|
file_b_container.setLayout(file_b_container_layout)
|
||||||
|
file_b_container.setToolTip(help_text_b)
|
||||||
|
|
||||||
|
file_b_layout.addWidget(file_b_container)
|
||||||
|
layout.addLayout(file_b_layout)
|
||||||
|
|
||||||
|
|
||||||
|
help_text_suffix = "This text will be appended to the original filename when saving. Default is 'flare'."
|
||||||
|
|
||||||
|
suffix_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
help_btn_suffix = QPushButton("?")
|
||||||
|
help_btn_suffix.setFixedWidth(25)
|
||||||
|
help_btn_suffix.setToolTip(help_text_suffix)
|
||||||
|
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
|
||||||
|
suffix_layout.addWidget(help_btn_suffix)
|
||||||
|
|
||||||
|
suffix_container = QWidget()
|
||||||
|
suffix_container_layout = QHBoxLayout()
|
||||||
|
suffix_container_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
suffix_container_layout.addWidget(self.label_suffix)
|
||||||
|
suffix_container_layout.addWidget(self.line_edit_suffix)
|
||||||
|
suffix_container.setLayout(suffix_container_layout)
|
||||||
|
suffix_container.setToolTip(help_text_suffix)
|
||||||
|
|
||||||
|
suffix_layout.addWidget(suffix_container)
|
||||||
|
layout.addLayout(suffix_layout)
|
||||||
|
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
buttons_layout.addWidget(self.btn_clear)
|
||||||
|
buttons_layout.addWidget(self.btn_go)
|
||||||
|
layout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def show_help_popup(self, text):
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||||
|
msg.setText(text)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def handle_link_click(self, link):
|
||||||
|
if link == "custom_link":
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle("Example Digitization File")
|
||||||
|
|
||||||
|
text = "nz: -1.91 85.175 -31.1525\n" \
|
||||||
|
"rpa: 80.3825 -17.1925 -57.2775\n" \
|
||||||
|
"lpa: -81.815 -17.1925 -57.965\n" \
|
||||||
|
"d1: 0.01 -97.5175 62.5875\n" \
|
||||||
|
"d2: 25.125 -103.415 45.045\n" \
|
||||||
|
"d3: 49.095 -97.9025 30.2075\n" \
|
||||||
|
"s1: 0.01 -112.43 32.595\n" \
|
||||||
|
"s2: 30.325 -84.3125 71.8975\n" \
|
||||||
|
"s3: 0.01 -70.6875 89.0925\n"
|
||||||
|
msg.setText(text)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def browse_file_a(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_a.setText(file_path)
|
||||||
|
|
||||||
|
def browse_file_b(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)")
|
||||||
|
if file_path:
|
||||||
|
self.line_edit_file_b.setText(file_path)
|
||||||
|
|
||||||
|
def clear_files(self):
|
||||||
|
self.line_edit_file_a.clear()
|
||||||
|
self.line_edit_file_b.clear()
|
||||||
|
|
||||||
|
def go_action(self):
|
||||||
|
file_a = self.line_edit_file_a.text()
|
||||||
|
file_b = self.line_edit_file_b.text()
|
||||||
|
suffix = self.line_edit_suffix.text().strip() or "flare"
|
||||||
|
|
||||||
|
if not file_a:
|
||||||
|
QMessageBox.critical(self, "Missing File", "Please select a SNIRF file.")
|
||||||
|
return
|
||||||
|
if not file_b:
|
||||||
|
QMessageBox.critical(self, "Missing File", "Please select a TXT file.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get original filename without extension
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_a))[0]
|
||||||
|
suggested_name = f"{base_name}_{suffix}.snirf"
|
||||||
|
|
||||||
|
# Open save dialog with default name
|
||||||
|
save_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save SNIRF File As",
|
||||||
|
suggested_name,
|
||||||
|
"SNIRF Files (*.snirf)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not save_path:
|
||||||
|
print("Save cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure .snirf extension
|
||||||
|
if not save_path.lower().endswith(".snirf"):
|
||||||
|
save_path += ".snirf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.update_optode_positions(file_a=file_a, file_b=file_b, save_path=save_path)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Error", f"Failed to write file:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}")
|
||||||
|
|
||||||
|
def update_optode_positions(self, file_a, file_b, save_path):
|
||||||
|
|
||||||
|
fiducials = {}
|
||||||
|
ch_positions = {}
|
||||||
|
|
||||||
|
extension = Path(file_b).suffix
|
||||||
|
|
||||||
|
# Read the lines from the optode file
|
||||||
|
if extension == '.txt':
|
||||||
|
with open(file_b, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.strip():
|
||||||
|
# Split by the semicolon and convert to meters
|
||||||
|
ch_name, coords_str = line.split(":")
|
||||||
|
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
|
||||||
|
|
||||||
|
# The key we have is a fiducial
|
||||||
|
if ch_name.lower() in ['lpa', 'nz', 'rpa']:
|
||||||
|
fiducials[ch_name.lower()] = coords
|
||||||
|
|
||||||
|
# The key we have is a source or detector
|
||||||
|
else:
|
||||||
|
ch_positions[ch_name.upper()] = coords
|
||||||
|
|
||||||
|
elif extension == '.xlsx':
|
||||||
|
|
||||||
|
# TODO: Bad! Why assume sheet1 has the contents?
|
||||||
|
df = pd.read_excel(file_b, sheet_name='Sheet1')
|
||||||
|
|
||||||
|
def _get_block_data(df, block_id, row_mapping, scale=0.001):
|
||||||
|
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
|
||||||
|
# 1. Isolate and clean
|
||||||
|
block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
|
||||||
|
block = block.apply(pd.to_numeric, errors='coerce')
|
||||||
|
|
||||||
|
# 2. Extract into dictionary based on mapping
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# If row_mapping is a dict (like {0: 'nz'}), use it directly
|
||||||
|
if isinstance(row_mapping, dict):
|
||||||
|
for row_idx, key in row_mapping.items():
|
||||||
|
if row_idx < len(block):
|
||||||
|
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
|
||||||
|
|
||||||
|
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
|
||||||
|
elif isinstance(row_mapping, str):
|
||||||
|
for i in range(len(block)):
|
||||||
|
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Identify blocks
|
||||||
|
is_empty = df.isnull().all(axis=1)
|
||||||
|
df['block_id'] = is_empty.cumsum()
|
||||||
|
clean_df = df[~is_empty].copy()
|
||||||
|
|
||||||
|
# Process Block 2: Landmarks
|
||||||
|
fiducials = _get_block_data(clean_df, 2, {0: 'nz', 2: 'rpa', 3: 'lpa'})
|
||||||
|
|
||||||
|
# Process Block 3: D-Points
|
||||||
|
d_points = _get_block_data(clean_df, 3, 'D')
|
||||||
|
|
||||||
|
# Process Block 4: S-Points
|
||||||
|
s_points = _get_block_data(clean_df, 4, 'S')
|
||||||
|
|
||||||
|
ch_positions = {**d_points, **s_points}
|
||||||
|
|
||||||
|
# Create montage with updated coords in head space
|
||||||
|
initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
|
||||||
|
|
||||||
|
# Read the SNIRF file, set the montage, and write it back
|
||||||
|
raw = read_raw_snirf(file_a, preload=True)
|
||||||
|
raw.set_montage(initial_montage)
|
||||||
|
write_raw_snirf(raw, save_path)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Filename: userguide.py
|
||||||
|
Description: User guide for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES, WIKI_URL
|
||||||
|
|
||||||
|
|
||||||
|
class UserGuideWindow(QWidget):
|
||||||
|
"""
|
||||||
|
Simple User Guide window displaying basic information on how to use the software.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent (QWidget, optional): Parent widget of this window. Defaults to None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent, Qt.WindowType.Window)
|
||||||
|
self.setWindowTitle(f"User Guide - {APP_NAME.upper()}")
|
||||||
|
self.resize(250, 100)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
label = QLabel("Progress Bar Stages:", self)
|
||||||
|
label2_text = "\n".join(f"Stage {idx + 1}: {name}" for idx, name in enumerate(PIPELINE_STAGES)) + "\n"
|
||||||
|
label2 = QLabel(label2_text, self)
|
||||||
|
|
||||||
|
label3 = QLabel(f"For more information, visit the Git wiki page <a href='{WIKI_URL}'>here</a>.", self)
|
||||||
|
label3.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
label3.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
||||||
|
label3.setOpenExternalLinks(True)
|
||||||
|
layout.addWidget(label)
|
||||||
|
layout.addWidget(label2)
|
||||||
|
layout.addWidget(label3)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Filename: viewerlauncher.py
|
||||||
|
Description: Analysis options launcher for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
|
||||||
|
from src.analysis.exportcsv import ExportDataAsCSVViewerWidget
|
||||||
|
from src.analysis.group import GroupViewerWidget
|
||||||
|
from src.analysis.groupbrain import GroupBrainViewerWidget
|
||||||
|
from src.analysis.groupfunctionalconnectivity import GroupFunctionalConnectivityWidget
|
||||||
|
from src.analysis.participant import ParticipantViewerWidget
|
||||||
|
from src.analysis.participantbrain import ParticipantBrainViewerWidget
|
||||||
|
from src.analysis.participantfoldchannels import ParticipantFoldChannelsWidget
|
||||||
|
from src.analysis.participantfunctionalconnectivity import ParticipantFunctionalConnectivityWidget
|
||||||
|
from src.shared.shareddata import APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
|
||||||
|
|
||||||
|
group_dict = {
|
||||||
|
file_path: config.get("GROUP", "Unknown")
|
||||||
|
for file_path, config in config_dict.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def launch(func, btn, *args):
|
||||||
|
func(*args)
|
||||||
|
self._trigger_success(btn)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
btn1 = QPushButton("Open Participant Viewer")
|
||||||
|
btn1.clicked.connect(lambda: launch(self.open_participant_viewer, btn1, haemo_dict, fig_bytes_dict))
|
||||||
|
btn1.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
btn2 = QPushButton("Open Participant Brain Viewer")
|
||||||
|
btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict))
|
||||||
|
btn2.setEnabled(not folding_bypass)
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Temporarily adds a green checkmark to the button text."""
|
||||||
|
original_text = button.text()
|
||||||
|
button.setText(f"{original_text} ✔")
|
||||||
|
button.setStyleSheet("color: green; font-weight: bold;")
|
||||||
|
|
||||||
|
# Revert after 1 second
|
||||||
|
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
|
||||||
|
|
||||||
|
def _revert_button(self, button, original_text):
|
||||||
|
button.setText(original_text)
|
||||||
|
button.setStyleSheet("")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Filename: welcome.py
|
||||||
|
Description: Welcome dialog for FLARES
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton
|
||||||
|
from PySide6.QtGui import QDesktopServices, QIcon
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest
|
||||||
|
|
||||||
|
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomeDialog(QDialog):
|
||||||
|
def __init__(self, parent=None, direct=True, first=False):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(f"What's New - {APP_NAME.upper()}")
|
||||||
|
self.setMinimumSize(550, 450)
|
||||||
|
self.resize(800, 500)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
header_layout = QHBoxLayout()
|
||||||
|
logo_label = QLabel(self)
|
||||||
|
|
||||||
|
# NOTE: might not work on mac and need the icns file
|
||||||
|
logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48))
|
||||||
|
if first:
|
||||||
|
title_label = QLabel(f"<h2>Welcome to {APP_NAME.upper()}!</h2>", self)
|
||||||
|
elif direct:
|
||||||
|
title_label = QLabel(f"<h2>{APP_NAME.upper()} has been sucessfully updated to version {CURRENT_VERSION}!</h2>", self)
|
||||||
|
else:
|
||||||
|
title_label = QLabel(f"<h2>{APP_NAME.upper()} is currently running version {CURRENT_VERSION}.</h2>", self)
|
||||||
|
|
||||||
|
header_layout.addWidget(logo_label)
|
||||||
|
header_layout.addWidget(title_label)
|
||||||
|
header_layout.addStretch()
|
||||||
|
layout.addLayout(header_layout)
|
||||||
|
|
||||||
|
self.text_browser = QTextBrowser(self)
|
||||||
|
self.text_browser.setHtml("<p style='color: gray;'>Loading latest updates from server...</p>")
|
||||||
|
|
||||||
|
# Ensure links open in the default web browser and not in this window
|
||||||
|
self.text_browser.setOpenLinks(False)
|
||||||
|
self.text_browser.anchorClicked.connect(QDesktopServices.openUrl)
|
||||||
|
layout.addWidget(self.text_browser)
|
||||||
|
|
||||||
|
footer_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
ok_button = QPushButton("OK", self)
|
||||||
|
ok_button.setDefault(True)
|
||||||
|
ok_button.clicked.connect(self.accept)
|
||||||
|
|
||||||
|
footer_layout.addStretch()
|
||||||
|
footer_layout.addWidget(ok_button)
|
||||||
|
layout.addLayout(footer_layout)
|
||||||
|
|
||||||
|
# Fetch markdown from the web asynchronously
|
||||||
|
self.network_manager = QNetworkAccessManager(self)
|
||||||
|
self.network_manager.finished.connect(self._on_download_complete)
|
||||||
|
self.network_manager.get(QNetworkRequest(QUrl(CHANGELOG_URL)))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_download_complete(self, reply):
|
||||||
|
"""Processes the downloaded markdown and drops it into the view frame."""
|
||||||
|
if reply.error() == reply.NetworkError.NoError:
|
||||||
|
raw_bytes = reply.readAll()
|
||||||
|
|
||||||
|
# Convert raw bytes to standard text string
|
||||||
|
markdown_text = str(raw_bytes, encoding='utf-8')
|
||||||
|
self.text_browser.setMarkdown(markdown_text)
|
||||||
|
else:
|
||||||
|
self.text_browser.setHtml(
|
||||||
|
f"<p style='color: red;'><b>Failed to load content.</b><br>Error: {reply.errorString()}</p>"
|
||||||
|
)
|
||||||
|
reply.deleteLater()
|
||||||
+13
-2
@@ -16,6 +16,7 @@ import shutil
|
|||||||
import zipfile
|
import zipfile
|
||||||
import traceback
|
import traceback
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import configparser
|
||||||
|
|
||||||
# External library imports
|
# External library imports
|
||||||
import psutil
|
import psutil
|
||||||
@@ -415,7 +416,7 @@ def wait_for_process_to_exit(process_name, timeout=10):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def finish_update_if_needed(platform_name, app_name):
|
def finish_update_if_needed(platform_name, app_name, cfg_path):
|
||||||
"""
|
"""
|
||||||
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
||||||
"""
|
"""
|
||||||
@@ -423,6 +424,17 @@ def finish_update_if_needed(platform_name, app_name):
|
|||||||
if "--finish-update" in sys.argv:
|
if "--finish-update" in sys.argv:
|
||||||
print("Finishing update...")
|
print("Finishing update...")
|
||||||
|
|
||||||
|
update_cfg = configparser.ConfigParser()
|
||||||
|
try:
|
||||||
|
update_cfg.read(cfg_path)
|
||||||
|
update_cfg.set("Options", "show_welcome_dialog", "true")
|
||||||
|
|
||||||
|
with open(cfg_path, "w") as f:
|
||||||
|
update_cfg.write(f)
|
||||||
|
print("Welcome dialog flag successfully reset to 'true' for next run.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not update welcome dialog preference flag: {e}")
|
||||||
|
|
||||||
if platform_name == 'darwin':
|
if platform_name == 'darwin':
|
||||||
app_dir = f'/tmp/{app_name}tempupdate'
|
app_dir = f'/tmp/{app_name}tempupdate'
|
||||||
else:
|
else:
|
||||||
@@ -519,7 +531,6 @@ def finish_update_if_needed(platform_name, app_name):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to delete update folder: {e}")
|
print(f"Failed to delete update folder: {e}")
|
||||||
|
|
||||||
QMessageBox.information(None, "Update Complete", "The application has been successfully updated.")
|
|
||||||
sys.argv.remove("--finish-update")
|
sys.argv.remove("--finish-update")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user