Source code for phenotypic.tools_.mixin._lazy_widget_mixin
from __future__ import annotations
import inspect
import re
import typing
from typing import TYPE_CHECKING, Any, Optional, get_args, get_origin, Literal
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.
"""
_ui: Optional[Widget] = None
_param_widgets: dict[str, Widget]
_view_dropdown: Optional[Widget] = None
_update_button: Optional[Widget] = None
_output_widget: Optional[Widget] = None
_image_ref: Optional[Image] = None
[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 __init__ parameters
# Skip for ImagePipeline
if not isinstance(self, pht.ImagePipeline):
sig = inspect.signature(self.__init__)
hints = typing.get_type_hints(self.__init__)
# Parse docstring for parameter descriptions
doc_params = self._parse_docstring()
for param_name, param in sig.parameters.items():
if (
param_name == "self"
or param_name == "args"
or param_name == "kwargs"
):
continue
# Get current value from instance
if hasattr(self, param_name):
current_val = getattr(self, param_name)
else:
# Fallback to default if attribute missing (shouldn't happen for well-behaved ops)
current_val = (
param.default
if param.default is not inspect.Parameter.empty
else None
)
# Skip if we can't determine value or it's private
if current_val is None and param.default is inspect.Parameter.empty:
continue
# Determine widget type
widget = self._create_widget_for_param(
param_name, hints.get(param_name, Any), current_val
)
if widget:
self._param_widgets[param_name] = widget
# Check for help text from docstring
if param_name in doc_params:
help_text = doc_params[param_name]
# 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.
Returns:
dict: A dictionary mapping parameter names to their description strings.
"""
doc = self.__doc__
if not doc:
return {}
params = {}
lines = doc.split("\n")
# State machine variables
in_param_section = False
current_param = None
current_desc = []
# Regex patterns
# Google: "Args:", "Attributes:", "Parameters:"
section_header_re = re.compile(
r"^\s*(Args|Attributes|Parameters)\s*:\s*$", re.IGNORECASE
)
# NumPy: "Parameters\n----------"
numpy_header_re = re.compile(r"^\s*Parameters\s*$", re.IGNORECASE)
numpy_underline_re = re.compile(r"^\s*-+\s*$")
# Param patterns
# Google: "name (type): description" or "name: description"
google_param_re = re.compile(r"^\s*(\w+)\s*(\(.*?\))?\s*:\s*(.+)$")
# NumPy: "name : type"
numpy_param_re = re.compile(r"^\s*(\w+)\s*:\s*(.*)$")
# Sphinx: ":param name: description" (can appear anywhere)
sphinx_param_re = re.compile(r"^\s*:param\s+(\w+)\s*:\s*(.+)$")
i = 0
while i < len(lines):
line = lines[i]
stripped_line = line.strip()
# Check for Sphinx style (line by line, no section needed)
sphinx_match = sphinx_param_re.match(line)
if sphinx_match:
# Save previous param if exists
if current_param:
params[current_param] = " ".join(current_desc).strip()
current_param = sphinx_match.group(1)
current_desc = [sphinx_match.group(2)]
in_param_section = False # Sphinx doesn't strictly enforce sections
i += 1
continue
# Check for section headers
if section_header_re.match(stripped_line):
in_param_section = True
# If previous param was pending, save it
if current_param:
params[current_param] = " ".join(current_desc).strip()
current_param = None
current_desc = []
i += 1
continue
if numpy_header_re.match(stripped_line):
# Check next line for underline
if i + 1 < len(lines) and numpy_underline_re.match(lines[i + 1]):
in_param_section = True
i += 2
continue
if in_param_section:
# End of section detection
# Heuristic: if line is unindented and not empty, and doesn't match param pattern, section might be over
if (
line
and not line[0].isspace()
and not google_param_re.match(line)
and not numpy_param_re.match(line)
):
in_param_section = False
if current_param:
params[current_param] = " ".join(current_desc).strip()
current_param = None
current_desc = []
i += 1
continue
# Check for new parameter definition
# Google style
g_match = google_param_re.match(line)
if g_match:
if current_param:
params[current_param] = " ".join(current_desc).strip()
current_param = g_match.group(1)
current_desc = [g_match.group(3)]
i += 1
continue
# NumPy style
n_match = numpy_param_re.match(line)
if n_match:
if current_param:
params[current_param] = " ".join(current_desc).strip()
current_param = n_match.group(1)
current_desc = [] # NumPy desc starts on next line
i += 1
continue
# Continuation of description
if current_param and stripped_line:
current_desc.append(stripped_line)
i += 1
# End of loop, save last param
if current_param:
params[current_param] = " ".join(current_desc).strip()
return params
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__