# Copyright (c) DataLab Platform Developers, BSD 3-Clause License
# See LICENSE file for details
"""
Plotter API
===========
The Plotter class provides visualization capabilities for the DataLab kernel.
It supports inline notebook display and optional DataLab GUI synchronization.
This module serves as the public API facade and contains shared helpers used
by both the matplotlib and Plotly backends:
- :mod:`datalab_kernel.matplotlib_backend` — static PNG rendering
- :mod:`datalab_kernel.plotly_backend` — interactive HTML rendering
The :class:`Plotter` class auto-selects the best available backend:
Plotly (interactive) if installed, otherwise matplotlib (static).
Users can override the choice via :meth:`Plotter.set_backend` or the
``DATALAB_PLOTTER_BACKEND`` environment variable.
Shared helper functions for metadata extraction, coordinate computation,
and style resolution live in this module so both backends can reuse them.
"""
from __future__ import annotations
import contextlib
import logging
import os
import warnings
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from matplotlib.axes import Axes
from datalab_kernel.workspace import Workspace
logger = logging.getLogger("datalab-kernel")
# Valid backend names (lowercase)
BACKEND_MATPLOTLIB = "matplotlib"
BACKEND_PLOTLY = "plotly"
_VALID_BACKENDS = {BACKEND_MATPLOTLIB, BACKEND_PLOTLY}
# Environment variable for backend override
BACKEND_ENV_VAR = "DATALAB_PLOTTER_BACKEND"
[docs]
def matplotlib_available() -> bool:
"""Check whether matplotlib is importable."""
try:
# pylint: disable=import-outside-toplevel,unused-import
import matplotlib # noqa: F401
return True
except ImportError:
return False
[docs]
def plotly_available() -> bool:
"""Check whether plotly is importable."""
try:
# pylint: disable=import-outside-toplevel,unused-import
import plotly # noqa: F401
return True
except ImportError:
return False
_NO_BACKEND_MSG = (
"Neither plotly nor matplotlib is installed. "
"Install at least one: pip install matplotlib or "
"pip install datalab-kernel[plotly]"
)
[docs]
def resolve_backend(requested: str | None = None) -> str:
"""Determine which plotting backend to use.
Resolution order:
1. *requested* argument (from :meth:`Plotter.set_backend` or constructor)
2. ``DATALAB_PLOTTER_BACKEND`` environment variable
3. Auto-detect: Plotly if available, else matplotlib
When the chosen backend is not installed the function falls back to the
other one and emits a :class:`UserWarning`. If **neither** is installed
an :class:`ImportError` is raised.
Args:
requested: ``"plotly"`` or ``"matplotlib"``, case-insensitive.
*None* means auto-detect.
Returns:
Resolved backend name (``"plotly"`` or ``"matplotlib"``).
Raises:
ValueError: If *requested* is not a recognised backend name.
ImportError: If neither matplotlib nor plotly is installed.
"""
# --- Step 1: determine the candidate ----------------------------------
if requested is not None:
candidate = requested.strip().lower()
if candidate not in _VALID_BACKENDS:
raise ValueError(
f"Unknown backend {requested!r}. Choose from {sorted(_VALID_BACKENDS)}."
)
else:
env = os.environ.get(BACKEND_ENV_VAR)
if env is not None:
candidate = env.strip().lower()
if candidate not in _VALID_BACKENDS:
warnings.warn(
f"Ignoring invalid {BACKEND_ENV_VAR}={env!r}. "
f"Choose from {sorted(_VALID_BACKENDS)}.",
UserWarning,
stacklevel=2,
)
candidate = None
else:
logger.debug("Backend from %s: %s", BACKEND_ENV_VAR, candidate)
else:
candidate = None
# --- Step 2: auto-detect if no explicit choice ------------------------
if candidate is None:
return _auto_detect_backend()
# --- Step 3: validate availability, fallback with warning -------------
avail = {
BACKEND_PLOTLY: plotly_available,
BACKEND_MATPLOTLIB: matplotlib_available,
}
if avail[candidate]():
return candidate
# Candidate unavailable — try the other
other = BACKEND_PLOTLY if candidate == BACKEND_MATPLOTLIB else BACKEND_MATPLOTLIB
if avail[other]():
warnings.warn(
f"Requested backend {candidate!r} is not installed. "
f"Falling back to {other!r}.",
UserWarning,
stacklevel=2,
)
return other
raise ImportError(_NO_BACKEND_MSG)
def _auto_detect_backend() -> str:
"""Return the best available backend (Plotly preferred)."""
if plotly_available():
return BACKEND_PLOTLY
if matplotlib_available():
return BACKEND_MATPLOTLIB
raise ImportError(_NO_BACKEND_MSG)
def _create_delegate(backend: str, workspace: Workspace):
"""Instantiate the concrete plotter for *backend*.
Args:
backend: ``"plotly"`` or ``"matplotlib"``.
workspace: The workspace containing objects to plot.
Returns:
A ``PlotlyPlotter`` or ``MatplotlibPlotter`` instance.
"""
# pylint: disable=import-outside-toplevel,redefined-outer-name
if backend == BACKEND_PLOTLY:
from datalab_kernel.plotly_backend import PlotlyPlotter
return PlotlyPlotter(workspace)
from datalab_kernel.matplotlib_backend import MatplotlibPlotter
return MatplotlibPlotter(workspace)
# Style configuration constants
MASK_OPACITY = 0.35 # Opacity for mask overlay
#: Default plot width in pixels for figure output.
#: Matches matplotlib's default figure width (6.4 in × 100 DPI) and fits
#: comfortably in standard Jupyter layouts (~960 px classic, ~700–900 px Lab).
DEFAULT_PLOT_WIDTH = 640
# Metadata prefix for geometry results (consistent with DataLab's GeometryAdapter)
GEOMETRY_META_PREFIX = "Geometry_"
# Metadata prefix for table results (consistent with DataLab's TableAdapter)
TABLE_META_PREFIX = "Table_"
def _build_results_html(
table_results: list,
geometry_results: list | None = None,
) -> str:
"""Build HTML for analysis results to display below a plot.
Renders table and geometry results as styled HTML tables using the
existing :class:`TableResultDisplay` and :class:`GeometryResultDisplay`
classes. The output is intended to be appended below the figure in
notebook cell output (via ``_ipython_display_``).
Args:
table_results: List of TableResult objects
geometry_results: Optional list of GeometryResult objects
Returns:
Concatenated HTML string (empty if no results)
"""
if not table_results and not geometry_results:
return ""
parts: list[str] = []
for tbl in table_results:
parts.append(TableResultDisplay(tbl)._repr_html_())
if geometry_results:
for geo in geometry_results:
parts.append(GeometryResultDisplay(geo)._repr_html_())
return "\n".join(parts)
def _extract_geometry_results_from_metadata(obj) -> list:
"""Extract GeometryResult objects from object metadata.
DataLab stores geometry results in object metadata with keys starting with
'Geometry_'. This function extracts and reconstructs those GeometryResult
objects for visualization.
Args:
obj: SignalObj or ImageObj with potential geometry results in metadata
Returns:
List of GeometryResult objects extracted from metadata
"""
results = []
if not hasattr(obj, "metadata") or obj.metadata is None:
return results
# Delayed import
# pylint: disable=import-outside-toplevel
from sigima.objects import GeometryResult
for key, value in obj.metadata.items():
if key.startswith(GEOMETRY_META_PREFIX) and isinstance(value, dict):
try:
geometry = GeometryResult.from_dict(value)
results.append(geometry)
except (ValueError, TypeError, KeyError):
# Skip invalid entries
pass
return results
def _extract_table_results_from_metadata(obj) -> list:
"""Extract TableResult objects from object metadata.
DataLab stores table results in object metadata with keys starting with
'Table_'. This function extracts and reconstructs those TableResult
objects for visualization.
Args:
obj: SignalObj or ImageObj with potential table results in metadata
Returns:
List of TableResult objects extracted from metadata
"""
results = []
if not hasattr(obj, "metadata") or obj.metadata is None:
return results
# Delayed import
# pylint: disable=import-outside-toplevel
from sigima.objects import TableResult
for key, value in obj.metadata.items():
if key.startswith(TABLE_META_PREFIX) and isinstance(value, dict):
try:
table = TableResult.from_dict(value)
results.append(table)
except (ValueError, TypeError, KeyError):
# Skip invalid entries
pass
return results
def _get_geometry_coord_labels(geometry) -> list[str]:
"""Get coordinate labels for a geometry result based on its kind.
Args:
geometry: GeometryResult object
Returns:
List of coordinate labels (e.g., ["x", "y", "r"] for circle)
"""
# Delayed import
# pylint: disable=import-outside-toplevel
from sigima.objects import KindShape
if geometry.kind == KindShape.POINT:
return ["x", "y"]
if geometry.kind == KindShape.MARKER:
return ["x", "y"]
if geometry.kind == KindShape.RECTANGLE:
return ["x0", "y0", "dx", "dy"]
if geometry.kind == KindShape.CIRCLE:
return ["x", "y", "r"]
if geometry.kind == KindShape.SEGMENT:
return ["x0", "y0", "x1", "y1"]
if geometry.kind == KindShape.ELLIPSE:
return ["x", "y", "a", "b", "θ"]
# Default for POLYGON and others
return [
f"c{i}"
for i in range(
len(geometry.coords[0])
if geometry.coords.ndim > 1
else len(geometry.coords)
)
]
def _get_image_extent_and_aspect(obj) -> tuple[list[float], float]:
"""Compute matplotlib extent and aspect ratio from image physical coordinates.
DataLab images use physical coordinates defined by:
- x0, y0: Origin (center of top-left pixel)
- dx, dy: Pixel spacing
For matplotlib's imshow:
- extent defines pixel edges: [left, right, bottom, top]
- aspect ratio is dx/dy to preserve physical proportions
With origin="upper", matplotlib expects:
- extent = [xmin - dx/2, xmax + dx/2, ymax + dy/2, ymin - dy/2]
Args:
obj: ImageObj with physical coordinate attributes
Returns:
Tuple of (extent, aspect_ratio) where:
- extent: [left, right, bottom, top] for imshow
- aspect_ratio: dx/dy for proper physical display
"""
# Get image shape
nrows, ncols = obj.data.shape[:2]
# Check if image has physical coordinates
has_coords = hasattr(obj, "x0") and hasattr(obj, "dx")
if has_coords:
x0 = getattr(obj, "x0", 0.0)
y0 = getattr(obj, "y0", 0.0)
dx = getattr(obj, "dx", 1.0)
dy = getattr(obj, "dy", 1.0)
# Compute pixel centers range (as in Sigima)
xmin = x0 # Center of leftmost column
xmax = x0 + (ncols - 1) * dx # Center of rightmost column
ymin = y0 # Center of topmost row
ymax = y0 + (nrows - 1) * dy # Center of bottommost row
# Convert to pixel edges for matplotlib extent
# extent = [left, right, bottom, top]
# For origin="upper", bottom is ymax and top is ymin
left = xmin - dx / 2
right = xmax + dx / 2
bottom = ymax + dy / 2 # Lower edge of bottom-most pixel
top = ymin - dy / 2 # Upper edge of top-most pixel
extent = [left, right, bottom, top]
# Aspect ratio preserves physical pixel proportions
aspect_ratio = dx / dy
else:
# No physical coordinates, use pixel indices
extent = [-0.5, ncols - 0.5, nrows - 0.5, -0.5]
aspect_ratio = 1.0
return extent, aspect_ratio
def _get_signal_style_from_metadata(obj) -> dict:
"""Extract line style parameters from signal object metadata.
DataLab stores line style parameters as direct keys in the metadata dict
(not prefixed with ``__``). Supported keys: ``color``, ``linestyle``,
``linewidth``.
Args:
obj: SignalObj with potential style metadata
Returns:
Dict of matplotlib-compatible style parameters
"""
meta = getattr(obj, "metadata", None) or {}
style: dict = {}
if "color" in meta:
style["color"] = meta["color"]
if "linestyle" in meta:
qt_to_mpl = {
"SolidLine": "-",
"DashLine": "--",
"DashDotLine": "-.",
"DashDotDotLine": ":",
}
style["linestyle"] = qt_to_mpl.get(meta["linestyle"], meta["linestyle"])
if "linewidth" in meta:
style["linewidth"] = meta["linewidth"]
return style
def _get_curve_style(obj) -> str:
"""Get curve style from signal object metadata.
Reads the ``__curvestyle`` metadata option. Possible values are
``"Lines"`` (default), ``"Sticks"``, or ``"Steps"``.
Args:
obj: SignalObj with potential curve style metadata
Returns:
Curve style string ("Lines", "Sticks", or "Steps")
"""
if hasattr(obj, "get_metadata_option"):
try:
return obj.get_metadata_option("curvestyle")
except (KeyError, AttributeError, ValueError):
pass
return "Lines"
def _apply_log_scale(ax: Axes, obj) -> None:
"""Apply logarithmic scale to axes if enabled on the object.
Checks ``xscalelog`` and ``yscalelog`` attributes and sets the
corresponding matplotlib axis scale to ``"log"``.
Args:
ax: Matplotlib axes object
obj: SignalObj or ImageObj with potential log scale attributes
"""
if getattr(obj, "xscalelog", False):
ax.set_xscale("log")
if getattr(obj, "yscalelog", False):
ax.set_yscale("log")
def _apply_axis_bounds(ax: Axes, obj) -> None:
"""Apply explicit axis bounds when autoscale is disabled.
If ``autoscale`` is ``False``, reads ``xscalemin``, ``xscalemax``,
``yscalemin``, ``yscalemax`` and applies them to the axes.
Args:
ax: Matplotlib axes object
obj: SignalObj or ImageObj with axis bound attributes
"""
if not getattr(obj, "autoscale", True):
xmin = getattr(obj, "xscalemin", None)
xmax = getattr(obj, "xscalemax", None)
ymin = getattr(obj, "yscalemin", None)
ymax = getattr(obj, "yscalemax", None)
if xmin is not None and xmax is not None:
ax.set_xlim(xmin, xmax)
if ymin is not None and ymax is not None:
ax.set_ylim(ymin, ymax)
def _is_non_uniform_image(obj) -> bool:
"""Check if an image object uses non-uniform coordinates.
Args:
obj: ImageObj to check
Returns:
True if the image has non-uniform coordinate arrays
"""
if not hasattr(obj, "is_uniform_coords"):
return False
if obj.is_uniform_coords:
return False
xcoords = getattr(obj, "xcoords", None)
ycoords = getattr(obj, "ycoords", None)
return xcoords is not None and ycoords is not None
def _get_image_colormap(obj, kwargs: dict) -> str:
"""Determine colormap for an image from kwargs or object metadata.
Priority: explicit kwarg > object metadata option > default ``"viridis"``.
Args:
obj: ImageObj with potential colormap metadata
kwargs: Keyword arguments dict (may contain ``"colormap"`` key)
Returns:
Matplotlib colormap name string
"""
colormap = kwargs.get("colormap")
if colormap is None and hasattr(obj, "get_metadata_option"):
with contextlib.suppress(KeyError, AttributeError, ValueError):
colormap = obj.get_metadata_option("colormap")
return colormap or "viridis"
def _get_image_lut_range(obj) -> tuple[float | None, float | None]:
"""Get LUT range (vmin, vmax) from image object attributes.
Args:
obj: ImageObj with potential ``zscalemin``/``zscalemax`` attributes
Returns:
Tuple of (vmin, vmax), either or both may be None
"""
vmin = getattr(obj, "zscalemin", None)
vmax = getattr(obj, "zscalemax", None)
return vmin, vmax
# -- Object classification --------------------------------------------------
#: Category name for signal-like objects.
_SIGNAL = "signal"
#: Category name for image-like objects.
_IMAGE = "image"
def _classify_object(obj) -> str:
"""Classify an object as ``"signal"`` or ``"image"``.
Classification rules:
* ``SignalObj`` → ``"signal"``
* ``ImageObj`` → ``"image"``
* ``tuple`` of length 2 → ``"signal"`` (interpreted as *(x, y)*)
* 1-D ``numpy.ndarray`` → ``"signal"`` (y-only data)
* 2-D ``numpy.ndarray`` → ``"image"``
Args:
obj: The object to classify.
Returns:
``"signal"`` or ``"image"``.
Raises:
TypeError: If *obj* cannot be classified.
"""
import numpy as np # pylint: disable=import-outside-toplevel
type_name = type(obj).__name__
if type_name == "SignalObj":
return _SIGNAL
if type_name == "ImageObj":
return _IMAGE
if isinstance(obj, tuple) and len(obj) == 2:
return _SIGNAL
if isinstance(obj, np.ndarray):
if obj.ndim == 1:
return _SIGNAL
if obj.ndim == 2:
return _IMAGE
raise TypeError(f"Cannot classify object of type {type(obj)!r} as signal or image.")
def _resolve_and_classify(objs_or_names, workspace) -> tuple[list, str]:
"""Resolve workspace names and classify a list of objects.
All items must be of the same category (all signals or all images).
Single-string inputs are **not** treated as lists — callers should handle
the scalar-vs-list distinction before calling this helper.
Args:
objs_or_names: Iterable of objects and/or workspace name strings.
workspace: The :class:`Workspace` used to resolve string names.
Returns:
A ``(resolved_objects, category)`` tuple where *category* is
``"signal"`` or ``"image"``.
Raises:
TypeError: If the list mixes signals and images.
"""
resolved: list = []
categories: set[str] = set()
for item in objs_or_names:
if isinstance(item, str):
item = workspace.get(item)
resolved.append(item)
categories.add(_classify_object(item))
if len(categories) > 1:
raise TypeError(
"Cannot mix signals and images in a single plot call. "
"Pass only signals or only images."
)
# Default to signal for empty lists (will produce an empty figure)
category = categories.pop() if categories else _SIGNAL
return resolved, category
# ============================================================================
# Plotter facade (delegates to active backend)
# ============================================================================
[docs]
class Plotter:
"""Visualization frontend for the DataLab kernel.
The Plotter provides methods to display signals and images inline in
Jupyter notebooks, and optionally synchronize views with a running
DataLab instance.
The backend is chosen automatically (Plotly if installed, otherwise
matplotlib) but can be overridden via the *backend* parameter, the
``DATALAB_PLOTTER_BACKEND`` environment variable, or at runtime with
:meth:`set_backend`.
Example::
# Single object
plotter.plot("i042")
plotter.plot(workspace.get("i042"))
# Multiple signals (overlay on shared axes)
plotter.plot([sig1, sig2, sig3])
# Multiple images (subplot grid)
plotter.plot([img1, img2])
# Display analysis results
plotter.display_table(result)
plotter.display_geometry(result)
# Switch backend at runtime
plotter.set_backend("matplotlib")
"""
def __init__(self, workspace: Workspace, backend: str | None = None) -> None:
"""Initialize plotter with workspace reference.
Args:
workspace: The workspace containing objects to plot
backend: ``"plotly"`` or ``"matplotlib"``. *None* (default)
auto-detects the best available backend (Plotly preferred).
"""
self._workspace = workspace
resolved = resolve_backend(backend)
self._delegate = _create_delegate(resolved, workspace)
self._backend = resolved
logger.debug("Plotter initialised with %s backend", resolved)
# -- Backend selection API ------------------------------------------------
@property
def backend(self) -> str:
"""Return the name of the active backend (``"plotly"`` or
``"matplotlib"``)."""
return self._backend
[docs]
def set_backend(self, backend: str) -> Plotter:
"""Switch the plotting backend at runtime.
Args:
backend: ``"plotly"`` or ``"matplotlib"`` (case-insensitive).
Returns:
*self*, for call-chaining.
Raises:
ValueError: If *backend* is not recognised.
ImportError: If the requested backend is not installed and no
fallback is available.
"""
resolved = resolve_backend(backend)
if resolved != self._backend:
self._delegate = _create_delegate(resolved, self._workspace)
self._backend = resolved
logger.debug("Plotter switched to %s backend", resolved)
return self
# -- Delegated plotting methods ------------------------------------------
[docs]
def plot(
self,
obj_or_name,
title=None,
show_roi=True,
show_results=True,
*,
xlabel=None,
ylabel=None,
xunit=None,
yunit=None,
zlabel=None,
zunit=None,
titles=None,
results=None,
**kwargs,
):
"""Plot one or more objects.
Accepts a single object (or workspace name) **or** a list of objects.
* **Single object** — renders one signal or image.
* **List of signals** — overlays all curves on shared axes.
* **List of images** — displays in a subplot grid.
* **Mixed list** — raises :class:`TypeError`.
A single-item list is unwrapped and treated as a single object.
Args:
obj_or_name: Object to plot, workspace name, or a *list* of
objects / names. Lists may contain ``SignalObj``, ``ImageObj``,
``numpy.ndarray`` (1-D → signal, 2-D → image), ``(x, y)``
tuples (signal), or workspace name strings.
title: Plot title (overall figure title for multi-plots).
show_roi: Whether to show ROIs defined in the objects.
show_results: Whether to show geometry/table results from
metadata.
xlabel: X-axis label override (multi-plots).
ylabel: Y-axis label override (multi-plots).
xunit: X-axis unit override (multi-plots).
yunit: Y-axis unit override (multi-plots).
zlabel: Colorbar label override (images only).
zunit: Colorbar unit override (images only).
titles: Per-image title list (images only).
results: List of ``GeometryResult`` objects to overlay
(images only).
Keyword Args:
height (int): Figure height in pixels. For images defaults to
an auto-computed value based on the aspect ratio.
colormap (str): Colormap name override (images only).
Returns:
A backend-specific result object with Jupyter display
capabilities.
Raises:
TypeError: If a list mixes signals and images.
KeyError: If a workspace name is not found.
"""
return self._delegate.plot(
obj_or_name,
title=title,
show_roi=show_roi,
show_results=show_results,
xlabel=xlabel,
ylabel=ylabel,
xunit=xunit,
yunit=yunit,
zlabel=zlabel,
zunit=zunit,
titles=titles,
results=results,
**kwargs,
)
[docs]
def display_table(
self, result, title=None, visible_only=True, transpose_single_row=True
):
"""Display a TableResult with rich HTML rendering.
See the active backend's ``display_table`` method for full
documentation.
"""
return self._delegate.display_table(
result,
title=title,
visible_only=visible_only,
transpose_single_row=transpose_single_row,
)
[docs]
def display_geometry(self, result, title=None):
"""Display a GeometryResult with rich HTML rendering.
See the active backend's ``display_geometry`` method for full
documentation.
"""
return self._delegate.display_geometry(result, title=title)
[docs]
class TableResultDisplay:
"""
Display wrapper for TableResult with rich Jupyter notebook rendering.
Provides HTML table display with automatic formatting, optional DataFrame
conversion, and support for ROI-indexed results.
Example::
# Display a TableResult from computation
result = proxy.compute_statistics()
display = TableResultDisplay(result)
display # Shows styled HTML table in Jupyter
# Get as DataFrame for further analysis
df = display.to_dataframe()
"""
# CSS styling for HTML tables
_TABLE_STYLE = """
<style>
.sigima-table {
border-collapse: collapse;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
margin: 10px 0;
}
.sigima-table th {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 8px 12px;
text-align: left;
font-weight: 600;
}
.sigima-table td {
border: 1px solid #dee2e6;
padding: 8px 12px;
text-align: right;
}
.sigima-table tr:nth-child(even) {
background-color: #f8f9fa;
}
.sigima-table tr:hover {
background-color: #e9ecef;
}
.sigima-table-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #495057;
}
</style>
"""
def __init__(
self,
result,
title: str | None = None,
visible_only: bool = True,
transpose_single_row: bool = True,
) -> None:
"""Initialize TableResult display.
Args:
result: TableResult object to display
title: Optional title override (uses result.title if None)
visible_only: If True, show only visible columns based on display prefs
transpose_single_row: If True, transpose single-row tables for readability
"""
self._result = result
self._title = title
self._visible_only = visible_only
self._transpose_single_row = transpose_single_row
def _repr_html_(self) -> str:
"""Return HTML representation for Jupyter display."""
try:
title = self._title or self._result.title
# Use TableResult's built-in to_html if available
# (to_html() already includes a styled title header)
if hasattr(self._result, "to_html"):
table_html = self._result.to_html(
visible_only=self._visible_only,
transpose_single_row=self._transpose_single_row,
)
return f"""
{self._TABLE_STYLE}
<div>
{table_html}
</div>
"""
# Fallback: manual HTML generation from DataFrame
df = self.to_dataframe()
# Transpose single-row tables for better readability
if self._transpose_single_row and len(df) == 1:
df = df.T
df.columns = ["Value"]
# Format numbers for display
html_table = df.to_html(
classes="sigima-table",
float_format=lambda x: f"{x:.6g}" if isinstance(x, float) else str(x),
)
return f"""
{self._TABLE_STYLE}
<div>
<div class="sigima-table-title">{title}</div>
{html_table}
</div>
"""
except Exception as e: # pylint: disable=broad-exception-caught
return f"<div>Error rendering table: {e}</div>"
[docs]
def to_dataframe(self):
"""Convert the TableResult to a pandas DataFrame.
Returns:
pandas DataFrame with result data
"""
if hasattr(self._result, "to_dataframe"):
return self._result.to_dataframe(visible_only=self._visible_only)
# Fallback: manual DataFrame creation
# pylint: disable=import-outside-toplevel
import pandas as pd
df = pd.DataFrame(self._result.data, columns=list(self._result.headers))
if self._result.roi_indices is not None:
df.insert(0, "roi_index", self._result.roi_indices)
return df
def __repr__(self) -> str:
"""Return string representation."""
result_type = type(self._result).__name__
title = self._title or getattr(self._result, "title", "Untitled")
n_rows = len(self._result.data) if hasattr(self._result, "data") else "?"
n_cols = len(self._result.headers) if hasattr(self._result, "headers") else "?"
return f"TableResultDisplay({result_type}: {title}, {n_rows}×{n_cols})"
[docs]
class GeometryResultDisplay:
"""
Display wrapper for GeometryResult with rich Jupyter notebook rendering.
Provides HTML table display showing coordinates and metadata for geometric
results like points, segments, circles, ellipses, rectangles, and polygons.
Example::
# Display a GeometryResult from computation
result = proxy.compute_peak_detection()
display = GeometryResultDisplay(result)
display # Shows styled HTML table in Jupyter
# Get as DataFrame for further analysis
df = display.to_dataframe()
"""
def __init__(
self,
result,
title: str | None = None,
) -> None:
"""Initialize GeometryResult display.
Args:
result: GeometryResult object to display
title: Optional title override (uses result.title if None)
"""
self._result = result
self._title = title
def _repr_html_(self) -> str:
"""Return HTML representation for Jupyter display."""
try:
title = self._title or self._result.title
# Use GeometryResult's built-in to_html if available
# (to_html() already includes a styled title header)
if hasattr(self._result, "to_html"):
table_html = self._result.to_html()
return f"""
{TableResultDisplay._TABLE_STYLE}
<div>
{table_html}
</div>
"""
# Fallback: manual HTML generation from DataFrame
df = self.to_dataframe()
# Format numbers for display
html_table = df.to_html(
classes="sigima-table",
float_format=lambda x: f"{x:.6g}" if isinstance(x, float) else str(x),
)
return f"""
{TableResultDisplay._TABLE_STYLE}
<div>
<div class="sigima-table-title">{title} ({self._result.kind.value})</div>
{html_table}
</div>
"""
except Exception as e: # pylint: disable=broad-exception-caught
return f"<div>Error rendering geometry: {e}</div>"
[docs]
def to_dataframe(self):
"""Convert the GeometryResult to a pandas DataFrame.
Returns:
pandas DataFrame with coordinate data
"""
if hasattr(self._result, "to_dataframe"):
return self._result.to_dataframe()
# Fallback: manual DataFrame creation based on kind
# pylint: disable=import-outside-toplevel
import pandas as pd
from sigima.objects import KindShape
coords = self._result.coords
kind = self._result.kind
# Create column names based on geometry kind
if kind == KindShape.POINT or kind == KindShape.MARKER:
columns = ["x", "y"]
elif kind == KindShape.SEGMENT:
columns = ["x0", "y0", "x1", "y1"]
elif kind == KindShape.RECTANGLE:
columns = ["x0", "y0", "width", "height"]
elif kind == KindShape.CIRCLE:
columns = ["xc", "yc", "radius"]
elif kind == KindShape.ELLIPSE:
columns = ["xc", "yc", "a", "b", "theta"]
elif kind == KindShape.POLYGON:
# Variable number of columns for polygons
n_coords = coords.shape[1]
columns = [
f"x{i // 2}" if i % 2 == 0 else f"y{i // 2}" for i in range(n_coords)
]
else:
columns = [f"c{i}" for i in range(coords.shape[1])]
df = pd.DataFrame(coords, columns=columns)
if self._result.roi_indices is not None:
df.insert(0, "roi_index", self._result.roi_indices)
return df
def __repr__(self) -> str:
"""Return string representation."""
title = self._title or getattr(self._result, "title", "Untitled")
kind = getattr(self._result, "kind", "?")
n_rows = len(self._result.coords) if hasattr(self._result, "coords") else "?"
return f"GeometryResultDisplay({kind}: {title}, {n_rows} items)"
# ============================================================================
# Backward-compatibility re-exports
# ============================================================================
# These aliases keep existing imports working after the matplotlib rendering
# code was extracted to :mod:`datalab_kernel.matplotlib_backend`.
# pylint: disable=wrong-import-position
try:
from datalab_kernel.matplotlib_backend import ( # noqa: E402
MatplotlibPlotter,
)
from datalab_kernel.matplotlib_backend import ( # noqa: E402
MplPlotResult as PlotResult,
)
except ImportError: # pragma: no cover — matplotlib is normally required
pass
__all__ = [
# Public API
"Plotter",
"TableResultDisplay",
"GeometryResultDisplay",
# Backend selection
"BACKEND_MATPLOTLIB",
"BACKEND_PLOTLY",
"BACKEND_ENV_VAR",
"matplotlib_available",
"plotly_available",
"resolve_backend",
# Backward-compat re-exports
"PlotResult",
"MatplotlibPlotter",
# Classification helpers (for backend use)
"_classify_object",
"_resolve_and_classify",
"_SIGNAL",
"_IMAGE",
# Shared helpers (for backend use)
"DEFAULT_PLOT_WIDTH",
"MASK_OPACITY",
"GEOMETRY_META_PREFIX",
"TABLE_META_PREFIX",
"_build_results_html",
]