1996 lines
77 KiB
Python
1996 lines
77 KiB
Python
"""
|
|
Filename: flaresbasewidget.py
|
|
Description: Custom window design and supporting methods for FLARES
|
|
|
|
Author: Tyler de Zeeuw
|
|
License: GPL-3.0
|
|
"""
|
|
|
|
import os
|
|
from copy import deepcopy
|
|
from typing import Sequence, Any
|
|
|
|
import pandas as pd
|
|
from pandas import DataFrame
|
|
|
|
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog
|
|
from PySide6.QtGui import QPalette, QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
|
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
|
|
|
|
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
|
|
|
|
class FullClickComboBox(QComboBox):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setEditable(True)
|
|
self.lineEdit().setReadOnly(True)
|
|
self.lineEdit().installEventFilter(self)
|
|
|
|
def eventFilter(self, obj, event):
|
|
if obj == self.lineEdit():
|
|
|
|
if event.type() == QEvent.MouseButtonPress:
|
|
return True
|
|
|
|
if event.type() == QEvent.MouseButtonRelease:
|
|
self.showPopup()
|
|
return True
|
|
|
|
return super().eventFilter(obj, event)
|
|
|
|
|
|
class ClickableLabel(QLabel):
|
|
def __init__(self, full_pixmap: QPixmap, thumbnail_pixmap: QPixmap):
|
|
super().__init__()
|
|
self._pixmap_full = full_pixmap
|
|
self.setPixmap(thumbnail_pixmap)
|
|
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.setFixedSize(thumbnail_pixmap.size())
|
|
self.setStyleSheet("border: 1px solid gray; margin: 2px;")
|
|
|
|
def mousePressEvent(self, event):
|
|
viewer = QWidget()
|
|
viewer.setWindowTitle(f"Expanded View - {APP_NAME.upper()}")
|
|
layout = QVBoxLayout(viewer)
|
|
label = QLabel()
|
|
label.setPixmap(self._pixmap_full)
|
|
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(label)
|
|
viewer.resize(1000, 800)
|
|
viewer.show()
|
|
self._expanded_viewer = viewer # keep reference alive
|
|
|
|
|
|
class ParameterInputDialog(QDialog):
|
|
def __init__(self, params_dict, parent=None):
|
|
"""
|
|
params_dict format:
|
|
{
|
|
idx: [
|
|
{
|
|
"key": "p_val",
|
|
"label": "Significance threshold P-value (e.g. 0.05)",
|
|
"default": "0.05",
|
|
"type": float,
|
|
},
|
|
{
|
|
"key": "graph_scale",
|
|
"label": "Graph scale factor",
|
|
"default": "1",
|
|
"type": int,
|
|
}
|
|
],
|
|
...
|
|
}
|
|
"""
|
|
super().__init__(parent)
|
|
self.setWindowTitle(f"Input Parameters - {APP_NAME.upper()}")
|
|
self.params_dict = params_dict
|
|
self.inputs = {} # {(idx, param_key): QLineEdit}
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
intro_label = QLabel(
|
|
"Some methods require parameters to continue:\n"
|
|
"Clicking OK will simply use default values if input is left empty."
|
|
)
|
|
main_layout.addWidget(intro_label)
|
|
self.setMinimumWidth(400)
|
|
self.scroll = QScrollArea()
|
|
self.scroll.setWidgetResizable(True)
|
|
self.scroll.setMaximumHeight(800)
|
|
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
|
|
self.scroll_content = QWidget()
|
|
self.scroll_layout = QVBoxLayout(self.scroll_content)
|
|
self.scroll_layout.setContentsMargins(10, 10, 10, 10)
|
|
self.scroll.setWidget(self.scroll_content)
|
|
|
|
main_layout.addWidget(self.scroll)
|
|
|
|
for idx, param_list in params_dict.items():
|
|
full_text = param_list[0].get('full_text', f"Index [{idx}]")
|
|
group_label = QLabel(f"{full_text} requires parameters:")
|
|
group_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
|
self.scroll_layout.addWidget(group_label)
|
|
|
|
for param_info in param_list:
|
|
label = QLabel(param_info["label"])
|
|
self.scroll_layout.addWidget(label)
|
|
|
|
if param_info.get("type") == list:
|
|
widget = QComboBox(self)
|
|
# Convert options to string just in case they aren't
|
|
options = [str(opt) for opt in param_info.get("options", [])]
|
|
widget.addItems(options)
|
|
|
|
# Set default choice if it exists in the options list
|
|
default_val = str(param_info.get("default", ""))
|
|
if default_val in options:
|
|
widget.setCurrentText(default_val)
|
|
else:
|
|
widget = QLineEdit(self)
|
|
widget.setPlaceholderText(str(param_info.get("default", "")))
|
|
|
|
self.scroll_layout.addWidget(widget)
|
|
|
|
self.inputs[(idx, param_info["key"])] = widget
|
|
|
|
# Buttons
|
|
btn_layout = QHBoxLayout()
|
|
ok_btn = QPushButton("OK", self)
|
|
cancel_btn = QPushButton("Cancel", self)
|
|
btn_layout.addWidget(ok_btn)
|
|
btn_layout.addWidget(cancel_btn)
|
|
main_layout.addLayout(btn_layout)
|
|
|
|
ok_btn.clicked.connect(self.accept)
|
|
cancel_btn.clicked.connect(self.reject)
|
|
|
|
def get_values(self):
|
|
"""
|
|
Validate and return values dict in form:
|
|
{
|
|
idx: {
|
|
param_key: value,
|
|
...
|
|
},
|
|
...
|
|
}
|
|
Returns None if validation fails (error dialog shown).
|
|
"""
|
|
values = {}
|
|
for (idx, param_key), widget in self.inputs.items():
|
|
if isinstance(widget, QComboBox):
|
|
text = widget.currentText().strip()
|
|
else:
|
|
text = widget.text().strip()
|
|
|
|
# Find param info dict
|
|
param_info = None
|
|
for p in self.params_dict[idx]:
|
|
if p['key'] == param_key:
|
|
param_info = p
|
|
break
|
|
if param_info is None:
|
|
# This shouldn't happen, but just in case:
|
|
self._show_error(f"Internal error: No param info for index {idx} key '{param_key}'")
|
|
return None
|
|
|
|
if not text:
|
|
text = str(param_info.get('default', ''))
|
|
|
|
param_type = param_info.get('type', str)
|
|
|
|
try:
|
|
if param_type == int:
|
|
val = int(text)
|
|
elif param_type == float:
|
|
val = float(text)
|
|
elif param_type == bool:
|
|
# Convert common bool strings to bool
|
|
val_lower = text.lower()
|
|
if val_lower in ('true', '1', 'yes', 'y'):
|
|
val = True
|
|
elif val_lower in ('false', '0', 'no', 'n'):
|
|
val = False
|
|
else:
|
|
raise ValueError(f"Invalid bool value: {text}")
|
|
elif param_type in (str, list):
|
|
val = text
|
|
else:
|
|
val = text # fallback
|
|
except (ValueError, TypeError):
|
|
type_name = "list option" if param_type == list else param_type.__name__
|
|
self._show_error(
|
|
f"Invalid input for index {idx} parameter '{param_key}': '{text}'\n"
|
|
f"Expected type: {type_name}"
|
|
)
|
|
return None
|
|
|
|
if idx not in values:
|
|
values[idx] = {}
|
|
values[idx][param_key] = val
|
|
|
|
return values
|
|
|
|
def _show_error(self, message):
|
|
error_box = QMessageBox(self)
|
|
error_box.setIcon(QMessageBox.Critical)
|
|
error_box.setWindowTitle(f"Input Error - {APP_NAME.upper()}")
|
|
error_box.setText(message)
|
|
error_box.exec_()
|
|
|
|
|
|
class FullClickComboBox(QComboBox):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setEditable(True)
|
|
self.lineEdit().setReadOnly(True)
|
|
self.lineEdit().installEventFilter(self)
|
|
|
|
def eventFilter(self, obj, event):
|
|
if obj == self.lineEdit():
|
|
|
|
if event.type() == QEvent.MouseButtonPress:
|
|
return True
|
|
|
|
if event.type() == QEvent.MouseButtonRelease:
|
|
self.showPopup()
|
|
return True
|
|
|
|
return super().eventFilter(obj, event)
|
|
|
|
|
|
|
|
class FilePickerWidget(QWidget):
|
|
# This custom signal lets our container mimic a standard QLineEdit
|
|
textChanged = Signal(str)
|
|
|
|
def __init__(self, default_val="", file_filter="JSON Files (*.json);;All Files (*)", parent=None):
|
|
super().__init__(parent)
|
|
|
|
# Inline layout to hold line edit and button side-by-side
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(5)
|
|
|
|
self.line_edit = QLineEdit()
|
|
self.line_edit.setText(str(default_val))
|
|
self.line_edit.setPlaceholderText("Select file path...")
|
|
# Forward internal text updates out through our custom component signal
|
|
self.line_edit.textChanged.connect(self.textChanged.emit)
|
|
|
|
self.browse_btn = QPushButton("Browse...")
|
|
self.browse_btn.clicked.connect(self.open_file_dialog)
|
|
|
|
layout.addWidget(self.line_edit)
|
|
layout.addWidget(self.browse_btn)
|
|
|
|
self.file_filter = file_filter
|
|
|
|
def open_file_dialog(self):
|
|
# Open PySide6 native file browser
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
self,
|
|
"Select Configuration File",
|
|
self.line_edit.text(),
|
|
self.file_filter
|
|
)
|
|
if file_path:
|
|
self.line_edit.setText(file_path)
|
|
|
|
# Mimic standard text getter/setter behaviors so parent systems remain unbothered
|
|
def text(self):
|
|
return self.line_edit.text()
|
|
|
|
def setText(self, text):
|
|
self.line_edit.setText(text)
|
|
|
|
|
|
class ParamSection(QWidget):
|
|
"""
|
|
Args:
|
|
section_data (dict): Dictionary containing section title and list of parameter info.
|
|
Expected format:
|
|
{
|
|
"title": str,
|
|
"params": [
|
|
{
|
|
"name": str,
|
|
"type": type,
|
|
"default": any,
|
|
"help": str (optional)
|
|
},
|
|
...
|
|
]
|
|
}
|
|
"""
|
|
|
|
dirty_state_changed = Signal(bool)
|
|
|
|
def __init__(self, section_data, global_widgets):
|
|
super().__init__()
|
|
layout = QVBoxLayout()
|
|
self.setLayout(layout)
|
|
self.widgets = global_widgets
|
|
self.dependencies = []
|
|
self.selected_path = None
|
|
|
|
self.param_rows = []
|
|
self.header_widgets = []
|
|
self.dirty_params = {}
|
|
self._updating_checkstates = False
|
|
|
|
# Title label
|
|
self.title_label = QLabel(section_data["title"])
|
|
self.title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;")
|
|
layout.addWidget(self.title_label)
|
|
self.header_widgets.append(self.title_label)
|
|
|
|
# Horizontal line
|
|
line = QFrame()
|
|
line.setFrameShape(QFrame.Shape.HLine)
|
|
line.setFrameShadow(QFrame.Shadow.Sunken)
|
|
layout.addWidget(line)
|
|
self.header_widgets.append(line)
|
|
|
|
for param in section_data["params"]:
|
|
h_layout = QHBoxLayout()
|
|
is_advanced = param.get("advanced", False)
|
|
param_name = param["name"]
|
|
help_text = param.get("help", "")
|
|
|
|
# Build label text and tooltips
|
|
label_text = f"⚠️ {param_name}" if is_advanced else param_name
|
|
label = QLabel(label_text)
|
|
|
|
if is_advanced:
|
|
label.setToolTip(f"ADVANCED: {help_text}")
|
|
else:
|
|
label.setToolTip(help_text)
|
|
|
|
help_btn = QPushButton("?")
|
|
help_btn.setFixedWidth(25)
|
|
help_btn.setToolTip(help_text)
|
|
help_btn.clicked.connect(lambda _, text=help_text, name=param_name: self.show_help_popup(name, text))
|
|
|
|
h_layout.addWidget(help_btn)
|
|
h_layout.addWidget(label)
|
|
h_layout.setStretch(0, 1)
|
|
h_layout.setStretch(1, 6)
|
|
|
|
default_val = param["default"]
|
|
param_type = param["type"]
|
|
type_str = str(param_type).lower()
|
|
|
|
# Create input widget based on type
|
|
if param_type == bool or "bool" in type_str:
|
|
widget = QComboBox()
|
|
widget.addItems(["True", "False"])
|
|
widget.setCurrentText(str(default_val))
|
|
widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
|
widget.currentTextChanged.connect(self.notify_global_update)
|
|
|
|
elif param_type == int or "int" in type_str:
|
|
widget = QLineEdit()
|
|
widget.setValidator(QIntValidator())
|
|
widget.setText(str(default_val))
|
|
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
|
|
|
elif param_type == float or "float" in type_str:
|
|
widget = QLineEdit()
|
|
widget.setValidator(QDoubleValidator())
|
|
widget.setText(str(default_val))
|
|
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
|
|
|
elif param_type == list or "list" in type_str:
|
|
options = param.get("options", [])
|
|
if param.get("exclusive", True):
|
|
widget = QComboBox()
|
|
widget.addItems(options)
|
|
initial_text = default_val[0] if (isinstance(default_val, list) and len(default_val) > 0) else str(default_val)
|
|
widget.setCurrentText(initial_text)
|
|
widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
|
widget.currentTextChanged.connect(self.notify_global_update)
|
|
else:
|
|
widget = self._create_multiselect_dropdown(options, default_val=default_val, param_name=param_name)
|
|
|
|
elif param_type == range or "range" in type_str:
|
|
widget = QSpinBox()
|
|
widget.setRange(0, 999)
|
|
if isinstance(default_val, range):
|
|
widget.setValue(default_val.stop)
|
|
elif str(default_val).isdigit():
|
|
widget.setValue(int(default_val))
|
|
else:
|
|
widget.setValue(15)
|
|
widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
|
|
|
elif param_type == "json_file":
|
|
widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)")
|
|
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
|
|
|
else:
|
|
widget = QLineEdit()
|
|
widget.setText(str(default_val))
|
|
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
|
|
|
self.widgets[param_name] = {
|
|
"widget": widget,
|
|
"label": label,
|
|
"default": default_val,
|
|
"saved_value": deepcopy(default_val),
|
|
"type": param_type,
|
|
"h_layout": h_layout
|
|
}
|
|
|
|
if "depends_on" in param:
|
|
deps_list = param["depends_on"] if isinstance(param["depends_on"], list) else [{
|
|
"parent_name": param["depends_on"],
|
|
"depends_value": param.get("depends_value", "True")
|
|
}]
|
|
self.dependencies.append({
|
|
"child_name": param_name,
|
|
"conditions": deps_list
|
|
})
|
|
|
|
widget.setToolTip(help_text)
|
|
h_layout.addWidget(widget)
|
|
h_layout.setStretch(2, 3)
|
|
|
|
layout.addLayout(h_layout)
|
|
self.param_rows.append(([help_btn, label, widget], h_layout, is_advanced))
|
|
|
|
self.update_dependencies()
|
|
|
|
def update_theme_colors(self):
|
|
self.title_label.style().unpolish(self.title_label)
|
|
self.title_label.style().polish(self.title_label)
|
|
|
|
def is_different(self, val_a, val_b, param_type=None):
|
|
"""Compares two parameter values to determine if they differ."""
|
|
type_str = str(param_type).lower()
|
|
|
|
# BOOL comparison
|
|
if param_type == bool or "bool" in type_str:
|
|
def to_bool(v):
|
|
if isinstance(v, bool):
|
|
return v
|
|
return str(v).strip().lower() in ("true", "1", "yes")
|
|
return to_bool(val_a) != to_bool(val_b)
|
|
|
|
# LIST comparison
|
|
if param_type == list or "list" in type_str:
|
|
def to_list(v):
|
|
if v is None:
|
|
return []
|
|
if isinstance(v, list):
|
|
res = []
|
|
for item in v:
|
|
res.extend(to_list(item))
|
|
return res
|
|
if isinstance(v, str):
|
|
s = v.strip()
|
|
if s.startswith('[') and s.endswith(']'):
|
|
s = s[1:-1]
|
|
items = [x.strip().strip("'\"") for x in s.split(',') if x.strip().strip("'\"")]
|
|
return [i for i in items if i != "<None Selected>"]
|
|
return [str(v).strip()]
|
|
|
|
return sorted(to_list(val_a)) != sorted(to_list(val_b))
|
|
|
|
# RANGE comparison
|
|
if param_type == range or "range" in type_str:
|
|
def to_range_stop(v):
|
|
if isinstance(v, range):
|
|
return v.stop
|
|
try:
|
|
return int(v)
|
|
except (ValueError, TypeError):
|
|
return 0
|
|
return to_range_stop(val_a) != to_range_stop(val_b)
|
|
|
|
# INT / FLOAT comparison
|
|
if param_type in (int, float) or "int" in type_str or "float" in type_str:
|
|
try:
|
|
if val_a is not None and val_b is not None and str(val_a).strip() != "" and str(val_b).strip() != "":
|
|
return float(val_a) != float(val_b)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# String / Default Fallback
|
|
str_a = "" if val_a is None else str(val_a).strip()
|
|
str_b = "" if val_b is None else str(val_b).strip()
|
|
return str_a != str_b
|
|
|
|
def check_if_changed(self, param_name, current_value=None, *args, **kwargs):
|
|
"""
|
|
Toggles bold font/blue color on the label if the value differs from default.
|
|
Always pulls current UI value from get_param_values() if not explicitly passed.
|
|
"""
|
|
info = self.widgets.get(param_name)
|
|
if not info:
|
|
return
|
|
|
|
label = info["label"]
|
|
default = info["default"]
|
|
saved = info.get("saved_value", default)
|
|
param_type = info["type"]
|
|
|
|
if current_value is None:
|
|
current_value = self.get_param_values().get(param_name)
|
|
|
|
# 1. COLORING LOGIC (Compares against 'default')
|
|
if self.is_different(current_value, default, param_type):
|
|
label.setStyleSheet("color: #3498db; font-weight: bold;")
|
|
else:
|
|
label.setStyleSheet("")
|
|
|
|
# 2. DIRTY STATE LOGIC (Compares against 'saved_value')
|
|
self.dirty_params[param_name] = self.is_different(current_value, saved, param_type)
|
|
self.dirty_state_changed.emit(any(self.dirty_params.values()))
|
|
|
|
def reset_to_defaults(self):
|
|
"""
|
|
Resets all UI input widgets back to their defined default values in SECTIONS,
|
|
resets saved baseline states, updates label styling (removes blue highlight),
|
|
and updates dependent widget visibility/enablement.
|
|
"""
|
|
for param_name, info in self.widgets.items():
|
|
widget = info["widget"]
|
|
default_val = info["default"]
|
|
param_type = info["type"]
|
|
type_str = str(param_type).lower()
|
|
|
|
# Update saved baseline to match default
|
|
info["saved_value"] = deepcopy(default_val)
|
|
|
|
# Reset Widget Values
|
|
if param_type == bool or "bool" in type_str:
|
|
if isinstance(widget, QComboBox):
|
|
widget.setCurrentText(str(default_val))
|
|
|
|
elif param_type in (int, float, str) or "int" in type_str or "float" in type_str or "str" in type_str:
|
|
if isinstance(widget, QLineEdit):
|
|
widget.setText("" if default_val is None else str(default_val))
|
|
|
|
elif param_type == list or "list" in type_str:
|
|
if isinstance(widget, FullClickComboBox):
|
|
defaults = default_val if isinstance(default_val, list) else ([default_val] if default_val else [])
|
|
model = widget.model()
|
|
self._updating_checkstates = True
|
|
normal_items = []
|
|
for i in range(2, model.rowCount()):
|
|
item = model.item(i)
|
|
normal_items.append(item)
|
|
state = Qt.Checked if item.text() in defaults else Qt.Unchecked
|
|
item.setCheckState(state)
|
|
|
|
# Sync toggle select all item
|
|
toggle_item = model.item(1)
|
|
if toggle_item and normal_items:
|
|
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
|
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
|
|
|
self._updating_checkstates = False
|
|
self.update_dropdown_label(param_name)
|
|
elif isinstance(widget, QComboBox):
|
|
initial_text = default_val[0] if (isinstance(default_val, list) and len(default_val) > 0) else str(default_val)
|
|
widget.setCurrentText(initial_text)
|
|
|
|
elif param_type == range or "range" in type_str:
|
|
if isinstance(widget, QSpinBox):
|
|
if isinstance(default_val, range):
|
|
widget.setValue(default_val.stop)
|
|
elif str(default_val).isdigit():
|
|
widget.setValue(int(default_val))
|
|
else:
|
|
widget.setValue(15)
|
|
|
|
elif param_type == "json_file":
|
|
if hasattr(widget, "setText"):
|
|
widget.setText("" if default_val is None else str(default_val))
|
|
|
|
# Re-evaluate visual styling and dirty flags
|
|
self.check_if_changed(param_name)
|
|
|
|
self.dirty_params.clear()
|
|
self.dirty_state_changed.emit(False)
|
|
self.update_dependencies()
|
|
|
|
# Aliases for clear button calls
|
|
def clear(self):
|
|
self.reset_to_defaults()
|
|
|
|
def reset(self):
|
|
self.reset_to_defaults()
|
|
|
|
def reset_baseline_to_default(self):
|
|
"""Resets baseline saved values back to defaults and re-checks visual styling."""
|
|
for name, info in self.widgets.items():
|
|
info["saved_value"] = deepcopy(info["default"])
|
|
self.check_if_changed(name)
|
|
|
|
self.dirty_params.clear()
|
|
self.dirty_state_changed.emit(False)
|
|
|
|
def save_current_as_baseline(self):
|
|
"""Call this when a project is saved to lock current UI state as saved_value."""
|
|
current_values = self.get_param_values()
|
|
for name, info in self.widgets.items():
|
|
if name in current_values:
|
|
info["saved_value"] = deepcopy(current_values[name])
|
|
self.check_if_changed(name)
|
|
|
|
self.dirty_params.clear()
|
|
self.dirty_state_changed.emit(False)
|
|
|
|
def set_advanced_visible(self, show_advanced: bool):
|
|
has_visible_rows = False
|
|
for row_widgets, _, is_advanced in self.param_rows:
|
|
visible = show_advanced or not is_advanced
|
|
for w in row_widgets:
|
|
w.setVisible(visible)
|
|
if visible:
|
|
has_visible_rows = True
|
|
|
|
for hw in self.header_widgets:
|
|
hw.setVisible(has_visible_rows)
|
|
|
|
self.setVisible(has_visible_rows)
|
|
|
|
def has_any_changes(self):
|
|
current_values = self.get_param_values()
|
|
for name, info in self.widgets.items():
|
|
if self.is_different(current_values.get(name), info["default"], info["type"]):
|
|
return True
|
|
return False
|
|
|
|
def notify_global_update(self):
|
|
parent = self.parent()
|
|
if parent:
|
|
for section in parent.findChildren(ParamSection):
|
|
section.update_dependencies()
|
|
|
|
def update_dependencies(self):
|
|
for dep in self.dependencies:
|
|
child_info = self.widgets.get(dep["child_name"])
|
|
if not child_info:
|
|
continue
|
|
|
|
all_conditions_met = True
|
|
for cond in dep["conditions"]:
|
|
parent_name = cond.get("parent_name") or cond.get("parent")
|
|
required_val = str(cond.get("depends_value") if "depends_value" in cond else cond.get("value", "True"))
|
|
|
|
parent_info = self.widgets.get(parent_name)
|
|
if not parent_info:
|
|
all_conditions_met = False
|
|
break
|
|
|
|
p_widget = parent_info["widget"]
|
|
if isinstance(p_widget, QComboBox):
|
|
curr_val = p_widget.currentText()
|
|
elif isinstance(p_widget, QLineEdit):
|
|
curr_val = p_widget.text()
|
|
elif isinstance(p_widget, QSpinBox):
|
|
curr_val = str(p_widget.value())
|
|
else:
|
|
curr_val = str(p_widget)
|
|
|
|
if curr_val != required_val:
|
|
all_conditions_met = False
|
|
break
|
|
|
|
h_layout = child_info["h_layout"]
|
|
for i in range(h_layout.count()):
|
|
item = h_layout.itemAt(i).widget()
|
|
if item:
|
|
item.setEnabled(all_conditions_met)
|
|
|
|
def _create_multiselect_dropdown(self, items, default_val=None, param_name=""):
|
|
combo = FullClickComboBox()
|
|
combo.setView(QListView())
|
|
model = QStandardItemModel()
|
|
combo.setModel(model)
|
|
combo.setEditable(True)
|
|
combo.lineEdit().setReadOnly(True)
|
|
combo.lineEdit().setPlaceholderText("Select...")
|
|
|
|
dummy_item = QStandardItem("<None Selected>")
|
|
dummy_item.setFlags(Qt.ItemIsEnabled)
|
|
model.appendRow(dummy_item)
|
|
|
|
toggle_item = QStandardItem("Toggle Select All")
|
|
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
|
|
model.appendRow(toggle_item)
|
|
|
|
defaults = default_val if isinstance(default_val, list) else ([default_val] if default_val else [])
|
|
|
|
if items:
|
|
for item_text in items:
|
|
standard_item = QStandardItem(item_text)
|
|
standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
initial_state = Qt.Checked if item_text in defaults else Qt.Unchecked
|
|
standard_item.setData(initial_state, Qt.CheckStateRole)
|
|
model.appendRow(standard_item)
|
|
|
|
combo.setInsertPolicy(QComboBox.NoInsert)
|
|
|
|
def on_view_clicked(index):
|
|
item = model.itemFromIndex(index)
|
|
if item.isCheckable():
|
|
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
|
|
item.setCheckState(new_state)
|
|
|
|
combo.view().pressed.connect(on_view_clicked)
|
|
|
|
def on_item_changed(item):
|
|
if self._updating_checkstates:
|
|
return
|
|
self._updating_checkstates = True
|
|
|
|
normal_items = [model.item(i) for i in range(2, model.rowCount())]
|
|
|
|
if item == toggle_item:
|
|
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
|
target_state = Qt.Unchecked if all_checked else Qt.Checked
|
|
for i in normal_items:
|
|
i.setCheckState(target_state)
|
|
toggle_item.setCheckState(target_state)
|
|
elif item != dummy_item:
|
|
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
|
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
|
|
|
self._updating_checkstates = False
|
|
|
|
if param_name:
|
|
self.update_dropdown_label(param_name)
|
|
self.check_if_changed(param_name)
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
return combo
|
|
|
|
def show_help_popup(self, param_name, text):
|
|
msg = QMessageBox(self)
|
|
msg.setWindowTitle(f"Parameter Info - {param_name} - {APP_NAME.upper()}")
|
|
msg.setText(text)
|
|
msg.exec()
|
|
|
|
def get_param_values(self):
|
|
values = {}
|
|
for name, info in self.widgets.items():
|
|
widget = info["widget"]
|
|
expected_type = info["type"]
|
|
type_str = str(expected_type).lower()
|
|
|
|
if expected_type == bool or "bool" in type_str:
|
|
values[name] = widget.currentText() == "True"
|
|
elif expected_type == list or "list" in type_str:
|
|
if isinstance(widget, FullClickComboBox):
|
|
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip() and x.strip() != "<None Selected>"]
|
|
elif isinstance(widget, QComboBox):
|
|
values[name] = widget.currentText()
|
|
elif expected_type == range or "range" in type_str:
|
|
values[name] = range(widget.value()) if isinstance(widget, QSpinBox) else range(15)
|
|
elif expected_type == "json_file":
|
|
values[name] = widget.text()
|
|
else:
|
|
raw_text = widget.text()
|
|
try:
|
|
if expected_type == int or "int" in type_str:
|
|
values[name] = int(raw_text)
|
|
elif expected_type == float or "float" in type_str:
|
|
values[name] = float(raw_text)
|
|
else:
|
|
values[name] = raw_text
|
|
except Exception:
|
|
values[name] = raw_text
|
|
|
|
return values
|
|
|
|
def _get_checked_items(self, combo):
|
|
checked = []
|
|
model = combo.model()
|
|
for i in range(model.rowCount()):
|
|
item = model.item(i)
|
|
if item.text() in ("<None Selected>", "Toggle Select All"):
|
|
continue
|
|
if item.checkState() == Qt.Checked:
|
|
checked.append(item.text())
|
|
return checked
|
|
|
|
def update_dropdown_label(self, param_name):
|
|
widget_info = self.widgets.get(param_name)
|
|
if not widget_info:
|
|
return
|
|
|
|
widget = widget_info["widget"]
|
|
if not isinstance(widget, FullClickComboBox):
|
|
return
|
|
|
|
selected = self._get_checked_items(widget)
|
|
widget.lineEdit().setText(", ".join(selected) if selected else "<None Selected>")
|
|
|
|
# def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1):
|
|
# file_paths = [bubble.file_path for bubble in bubble_widgets.values()]
|
|
# if not file_paths:
|
|
# return
|
|
|
|
# # 1. Start the UI immediately
|
|
# progress = QProgressDialog("Accessing Workers...", "Cancel", 0, len(file_paths), self)
|
|
# progress.setWindowModality(Qt.WindowModality.WindowModal)
|
|
# progress.setMinimumDuration(0)
|
|
# progress.setValue(0)
|
|
|
|
# # Force the UI to draw the window NOW before we start the loop
|
|
# progress.show()
|
|
# QApplication.processEvents()
|
|
|
|
# annotation_sets = []
|
|
|
|
# # 2. Use the persistent executor (don't use 'with' here!)
|
|
# for i, path in enumerate(file_paths):
|
|
# progress.setValue(i)
|
|
# progress.setLabelText(f"Reading file {i+1} of {len(file_paths)}...")
|
|
# QApplication.processEvents() # Keeps the UI snappy
|
|
|
|
# if progress.wasCanceled():
|
|
# break
|
|
|
|
# # This call is now nearly instant because the process is already warm
|
|
# future = self.file_executor.submit(_extract_annotations, path)
|
|
# try:
|
|
# labels_list = future.result()
|
|
# if labels_list:
|
|
# annotation_sets.append(set(labels_list))
|
|
# except Exception as e:
|
|
# print(f"Worker Error: {e}")
|
|
|
|
# progress.setValue(len(file_paths))
|
|
|
|
# # 3. Final Logic
|
|
# if not annotation_sets:
|
|
# self.update_dropdown_items("REMOVE_EVENTS", [])
|
|
# button1.setVisible(False)
|
|
# return
|
|
|
|
# common = set.intersection(*annotation_sets) if len(annotation_sets) > 1 else annotation_sets[0]
|
|
# self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common)))
|
|
|
|
|
|
class ProgressBubble(QWidget):
|
|
"""
|
|
A clickable widget displaying a progress bar made of colored rectangles and a label.
|
|
|
|
Args:
|
|
display_name (str): Text to display above the progress bar.
|
|
file_path (str): Associated file path stored with the bubble.
|
|
|
|
"""
|
|
|
|
clicked = Signal(object)
|
|
rightClicked = Signal(object, QPoint)
|
|
|
|
def __init__(self, display_name, file_path):
|
|
super().__init__()
|
|
|
|
self.layout = QVBoxLayout()
|
|
self.label = QLabel(display_name)
|
|
self.loading_timer = QTimer(self)
|
|
self.loading_timer.timeout.connect(self._rotate_spinner)
|
|
self.spinner_frames = ["◐", "◓", "◑", "◒"] #cute
|
|
self.spinner_idx = 0
|
|
self.is_loading = False
|
|
self.base_text = display_name
|
|
self.status_icon = ""
|
|
self.suffix_text = ""
|
|
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.label.setStyleSheet("""
|
|
QLabel {
|
|
border: 1px solid #888;
|
|
border-radius: 10px;
|
|
padding: 8px 12px;
|
|
background-color: #e0f0ff;
|
|
color: #000000;
|
|
}
|
|
""")
|
|
|
|
self.progress_layout = QHBoxLayout()
|
|
|
|
self.rects = []
|
|
for i in range(28):
|
|
rect = QFrame()
|
|
rect.setFixedSize(10, 18)
|
|
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
|
stage_name = PIPELINE_STAGES[i]
|
|
rect.setToolTip(f"Stage {i + 1}: {stage_name}")
|
|
self.progress_layout.addWidget(rect)
|
|
self.rects.append(rect)
|
|
|
|
self.layout.addWidget(self.label)
|
|
self.layout.addLayout(self.progress_layout)
|
|
self.setLayout(self.layout)
|
|
|
|
# Store the file path
|
|
self.file_path = os.path.normpath(file_path)
|
|
|
|
self.current_step = 0
|
|
|
|
# Make the bubble appear to the user as clickable
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
|
|
# Resize policy to make bubbles responsive
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
|
|
|
|
|
def _update_label_text(self):
|
|
"""Combines base text, green checkmark/spinner, and metadata into one display label."""
|
|
text = self.base_text
|
|
if self.status_icon:
|
|
text += f" {self.status_icon}"
|
|
if self.suffix_text:
|
|
text += f" ({self.suffix_text})"
|
|
self.label.setText(text)
|
|
|
|
|
|
def set_loading_state(self, loading=True):
|
|
self.is_loading = loading
|
|
if loading:
|
|
self.loading_timer.start(150) # Rotate every 150ms
|
|
else:
|
|
self.loading_timer.stop()
|
|
# Transition to a green checkmark
|
|
self.status_icon = "<span style='color: green;'>✔</span>"
|
|
self._update_label_text()
|
|
|
|
|
|
def setSuffixText(self, suffix):
|
|
"""Updates the metadata text portion without destroying the checkmark."""
|
|
self.suffix_text = suffix if suffix else ""
|
|
self._update_label_text()
|
|
|
|
|
|
def update_progress(self, step_index, active=True):
|
|
self.current_step = step_index
|
|
for i, rect in enumerate(self.rects):
|
|
if i < step_index:
|
|
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
|
|
elif i == step_index:
|
|
color = "yellow" if active else "white"
|
|
rect.setStyleSheet(f"background-color: {color}; border: 1px solid gray;")
|
|
else:
|
|
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
|
|
|
|
|
def mark_cancelled(self):
|
|
for i, rect in enumerate(self.rects):
|
|
if i < self.current_step:
|
|
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
|
|
elif i == self.current_step:
|
|
rect.setStyleSheet("background-color: red; border: 1px solid gray;")
|
|
else:
|
|
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
|
|
|
|
|
def mousePressEvent(self, event):
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self.clicked.emit(self)
|
|
elif event.button() == Qt.MouseButton.RightButton:
|
|
self.rightClicked.emit(self, event.globalPosition().toPoint())
|
|
super().mousePressEvent(event)
|
|
|
|
|
|
def _rotate_spinner(self):
|
|
frame = self.spinner_frames[self.spinner_idx % len(self.spinner_frames)]
|
|
# Using HTML in setText allows us to style the spinner specifically
|
|
self.status_icon = f"<span style='color: #555;'>{frame}</span>"
|
|
self.spinner_idx += 1
|
|
self._update_label_text()
|
|
|
|
def reset(self):
|
|
"""Resets the bubble's visual state, progress bars, timers, and labels back to initial state."""
|
|
# Reset progress metrics and visual rectangles back to white
|
|
self.current_step = 0
|
|
for rect in self.rects:
|
|
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
|
|
|
|
|
class FlaresBaseWidget(QWidget):
|
|
def __init__(self, caller):
|
|
super().__init__()
|
|
self.caller = caller
|
|
self.haemo_dict = None
|
|
self._updating_checkstates = False
|
|
self.participant_map = {}
|
|
self.show_all_events = True
|
|
|
|
# These will be defined by the children, but we'll
|
|
# initialize them as None so the code doesn't crash.
|
|
self.participant_dropdown = None
|
|
self.event_dropdown = None
|
|
self.image_index_dropdown = None
|
|
|
|
|
|
def _create_multiselect_dropdown(
|
|
self,
|
|
items: Sequence[str]
|
|
) -> FullClickComboBox:
|
|
|
|
combo = FullClickComboBox()
|
|
combo.setView(QListView())
|
|
model = QStandardItemModel()
|
|
combo.setModel(model)
|
|
combo.setEditable(True)
|
|
combo.lineEdit().setReadOnly(True)
|
|
combo.lineEdit().setPlaceholderText("Select...")
|
|
|
|
# Setup internal items
|
|
dummy = QStandardItem("<None Selected>")
|
|
dummy.setFlags(Qt.ItemIsEnabled)
|
|
model.appendRow(dummy)
|
|
|
|
toggle = QStandardItem("Toggle Select All")
|
|
toggle.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
toggle.setData(Qt.Unchecked, Qt.CheckStateRole)
|
|
model.appendRow(toggle)
|
|
|
|
for text in items:
|
|
item = QStandardItem(text)
|
|
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
item.setData(Qt.Unchecked, Qt.CheckStateRole)
|
|
model.appendRow(item)
|
|
|
|
# Handle clicking the view directly
|
|
def on_view_clicked(index):
|
|
item = model.itemFromIndex(index)
|
|
if item.isCheckable():
|
|
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
|
|
item.setCheckState(new_state)
|
|
combo.view().pressed.connect(on_view_clicked)
|
|
|
|
# Logic for "Select All" and Signal Propagation
|
|
def on_item_changed(item):
|
|
if getattr(self, '_updating_checkstates', False):
|
|
return
|
|
self._updating_checkstates = True
|
|
|
|
normal_items = [model.item(i) for i in range(2, model.rowCount())]
|
|
|
|
if item == toggle:
|
|
state = toggle.checkState()
|
|
for i in normal_items:
|
|
i.setCheckState(state)
|
|
else:
|
|
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
|
toggle.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
|
|
|
# Trigger the widget's update logic via the existing signal
|
|
combo.currentIndexChanged.emit(combo.currentIndex())
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
combo.setInsertPolicy(QComboBox.NoInsert)
|
|
return combo
|
|
|
|
|
|
# def _get_checked_items(self, combo):
|
|
# model = combo.model()
|
|
# checked = []
|
|
# for i in range(2, model.rowCount()): # Start at 2 to skip dummy/toggle
|
|
# item = model.item(i)
|
|
# if item.checkState() == Qt.Checked:
|
|
# checked.append(item.text())
|
|
# return checked
|
|
|
|
def _get_checked_items(
|
|
self,
|
|
combo: QComboBox | None = None
|
|
) -> list[str]:
|
|
|
|
target = combo if combo is not None else getattr(self, 'participant_dropdown', None)
|
|
|
|
if target is None or target.model() is None:
|
|
return []
|
|
|
|
model = target.model()
|
|
checked_items = []
|
|
|
|
# Exclusion list: any item text that should never be treated as data
|
|
forbidden = {"Toggle All", "Select All", "<None Selected>", "Toggle"}
|
|
|
|
for row in range(model.rowCount()):
|
|
item = model.item(row)
|
|
if item.checkState() == Qt.CheckState.Checked:
|
|
text = item.text()
|
|
# Only add if it's not a 'UI control' item
|
|
if text not in forbidden and not text.startswith("Toggle"):
|
|
checked_items.append(text)
|
|
|
|
return checked_items
|
|
|
|
|
|
def update_participant_dropdown_label(
|
|
self,
|
|
combo: QComboBox | int | None = None
|
|
) -> None:
|
|
"""
|
|
Handles label updates for ANY participant dropdown.
|
|
If 'combo' is None, it defaults to the standard self.participant_dropdown.
|
|
"""
|
|
if isinstance(combo, int):
|
|
combo = None
|
|
|
|
# 1. Figure out which dropdown we are talking to
|
|
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
|
|
|
|
if target_combo is None:
|
|
return # Safety check: nothing to update
|
|
|
|
# 2. Get the checked items and format the text
|
|
selected = self._get_checked_items(target_combo)
|
|
if not selected:
|
|
target_combo.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Extract just "Participant N"
|
|
selected_short = [s.split(" ")[0] + " " + s.split(" ")[1] for s in selected]
|
|
target_combo.lineEdit().setText(", ".join(selected_short))
|
|
|
|
# 3. Conditional trigger for event updates
|
|
# We only update events if we aren't in one of the excluded viewers
|
|
excluded_viewers = {
|
|
"ParticipantImage",
|
|
"ParticipantFoldChannels",
|
|
"ExportToCSV",
|
|
}
|
|
|
|
if getattr(self, "caller", None) not in excluded_viewers:
|
|
self._update_event_dropdown()
|
|
|
|
|
|
def update_image_index_dropdown_label(self):
|
|
selected = self._get_checked_items(self.image_index_dropdown)
|
|
if not selected:
|
|
self.image_index_dropdown.lineEdit().setText("<None Selected>")
|
|
else:
|
|
# Only show the index part
|
|
index_labels = [s.split(" ")[0] for s in selected]
|
|
self.image_index_dropdown.lineEdit().setText(", ".join(index_labels))
|
|
|
|
|
|
def _update_event_dropdown(self):
|
|
is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b')
|
|
|
|
bypass = False
|
|
main_win = next((w for w in QApplication.topLevelWidgets()
|
|
if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None)
|
|
if main_win:
|
|
bypass = getattr(main_win, "missing_events_bypass", False)
|
|
|
|
if is_split_group:
|
|
names_a = self._get_checked_items(self.participant_dropdown_a)
|
|
names_b = self._get_checked_items(self.participant_dropdown_b)
|
|
|
|
if not names_a or not names_b:
|
|
self._clear_event_dropdown()
|
|
return
|
|
|
|
map_a = getattr(self, 'participant_map_a', {})
|
|
rev_a = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_a.items()}
|
|
sets_a = []
|
|
for n in names_a:
|
|
raw = self.haemo_dict.get(rev_a.get(n))
|
|
if raw and hasattr(raw, "annotations"):
|
|
sets_a.append(set(raw.annotations.description))
|
|
|
|
map_b = getattr(self, 'participant_map_b', {})
|
|
rev_b = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_b.items()}
|
|
sets_b = []
|
|
for n in names_b:
|
|
raw = self.haemo_dict.get(rev_b.get(n))
|
|
if raw and hasattr(raw, "annotations"):
|
|
sets_b.append(set(raw.annotations.description))
|
|
|
|
if not sets_a or not sets_b:
|
|
self._clear_event_dropdown()
|
|
return
|
|
|
|
if not bypass:
|
|
final_annotations = set.intersection(*(sets_a + sets_b))
|
|
else:
|
|
all_events_a = {event for s in sets_a for event in s}
|
|
all_events_b = {event for s in sets_b for event in s}
|
|
|
|
valid_a = set()
|
|
for event in all_events_a:
|
|
count = sum(1 for s in sets_a if event in s)
|
|
if count >= 2:
|
|
valid_a.add(event)
|
|
|
|
valid_b = set()
|
|
for event in all_events_b:
|
|
count = sum(1 for s in sets_b if event in s)
|
|
if count >= 2:
|
|
valid_b.add(event)
|
|
|
|
final_annotations = valid_a.intersection(valid_b)
|
|
|
|
else:
|
|
names = self._get_checked_items(self.participant_dropdown)
|
|
if not names:
|
|
self._clear_event_dropdown()
|
|
return
|
|
|
|
map_single = getattr(self, 'participant_map', {})
|
|
rev_single = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_single.items()}
|
|
all_sets = []
|
|
for n in names:
|
|
raw = self.haemo_dict.get(rev_single.get(n))
|
|
if raw and hasattr(raw, "annotations"):
|
|
all_sets.append(set(raw.annotations.description))
|
|
|
|
if not all_sets:
|
|
self._clear_event_dropdown()
|
|
return
|
|
|
|
if not bypass:
|
|
final_annotations = set.intersection(*all_sets)
|
|
else:
|
|
final_annotations = set.union(*all_sets)
|
|
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
for ann in sorted(final_annotations):
|
|
self.event_dropdown.addItem(ann)
|
|
|
|
def _clear_event_dropdown(self):
|
|
if hasattr(self, 'event_dropdown'):
|
|
self.event_dropdown.clear()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
|
|
|
|
def _connect_select_all_toggle(self, toggle_item, model):
|
|
"""Helper function to connect the Select All functionality."""
|
|
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle
|
|
|
|
def on_item_changed(item):
|
|
if self._updating_checkstates:
|
|
return
|
|
self._updating_checkstates = True
|
|
|
|
if item == toggle_item:
|
|
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
|
if all_checked:
|
|
for i in normal_items:
|
|
i.setCheckState(Qt.Unchecked)
|
|
toggle_item.setCheckState(Qt.Unchecked)
|
|
else:
|
|
for i in normal_items:
|
|
i.setCheckState(Qt.Checked)
|
|
toggle_item.setCheckState(Qt.Checked)
|
|
|
|
else:
|
|
# When normal items change, update toggle item
|
|
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
|
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
|
|
|
if hasattr(self, 'participant_dropdown_a') and model == self.participant_dropdown_a.model():
|
|
self.update_participant_dropdown_label(self.participant_dropdown_a)
|
|
elif hasattr(self, 'participant_dropdown_b') and model == self.participant_dropdown_b.model():
|
|
self.update_participant_dropdown_label(self.participant_dropdown_b)
|
|
|
|
# Update label text immediately after change
|
|
if self.participant_dropdown:
|
|
self.update_participant_dropdown_label()
|
|
|
|
self._updating_checkstates = False
|
|
|
|
model.itemChanged.connect(on_item_changed)
|
|
|
|
|
|
|
|
def update_participant_list_for_group(self, group_name=None, combo=None):
|
|
|
|
target_combo = combo if combo is not None else getattr(self, "participant_dropdown", None)
|
|
if not target_combo:
|
|
return
|
|
|
|
if isinstance(group_name, int) and combo is None:
|
|
target_group = self.group_dropdown.currentText()
|
|
elif group_name is not None:
|
|
target_group = group_name
|
|
else:
|
|
# If we have no group_name, look up the text from the correct dropdown
|
|
if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a:
|
|
target_group = self.group_a_dropdown.currentText()
|
|
elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b:
|
|
target_group = self.group_b_dropdown.currentText()
|
|
else:
|
|
target_group = self.group_dropdown.currentText()
|
|
|
|
if hasattr(self, 'participant_dropdown_a') and target_combo is self.participant_dropdown_a:
|
|
self.participant_map_a = {}
|
|
active_map = self.participant_map_a
|
|
elif hasattr(self, 'participant_dropdown_b') and target_combo is self.participant_dropdown_b:
|
|
self.participant_map_b = {}
|
|
active_map = self.participant_map_b
|
|
else:
|
|
self.participant_map = {}
|
|
active_map = self.participant_map
|
|
|
|
# 4. Refresh the Model
|
|
model = target_combo.model()
|
|
model.clear()
|
|
|
|
for text in ["<None Selected>", "Toggle Select All"]:
|
|
item = QStandardItem(str(text))
|
|
if text == "Toggle Select All":
|
|
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
item.setData(Qt.Unchecked, Qt.CheckStateRole)
|
|
toggle_ref = item
|
|
else:
|
|
item.setFlags(Qt.ItemIsEnabled)
|
|
model.appendRow(item)
|
|
|
|
# 5. Populate Data
|
|
if str(target_group) == "<None Selected>":
|
|
target_combo.setEnabled(False)
|
|
self.update_participant_dropdown_label(combo=target_combo)
|
|
return
|
|
|
|
target_combo.setEnabled(True)
|
|
# Get file paths (handles target_group as int or str)
|
|
group_file_paths = self.group_to_paths.get(target_group, [])
|
|
|
|
for i, file_path in enumerate(group_file_paths, start=1):
|
|
short_label = f"Participant {i}"
|
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
|
active_map[file_path] = short_label
|
|
|
|
item = QStandardItem(display_label)
|
|
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
item.setData(Qt.Unchecked, Qt.CheckStateRole)
|
|
model.appendRow(item)
|
|
|
|
self._connect_select_all_toggle(toggle_ref, model)
|
|
self.update_participant_dropdown_label(combo=target_combo)
|
|
|
|
|
|
class InterGroupUIMixin:
|
|
|
|
participant_map: dict[str, str]
|
|
|
|
def setup_inter_group_ui(
|
|
self,
|
|
index_texts: Sequence[str],
|
|
placeholder_text: str = ""
|
|
) -> None:
|
|
|
|
self.group_to_paths = {}
|
|
for file_path, group_name in self.group_dict.items():
|
|
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
|
|
|
self.group_names = sorted(self.group_to_paths.keys())
|
|
|
|
self.main_layout = QVBoxLayout(self)
|
|
self.top_bar = QHBoxLayout()
|
|
self.main_layout.addLayout(self.top_bar)
|
|
|
|
|
|
self.group_a_dropdown = QComboBox()
|
|
self.group_a_dropdown.addItem("<None Selected>")
|
|
self.group_a_dropdown.addItems(self.group_names)
|
|
self.group_a_dropdown.currentIndexChanged.connect(self._update_group_a_options)
|
|
|
|
|
|
self.group_b_dropdown = QComboBox()
|
|
self.group_b_dropdown.addItem("<None Selected>")
|
|
self.group_b_dropdown.addItems(self.group_names)
|
|
self.group_b_dropdown.currentIndexChanged.connect(self._update_group_b_options)
|
|
|
|
|
|
self.event_dropdown = QComboBox()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
|
|
|
|
self.participant_dropdown_a = self._create_multiselect_dropdown([])
|
|
line_edit = self.participant_dropdown_a.lineEdit()
|
|
assert line_edit is not None, "Dropdown A must be editable to have a lineEdit"
|
|
line_edit.setPlaceholderText("Select participants (Group A)")
|
|
model = self.participant_dropdown_a.model()
|
|
assert isinstance(model, QStandardItemModel), "Model must be QStandardItemModel"
|
|
model.itemChanged.connect(self._on_participants_changed)
|
|
|
|
|
|
self.participant_dropdown_b = self._create_multiselect_dropdown([])
|
|
line_edit = self.participant_dropdown_b.lineEdit()
|
|
assert line_edit is not None, "Dropdown B must be editable to have a lineEdit"
|
|
line_edit.setPlaceholderText("Select participants (Group B)")
|
|
model = self.participant_dropdown_b.model()
|
|
assert isinstance(model, QStandardItemModel), "Model must be QStandardItemModel"
|
|
model.itemChanged.connect(self._on_participants_changed)
|
|
|
|
|
|
self.index_texts = index_texts
|
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
|
|
|
|
|
self.submit_button = QPushButton("Submit")
|
|
self.submit_button.clicked.connect(self.process_request)
|
|
|
|
|
|
self.top_bar.addWidget(QLabel("Group A:"))
|
|
self.top_bar.addWidget(self.group_a_dropdown)
|
|
self.top_bar.addWidget(QLabel("Participants (Group A):"))
|
|
self.top_bar.addWidget(self.participant_dropdown_a)
|
|
self.top_bar.addWidget(QLabel("Group B:"))
|
|
self.top_bar.addWidget(self.group_b_dropdown)
|
|
self.top_bar.addWidget(QLabel("Participants (Group B):"))
|
|
self.top_bar.addWidget(self.participant_dropdown_b)
|
|
self.top_bar.addWidget(QLabel("Event:"))
|
|
self.top_bar.addWidget(self.event_dropdown)
|
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
self.top_bar.addWidget(self.image_index_dropdown)
|
|
self.top_bar.addWidget(self.submit_button)
|
|
|
|
self.scroll_area = QScrollArea()
|
|
self.scroll_area.setWidgetResizable(True)
|
|
self.scroll_content = QWidget()
|
|
self.grid_layout = QGridLayout(self.scroll_content)
|
|
self.scroll_area.setWidget(self.scroll_content)
|
|
self.placeholder_label = QLabel(placeholder_text)
|
|
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
|
|
self.placeholder_label.setWordWrap(True)
|
|
self.placeholder_label.setScaledContents(True)
|
|
self.main_layout.addWidget(self.scroll_area)
|
|
|
|
self.thumb_size = QSize(280, 180)
|
|
self.showMaximized()
|
|
|
|
def _update_group_b_options(self):
|
|
"""Triggered when Group B changes: Update Group A to exclude B's choice"""
|
|
selected_b = self.group_b_dropdown.currentText()
|
|
|
|
# Refresh Group A and exclude what was just picked in Group B
|
|
self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b)
|
|
|
|
# Update the participants for Group B
|
|
self.update_participant_list_for_group(selected_b, self.participant_dropdown_b)
|
|
self._update_event_dropdown()
|
|
|
|
def _update_group_a_options(self):
|
|
"""Triggered when Group A changes: Update Group B to exclude A's choice"""
|
|
selected_a = self.group_a_dropdown.currentText()
|
|
|
|
# Refresh Group B and exclude what was just picked in Group A
|
|
self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a)
|
|
|
|
# Update the participants for Group A
|
|
self.update_participant_list_for_group(selected_a, self.participant_dropdown_a)
|
|
self._update_event_dropdown()
|
|
|
|
def _on_participants_changed(self, item=None):
|
|
self._update_event_dropdown()
|
|
|
|
|
|
def _refresh_group_dropdown(self, dropdown, exclude):
|
|
current = dropdown.currentText()
|
|
dropdown.blockSignals(True)
|
|
dropdown.clear()
|
|
dropdown.addItem("<None Selected>")
|
|
for group in self.group_names:
|
|
if group != exclude:
|
|
dropdown.addItem(group)
|
|
# Restore previous selection if still valid
|
|
if current != "<None Selected>" and current != exclude and dropdown.findText(current) != -1:
|
|
dropdown.setCurrentText(current)
|
|
else:
|
|
dropdown.setCurrentIndex(0) # Reset to "<None Selected>"
|
|
dropdown.blockSignals(False)
|
|
|
|
|
|
|
|
|
|
def _get_file_paths_from_labels(self, labels, group_name):
|
|
file_paths = []
|
|
|
|
if group_name == self.group_a_dropdown.currentText():
|
|
participant_map = self.participant_map_a
|
|
elif group_name == self.group_b_dropdown.currentText():
|
|
participant_map = self.participant_map_b
|
|
else:
|
|
return []
|
|
|
|
# Reverse map: display label -> file path
|
|
reverse_map = {
|
|
f"{label} ({os.path.basename(fp)})": fp
|
|
for fp, label in participant_map.items()
|
|
}
|
|
|
|
for label in labels:
|
|
file_path = reverse_map.get(label)
|
|
if file_path:
|
|
file_paths.append(file_path)
|
|
|
|
return file_paths
|
|
|
|
def get_common_request_data(
|
|
self,
|
|
parameterized_indexes: dict[int, list[dict[str, Any]]],
|
|
df_ind_dict: dict[str, DataFrame] | None = None,
|
|
contrast_dfs: dict[str, dict[str, Any]] | None = None,
|
|
) -> tuple[str | None, list[str], list[str], list[str], list[int], dict[str, Any]] | None:
|
|
|
|
selected_event = self.event_dropdown.currentText()
|
|
if selected_event == "<None Selected>":
|
|
selected_event = None
|
|
|
|
participants_a = self._get_checked_items(self.participant_dropdown_a)
|
|
file_paths_a = self._get_file_paths_from_labels(
|
|
participants_a, self.group_a_dropdown.currentText()
|
|
)
|
|
|
|
participants_b = self._get_checked_items(self.participant_dropdown_b)
|
|
file_paths_b = self._get_file_paths_from_labels(
|
|
participants_b, self.group_b_dropdown.currentText()
|
|
)
|
|
|
|
selected_indexes = [
|
|
int(s.split(" ")[0])
|
|
for s in self._get_checked_items(self.image_index_dropdown)
|
|
]
|
|
|
|
all_selected_paths = list(set(file_paths_a + file_paths_b))
|
|
|
|
if not all_selected_paths:
|
|
print("No participants selected.")
|
|
return None
|
|
|
|
# Inject full_text
|
|
for idx, params_list in parameterized_indexes.items():
|
|
full_text = self.index_texts[idx]
|
|
for param in params_list:
|
|
param["full_text"] = full_text
|
|
|
|
indexes_needing_params = {
|
|
idx: parameterized_indexes[idx]
|
|
for idx in selected_indexes
|
|
if idx in parameterized_indexes
|
|
}
|
|
|
|
dynamic_rois = []
|
|
|
|
if df_ind_dict:
|
|
roi_set = set()
|
|
for fp in all_selected_paths:
|
|
df_roi = df_ind_dict.get(fp)
|
|
if isinstance(df_roi, pd.DataFrame) and "ROI" in df_roi.columns:
|
|
roi_set.update(df_roi["ROI"].dropna().unique())
|
|
|
|
if roi_set:
|
|
dynamic_rois = sorted(list(roi_set))
|
|
|
|
# Fallback to prevent UI crashes if JSON file doesn't exist or is empty
|
|
if not dynamic_rois:
|
|
dynamic_rois = ["Option 1", "Option 2"]
|
|
|
|
dynamic_contrasts = []
|
|
if contrast_dfs:
|
|
contrast_set = set()
|
|
for fp in all_selected_paths:
|
|
# Get the contrasts dictionary associated with this file path
|
|
file_contrasts = contrast_dfs.get(fp, {})
|
|
for contrast_name in file_contrasts.keys():
|
|
# If no event is selected, display all contrasts.
|
|
# If an event is selected, only keep contrasts containing the event name as a substring.
|
|
if selected_event is None or selected_event in contrast_name:
|
|
contrast_set.add(contrast_name)
|
|
|
|
# Sort them cleanly for the UI
|
|
dynamic_contrasts = sorted(list(contrast_set))
|
|
|
|
# 2. Loop through the active parameters needing input and intercept 'roi_a' and 'roi_b'
|
|
for idx, params_list in indexes_needing_params.items():
|
|
for param_info in params_list:
|
|
if param_info["key"] == "roi_a":
|
|
# Inject options list dynamically
|
|
param_info["options"] = dynamic_rois
|
|
# Default to the very first item
|
|
param_info["default"] = dynamic_rois[0] if dynamic_rois else ""
|
|
|
|
elif param_info["key"] == "roi_b":
|
|
# Inject the same options list
|
|
param_info["options"] = dynamic_rois
|
|
# Default to the first item not taken (index 1), with safety fallbacks
|
|
if len(dynamic_rois) > 1:
|
|
param_info["default"] = dynamic_rois[1]
|
|
elif len(dynamic_rois) == 1:
|
|
param_info["default"] = dynamic_rois[0]
|
|
else:
|
|
param_info["default"] = ""
|
|
|
|
elif param_info["key"] == "contrast_name":
|
|
param_info["options"] = dynamic_contrasts
|
|
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
|
|
|
param_values = {}
|
|
if indexes_needing_params:
|
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
return None
|
|
|
|
param_values = dialog.get_values()
|
|
if param_values is None:
|
|
return None
|
|
|
|
return (
|
|
selected_event,
|
|
file_paths_a,
|
|
file_paths_b,
|
|
all_selected_paths,
|
|
selected_indexes,
|
|
param_values,
|
|
)
|
|
|
|
|
|
class CSVUIMixin:
|
|
|
|
def setup_csv_ui(
|
|
self,
|
|
index_texts: Sequence[str]
|
|
) -> None:
|
|
|
|
# Create mappings: file_path -> participant label and dropdown display text
|
|
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
|
self.participant_dropdown_items: list[str] = [] # "Participant 1 (filename)"
|
|
|
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
|
short_label = f"Participant {i}"
|
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
|
self.participant_map[file_path] = short_label
|
|
self.participant_dropdown_items.append(display_label)
|
|
|
|
self.layout = QVBoxLayout(self)
|
|
self.top_bar = QHBoxLayout()
|
|
self.layout.addLayout(self.top_bar)
|
|
|
|
self.participant_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
|
|
|
self.index_texts = index_texts
|
|
|
|
self.image_index_dropdown: FullClickComboBox = self._create_multiselect_dropdown(self.index_texts)
|
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
|
|
|
self.submit_button = QPushButton("Submit")
|
|
self.submit_button.clicked.connect(self.process_request)
|
|
|
|
self.top_bar.addWidget(QLabel("Participants:"))
|
|
self.top_bar.addWidget(self.participant_dropdown)
|
|
self.top_bar.addWidget(QLabel("Export Type:"))
|
|
self.top_bar.addWidget(self.image_index_dropdown)
|
|
self.top_bar.addWidget(self.submit_button)
|
|
|
|
self.scroll = QScrollArea()
|
|
self.scroll.setWidgetResizable(True)
|
|
self.scroll_content = QWidget()
|
|
self.grid_layout = QGridLayout(self.scroll_content)
|
|
self.scroll.setWidget(self.scroll_content)
|
|
self.placeholder_label = QLabel("")
|
|
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
|
|
self.placeholder_label.setWordWrap(True)
|
|
self.placeholder_label.setScaledContents(True)
|
|
self.layout.addWidget(self.scroll)
|
|
|
|
self.thumb_size = QSize(280, 180)
|
|
self.showMaximized()
|
|
|
|
|
|
|
|
class IntraGroupUIMixin:
|
|
|
|
def setup_intra_group_ui(
|
|
self,
|
|
index_texts: Sequence[str],
|
|
placeholder_text: str = ""
|
|
) -> None:
|
|
|
|
self.show_all_events = True
|
|
self._updating_checkstates = False
|
|
|
|
# Create mappings: file_path -> participant label and dropdown display text
|
|
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
|
|
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
|
short_label = f"Participant {i}"
|
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
|
self.participant_map[file_path] = short_label
|
|
self.participant_dropdown_items.append(display_label)
|
|
|
|
self.layout = QVBoxLayout(self)
|
|
self.top_bar = QHBoxLayout()
|
|
self.layout.addLayout(self.top_bar)
|
|
|
|
self.group_to_paths = {}
|
|
for file_path, group_name in self.group_dict.items():
|
|
self.group_to_paths.setdefault(group_name, []).append(file_path)
|
|
|
|
self.group_names = sorted(self.group_to_paths.keys())
|
|
|
|
self.group_dropdown = QComboBox()
|
|
self.group_dropdown.addItem("<None Selected>")
|
|
self.group_dropdown.addItems(self.group_names)
|
|
self.group_dropdown.setCurrentIndex(0)
|
|
self.group_dropdown.currentIndexChanged.connect(self.update_participant_list_for_group)
|
|
|
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
|
self.participant_dropdown.setEnabled(False)
|
|
|
|
self.event_dropdown = QComboBox()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
|
|
self.index_texts = index_texts
|
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
|
|
|
self.submit_button = QPushButton("Submit")
|
|
self.submit_button.clicked.connect(self.process_request)
|
|
|
|
self.top_bar.addWidget(QLabel("Group:"))
|
|
self.top_bar.addWidget(self.group_dropdown)
|
|
self.top_bar.addWidget(QLabel("Participants:"))
|
|
self.top_bar.addWidget(self.participant_dropdown)
|
|
self.top_bar.addWidget(QLabel("Event:"))
|
|
self.top_bar.addWidget(self.event_dropdown)
|
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
self.top_bar.addWidget(self.image_index_dropdown)
|
|
self.top_bar.addWidget(self.submit_button)
|
|
|
|
self.scroll = QScrollArea()
|
|
self.scroll.setWidgetResizable(True)
|
|
self.scroll_content = QWidget()
|
|
self.grid_layout = QGridLayout(self.scroll_content)
|
|
self.scroll.setWidget(self.scroll_content)
|
|
self.placeholder_label = QLabel(placeholder_text)
|
|
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
|
|
self.placeholder_label.setWordWrap(True)
|
|
self.placeholder_label.setScaledContents(True)
|
|
self.layout.addWidget(self.scroll)
|
|
|
|
self.thumb_size = QSize(280, 180)
|
|
self.showMaximized()
|
|
|
|
def get_common_request_data(
|
|
self,
|
|
parameterized_indexes: dict[int, list[dict[str, Any]]],
|
|
df_ind_dict: dict[str, DataFrame] | None = None,
|
|
contrast_dfs: dict[str, dict[str, Any]] | None = None,
|
|
) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None:
|
|
|
|
selected_event = self.event_dropdown.currentText()
|
|
if selected_event == "<None Selected>":
|
|
selected_event = None
|
|
|
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
|
selected_file_paths = []
|
|
for display_name in selected_display_names:
|
|
for fp, short_label in self.participant_map.items():
|
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
|
if display_name == expected_display:
|
|
selected_file_paths.append(fp)
|
|
break
|
|
|
|
if selected_event:
|
|
valid_paths = []
|
|
for fp in selected_file_paths:
|
|
raw = self.haemo_dict.get(fp)
|
|
# Check if this participant actually has the event in their annotations
|
|
if raw is not None and hasattr(raw, "annotations"):
|
|
if selected_event in raw.annotations.description:
|
|
valid_paths.append(fp)
|
|
|
|
selected_file_paths = valid_paths
|
|
|
|
selected_indexes = [
|
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
|
]
|
|
|
|
if not selected_file_paths:
|
|
print("No participants selected.")
|
|
return
|
|
|
|
|
|
# Inject full_text from index_texts
|
|
for idx, params_list in parameterized_indexes.items():
|
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
|
for param_info in params_list:
|
|
param_info["full_text"] = full_text
|
|
|
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
|
|
|
dynamic_rois = []
|
|
|
|
if df_ind_dict:
|
|
roi_set = set()
|
|
for fp in selected_file_paths:
|
|
df_roi = df_ind_dict.get(fp)
|
|
if isinstance(df_roi, pd.DataFrame) and "ROI" in df_roi.columns:
|
|
roi_set.update(df_roi["ROI"].dropna().unique())
|
|
|
|
if roi_set:
|
|
dynamic_rois = sorted(list(roi_set))
|
|
|
|
# Fallback to prevent UI crashes if JSON file doesn't exist or is empty
|
|
if not dynamic_rois:
|
|
dynamic_rois = ["Option 1", "Option 2"]
|
|
|
|
dynamic_contrasts = []
|
|
if contrast_dfs:
|
|
contrast_set = set()
|
|
for fp in selected_file_paths:
|
|
# Get the contrasts dictionary associated with this file path
|
|
file_contrasts = contrast_dfs.get(fp, {})
|
|
for contrast_name in file_contrasts.keys():
|
|
# If no event is selected, display all contrasts.
|
|
# If an event is selected, only keep contrasts containing the event name as a substring.
|
|
if selected_event is None or selected_event in contrast_name:
|
|
contrast_set.add(contrast_name)
|
|
|
|
# Sort them cleanly for the UI
|
|
dynamic_contrasts = sorted(list(contrast_set))
|
|
|
|
# 2. Loop through the active parameters needing input and intercept 'roi_a' and 'roi_b'
|
|
for idx, params_list in indexes_needing_params.items():
|
|
for param_info in params_list:
|
|
if param_info["key"] == "roi_a":
|
|
# Inject options list dynamically
|
|
param_info["options"] = dynamic_rois
|
|
# Default to the very first item
|
|
param_info["default"] = dynamic_rois[0] if dynamic_rois else ""
|
|
|
|
elif param_info["key"] == "roi_b":
|
|
# Inject the same options list
|
|
param_info["options"] = dynamic_rois
|
|
# Default to the first item not taken (index 1), with safety fallbacks
|
|
if len(dynamic_rois) > 1:
|
|
param_info["default"] = dynamic_rois[1]
|
|
elif len(dynamic_rois) == 1:
|
|
param_info["default"] = dynamic_rois[0]
|
|
else:
|
|
param_info["default"] = ""
|
|
|
|
elif param_info["key"] == "contrast_name":
|
|
param_info["options"] = dynamic_contrasts
|
|
param_info["default"] = dynamic_contrasts[0] if dynamic_contrasts else ""
|
|
|
|
|
|
param_values = {}
|
|
if indexes_needing_params:
|
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
|
if dialog.exec_() == QDialog.Accepted:
|
|
param_values = dialog.get_values()
|
|
if param_values is None:
|
|
return
|
|
else:
|
|
return
|
|
|
|
return (
|
|
selected_event,
|
|
selected_file_paths,
|
|
selected_indexes,
|
|
param_values,
|
|
)
|
|
|
|
class ParticipantUIMixin:
|
|
def setup_participant_ui(
|
|
self,
|
|
index_texts: Sequence[str],
|
|
placeholder_text: str = ""
|
|
) -> None:
|
|
|
|
# Create mappings: file_path -> participant label and dropdown display text
|
|
self.participant_map: dict[str, str] = {} # file_path -> "Participant 1"
|
|
self.participant_dropdown_items = [] # "Participant 1 (filename)"
|
|
|
|
for i, file_path in enumerate(self.haemo_dict.keys(), start=1):
|
|
short_label = f"Participant {i}"
|
|
display_label = f"{short_label} ({os.path.basename(file_path)})"
|
|
self.participant_map[file_path] = short_label
|
|
self.participant_dropdown_items.append(display_label)
|
|
|
|
self.main_layout = QVBoxLayout(self)
|
|
self.top_bar = QHBoxLayout()
|
|
self.main_layout.addLayout(self.top_bar)
|
|
|
|
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
|
|
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
|
|
|
|
self.event_dropdown = QComboBox()
|
|
self.event_dropdown.addItem("<None Selected>")
|
|
|
|
|
|
self.index_texts = index_texts
|
|
|
|
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
|
|
self.image_index_dropdown.currentIndexChanged.connect(self.update_image_index_dropdown_label)
|
|
|
|
self.submit_button = QPushButton("Submit")
|
|
self.submit_button.clicked.connect(self.process_request)
|
|
|
|
self.top_bar.addWidget(QLabel("Participants:"))
|
|
self.top_bar.addWidget(self.participant_dropdown)
|
|
self.top_bar.addWidget(QLabel("Event:"))
|
|
self.top_bar.addWidget(self.event_dropdown)
|
|
self.top_bar.addWidget(QLabel("Image Indexes:"))
|
|
self.top_bar.addWidget(self.image_index_dropdown)
|
|
self.top_bar.addWidget(self.submit_button)
|
|
|
|
self.scroll_area = QScrollArea()
|
|
self.scroll_area.setWidgetResizable(True)
|
|
self.scroll_content = QWidget()
|
|
self.grid_layout = QGridLayout(self.scroll_content)
|
|
self.scroll_area.setWidget(self.scroll_content)
|
|
self.placeholder_label = QLabel(placeholder_text)
|
|
self.grid_layout.addWidget(self.placeholder_label, 0, 0)
|
|
self.placeholder_label.setWordWrap(True)
|
|
self.placeholder_label.setScaledContents(True)
|
|
self.main_layout.addWidget(self.scroll_area)
|
|
|
|
self.thumb_size = QSize(280, 180)
|
|
self.showMaximized()
|
|
|
|
|
|
def get_common_request_data(
|
|
self,
|
|
parameterized_indexes: dict[int, list[dict[str, Any]]]
|
|
) -> tuple[str | None, list[str], list[int], dict[str, Any]] | None:
|
|
|
|
selected_event = self.event_dropdown.currentText()
|
|
if selected_event == "<None Selected>":
|
|
selected_event = None
|
|
|
|
selected_display_names = self._get_checked_items(self.participant_dropdown)
|
|
selected_file_paths = []
|
|
for display_name in selected_display_names:
|
|
for fp, short_label in self.participant_map.items():
|
|
expected_display = f"{short_label} ({os.path.basename(fp)})"
|
|
if display_name == expected_display:
|
|
selected_file_paths.append(fp)
|
|
break
|
|
|
|
selected_indexes = [
|
|
int(s.split(" ")[0]) for s in self._get_checked_items(self.image_index_dropdown)
|
|
]
|
|
|
|
|
|
|
|
|
|
# Inject full_text from index_texts
|
|
for idx, params_list in parameterized_indexes.items():
|
|
full_text = self.index_texts[idx] if idx < len(self.index_texts) else f"{idx} (No label found)"
|
|
for param_info in params_list:
|
|
param_info["full_text"] = full_text
|
|
|
|
indexes_needing_params = {idx: parameterized_indexes[idx] for idx in selected_indexes if idx in parameterized_indexes}
|
|
|
|
param_values = {}
|
|
if indexes_needing_params:
|
|
dialog = ParameterInputDialog(indexes_needing_params, parent=self)
|
|
if dialog.exec_() == QDialog.Accepted:
|
|
param_values = dialog.get_values()
|
|
if param_values is None:
|
|
return
|
|
else:
|
|
return
|
|
|
|
return (
|
|
selected_event,
|
|
selected_file_paths,
|
|
selected_indexes,
|
|
param_values,
|
|
) |