diff --git a/.idea/misc.xml b/.idea/misc.xml index a909caa..f143d42 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,5 +3,5 @@ - + \ No newline at end of file diff --git a/.idea/pyORG_Calculation.iml b/.idea/pyORG_Calculation.iml index 3437622..201e3f1 100644 --- a/.idea/pyORG_Calculation.iml +++ b/.idea/pyORG_Calculation.iml @@ -6,7 +6,7 @@ - + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/ocvl/function/gui/constructors.py b/ocvl/function/gui/constructors.py index dd548b0..90edf55 100644 --- a/ocvl/function/gui/constructors.py +++ b/ocvl/function/gui/constructors.py @@ -6,7 +6,7 @@ QLabel, QComboBox, QTextEdit, QLineEdit, QPushButton, QRadioButton, QHBoxLayout, QButtonGroup, QCheckBox, QSizePolicy, QDialog, QDialogButtonBox, QFileDialog, QSplitter, QListWidget, - QInputDialog, QMenu, QMessageBox, QFrame, QGridLayout) + QInputDialog, QMenu, QMessageBox, QFrame, QGridLayout, QListWidgetItem) from PySide6.QtCore import Qt, QSize, Signal from PySide6.QtWidgets import QWidget, QHBoxLayout, QCheckBox @@ -323,25 +323,39 @@ class FormatElementsEditor(QDialog): """Dialog for editing filename format using predefined elements""" copyRequested = Signal(str, str, str) - def __init__(self, current_format=None, parent=None, queryloc=False, section_name=None, format_key=None, enable_copy=True): + def __init__(self, current_format=None, parent=None, type=None, section_name=None, format_key=None, + enable_copy=True, show_extensions=True): super().__init__(parent) self.setWindowTitle("Format Editor") - self.setGeometry(500, 500, 650, 500) + self.setGeometry(600, 600, 650, 500) self.original_elements = [ ":.1", "Day", "Eye", "FOV_Height", "FOV_Width", "IDnum", "LocX", "LocY", "Modality", "Month", "VidNum", "Year" ] - if queryloc: self.original_elements.append("QueryLoc") + if type == 'queryloc': + self.original_elements.append("QueryLoc") self.available_elements = self.original_elements.copy() self.section_name = section_name self.format_key = format_key + self.type = type + + self.extension_options = self._get_extension_options() + self.existing_extension = None # Track existing extension from imported format self.copy_button = QPushButton("Copy to All in Section") self.copy_button.clicked.connect(self.copy_to_all) + # Extension dropdown is optional (GroupBy should not show one) + self.file_type_combo = None + self.show_extensions = show_extensions + if self.show_extensions: + self.file_type_combo = QComboBox() + self.file_type_combo.addItems(self.extension_options) + self.file_type_combo.currentTextChanged.connect(self.update_preview) + # === MAIN VERTICAL LAYOUT === window_layout = QVBoxLayout(self) @@ -351,15 +365,20 @@ def __init__(self, current_format=None, parent=None, queryloc=False, section_nam self.preview_label.setStyleSheet("font-weight: bold; padding: 6px;") self.preview_display = QLabel("") self.preview_display.setStyleSheet(""" - QLabel { - padding: 6px; - color: #333; - min-height: 40px; + QToolButton { + border: none; + text-align: left; + padding: 5px; + } + QToolButton:disabled { + color: gray; } """) preview_layout.addWidget(self.preview_label) preview_layout.addWidget(self.preview_display) + if self.file_type_combo is not None: + preview_layout.addWidget(self.file_type_combo) if enable_copy: preview_layout.addWidget(self.copy_button) preview_layout.setAlignment(Qt.AlignLeft) @@ -438,7 +457,7 @@ def __init__(self, current_format=None, parent=None, queryloc=False, section_nam self.move_up_button = QPushButton("Move Up") self.move_down_button = QPushButton("Move Down") - self.add_separator_button = QPushButton("Add Static Text") + self.add_separator_button = QPushButton("Add Text") self.clear_button = QPushButton("Clear All") # Make buttons consistent size @@ -510,6 +529,64 @@ def __init__(self, current_format=None, parent=None, queryloc=False, section_nam # Update the preview initially self.update_preview() + def _add_item_to_selected_list(self, internal_text, display_text=None): + """Helper method to add items to selected list with internal and display text""" + item = QListWidgetItem() + item.setData(Qt.UserRole, internal_text) # Store internal representation + item.setText(display_text if display_text else internal_text) # Set display text + self.selected_list.addItem(item) + return item + + def _get_item_internal_text(self, item): + """Get the internal text representation of an item""" + internal_text = item.data(Qt.UserRole) + return internal_text if internal_text else item.text() + + def _get_extension_options(self): + """Get file extension options based on format type""" + if self.type == "image": + return [".tif", ".png", ".jpg", ".mat", ".npy"] + elif self.type in ("video", "mask"): + return [".avi", ".mov", ".mat", ".npy"] + elif self.type == "meta": + return [".txt", ".json", ".xml", ".csv", ".log"] + elif self.type == "queryloc": + return [".txt", ".csv", ".json", ".dat"] + else: + return [".txt", ".dat", ".log"] + + def _detect_existing_extension(self, format_string): + """Detect if the format string already contains a file extension""" + if not format_string: + return None + + # Common file extensions to check for + common_extensions = ['.tif', '.png', '.jpg', '.jpeg', '.avi', '.mov', '.mat', '.npy', + '.txt', '.json', '.xml', '.csv', '.log', '.dat'] + + for ext in common_extensions: + if format_string.lower().endswith(ext.lower()): + return ext + + # Check for any extension pattern at the end (dot followed by 2-5 characters) + import re + match = re.search(r'\.([a-zA-Z0-9]{2,5})$', format_string) + if match: + return '.' + match.group(1) + + return None + + def _add_existing_extension_to_dropdown(self, extension): + """Add the existing extension to the dropdown if it's not already there""" + if self.file_type_combo is None: + return # No extension dropdown for this editor + + if extension and extension not in [self.file_type_combo.itemText(i) for i in + range(self.file_type_combo.count())]: + self.file_type_combo.insertItem(0, extension) + self.file_type_combo.insertSeparator(1) + self.file_type_combo.setCurrentText(extension) + def copy_to_all(self): reply = QMessageBox.question( self, @@ -529,7 +606,8 @@ def clear_all_elements(self): items_to_return = [] for i in range(self.selected_list.count()): - item_text = self.selected_list.item(i).text() + item = self.selected_list.item(i) + item_text = self._get_item_internal_text(item) # Only return format elements (not static text) to available list if item_text.startswith("{") and item_text.endswith("}") and not item_text.startswith("{Added Text:"): @@ -568,7 +646,7 @@ def set_element_width(self): "Please select an element from the Selected Format Elements list first.") return - item_text = current_item.text() + item_text = self._get_item_internal_text(current_item) # Check if it's a format element (not a separator) if not item_text.startswith("{") or not item_text.endswith("}") or item_text.startswith("{Added Text:"): @@ -606,20 +684,33 @@ def set_element_width(self): else: # Update the element with new width new_element = f"{{{element_name}:.{width}}}" - current_item.setText(new_element) + + current_item.setData(Qt.UserRole, new_element) + current_item.setText(new_element) # Display text same as internal for format elements self.update_preview() except ValueError: QMessageBox.warning(self, "Invalid Width", "Width must be a number") def parse_format(self, format_string): """Parse an existing format string into elements""" + # Check if format string contains an existing extension + self.existing_extension = self._detect_existing_extension(format_string) + + # If we found an existing extension, remove it from the format string for parsing + # and add it to the dropdown + if self.existing_extension: + format_without_extension = format_string[:-len(self.existing_extension)] + self._add_existing_extension_to_dropdown(self.existing_extension) + else: + format_without_extension = format_string + # First reset available elements self.available_elements = self.original_elements.copy() self.available_list.clear() self.available_list.addItems(self.available_elements) # Parse the string by looking for elements enclosed in brackets - remaining = format_string + remaining = format_without_extension # Process the format string while remaining: @@ -628,25 +719,25 @@ def parse_format(self, format_string): if start_idx == -1: # No more elements in brackets, add remaining as separator if not empty if remaining: - self.selected_list.addItem(f"{{Added Text: {remaining}}}") + self._add_item_to_selected_list(f"{{Added Text: {remaining}}}", remaining) break # Add any text before the bracket as a separator if start_idx > 0: separator = remaining[:start_idx] - self.selected_list.addItem(f"{{Added Text: {separator}}}") + self._add_item_to_selected_list(f"{{Added Text: {separator}}}", separator) # Find closing bracket end_idx = remaining.find('}', start_idx) if end_idx == -1: # No closing bracket, add the rest as a separator - self.selected_list.addItem(f"{{Added Text: {remaining}}}") + self._add_item_to_selected_list(f"{{Added Text: {remaining}}}", remaining) break # Extract the element name element = remaining[start_idx + 1:end_idx] if element in self.available_elements or element == ":.1": - self.selected_list.addItem(f"{{{element}}}") + self._add_item_to_selected_list(f"{{{element}}}") # Remove from available elements if element in self.available_elements: self.available_elements.remove(element) @@ -656,7 +747,7 @@ def parse_format(self, format_string): self.available_list.addItems(self.available_elements) else: # Not a recognized element, treat as a separator with brackets - self.selected_list.addItem(f"{{Added Text: {{{element}}}}}") + self._add_item_to_selected_list(f"{{Added Text: {{{element}}}}}", f"{{{element}}}") # Continue with the remaining string remaining = remaining[end_idx + 1:] @@ -665,7 +756,8 @@ def get_used_elements(self): """Return a set of elements already in use""" used_elements = set() for i in range(self.selected_list.count()): - item_text = self.selected_list.item(i).text() + item = self.selected_list.item(i) + item_text = self._get_item_internal_text(item) if item_text.startswith("{") and item_text.endswith("}") and not item_text.startswith("{Added Text:"): element = item_text[1:-1] # Remove the braces # Get base element name (without width specification) @@ -682,7 +774,7 @@ def add_selected_element(self): return element = self.available_list.currentItem().text() - self.selected_list.addItem(f"{{{element}}}") + self._add_item_to_selected_list(f"{{{element}}}") # Remove from available list row = self.available_list.currentRow() @@ -699,7 +791,7 @@ def remove_selected_element(self): return item = self.selected_list.currentItem() - item_text = item.text() + item_text = self._get_item_internal_text(item) # Only return to available list if it's a format element (not a separator) if item_text.startswith("{") and item_text.endswith("}") and not item_text.startswith("{Added Text:"): @@ -727,7 +819,7 @@ def remove_selected_element(self): def handle_double_click_available(self, item): """Handle double-click on available list item""" element = item.text() - self.selected_list.addItem(f"{{{element}}}") + self._add_item_to_selected_list(f"{{{element}}}") # Remove from available list row = self.available_list.row(item) @@ -738,7 +830,7 @@ def handle_double_click_available(self, item): def handle_double_click_selected(self, item): """Handle double-click on selected list item""" - item_text = item.text() + item_text = self._get_item_internal_text(item) # Only return to available list if it's a format element (not a separator) if item_text.startswith("{") and item_text.endswith("}") and not item_text.startswith("{Added Text:"): @@ -792,35 +884,52 @@ def add_separator(self): # Get selected position or append to end current_row = self.selected_list.currentRow() if current_row >= 0: - self.selected_list.insertItem(current_row + 1, f"{{Added Text: {text}}}") + item = self._add_item_to_selected_list(f"{{Added Text: {text}}}", text) + self.selected_list.insertItem(current_row + 1, + self.selected_list.takeItem(self.selected_list.count() - 1)) self.selected_list.setCurrentRow(current_row + 1) else: - self.selected_list.addItem(f"{{Added Text: {text}}}") + self._add_item_to_selected_list(f"{{Added Text: {text}}}", text) self.selected_list.setCurrentRow(self.selected_list.count() - 1) self.update_preview() def update_preview(self): - """Update the preview label with the current format string with different styling for elements and static text.""" + """Update the preview label with the current format string including file extension (if enabled)""" preview_html = "" - # preview_html = " for i in range(self.selected_list.count()): - item_text = self.selected_list.item(i).text() + item = self.selected_list.item(i) + item_text = self._get_item_internal_text(item) if item_text.startswith("{Added Text: ") and item_text.endswith("}"): - # Static text - display in blue and bold separator = item_text[13:-1] preview_html += f"{separator}" - # preview_html += f"{separator}" else: - # Format element - display in dark green with slight italic preview_html += f"{item_text}" - # preview_html += f"{item_text}" - preview_html += "" - # preview_html += "" + # Only append extension if this editor supports extensions + if self.file_type_combo is not None: + selected_extension = self.file_type_combo.currentText() + if selected_extension and not self._has_extension_in_format(): + preview_html += selected_extension + self.preview_display.setText(preview_html) + def _has_extension_in_format(self): + """Check if the current format already contains a file extension""" + format_without_dropdown = "" + for i in range(self.selected_list.count()): + item = self.selected_list.item(i) + item_text = self._get_item_internal_text(item) + + if item_text.startswith("{Added Text: ") and item_text.endswith("}"): + separator = item_text[13:-1] + format_without_dropdown += separator + else: + format_without_dropdown += item_text + + return self._detect_existing_extension(format_without_dropdown) is not None + def update_tooltip(self, item): """Update tooltip box when hovering over available or selected elements.""" tooltips = { @@ -836,33 +945,31 @@ def update_tooltip(self, item): "Month": "Calendar month in numerical format (1-12)", "VidNum": "Video number identifier", "Year": "Calendar year (4 digits)", - "{Added Text:": "Static text that will appear literally in the filename" } if not item: self.tooltip_label.setText("Hover over an element to see its description") return - item_text = item.text() - - # Handle selected list items (which might have formatting) - if item_text.startswith("{") and item_text.endswith("}"): - if item_text.startswith("{Added Text:"): - # Static text separator + # Check if this is from the selected list (has internal data) + if hasattr(item, 'data') and item.data(Qt.UserRole): + internal_text = item.data(Qt.UserRole) + if internal_text.startswith("{Added Text:"): self.tooltip_label.setText("Static text that will appear literally in the filename") - else: + return + elif internal_text.startswith("{") and internal_text.endswith("}"): # Format element - extract the base name - element = item_text[1:-1] # Remove braces + element = internal_text[1:-1] # Remove braces if ':' in element: element = element.split(':')[0] # Get base element before width spec - - # Get the tooltip or fall back to available elements tooltip - tooltip = tooltips.get(element, tooltips.get(item_text, "No description available")) + tooltip = tooltips.get(element, "No description available") self.tooltip_label.setText(f"{element}: {tooltip}") - else: - # Available list item - tooltip = tooltips.get(item_text, "No description available") - self.tooltip_label.setText(f"{item_text}: {tooltip}") + return + + # Handle available list items or items without internal data + item_text = item.text() + tooltip = tooltips.get(item_text, "No description available") + self.tooltip_label.setText(f"{item_text}: {tooltip}") def show_context_menu(self, position): """Show context menu for the selected list items""" @@ -882,34 +989,40 @@ def show_context_menu(self, position): action = menu.exec(self.selected_list.mapToGlobal(position)) if action == edit_action: - item_text = item.text() + item_text = self._get_item_internal_text(item) # Check if it's a separator if item_text.startswith("{Added Text: ") and item_text.endswith("}"): old_text = item_text[13:-1] # Extract text between "{Added Text: " and "}" new_text, ok = QInputDialog.getText(self, "Edit Added Text", "Edit added text:", text=old_text) if ok: - item.setText(f"{{Added Text: {new_text}}}") + item.setData(Qt.UserRole, f"{{Added Text: {new_text}}}") + item.setText(new_text) # Display just the text elif action == remove_action: self.remove_selected_element() self.update_preview() def get_format_string(self): - """Return the complete format string as plain text without HTML formatting""" + """Return the complete format string including file extension (if enabled)""" format_string = "" for i in range(self.selected_list.count()): - item_text = self.selected_list.item(i).text() + item = self.selected_list.item(i) + item_text = self._get_item_internal_text(item) if item_text.startswith("{Added Text: ") and item_text.endswith("}"): - # Static text - just add the text part separator = item_text[13:-1] format_string += separator else: - # Format element - add as is format_string += item_text + # Only append extension if this editor supports extensions + if self.file_type_combo is not None: + selected_extension = self.file_type_combo.currentText() + if selected_extension and not self._has_extension_in_format(): + format_string += selected_extension + return format_string def get_formatted_preview(self): @@ -931,12 +1044,12 @@ class FormatEditorWidget(QWidget): formatChanged = Signal(str) copyToAllRequested = Signal(str, str, str) - def __init__(self, label_text, default_format="", parent=None, queryloc=False, section_name=None, format_key=None): + def __init__(self, label_text, default_format="", parent=None, type=None, section_name=None, format_key=None): super().__init__(parent) self.default_format = default_format self.current_format = default_format # Initialize with default self.return_text = default_format # Initialize return text with default - self.queryloc = queryloc + self.type = type self.section_name = section_name self.format_key = format_key @@ -957,7 +1070,7 @@ def __init__(self, label_text, default_format="", parent=None, queryloc=False, s def _open_format_editor(self): """Open the format editor dialog""" - dialog = FormatElementsEditor(self.current_format, self, self.queryloc, self.section_name, self.format_key) + dialog = FormatElementsEditor(self.current_format, self, self.type, self.section_name, self.format_key) dialog.copyRequested.connect(self.relay_copy_request) @@ -1068,7 +1181,7 @@ def _open_format_editor(self): # If current format is "null", pass empty string to the dialog current_format = None if self.current_format == "null" else self.current_format - dialog = FormatElementsEditor(current_format, self, enable_copy=False) + dialog = FormatElementsEditor(current_format, self, enable_copy=False, show_extensions=False) # Override the dialog's available elements with our dynamic ones dialog.original_elements = self.available_elements.copy() @@ -1234,10 +1347,6 @@ def __init__(self, title="", default=None, parent=None): border: none; text-align: left; padding: 5px; - color: black; - } - QToolButton:checked { - background-color: #f0f0f0; } QToolButton:disabled { color: gray; @@ -1671,7 +1780,7 @@ def set_value(self, value): """Set the current modality""" self.comboBox.setCurrentText(value if value else "null") -class freeNumber(QWidget): +class freeFloat(QWidget): def __init__(self, parent=None): super().__init__(parent) @@ -1696,6 +1805,31 @@ def get_text(self): elif text: return text +class freeInt(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + main_layout = QVBoxLayout(self) + self.textbox = QLineEdit(self) + int_validator = QIntValidator(-999, 999) + self.textbox.setValidator(int_validator) + main_layout.addWidget(self.textbox) + + def set_text(self, text): + self.textbox.setText(text) + + def get_text(self): + if isinstance(self.textbox, QTextEdit): + text = self.textbox.toPlainText() + else: + text = self.textbox.text() + + # Return placeholder text if the field is empty + if text == "null" or "": + return None + elif text: + return text + class rangeSelector(QWidget): def __init__(self, def_min = None, def_max = None, parent=None): super().__init__(parent) diff --git a/ocvl/function/gui/import_generation.py b/ocvl/function/gui/import_generation.py index d7ce21c..9101160 100644 --- a/ocvl/function/gui/import_generation.py +++ b/ocvl/function/gui/import_generation.py @@ -15,33 +15,59 @@ def extract_widget_type(field_def): return field_def.get("type") return field_def +def create_format_editor_widget_from_spec(field_key: str, widget_spec: dict): + """Build a FormatEditorWidget with its type coming from the template's 'format_type'.""" + fmt_type = (widget_spec or {}).get("format_type") + # Label shown left of the widget row uses format_label(key) elsewhere. + # The FormatEditorWidget itself shows the format string, so label text isn't critical here. + # We still pass something readable as label_text: + return FormatEditorWidget(label_text=format_label(field_key), default_format="", type=fmt_type) + + WIDGET_FACTORY = { - "freeText": lambda: FreetextBox(), - "freeNumber": lambda: freeNumber(), # or use a QSpinBox/DoubleSpinBox if you make one - "trueFalse": lambda: TrueFalseSelector(), - "comboBox": lambda: DropdownMenu(default="null"), - "outputSubfolderMethodComboBox": lambda: DropdownMenu(options=["DateTime", "Date", "Sequential"]), - "shapeComboBox": lambda: DropdownMenu(default="null", options=["disk", "box"]), - "summaryComboBox": lambda: DropdownMenu(default="null", options=["mean", "median"]), - "typeComboBox": lambda: DropdownMenu(default="null", options=["stim-relative", "absolute"]), - "unitsComboBox": lambda: DropdownMenu(default="null", options=["time", "frames"]), - "standardizationMethodComboBox": lambda: DropdownMenu(default="null", + # Main fields + "freeText": lambda config=None: FreetextBox(), + "freeFloat": lambda config=None: freeFloat(), # or use a QSpinBox/DoubleSpinBox if you make one + "freeInt": lambda config=None: freeInt(), + "trueFalse": lambda config=None: TrueFalseSelector(), + "comboBox": lambda config=None: DropdownMenu(default="null"), + "outputSubfolderMethodComboBox": lambda config=None: DropdownMenu(options=["DateTime", "Date", "Sequential"]), + "shapeComboBox": lambda config=None: DropdownMenu(default="null", options=["disk", "box"]), + "summaryComboBox": lambda config=None: DropdownMenu(default="null", options=["mean", "median"]), + "typeComboBox": lambda config=None: DropdownMenu(default="null", options=["stim-relative", "absolute"]), + "unitsComboBox": lambda config=None: DropdownMenu(default="null", options=["time", "frames"]), + "standardizationMethodComboBox": lambda config=None: DropdownMenu(default="null", options=["mean_stddev", "stddev", "linear_stddev", "linear_vast", "relative_change", "none"]), - "summaryMethodComboBox": lambda: DropdownMenu(default="null", options=["rms", "stddev", "var", "avg"]), - "controlComboBox": lambda: DropdownMenu(default="null", options=["none", "subtraction", "division"]), - "listEditor": lambda: ListEditorWidget(), - "openFolder": lambda: OpenFolder(), - "formatEditor": lambda: FormatEditorWidget("Format"), - "groupbyEditor": lambda: GroupByFormatEditorWidget(None, None, None, "Group By"), - "formatEditorQueryloc": lambda: FormatEditorWidget("Format", queryloc=True), - "cmapSelector": lambda: ColorMapSelector(), - "affineRigidSelector": lambda: AffineRigidSelector(), - "saveasSelector": lambda: SaveasExtensionsEditorWidget("Save as"), - "rangeSelector": lambda: rangeSelector(), - "null": lambda: QLabel("null"), + "summaryMethodComboBox": lambda config=None: DropdownMenu(default="null", options=["rms", "stddev", "var", "avg"]), + "controlComboBox": lambda config=None: DropdownMenu(default="null", options=["none", "subtraction", "division"]), + "listEditor": lambda config=None: ListEditorWidget(), + "openFolder": lambda config=None: OpenFolder(), + "formatEditor": lambda key, spec=None: create_format_editor_widget_from_spec(key, spec or {}), + "groupbyEditor": lambda config=None: GroupByFormatEditorWidget(None, None, None, "Group By"), + "cmapSelector": lambda config=None: ColorMapSelector(), + "affineRigidSelector": lambda config=None: AffineRigidSelector(), + "saveasSelector": lambda config=None: SaveasExtensionsEditorWidget("Save as"), + "rangeSelector": lambda config=None: rangeSelector(), + "null": lambda config=None: QLabel("null"), + + # Subfields + "text_file": lambda config=None: QLabel("text_file"), # For metadata type + "folder": lambda config=None: QLabel("folder"), # For control location + "score": lambda config=None: QLabel("score"), # For normalization method + "mean_sub": lambda config=None: QLabel("mean_sub"), # For standardization method + "auto": lambda config=None: QLabel("auto"), # For radius + "disk": lambda config=None: QLabel("disk"), # For shape + "mean": lambda config=None: QLabel("mean"), # For summary + "rms": lambda config=None: QLabel("rms"), # For summary method + "subtraction": lambda config=None: QLabel("subtraction"), # For control + "stim-relative": lambda config=None: QLabel("stim-relative"), # For type + "time": lambda config=None: QLabel("time"), # For units + "viridis": lambda config=None: QLabel("viridis"), # For cmap + "plasma": lambda config=None: QLabel("plasma"), # For cmap } + def build_form_from_template(template: dict, data: dict, adv=False, parent_name="", saved_widgets=None) -> QWidget: if saved_widgets is None: saved_widgets = {} @@ -82,30 +108,52 @@ def build_form_from_template(template: dict, data: dict, adv=False, parent_name= dependencies = None if not widget_type or widget_type not in WIDGET_FACTORY: - continue + # If no widget type is defined, create a default widget based on value type + if isinstance(val, bool): + widget_type = "trueFalse" + elif isinstance(val, (int, float)): + widget_type = "freeText" # Use freeText for numbers + elif isinstance(val, list): + widget_type = "listEditor" + elif val is None: + widget_type = "null" + else: + widget_type = "freeText" widget_constructor = WIDGET_FACTORY.get(widget_type) - field_widget = widget_constructor() + if not widget_constructor: + continue + + if widget_type == "formatEditor": + field_widget = widget_constructor(key, widget_def if isinstance(widget_def, dict) else {}) + else: + field_widget = widget_constructor() if isinstance(field_widget, FormatEditorWidget): - field_widget.section_name = parent_name # e.g., "preanalysis" or "analysis" + field_widget.section_name = parent_name field_widget.format_key = key field_widget.copyToAllRequested.connect( lambda s, k, v, sw=saved_widgets: propagate_advanced_copy(sw, s, k, v) ) + # Set the value based on the actual data type if val is not None: - if hasattr(field_widget, "set_text"): - field_widget.set_text(str(val)) - elif hasattr(field_widget, "set_value"): - if isinstance(field_widget, ListEditorWidget) and isinstance(val, list): + if hasattr(field_widget, "set_value"): + # Handle different value types appropriately + if isinstance(val, bool): field_widget.set_value(val) - elif isinstance(val, (list, dict)) and not isinstance(field_widget, ListEditorWidget): + elif isinstance(val, (int, float)): + # For numeric values, convert to string for widgets that expect text field_widget.set_value(str(val)) - elif isinstance(val, bool): + elif isinstance(val, list) and isinstance(field_widget, ListEditorWidget): field_widget.set_value(val) + elif isinstance(val, (list, dict)): + # For complex types, convert to string representation + field_widget.set_value(str(val)) else: field_widget.set_value(str(val)) + elif hasattr(field_widget, "set_text"): + field_widget.set_text(str(val)) # Save widget if marked for saving if save_widget and parent_name: @@ -249,72 +297,65 @@ def update_modalities_enabled(): # Initial update update_modalities_enabled() -def generate_json(form_container, template): - result = {} - form_layout = form_container.layout() - if not form_layout: - return result - - for i in range(form_layout.count()): - item = form_layout.itemAt(i) - widget = item.widget() - if not widget: - continue +def generate_json(form_container, template, skip_disabled=True): + """ + Build JSON from the form without ever re-parenting layouts. + (Re-parenting was breaking collapsibles after Review -> Back.) + """ + def walk_layout(layout, template_for_layout): + result = {} + if not layout: + return result + + for i in range(layout.count()): + item = layout.itemAt(i) + widget = item.widget() + if not widget: + continue - # Handle collapsible sections (nested objects) - if isinstance(widget, CollapsibleSection): + # ---- Collapsible section (nested object) ---- if isinstance(widget, CollapsibleSection): - if not widget.is_enabled(): # Skip disabled sections + if skip_disabled and not widget.is_enabled(): continue - section_title = widget.title().replace(':', '').replace(' ', '_').lower() - + section_key = widget.title().replace(':', '').replace(' ', '_').lower() content_layout = widget.content_area.layout() if not content_layout: continue - content_widget = QWidget() - content_widget.setLayout(content_layout) - - template_for_section = template.get(section_title, {}) - - section_data = generate_json(content_widget, template_for_section) + section_template = template_for_layout.get(section_key, {}) + section_data = walk_layout(content_layout, section_template) if section_data: - result[section_title] = section_data + result[section_key] = section_data continue - # Handle regular form rows - if isinstance(widget, QWidget): + # ---- Regular row widget ---- row_layout = widget.layout() if not row_layout or row_layout.count() < 2: continue - # The first item is the label, second is the widget (or OptionalField wrapper) label_widget = row_layout.itemAt(0).widget() field_widget = row_layout.itemAt(1).widget() if not isinstance(label_widget, QLabel): continue - # Get the original key from the label label_text = label_widget.text().replace(':', '') key = label_text.replace(' ', '_').lower() - # Handle OptionalField wrapper if present + # OptionalField wrapper if isinstance(field_widget, OptionalField): - if not field_widget.is_checked(): - continue # Skip if the field is disabled + if skip_disabled and not field_widget.is_checked(): + continue field_widget = field_widget.field_widget - # Get the widget type from template to determine how to get the value - widget_type_def = template.get(key) + widget_type_def = template_for_layout.get(key) widget_type = extract_widget_type(widget_type_def) if widget_type_def else None - # Skip if we don't know how to handle this widget type if not widget_type or not isinstance(widget_type, str) or widget_type not in WIDGET_FACTORY: continue - # Get the value from the widget based on its type + # Pull value value = None if hasattr(field_widget, 'get_value'): value = field_widget.get_value() @@ -322,34 +363,41 @@ def generate_json(form_container, template): value = field_widget.get_text() elif hasattr(field_widget, 'get_list'): value = field_widget.get_list() + elif hasattr(field_widget, 'currentText'): + value = field_widget.currentText() + elif hasattr(field_widget, 'text'): + value = field_widget.text() + elif hasattr(field_widget, 'isChecked'): + value = field_widget.isChecked() elif isinstance(field_widget, QLabel): value = field_widget.text() - - # Convert string values to appropriate types if needed + # Convert string types where appropriate if value is not None: - if widget_type in ["freeNumber"]: + if isinstance(value, str): try: - if '.' in str(value): + if '.' in value: value = float(value) else: value = int(value) - except ValueError: - pass # Keep as string if conversion fails + except (ValueError, TypeError): + if value.lower() == "null": + value = None + elif value.lower() == "true": + value = True + elif value.lower() == "false": + value = False + elif widget_type == "freeInt" and isinstance(value, (int, float)): + value = int(value) + elif widget_type == "freeFloat" and isinstance(value, (int, float)): + value = float(value) elif widget_type == "trueFalse": value = bool(value) elif widget_type == "null": value = None - elif isinstance(value, str): - # Handle special string cases - if value.lower() == "null": - value = None - elif value.lower() == "true": - value = True - elif value.lower() == "false": - value = False - - # Only add to result if we got a value (including None) + result[key] = value - return result \ No newline at end of file + return result + + return walk_layout(form_container.layout(), template) \ No newline at end of file diff --git a/ocvl/function/gui/master_config_files/advanced_config_JSON.json b/ocvl/function/gui/master_config_files/advanced_config_JSON.json index 3967b5e..4703e12 100644 --- a/ocvl/function/gui/master_config_files/advanced_config_JSON.json +++ b/ocvl/function/gui/master_config_files/advanced_config_JSON.json @@ -33,8 +33,8 @@ "dewarp" : "ocvl" }, "trim": { - "start_idx": 0.0, - "end_idx": -1.0 + "start_frm": 0.0, + "end_frm": -1.0 } } }, @@ -60,6 +60,9 @@ "output_folder": null, "output_subfolder": true, "output_subfolder_method": "DateTime", + "output_indiv_stdize_orgs": false, + "output_sum_pop_orgs": false, + "output_sum_indiv_orgs": false, "normalization": { "rescaled": true, "rescale_mean": 70.0 @@ -111,23 +114,33 @@ "height": -1.0 } }, + "debug": { + "output_norm_video": false, + "plot_refine_to_ref": false, + "plot_refine_to_vid": false, + "plot_pop_extracted_orgs": false, + "plot_pop_stdize_orgs": false, + "plot_indiv_stdize_orgs": false, + "plot_valid_cells": false, + "stimulus": true, + "control": true, + "axes": { + "xmin": 0, + "xmax": 6, + "ymin": -255, + "ymax": 255, + "cmap": "viridis", + "legend": true + } + }, "display_params": { - "debug": { - "output_norm_video": false, - "plot_refine_to_ref": false, - "plot_refine_to_vid": false, - "plot_pop_extracted_orgs": false, - "plot_pop_stdize_orgs": false, - "plot_indiv_stdize_orgs": false, - "output_indiv_stdize_orgs": false, - "stimulus": true, - "control": true - }, "pop_summary_overlap": { "stimulus": true, "control": true, "relative": true, "pooled": true, + "cross_group": false, + "annotations": false, "axes": { "xmin": 0, "xmax": 4, @@ -166,6 +179,8 @@ "histogram": true, "cumulative_histogram": true, "map_overlay": true, + "org_video": false, + "cross_group": false, "axes": { "xmin": null, "xstep": null, diff --git a/ocvl/function/gui/master_config_files/master_JSON.json b/ocvl/function/gui/master_config_files/master_JSON.json index 35d3c96..d925093 100644 --- a/ocvl/function/gui/master_config_files/master_JSON.json +++ b/ocvl/function/gui/master_config_files/master_JSON.json @@ -5,17 +5,44 @@ "description": { "type": "freeText" }, + "raw" : { + "video_format": { + "type": "formatEditor", + "format_type" : "video", + "save": true + }, + "metadata" : { + "type": { + "type": "freeText" + }, + "metadata_format": { + "type": "formatEditor", + "format_type" : "meta" + }, + "fields_to_load": { + "timestamps": { + "type": "freeText" + }, + "stimulus_train": { + "type": "freeText" + } + } + } + }, "preanalysis": { "image_format": { "type": "formatEditor", + "format_type" : "image", "save": true }, "video_format": { "type": "formatEditor", + "format_type" : "video", "save": true }, "mask_format": { "type": "formatEditor", + "format_type" : "mask", "save": true }, "metadata": { @@ -23,7 +50,8 @@ "type": "freeText" }, "metadata_format": { - "type": "formatEditor" + "type": "formatEditor", + "format_type" : "meta" }, "fields_to_load": { "framestamps": { @@ -95,10 +123,10 @@ "dewarp" : "freeText" }, "trim": { - "start_idx": { + "start_frm": { "type": "freeNumber" }, - "end_idx": { + "end_frm": { "type": "freeNumber" } } @@ -107,14 +135,17 @@ "analysis": { "image_format": { "type": "formatEditor", + "format_type" : "image", "save": true }, "queryloc_format": { - "type": "formatEditorQueryloc", + "type": "formatEditor", + "format_type" : "queryloc", "save": true }, "video_format": { "type": "formatEditor", + "format_type" : "video", "save": true }, "metadata": { @@ -122,7 +153,8 @@ "type": "freeText" }, "metadata_format": { - "type": "formatEditor" + "type": "formatEditor", + "format_type" : "meta" }, "stimulus_sequence": { "type": "freeText" @@ -162,6 +194,15 @@ "output_subfolder_method": { "type": "outputSubfolderMethodComboBox" }, + "output_indiv_stdize_orgs": { + "type": "trueFalse" + }, + "output_sum_pop_orgs": { + "type": "trueFalse" + }, + "output_sum_indiv_orgs": { + "type": "trueFalse" + }, "normalization": { "method": { "type": "score" @@ -273,56 +314,56 @@ } } }, - "display_params": { - "debug": { - "output_norm_video": { - "type": "trueFalse" - }, - "plot_refine_to_ref": { - "type": "trueFalse" - }, - "plot_refine_to_vid": { - "type": "trueFalse" - }, - "plot_pop_extracted_orgs": { - "type": "trueFalse" + "debug": { + "output_norm_video": { + "type": "trueFalse" + }, + "plot_refine_to_ref": { + "type": "trueFalse" + }, + "plot_refine_to_vid": { + "type": "trueFalse" + }, + "plot_pop_extracted_orgs": { + "type": "trueFalse" + }, + "plot_pop_stdize_orgs": { + "type": "trueFalse" + }, + "plot_indiv_stdize_orgs": { + "type": "trueFalse" + }, + "plot_valid_cells": { + "type": "trueFalse" + }, + "stimulus": { + "type": "trueFalse" + }, + "control": { + "type": "trueFalse" + }, + "axes": { + "xmin": { + "type": "freeNumber" }, - "plot_pop_stdize_orgs": { - "type": "trueFalse" + "xmax": { + "type": "freeNumber" }, - "plot_indiv_stdize_orgs": { - "type": "trueFalse" + "ymin": { + "type": "freeNumber" }, - "output_indiv_stdize_orgs": { - "type": "trueFalse" + "ymax": { + "type": "freeNumber" }, - "stimulus": { - "type": "trueFalse" + "cmap": { + "type": "cmapSelector" }, - "control": { + "legend": { "type": "trueFalse" - }, - "axes": { - "xmin": { - "type": "freeNumber" - }, - "xmax": { - "type": "freeNumber" - }, - "ymin": { - "type": "freeNumber" - }, - "ymax": { - "type": "freeNumber" - }, - "cmap": { - "type": "cmapSelector" - }, - "legend": { - "type": "trueFalse" - } } - }, + } + }, + "display_params": { "pop_summary_overlap": { "stimulus": { "type": "trueFalse" @@ -336,6 +377,12 @@ "pooled": { "type": "trueFalse" }, + "cross_group": { + "type": "trueFalse" + }, + "annotations": { + "type": "trueFalse" + }, "axes": { "xmin": { "type": "freeNumber" @@ -426,6 +473,12 @@ "map_overlay": { "type": "trueFalse" }, + "org_video": { + "type": "trueFalse" + }, + "cross_group": { + "type": "trueFalse" + }, "axes": { "xmin": { "type": "freeNumber" diff --git a/ocvl/function/gui/wizard_creator.py b/ocvl/function/gui/wizard_creator.py index d558fd7..e72a648 100644 --- a/ocvl/function/gui/wizard_creator.py +++ b/ocvl/function/gui/wizard_creator.py @@ -3,15 +3,11 @@ from PySide6 import QtGui from PySide6.QtGui import QFont, QMovie -from PySide6.QtWidgets import (QApplication, QWizard, QWizardPage, - QLabel, QLineEdit, QVBoxLayout, - QCheckBox, QComboBox, QHBoxLayout, QRadioButton, QButtonGroup, QSizePolicy, QScrollArea, - QWidget, QFileDialog, QMessageBox, QFrame) -from PySide6.QtCore import Qt, QSize, Signal -from advancedconfig import create_advanced_setup_widget, description_layer +from PySide6.QtWidgets import QWizard import constructors -import advancedconfig from import_generation import * +#from ocvl.function.utility.dataset import parse_metadata +import tempfile bold = QtGui.QFont() bold.setBold(True) @@ -28,6 +24,16 @@ class TextColor: YELLOW = '\033[93m' RED = '\033[91m' +def _clear_layout(layout): + while layout and layout.count(): + item = layout.takeAt(0) + w = item.widget() + if w is not None: + w.setParent(None) + w.deleteLater() + elif item.layout(): + _clear_layout(item.layout()) + def has_modality(format_string): return "{Modality}" in format_string if format_string else False @@ -84,8 +90,10 @@ def update_button_text(self, id): if id == 0: # On intro page, check which option is selected self.update_button_text_for_intro() - elif id == 6 | 7: + elif id == 6: # Review page self.button(QWizard.NextButton).setText("Save >") + elif id == 7: # Import editor page + self.button(QWizard.NextButton).setText("Review >") # Or "Next >" else: self.button(QWizard.NextButton).setText("Next >") @@ -102,8 +110,8 @@ def update_button_text_for_intro(self): class IntroPage(QWizardPage): def __init__(self, parent=None): super().__init__(parent) - self.setTitle("Welcome to the MEAO Configuration JSON Generator!") - self.setSubTitle('To begin, choose if you wish to import an existing config JSON, or create a new one\n' + self.setTitle("Welcome to the MEAO Configuration File Generator!") + self.setSubTitle('To begin, choose if you wish to import an existing config file, or create a new one\n' '• Note: Importing an existing config file will bring you to "advanced" setup') self.imported_config = None # To store the imported config @@ -126,12 +134,39 @@ def __init__(self, parent=None): # Label label = QLabel("Select an option:") - label.setToolTip("Test1") center_layout.addWidget(label) - # Radio buttons - self.create_button = QRadioButton("Create New Configuration JSON") - self.import_button = QRadioButton("Import Existing Configuration JSON") + self.create_button = QRadioButton("Create New Configuration") + self.import_button = QRadioButton("Import Existing Configuration") + + # Style to make buttons bigger + radio_style = """ + QRadioButton { + min-width: 350px; + min-height: 45px; + font-size: 20px; + padding: 5px; + spacing: 5px; + } + QRadioButton::indicator { + width: 20px; + height: 20px; + } + """ + label_style = """ + QLabel { + min-width: 350px; + min-height: 30px; + font-size: 20px; + font-weight: bold; + padding: 5px; + qproperty-alignment: AlignCenter; + } + """ + + label.setStyleSheet(label_style) + self.create_button.setStyleSheet(radio_style) + self.import_button.setStyleSheet(radio_style) self.button_group = QButtonGroup() self.button_group.addButton(self.create_button, 0) @@ -154,37 +189,55 @@ def __init__(self, parent=None): main_layout.addWidget(scroll) main_layout.setContentsMargins(0, 0, 0, 0) - def nextId(self): + def validatePage(self): + """ + Only called when the user clicks Next/Import. + We open the file dialog here (NOT in nextId), so Back navigation + doesn't re-trigger the explorer and Qt doesn't flip Next->Finish. + """ if self.create_button.isChecked(): - return 1 - elif self.import_button.isChecked(): + # Create mode: nothing special to validate + return True - # Show file dialog to select JSON file - file_dialog = QFileDialog() - file_path, _ = file_dialog.getOpenFileName( + if self.import_button.isChecked(): + # Always prompt in import mode when advancing from Intro + file_path, _ = QFileDialog.getOpenFileName( self, "Open Configuration File", "", "JSON Files (*.json);;All Files (*)" ) - if file_path: - try: - with open(file_path, 'r') as f: - self.imported_config = json.load(f) - return 7 # Go to advanced setup page - except Exception as e: - QMessageBox.warning(self, "Error", f"Failed to load file:\n{str(e)}") - return -1 # Stay on current page - return -1 # Stay on current page if no file selected - return 2 + if not file_path: + # User canceled: stay on Intro + return False + + try: + with open(file_path, "r") as f: + self.imported_config = json.load(f) + return True + except Exception as e: + QMessageBox.warning(self, "Error", f"Failed to load file:\n{str(e)}") + return False + + return True + + def nextId(self): + """ + nextId() must be deterministic and NEVER open dialogs. + """ + if self.create_button.isChecked(): + return 1 + if self.import_button.isChecked(): + return 7 + return 1 class SelectionPage(QWizardPage): def __init__(self, parent=None): super().__init__(parent) self.setTitle('Choose your desired type of setup and click "Next"') - self.setSubTitle("• Simple generation: Step-by-step process to create configuration JSON\n" - "• Advanced generation: In-depth menu with access to change any and all fields in the configuration JSON in one step") + self.setSubTitle("• Simple generation: Step-by-step process to create configuration file\n" + "• Advanced generation: In-depth menu with access to change any and all fields in the configuration file in one step") # Create scrollable area scroll = QScrollArea() @@ -198,17 +251,73 @@ def __init__(self, parent=None): outer_layout = QVBoxLayout(container) outer_layout.setAlignment(Qt.AlignCenter) # Center everything - # Inner layout: label and buttons side by side center_layout = QHBoxLayout() center_layout.setAlignment(Qt.AlignCenter) + placeholder = "Hover over an option to see details" + + # Tooltip label + self.tooltip_label = QLabel(placeholder) + self.tooltip_label.setWordWrap(True) + self.tooltip_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.tooltip_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + self.tooltip_label.setStyleSheet(""" + QLabel { + border: 1px solid #ccc; + border-radius: 6px; + background-color: #f0f0f0; + padding: 6px 12px; + font-style: italic; + color: #333; + min-height: 40px; + } + """) + + def create_hover_widget(layout, tooltip_text): + wrapper = QFrame() + wrapper.setLayout(layout) + wrapper.setMouseTracking(True) + wrapper.setStyleSheet("QFrame { background: transparent; }") + wrapper.enterEvent = lambda event: self.tooltip_label.setText(tooltip_text) + wrapper.leaveEvent = lambda event: self.tooltip_label.setText(placeholder) + return wrapper + + # Label label = QLabel("Choose type of generation:") center_layout.addWidget(label) # Radio buttons - self.simple_button = QRadioButton("Simple") - self.adv_button = QRadioButton("Advanced") + self.simple_button = QRadioButton("Simple Generation") + self.adv_button = QRadioButton("Advanced Generation") + + radio_style = """ + QRadioButton { + min-width: 350px; + min-height: 45px; + font-size: 20px; + padding: 5px; + spacing: 5px; + } + QRadioButton::indicator { + width: 20px; + height: 20px; + } + """ + label_style = """ + QLabel { + min-width: 350px; + min-height: 30px; + font-size: 20px; + font-weight: bold; + padding: 5px; + qproperty-alignment: AlignCenter; + } + """ + + self.simple_button.setStyleSheet(radio_style) + self.adv_button.setStyleSheet(radio_style) + label.setStyleSheet(label_style) self.button_group = QButtonGroup() self.button_group.addButton(self.simple_button, 0) @@ -223,6 +332,7 @@ def __init__(self, parent=None): center_layout.addLayout(button_layout) + # Add to outer layout outer_layout.addLayout(center_layout) @@ -370,7 +480,7 @@ def create_hover_widget(layout, tooltip_text): image_layout = QHBoxLayout() image_layout.setAlignment(Qt.AlignLeft) image_label = QLabel("Image Format:") - self.image_format_value = constructors.FormatEditorWidget("Image Format:", "{IDnum}{Year}{Month}{Day}{VidNum}{Modality}") + self.image_format_value = constructors.FormatEditorWidget("Image Format:", "{IDnum}_{Year}{Month}{Day}_{Eye}_({LocX},{LocY})_{FOV_Width}x{FOV_Height}_{VidNum}_{Modality}.tif", type='image') image_layout.addWidget(image_label) image_layout.addWidget(self.image_format_value) image_widget = create_hover_widget(image_layout, "Image Format: Format string for image filenames.") @@ -379,7 +489,7 @@ def create_hover_widget(layout, tooltip_text): video_layout = QHBoxLayout() video_layout.setAlignment(Qt.AlignLeft) video_label = QLabel("Video Format:") - self.video_format_value = constructors.FormatEditorWidget("Video Format:", "{IDnum}{Year}{Month}{Day}{VidNum}{Modality}") + self.video_format_value = constructors.FormatEditorWidget("Video Format:", "{IDnum}_{Year}{Month}{Day}_{Eye}_({LocX},{LocY})_{FOV_Width}x{FOV_Height}_{VidNum}_{Modality}.avi", type='video') video_layout.addWidget(video_label) video_layout.addWidget(self.video_format_value) video_widget = create_hover_widget(video_layout, "Video Format: Format string for video filenames.") @@ -388,7 +498,7 @@ def create_hover_widget(layout, tooltip_text): mask_layout = QHBoxLayout() mask_layout.setAlignment(Qt.AlignLeft) mask_label = QLabel("Mask Format:") - self.mask_format_value = constructors.FormatEditorWidget("Mask Format:", "{IDnum}{Year}{Month}{Day}{VidNum}{Modality}") + self.mask_format_value = constructors.FormatEditorWidget("Mask Format:", "{IDnum}_{Year}{Month}{Day}_{Eye}_({LocX},{LocY})_{FOV_Width}x{FOV_Height}_{VidNum}_{Modality}.avi", type='mask') mask_layout.addWidget(mask_label) mask_layout.addWidget(self.mask_format_value) mask_widget = create_hover_widget(mask_layout, "Mask Format: Format string for mask filenames.") @@ -560,7 +670,7 @@ def create_hover_widget(layout, tooltip_text): image_layout = QHBoxLayout() image_layout.setAlignment(Qt.AlignLeft) image_label = QLabel("Image Format:") - self.image_format_value = constructors.FormatEditorWidget("Image Format:", "{IDnum}{Year}{Month}{Day}{VidNum}{Modality}") + self.image_format_value = constructors.FormatEditorWidget("Image Format:", "{IDnum}_{Year}{Month}{Day}_{Eye}_({LocX},{LocY})_{FOV_Width}x{FOV_Height}_{VidNum}_{Modality}.tif", type='image') image_layout.addWidget(image_label) image_layout.addWidget(self.image_format_value) image_widget = create_hover_widget(image_layout, "Image Format: Format string for image filenames.") @@ -568,7 +678,7 @@ def create_hover_widget(layout, tooltip_text): queryloc_layout = QHBoxLayout() queryloc_layout.setAlignment(Qt.AlignLeft) queryloc_label = QLabel("Query Loc Format:") - self.queryloc_format_value = constructors.FormatEditorWidget("Queryloc Format:", "{IDnum}{Year}{Month}{Day}{VidNum}{Modality}", queryloc=True) + self.queryloc_format_value = constructors.FormatEditorWidget("Queryloc Format:", "{IDnum}_{Year}{Month}{Day}_{Eye}_({LocX},{LocY})_{FOV_Width}x{FOV_Height}_{VidNum}_{Modality}.csv", type='queryloc') queryloc_layout.addWidget(queryloc_label) queryloc_layout.addWidget(self.queryloc_format_value) queryloc_widget = create_hover_widget(queryloc_layout, "Query Loc Format: Filename format used to locate the reference location (query).") @@ -576,7 +686,7 @@ def create_hover_widget(layout, tooltip_text): video_layout = QHBoxLayout() video_layout.setAlignment(Qt.AlignLeft) video_label = QLabel("Video Format:") - self.video_format_value = constructors.FormatEditorWidget("Video Format:", "{IDnum}{Year}{Month}{Day}{VidNum}{Modality}") + self.video_format_value = constructors.FormatEditorWidget("Video Format:", "{IDnum}_{Year}{Month}{Day}_{Eye}_({LocX},{LocY})_{FOV_Width}x{FOV_Height}_{VidNum}_{Modality}.avi", type='video') video_layout.addWidget(video_label) video_layout.addWidget(self.video_format_value) video_widget = create_hover_widget(video_layout, "Video Format: Format string for video filenames.") @@ -657,9 +767,9 @@ def __init__(self, parent=None): 'You can edit any configuration field from here. For a more simple setup, go back and select "Simple Setup"\n' 'Tip: Expand window for better visibility') - with open("config_files/advanced_config_JSON.json", "r") as f: + with open(r"ocvl/function/gui/master_config_files/advanced_config_JSON.json", "r") as f: advanced_config_json = json.load(f) - with open("config_files/master_JSON.json", "r") as f: + with open(r"ocvl/function/gui/master_config_files/master_JSON.json", "r") as f: self.master_json = json.load(f) scroll_area = QScrollArea() @@ -687,134 +797,268 @@ def __init__(self, parent=None): self.saved_file_path = None self.generated_config = None - self.layout = QVBoxLayout(self) - self.label = QLabel() - self.label.setWordWrap(True) - - self.label2 = QLabel() - self.label2.setWordWrap(True) - - self.label3 = QLabel() - self.label3.setWordWrap(True) - - self.label4 = QLabel() - self.label4.setWordWrap(True) + # Scroll container + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) - self.label5 = QLabel() - self.label5.setWordWrap(True) + self.container = QWidget() + self.scroll.setWidget(self.container) - self.layout.addWidget(self.label) - self.layout.addWidget(self.label2) - self.layout.addWidget(self.label3) - self.layout.addWidget(self.label4) - self.layout.addWidget(self.label5) + self.main_layout = QVBoxLayout(self) + self.main_layout.addWidget(self.scroll) - self.layout.addStretch() + self.container_layout = QVBoxLayout(self.container) def nextId(self): return 8 - def initializePage(self): - wizard = self.wizard() - - if wizard.page(1).adv_button.isChecked(): - self.label.setText( - "Advanced setup selected.\n\nPlease review your inputs on the previous page before continuing.") - self.generated_config = None # Don't regenerate config in advanced mode + # ---------- helpers: consistent formatting ---------- + def _fmt_value(self, v): + """Human-friendly rendering of values for the review UI.""" + if v is None: + return "null" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float, str)): + return str(v) + if isinstance(v, (list, tuple)): + # render one item per line if long; inline if short + if len(v) <= 3 and all(isinstance(x, (int, float, str)) for x in v): + return "[" + ", ".join(self._fmt_value(x) for x in v) + "]" + return "\n• " + "\n• ".join(self._fmt_value(x) for x in v) + # fallback to JSON-ish for unusual objects + return str(v) + + def _mk_field(self, title, value): + lab = QLabel(f"{title}: {self._fmt_value(value)}") + lab.setWordWrap(True) + lab.setStyleSheet( + "QLabel { padding: 3px 6px; margin: 2px 0; border-left: 3px solid #007ACC; }" + ) + return lab + + def _mk_section(self, title): + lab = QLabel(title.upper()) + f = lab.font(); + f.setBold(True); + lab.setFont(f) + lab.setStyleSheet("QLabel { font-size: 14px; margin: 10px 0 4px 0; }") + return lab + + def _titleize(self, key): + return str(key).replace("_", " ").title() + + # ---------- schema-walking renderers ---------- + def _render_by_template(self, template_node, data_node, header_name=None): + """ + Render using the 'template' that defined the page (keeps exact key order). + Dict in template -> section; non-dict -> leaf (primitive) field. + """ + if header_name is not None: + self.container_layout.addWidget(self._mk_section(header_name)) + + # If template is a dict: walk keys in order + if isinstance(template_node, dict): + for k, tmpl_child in template_node.items(): + # Skip top-level metadata fields we already printed + if header_name is None and k in ("version", "description"): + continue + + pretty = self._titleize(k) + data_child = None if not isinstance(data_node, dict) else data_node.get(k, None) + + if isinstance(tmpl_child, dict): + # subsection (even if data_child is empty/None, still show header to match UI) + self._render_by_template(tmpl_child, data_child if isinstance(data_child, dict) else {}, pretty) + else: + # leaf: show scalar value taken from data_node (or template default if missing) + value = data_child if data_child is not None else tmpl_child + self.container_layout.addWidget(self._mk_field(pretty, value)) return - # Simple mode: generate config with only simple-mode configurable elements - config = { - "version": wizard.page(2).version_value.text(), - "description": wizard.page(2).description_value.text(), - "preanalysis": { - "image_format": wizard.page(3).image_format_value.get_value(), - "video_format": wizard.page(3).video_format_value.get_value(), - "mask_format": wizard.page(3).mask_format_value.get_value(), - "recursive_search" : wizard.page(3).recursive_search_tf.get_value(), - "pipeline_params": { - "modalities": wizard.page(3).modalities_list_creator.get_list(), - "alignment_reference_modality" : wizard.page(3).alignment_ref_value.get_value(), - "group_by": None if wizard.page(3).groupby_value.get_value() == "null" else wizard.page(3).groupby_value.get_value(), - } - }, - "analysis": { - "image_format": wizard.page(4).image_format_value.get_value(), - "queryloc_format": wizard.page(4).queryloc_format_value.get_value(), - "video_format": wizard.page(4).video_format_value.get_value(), - "recursive_search": wizard.page(4).recursive_search_tf.get_value(), - "analysis_params": { - "modalities": wizard.page(4).modalities_list_creator.get_list() + # If template is not a dict, it's a primitive leaf and should have been handled above. + + def _render_pruned_template(self, template_node, data_node): + """ + Make a pruned copy of 'template_node' that only keeps keys present in data_node. + Preserves order from the template. + """ + if not isinstance(template_node, dict) or not isinstance(data_node, dict): + return data_node # primitive (or mismatched) – just return data as-is + + pruned = {} + for k, tmpl_child in template_node.items(): + if k in data_node: + dv = data_node[k] + if isinstance(tmpl_child, dict) and isinstance(dv, dict): + pruned[k] = self._render_pruned_template(tmpl_child, dv) + else: + pruned[k] = tmpl_child + return pruned + + # ---------- SIMPLE ---------- + def _display_simple_mode(self, cfg, wiz): + # (unchanged from your version if you like) – but still benefits from _fmt_value + if "version" in cfg: + self.container_layout.addWidget(self._mk_field("Version", cfg["version"])) + if "description" in cfg: + self.container_layout.addWidget(self._mk_field("Description", cfg["description"])) + + if "preanalysis" in cfg: + pre = cfg["preanalysis"] + self.container_layout.addWidget(self._mk_section("Pre-Analysis")) + for title, k in [("Image Format", "image_format"), + ("Video Format", "video_format"), + ("Mask Format", "mask_format"), + ("Recursive Search", "recursive_search")]: + if k in pre: + self.container_layout.addWidget(self._mk_field(title, pre[k])) + if "pipeline_params" in pre: + params = pre["pipeline_params"] + self.container_layout.addWidget(self._mk_section("Pipeline Parameters")) + for title, k in [("Modalities", "modalities"), + ("Alignment Reference Modality", "alignment_reference_modality"), + ("Group By", "group_by")]: + if k in params: + self.container_layout.addWidget(self._mk_field(title, params[k])) + + if "analysis" in cfg: + ana = cfg["analysis"] + self.container_layout.addWidget(self._mk_section("Analysis")) + for title, k in [("Image Format", "image_format"), + ("Queryloc Format", "queryloc_format"), + ("Video Format", "video_format"), + ("Recursive Search", "recursive_search")]: + if k in ana: + self.container_layout.addWidget(self._mk_field(title, ana[k])) + if "analysis_params" in ana: + params = ana["analysis_params"] + self.container_layout.addWidget(self._mk_section("Analysis Params")) + if "modalities" in params: + self.container_layout.addWidget(self._mk_field("Modalities", params["modalities"])) + + # ---------- ADVANCED ---------- + def _display_advanced_mode(self, cfg, wiz): + # Header fields first + if "version" in cfg: + self.container_layout.addWidget(self._mk_field("Version", cfg["version"])) + if "description" in cfg: + self.container_layout.addWidget(self._mk_field("Description", cfg["description"])) + + # Use the SAME template that built Advanced (preserves visual order) + with open( + r"ocvl/function/gui/master_config_files/advanced_config_JSON.json", + "r") as f: + advanced_template = json.load(f) + + # Render recursively; nested dicts become sections; primitives become leaf rows + self._render_by_template(advanced_template, cfg) + + # ---------- IMPORT ---------- + def _display_import_mode(self, cfg, wiz): + if "version" in cfg: + self.container_layout.addWidget(self._mk_field("Version", cfg["version"])) + if "description" in cfg: + self.container_layout.addWidget(self._mk_field("Description", cfg["description"])) + + # Start from the master template, then prune to only the keys actually present in the imported file + with open(r"ocvl/function/gui/master_config_files/master_JSON.json", + "r") as f: + master_template = json.load(f) + + imported = self.wizard().page(0).imported_config or {} + pruned_template = self._render_pruned_template(master_template, imported) + + self._render_by_template(pruned_template, cfg) + + # ---------- main flow ---------- + def initializePage(self): + wiz = self.wizard() + + # Clear previous render (keep layout) + while self.container_layout.count(): + item = self.container_layout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + # Build the config (unchanged logic) + if wiz.page(1).adv_button.isChecked(): + # Advanced + advanced_widget = wiz.page(5).advanced_widget + master_json = wiz.page(5).master_json + self.generated_config = generate_json(advanced_widget, master_json) + elif wiz.page(0).import_button.isChecked(): + # Import editor + import_config_widget = wiz.page(7).form_widget + master_json = wiz.page(5).master_json + self.generated_config = generate_json(import_config_widget, master_json, skip_disabled=False) + else: + # Simple (you already build this struct explicitly) + self.generated_config = { + "version": wiz.page(2).version_value.text(), + "description": wiz.page(2).description_value.text(), + "preanalysis": { + "image_format": wiz.page(3).image_format_value.get_value(), + "video_format": wiz.page(3).video_format_value.get_value(), + "mask_format": wiz.page(3).mask_format_value.get_value(), + "recursive_search": wiz.page(3).recursive_search_tf.get_value(), + "pipeline_params": { + "modalities": wiz.page(3).modalities_list_creator.get_list(), + "alignment_reference_modality": wiz.page(3).alignment_ref_value.get_value(), + "group_by": None if wiz.page(3).groupby_value.get_value() == "null" else wiz.page(3).groupby_value.get_value(), + } + }, + "analysis": { + "image_format": wiz.page(4).image_format_value.get_value(), + "queryloc_format": wiz.page(4).queryloc_format_value.get_value(), + "video_format": wiz.page(4).video_format_value.get_value(), + "recursive_search": wiz.page(4).recursive_search_tf.get_value(), + "analysis_params": { + "modalities": wiz.page(4).modalities_list_creator.get_list() + } } } - } - - self.generated_config = config - - # Show summary to user - summary_text1 = f"Version: {config['version']}\n" \ - f"Description: {config['description']}\n" - - summary_text2 = f"Image Format: {config['preanalysis']['image_format']}\n" \ - f"Video Format: {config['preanalysis']['video_format']}\n" \ - f"Mask Format: {config['preanalysis']['mask_format']}\n" \ - f"Recursive Search: {config['preanalysis']['recursive_search']}\n" \ - f"Modalities: {config['preanalysis']['pipeline_params']['modalities']}\n" \ - f"Alignment Reference Modality : {config['preanalysis']['pipeline_params']['alignment_reference_modality']}\n" \ - f"Group By: {config['preanalysis']['pipeline_params']['group_by']}\n" - - summary_text3 = f"Image Format: {config['analysis']['image_format']}\n" \ - f"QueryLoc Format: {config['analysis']['queryloc_format']}\n" \ - f"Video Format: {config['analysis']['video_format']}\n" \ - f"Recursive Search: {config['analysis']['recursive_search']}\n" \ - f"Modalities: {config['analysis']['analysis_params']['modalities']}" - - self.label.setText( - "Please review the following configurations before clicking 'Save >':\n\n" + summary_text1) - self.label2.setText('Pre-Analysis') - self.label2.setFont(bold) - self.label3.setText(summary_text2) - self.label4.setText('Analysis') - self.label4.setFont(bold) - self.label5.setText(summary_text3) - def validatePage(self): - wizard = self.wizard() + # ----- Render by PATH ----- + try: + if wiz.page(1).adv_button.isChecked(): + self._display_advanced_mode(self.generated_config, wiz) + elif wiz.page(0).import_button.isChecked(): + self._display_import_mode(self.generated_config, wiz) + else: + self._display_simple_mode(self.generated_config, wiz) + except Exception as e: + err = QLabel(f"Error rendering review: {e}") + err.setStyleSheet("color: red; font-weight: bold;") + self.container_layout.addWidget(err) - if wizard.page(1).adv_button.isChecked(): - # Advanced mode: generate config from widgets directly - advanced_widget = wizard.page(5).advanced_widget - master_json = wizard.page(5).master_json - config = generate_json(advanced_widget, master_json) - else: - # Simple mode: use the generated config from initializePage - config = self.generated_config - if not config: - self.label.setText("No configuration data available") - return False + self.container_layout.addStretch() + + def validatePage(self): + """Save the generated configuration to a file (unchanged).""" + if not self.generated_config: + QMessageBox.warning(self, "Error", "No configuration data available") + return False file_dialog = QFileDialog() file_path, _ = file_dialog.getSaveFileName( - wizard, - "Save Configuration File", - "", - "JSON Files (*.json);;All Files (*)" + self, "Save Configuration File", "", "JSON Files (*.json);;All Files (*)" ) if file_path: if not file_path.endswith('.json'): file_path += '.json' - try: with open(file_path, 'w') as f: - json.dump(config, f, indent=2) + json.dump(self.generated_config, f, indent=2) self.saved_file_path = file_path return True except Exception as e: - self.label.setText(f"Failed to save file:\n{str(e)}") + QMessageBox.warning(self, "Error", f"Failed to save file:\n{str(e)}") return False else: - self.label.setText("No file selected. Please choose a path to save the configuration.") + QMessageBox.warning(self, "Error", "No file selected. Please choose a path to save the configuration.") return False @@ -844,7 +1088,7 @@ def initializePage(self): intro_page = wizard.page(0) if hasattr(intro_page, 'imported_config') and intro_page.imported_config: - with open("config_files/master_JSON.json", "r") as f: + with open(r"ocvl/function/gui/master_config_files/master_JSON.json", "r") as f: master_json = json.load(f) self.form_widget = build_form_from_template(master_json, intro_page.imported_config) @@ -859,52 +1103,8 @@ def initializePage(self): error_label.setAlignment(Qt.AlignCenter) self.layout.addWidget(error_label) - def validatePage(self): - if not self.form_widget: - QMessageBox.warning(self, "Error", "No configuration to save") - return False - - # Get the master template - with open("config_files/master_JSON.json", "r") as f: - master_json = json.load(f) - - wizard = self.wizard() - intro_page = wizard.page(0) - - # Generate the JSON from the form - config = generate_json(self.form_widget, master_json) - - if not config: - QMessageBox.warning(self, "Error", "No configuration data to save") - return False - - # Show save file dialog - file_dialog = QFileDialog() - file_path, _ = file_dialog.getSaveFileName( - self, - "Save Configuration File", - "", - "JSON Files (*.json);;All Files (*)" - ) - - if file_path: - if not file_path.endswith('.json'): - file_path += '.json' - - try: - with open(file_path, 'w') as f: - json.dump(config, f, indent=2) - self.saved_file_path = file_path - return True - except Exception as e: - QMessageBox.warning(self, "Error", f"Failed to save file:\n{str(e)}") - return False - - QMessageBox.warning(self, "Error", "No file selected") - return False - def nextId(self): - return 8 + return 6 class EndPage(QWizardPage): def __init__(self, parent=None):