Source code for phenotypic.sdk_.mixin._lazy_widget_mixin

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Optional, get_args, get_origin, Literal

from pydantic_core import PydanticUndefined

if TYPE_CHECKING:
    from phenotypic._core._image import Image
    from ipywidgets import Widget


[docs] class LazyWidgetMixin: """Mixin providing a lazy ipywidget interface. This mixin allows ImageOperation classes to automatically generate a Jupyter widget interface for parameter tuning and visualization. The six lazily-populated UI handles this mixin uses (``_ui``, ``_param_widgets``, ``_view_dropdown``, ``_update_button``, ``_output_widget``, ``_image_ref``) are declared as ``PrivateAttr`` on :class:`~phenotypic.abc_.ImageOperation` rather than here. A plain (non-``BaseModel``) mixin's ``PrivateAttr`` declarations are not collected by pydantic, so this mixin stays a stateless methods-only class and the private attrs live on the pydantic model that mixes it in. This keeps the mixin safe to combine with any ``BaseModel`` in any MRO position. """
[docs] def widget(self, image: Optional[Image] = None, show: bool = False) -> Widget: """Return (and optionally display) the root widget. Args: image (Image | None): Optional image to visualize. If provided, visualization controls will be added to the widget. show (bool): Whether to display the widget immediately. Defaults to False. Returns: ipywidgets.Widget: The root widget. Raises: ImportError: If ipywidgets or IPython are not installed. """ try: import ipywidgets from IPython.display import display except ImportError as e: raise ImportError( "The 'ipywidgets' and 'IPython' packages are required for the widget interface. " "Please install the 'jupyter' optional dependency group: " "pip install 'phenotypic[jupyter]'" ) from e # Store image reference for visualization if image is not None: self._image_ref = image if self._ui is None: self._create_widgets() has_viz = getattr(self, "_output_widget", None) is not None if image is not None and not has_viz: self._create_widgets() # Re-create to include viz if show: display(self._ui) return self._ui
def _create_doc_widget(self) -> Optional[Widget]: """Create a widget displaying the class docstring.""" import ipywidgets as widgets import inspect import html doc = self.__doc__ if not doc: return None # Clean up docstring indentation doc = inspect.cleandoc(doc) # Reflow paragraphs to improve readability doc = self._reflow_docstring(doc) # Escape HTML to prevent rendering issues with code examples doc = html.escape(doc) # Format docstring for display (preserve formatting) # Use pre-wrap to handle newlines correctly while wrapping text html_content = f"<pre style='font-family: monospace; font-size: 11px; white-space: pre-wrap; margin: 0;'>{doc}</pre>" doc_html = widgets.HTML(value=html_content) accordion = widgets.Accordion(children=[doc_html]) accordion.set_title(0, "Description") accordion.selected_index = None # Collapsed by default return accordion def _reflow_docstring(self, text: str) -> str: """Reflow docstring text to merge paragraphs while preserving structure.""" lines = text.split("\n") new_lines = [] buffer = [] def flush_buffer(): if buffer: new_lines.append(" ".join(buffer)) buffer.clear() for line in lines: # Check if line should break the current paragraph # 1. Empty lines # 2. Indented lines (code blocks, parameters) # 3. List items (-, *, +) # 4. Doctests (>>>) # 5. Headers or blockquotes (#, >) stripped = line.strip() is_empty = not stripped # Check for raw indentation (before strip) is_indented = line.startswith(" ") or line.startswith("\t") # Check content markers is_list = stripped.startswith(("-", "*", "+")) is_special = stripped.startswith((">>>", ">", "#", ":")) if is_empty or is_indented or is_list or is_special: flush_buffer() new_lines.append(line) else: buffer.append(stripped) flush_buffer() return "\n".join(new_lines) def _create_widgets(self) -> None: """Create and assign the root widget to self._ui.""" import ipywidgets as widgets import phenotypic as pht self._param_widgets = {} controls = [] # 0. Docstring widget doc_widget = self._create_doc_widget() if doc_widget: controls.append(doc_widget) # 1. Introspect pydantic model fields # Skip for ImagePipeline. # # Operations are pydantic ``BaseModel``s after the v2 migration, # so ``inspect.signature(self.__init__)`` no longer yields the # parameters (it returns the generic ``(self, data)`` signature). # The contract now lives in ``type(self).model_fields`` — a # ``dict[str, FieldInfo]`` whose entries carry ``.annotation``, # ``.default`` and ``.description``. if not isinstance(self, pht.ImagePipeline): model_fields = getattr(type(self), "model_fields", {}) for param_name, field in model_fields.items(): # Resolve the current instance value, falling back to the # field default (``PydanticUndefined`` marks a required # field with no default). if hasattr(self, param_name): current_val = getattr(self, param_name) elif field.default is not PydanticUndefined: current_val = field.default else: current_val = None # Skip if we can't determine a value for a required field. if current_val is None and field.default is PydanticUndefined: continue # Type comes straight from the field annotation; the # ``_create_*_widget`` helpers already handle ``Optional`` # / ``Union`` / ``Literal`` forms. type_hint = field.annotation if field.annotation is not None else Any # Determine widget type widget = self._create_widget_for_param( param_name, type_hint, current_val ) if widget: self._param_widgets[param_name] = widget # Help text comes from the field description, which # ``BaseOperation``'s docstring hook populated from # the class ``Args:`` block. help_text = field.description if help_text: # Create help label help_label = widgets.HTML( value=f"<span style='color: #666; font-size: 0.85em; font-style: italic; margin-left: 2px;'>{help_text}</span>" ) # Wrap widget and help in VBox control_group = widgets.VBox( [widget, help_label], layout=widgets.Layout(margin="0px 0px 8px 0px"), ) controls.append(control_group) else: controls.append(widget) # Bind change widget.observe(self._on_param_change, names="value") # 2. Recursive Inspection for _ops (Pipelines) if hasattr(self, "_ops") and isinstance(self._ops, dict): op_accordions = [] for op_name, op in self._ops.items(): # Check if the operation supports widgets (inherits LazyWidgetMixin or similar) if hasattr(op, "widget"): # Recursively create widget tree for the child op # We don't pass image down recursively for viz yet, main pipeline handles viz # We also don't show it immediately child_widget = op.widget(image=None, show=False) # Create accordion/group acc = widgets.Accordion(children=[child_widget]) acc.set_title(0, f"Operation: {op_name}") op_accordions.append(acc) if op_accordions: # Add operations section to controls ops_container = widgets.VBox( op_accordions, layout=widgets.Layout(margin="10px 0px 0px 0px") ) controls.append(ops_container) # 3. Visualization controls (if image provided) if self._image_ref is not None: viz_controls = self._create_viz_widgets() # Combine left_panel = widgets.VBox(controls, layout=widgets.Layout(width="40%")) right_panel = widgets.VBox(viz_controls, layout=widgets.Layout(width="60%")) self._ui = widgets.HBox([left_panel, right_panel]) else: self._ui = widgets.VBox(controls) def _create_widget_for_param( self, name: str, type_hint: Any, value: Any ) -> Optional[Widget]: import types from typing import Union origin = get_origin(type_hint) if origin is Union or ( hasattr(types, "UnionType") and origin is types.UnionType ): return self._create_union_widget(name, type_hint, value) return self._create_simple_widget(name, type_hint, value) def _create_union_widget( self, name: str, type_hint: Any, value: Any ) -> Optional[Widget]: import ipywidgets as widgets import traitlets args = get_args(type_hint) type_map = {} for t in args: if t is type(None): type_map["None"] = t else: if hasattr(t, "__name__"): t_name = t.__name__ else: t_name = str(t) type_map[t_name] = t if not type_map: return None class UnionWidget(widgets.VBox): value = traitlets.Any() def __init__(self, name, type_map, initial_value, mixin_ref): self.type_map = type_map self.mixin_ref = mixin_ref self.name = name self._ignore_updates = False self.selector = widgets.Dropdown( options=list(type_map.keys()), description=f"{name} type:", style={"description_width": "initial"}, ) self.inner_container = widgets.VBox() self.inner_container.layout = widgets.Layout(margin="0px 0px 0px 20px") self.inner_widget = None super().__init__([self.selector, self.inner_container]) self.value = initial_value self._set_ui_from_value(initial_value) self.selector.observe(self._on_type_change, names="value") def _on_type_change(self, change): if self._ignore_updates: return selected_type_name = change["new"] selected_type = self.type_map[selected_type_name] self._create_inner_widget(selected_type) self._update_value_from_inner() def _create_inner_widget(self, type_cls, value=None): self.inner_container.children = [] if type_cls is type(None): self.inner_widget = None self.inner_container.children = [widgets.Label(value="Value: None")] return if value is None: if type_cls is int: value = 0 elif type_cls is float: value = 0.0 elif type_cls is str: value = "" elif type_cls is bool: value = False elif get_origin(type_cls) is Literal: opts = get_args(type_cls) value = opts[0] if opts else None self.inner_widget = self.mixin_ref._create_simple_widget( self.name, type_cls, value ) if self.inner_widget: self.inner_container.children = [self.inner_widget] self.inner_widget.observe(self._on_inner_change, names="value") else: self.inner_container.children = [ widgets.Label(f"No widget for {type_cls}") ] self.inner_widget = None def _on_inner_change(self, change): if self._ignore_updates: return self.value = change["new"] def _update_value_from_inner(self): if self.inner_widget: self.value = self.inner_widget.value else: selected_type = self.type_map[self.selector.value] if selected_type is type(None): self.value = None @traitlets.observe("value") def _on_value_change(self, change): new_val = change["new"] self._set_ui_from_value(new_val) def _set_ui_from_value(self, val): self._ignore_updates = True try: target_type_name = None target_type = None if val is None: if "None" in self.type_map: target_type_name = "None" target_type = type(None) else: for name, t in self.type_map.items(): if t is type(None): continue if type(val) == t: target_type_name = name target_type = t break if not target_type_name: for name, t in self.type_map.items(): if t is type(None): continue # Handle generics for isinstance check check_type = t origin = get_origin(t) if origin is Literal: # For Literal, we check if the value is one of the args lit_args = get_args(t) if val in lit_args: target_type_name = name target_type = t break continue if origin is not None: check_type = origin if isinstance(val, check_type): target_type_name = name target_type = t break if target_type_name: if self.selector.value != target_type_name: self.selector.value = target_type_name self._create_inner_widget(target_type, val) else: if self.inner_widget is None and target_type is not type( None ): self._create_inner_widget(target_type, val) if self.inner_widget: if self.inner_widget.value != val: self.inner_widget.value = val elif target_type is type(None): pass finally: self._ignore_updates = False return UnionWidget(name, type_map, value, self) def _create_simple_widget( self, name: str, type_hint: Any, value: Any ) -> Optional[Widget]: import ipywidgets as widgets # Handle Literal if get_origin(type_hint) is Literal: options = get_args(type_hint) return widgets.Dropdown( options=options, value=value, description=name, style={"description_width": "initial"}, ) # Basic types if isinstance(value, bool): return widgets.Checkbox(value=value, description=name) elif isinstance(value, int): return widgets.IntText( value=value, description=name, style={"description_width": "initial"} ) elif isinstance(value, float): return widgets.FloatText( value=value, description=name, style={"description_width": "initial"} ) elif isinstance(value, str): return widgets.Text( value=value, description=name, style={"description_width": "initial"} ) # Fallback for known types if value is None but hint exists? # For now, skip complex types return None def _parse_docstring(self) -> dict[str, str]: """Parse the class docstring to extract parameter descriptions. Supports Google, NumPy, and Sphinx/ReST styles. Thin wrapper around the shared :func:`~phenotypic.sdk_._docstring_params.parse_param_descriptions` — the canonical home of this parser since the pydantic v2 migration. Operation parameter descriptions are now read straight from ``FieldInfo.description`` during widget generation; this method is retained as a stable, instance-level entry point. Returns: dict: A dictionary mapping parameter names to their description strings. """ from phenotypic.sdk_._docstring_params import parse_param_descriptions return parse_param_descriptions(self.__doc__) def _create_viz_widgets(self) -> list[Widget]: import ipywidgets as widgets self._view_dropdown = widgets.Dropdown( options=["overlay", "rgb", "gray", "detect_mat", "objmap", "objmask"], value="overlay", description="View:", ) self._update_button = widgets.Button( description="Update View", button_style="info", icon="refresh" ) self._update_button.on_click(self._on_update_view_click) self._output_widget = widgets.Output() return [ widgets.HBox([self._view_dropdown, self._update_button]), self._output_widget, ] def _on_param_change(self, change): if change["type"] != "change" or change["name"] != "value": return # Find which parameter this widget belongs to owner = change["owner"] param_name = None for name, widget in self._param_widgets.items(): if widget == owner: param_name = name break if param_name: setattr(self, param_name, change["new"]) def _on_update_view_click(self, b): if self._output_widget is None or self._image_ref is None: return # Set loading state if b is not None: original_icon = b.icon original_desc = b.description b.icon = "spinner" b.description = "Rendering..." b.disabled = True self._output_widget.clear_output(wait=True) try: with self._output_widget: try: # Create copy and apply # Note: self.apply is expected to exist on the subclass (ImageOperation) img_copy = self._image_ref.copy() # Check if we are in an ImageOperation if hasattr(self, "apply"): # Use inplace=True for efficiency on the copy # Check signature of apply to be safe (some might not support inplace?) # Standard ImageOperation.apply supports inplace. self.apply(img_copy, inplace=True) else: print("Error: Mixin used on class without apply()") return # Show view = self._view_dropdown.value if view == "overlay": fig = img_copy.dash(overlay=True) fig.show() elif view == "rgb": if not img_copy.rgb.isempty(): fig = img_copy.rgb.dash() fig.show() else: print("No RGB data available.") elif view == "gray": fig = img_copy.gray.dash() fig.show() elif view == "detect_mat": fig = img_copy.detect_mat.dash() fig.show() elif view in ("objmap", "objmask"): import matplotlib.pyplot as _plt getattr(img_copy, view).show() _plt.show() except Exception as e: print(f"Error during visualization: {e}") import traceback traceback.print_exc() finally: # Restore button state if b is not None: b.icon = original_icon b.description = original_desc b.disabled = False def _sync_widgets_from_state(self) -> None: """Push internal state into widgets.""" if not hasattr(self, "_param_widgets"): return for name, widget in self._param_widgets.items(): val = getattr(self, name, None) if val is not None: widget.value = val def _dispose_widgets(self) -> None: """Drop references to the UI widgets.""" self._ui = None self._param_widgets = {} self._view_dropdown = None self._update_button = None self._output_widget = None self._image_ref = None # Propagate to children in _ops if they exist (e.g. for ImagePipeline) if hasattr(self, "_ops") and isinstance(self._ops, dict): for op in self._ops.values(): if hasattr(op, "dispose_widgets") and callable(op._dispose_widgets): op._dispose_widgets()
[docs] def __getstate__(self): """ Prepare the object for pickling by disposing of any widgets. This ensures that UI components (which may contain unpickleable objects like input functions or thread locks) are cleaned up before serialization. Note: This method modifies the object state by calling dispose_widgets(). Any active widgets will be detached from the object. """ self._dispose_widgets() return self.__dict__