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__