Source code for sigima.viz.viz_plotpy

# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
Visualization tools for Sigima (PlotPy backend)
===============================================

This module provides PlotPy-based visualization utilities for Sigima objects.
It offers interactive plotting with Qt dialogs, supporting curve and image display,
ROI visualization, and geometry result overlays.
"""

from __future__ import annotations

import os
import sys
from typing import Generator, Literal

import numpy as np
import plotpy.tools
from guidata.qthelpers import exec_dialog as guidata_exec_dialog
from plotpy.builder import make
from plotpy.config import CONF
from plotpy.items import (
    AnnotatedCircle,
    AnnotatedEllipse,
    AnnotatedPoint,
    AnnotatedPolygon,
    AnnotatedRectangle,
    AnnotatedSegment,
    AnnotatedShape,
    AnnotatedXRange,
    AnnotatedYRange,
    CurveItem,
    ImageItem,
    LabelItem,
    Marker,
    MaskedImageItem,
    MaskedXYImageItem,
)
from plotpy.plot import (
    BasePlot,
    BasePlotOptions,
    PlotDialog,
    PlotOptions,
    SyncPlotDialog,
)
from plotpy.styles import LINESTYLES, ShapeParam
from qtpy import QtWidgets as QW

from sigima.config import _
from sigima.enums import ContourShape
from sigima.objects import (
    CircularROI,
    GeometryResult,
    ImageObj,
    KindShape,
    PolygonalROI,
    RectangularROI,
    SegmentROI,
    SignalObj,
)
from sigima.tools import coordinates


# Optional imports for test environment integration
def _get_default_name(suffix: str | None = None) -> str:
    """Return default name based on script name.

    Fallback if sigima.tests not available.
    """
    name = os.path.splitext(os.path.basename(sys.argv[0]))[0]
    if suffix is not None:
        name += "_" + suffix
    return name


def _debug_print(message: str) -> None:
    """Debug print (fallback if sigima.tests.env not available)."""
    if os.environ.get("SIGIMA_DEBUG", "").lower() in ("1", "true"):
        print(message)


# Try to import from sigima.tests for full functionality in test environment
try:
    from sigima.tests.env import execenv

    debug_print = execenv.print
except ImportError:
    debug_print = _debug_print

try:
    from sigima.tests.helpers import get_default_test_name
except ImportError:
    get_default_test_name = _get_default_name


QAPP: QW.QApplication | None = None

WIDGETS: list[QW.QWidget] = []

CONF.set("plot", "title/font/size", 11)


def __ensure_qapp() -> QW.QApplication:
    """Ensure that a QApplication instance exists."""
    global QAPP  # pylint: disable=global-statement
    if QAPP is None:
        QAPP = QW.QApplication.instance()
        if QAPP is None:
            QAPP = QW.QApplication([])  # type: ignore[assignment]
    return QAPP


def __exec_dialog(dlg: QW.QDialog) -> None:
    """Execute a dialog, supporting Sphinx-Gallery scraping."""
    global WIDGETS  # pylint: disable=global-statement,global-variable-not-assigned
    gallery_building = os.getenv("SPHINX_GALLERY_BUILDING")
    if gallery_building:
        dlg.show()
        WIDGETS.append(dlg)
    else:
        guidata_exec_dialog(dlg)


TEST_NB = {}

# Default image parameters
IMAGE_PARAMETERS = {
    "interpolation": "nearest",
    "eliminate_outliers": 0.1,
    "colormap": "viridis",
}

#: Curve colors
COLORS = (
    "#1f77b4",  # muted blue
    "#ff7f0e",  # safety orange
    "#2ca02c",  # cooked asparagus green
    "#d62728",  # brick red
    "#9467bd",  # muted purple
    "#8c564b",  # chestnut brown
    "#e377c2",  # raspberry yogurt pink
    "#7f7f7f",  # gray
    "#bcbd22",  # curry yellow-green
    "#17becf",  # blue-teal
)


def __style_generator() -> Generator[tuple[str, str], None, None]:
    """Cycling through curve styles"""
    while True:
        for linestyle in LINESTYLES:
            for color in COLORS:
                yield (color, linestyle)


make.style = __style_generator()


def __generate_widget_name_title(
    name: str | None, title: str | None
) -> tuple[str, str]:
    """Return (default) widget name and title

    Args:
        name: Name of the widget, or None to use a default name
        title: Title of the widget, or None to use a default title

    Returns:
        A tuple (name, title) where:
        - `name` is the widget name, which is either the provided name or a default
        - `title` is the widget title, which is either the provided title or a default
    """
    if name is None:
        TEST_NB[name] = TEST_NB.setdefault(name, 0) + 1
        name = get_default_test_name(f"{TEST_NB[name]:02d}")
    if title is None:
        title = f"{_('Plot dialog')} `{name}`"
    return name, title


def __create_curve_dialog(
    name: str | None = None,
    title: str | None = None,
    xlabel: str | None = None,
    ylabel: str | None = None,
    xunit: str | None = None,
    yunit: str | None = None,
    size: tuple[int, int] | None = None,
) -> PlotDialog:
    """Create Curve Dialog

    Args:
        name: Name of the dialog, or None to use a default name
        title: Title of the dialog, or None to use a default title
        xlabel: Label for the x-axis, or None for no label
        ylabel: Label for the y-axis, or None for no label
        xunit: Unit for the x-axis, or None for no unit
        yunit: Unit for the y-axis, or None for no unit
        size: Size of the dialog as a tuple (width, height), or None for default size

    Returns:
        A `PlotDialog` instance configured for curve plotting
    """
    name, title = __generate_widget_name_title(name, title)
    win = PlotDialog(
        edit=False,
        toolbar=True,
        title=title,
        options=PlotOptions(
            title=title,
            type="curve",
            xlabel=xlabel,
            ylabel=ylabel,
            xunit=xunit,
            yunit=yunit,
            curve_antialiasing=True,
        ),
        size=(800, 600) if size is None else size,
    )
    win.setObjectName(name)
    return win


[docs] def create_curve(x: np.ndarray, y: np.ndarray, title: str | None = None) -> CurveItem: """Create a curve item from x and y data Args: x: X data array y: Y data array title: Label for the curve, or None for no label Returns: A `CurveItem` representing the curve """ item = make.mcurve(x, y, label=title) return item
[docs] def create_image( data: np.ndarray, title: str | None = None, interpolation: str = "linear", colormap: str | None = None, alpha_function: str | None = None, xdata: list[float] | None = None, ydata: list[float] | None = None, ) -> ImageItem: """Create an image item from data Args: data: 2D array of image data title: Title for the image interpolation: Interpolation method for image display colormap: Colormap for the image, or None for default alpha_function: Alpha function for the image, or None for default xdata: X coordinates for the image (optional) ydata: Y coordinates for the image (optional) """ item = make.image( data, title=title, interpolation=interpolation, colormap=colormap, xdata=[None, None] if xdata is None else xdata, ydata=[None, None] if ydata is None else ydata, eliminate_outliers=2.0, alpha_function=alpha_function, ) return item
[docs] def create_contour_shapes( coords: np.ndarray, shape: ContourShape ) -> list[AnnotatedShape]: """Create plotpy items for a specific contour shape. Args: coords: Coordinates of the contours shape: ContourShape enum value Returns: List of plotpy items representing the detected contours """ items = [] debug_print(f"Coordinates ({shape}s): {coords}") for shapeargs in coords: if shape == ContourShape.CIRCLE: xc, yc, r = shapeargs x0, y0, x1, y1 = coordinates.circle_to_diameter(xc, yc, r) item = make.circle(x0, y0, x1, y1) elif shape == ContourShape.ELLIPSE: xc, yc, a, b, theta = shapeargs coords_ellipse = coordinates.ellipse_to_diameters(xc, yc, a, b, theta) x0, y0, x1, y1, x2, y2, x3, y3 = coords_ellipse item = make.ellipse(x0, y0, x1, y1, x2, y2, x3, y3) else: # ContourShape.POLYGON # `shapeargs` is a flattened array of x, y coordinates x, y = shapeargs[::2], shapeargs[1::2] item = make.polygon(x, y, closed=False) items.append(item) return items
[docs] def create_circle( xc: float, yc: float, r: float, label: str | None = None, ) -> AnnotatedCircle: """Create an annotated circle item Args: xc: X-coordinate of the circle center yc: Y-coordinate of the circle center r: Radius of the circle label: Label for the circle, or None for no label Returns: An `AnnotatedCircle` object representing the circle """ item = make.annotated_circle(xc - r, yc, xc + r, yc, label, show_computations=False) item.label.labelparam.bgalpha = 0.5 item.label.labelparam.anchor = "T" item.label.labelparam.yc = 10 item.label.labelparam.update_item(item.label) p: ShapeParam = item.shape.shapeparam p.line.color = "#ff9933" p.line.width = 2 p.symbol.facecolor = "#ffb366" p.symbol.edgecolor = "#ff9933" p.symbol.marker = "Ellipse" p.symbol.size = 5 p.update_item(item.shape) item.set_movable(False) item.set_resizable(False) item.set_selectable(False) return item
[docs] def create_segment( x0: float, y0: float, x1: float, y1: float, label: str | None = None, ) -> AnnotatedSegment: """Create an annotated segment item Args: x0: X-coordinate of the start point y0: Y-coordinate of the start point x1: X-coordinate of the end point y1: Y-coordinate of the end point label: Label for the segment, or None for no label Returns: An `AnnotatedSegment` object representing the segment """ item = make.annotated_segment(x0, y0, x1, y1, label, show_computations=False) item.label.labelparam.bgalpha = 0.5 item.label.labelparam.anchor = "T" item.label.labelparam.yc = 10 item.label.labelparam.update_item(item.label) p: ShapeParam = item.shape.shapeparam p.line.color = "#33ff00" p.line.width = 5 p.symbol.facecolor = "#26be00" p.symbol.edgecolor = "#33ff00" p.symbol.marker = "Ellipse" p.symbol.size = 11 p.update_item(item.shape) item.set_movable(False) item.set_resizable(False) item.set_selectable(False) return item
[docs] def create_cursor( orientation: Literal["h", "v", "x"], position: float | tuple[float, float], label: str, ) -> Marker: """Create a horizontal or vertical cursor item Args: orientation: 'h' for horizontal cursor, 'v' for vertical cursor, 'x' for crosshair position: Position of the cursor along the relevant axis, or (x, y) tuple for crosshair label: Label format string for the cursor Returns: A `Marker` representing the cursor """ if orientation == "h": cursor = make.hcursor(position, label=label) elif orientation == "v": cursor = make.vcursor(position, label=label) elif orientation == "x": assert isinstance(position, tuple) and len(position) == 2 cursor = make.xcursor(position[0], position[1], label=label) else: raise ValueError("Orientation must be 'h' or 'v'") cursor.set_resizable(False) cursor.set_movable(False) cursor.set_selectable(False) cursor.markerparam.line.color = "#a7ff33" cursor.markerparam.line.width = 2 cursor.markerparam.symbol.marker = "NoSymbol" cursor.markerparam.text.textcolor = "#ffffff" cursor.markerparam.text.background_color = "#000000" cursor.markerparam.text.background_alpha = 0.5 cursor.markerparam.text.font.bold = True cursor.markerparam.update_item(cursor) return cursor
[docs] def create_range( orientation: Literal["h", "v"], pos_min: float, pos_max: float, title: str ) -> AnnotatedXRange | AnnotatedYRange: """Create a horizontal or vertical range item Args: orientation: 'h' for horizontal range, 'v' for vertical range pos_min: Minimum position of the range along the relevant axis pos_max: Maximum position of the range along the relevant axis title: Title for the range Returns: An `AnnotatedXRange` or `AnnotatedYRange` representing the range """ if orientation == "h": item = make.annotated_xrange( pos_min, pos_max, title=title, show_computations=False ) elif orientation == "v": item = make.annotated_yrange( pos_min, pos_max, title=title, show_computations=False ) else: raise ValueError("Orientation must be 'h' or 'v'") item.label.labelparam.bgalpha = 0.5 item.label.labelparam.anchor = "L" item.label.labelparam.xc = 20 item.label.labelparam.update_item(item.label) item.set_movable(False) item.set_resizable(False) item.set_selectable(False) return item
[docs] def create_label(text: str) -> LabelItem: """Create a text label item Args: text: Text content of the label Returns: A `LabelItem` representing the text label """ item = make.label(text, "TL", (0, 0), "TL") return item
[docs] def create_marker(x: float, y: float, title: str | None = None) -> Marker: """Create a marker item Args: x: x coordinate y: y coordinate title: title of the marker Returns: A `Marker` representing the marker """ if title is None: return make.marker((x, y)) return make.marker((x, y), "%.3f", title)
def __make_marker_item(x0: float, y0: float, fmt: str, title: str) -> Marker: """Make marker item Args: x0: x coordinate y0: y coordinate fmt: numeric format (e.g. '%.3f') title: title of the marker """ if np.isnan(x0): mstyle = "-" def label(x, y): # pylint: disable=unused-argument return (title + ": " + fmt) % y elif np.isnan(y0): mstyle = "|" def label(x, y): # pylint: disable=unused-argument return (title + ": " + fmt) % x else: mstyle = "+" txt = title + ": (" + fmt + ", " + fmt + ")" def label(x, y): return txt % (x, y) return make.marker( position=(x0, y0), markerstyle=mstyle, label_cb=label, linestyle="DashLine", color="yellow", ) def __create_curve_item( obj: SignalObj | tuple[np.ndarray, np.ndarray], title: str | None = None ) -> CurveItem: """Create a curve item from a SignalObj or (xdata, ydata) tuple Args: obj: Signal object or tuple of (xdata, ydata) title: Title for the curve item Returns: A `CurveItem` representing the signal data """ if isinstance(obj, (tuple, list)): xdata, ydata = obj title = title or "" else: assert obj.xydata is not None xdata, ydata = obj.xydata title = obj.title or title or "" # Only display the real part for signals (for simplicity): item = create_curve(xdata.real, ydata.real, title=title) item.param.line.width = 1.25 item.param.update_item(item) return item def __create_curve_roi_items(obj: SignalObj) -> list[AnnotatedXRange]: """Create signal ROI items from a SignalObj Args: obj: Signal object Returns: A list of `AnnotatedXRange` items representing the ROIs """ items = [] if obj.roi is not None and not obj.roi.is_empty(): for single_roi in obj.roi: assert isinstance(single_roi, SegmentROI) x0, x1 = single_roi.get_physical_coords(obj) roi_item = make.annotated_xrange(x0, x1, single_roi.title) roi_item.label.labelparam.anchor = "T" roi_item.label.labelparam.xc = 20 roi_item.label.labelparam.update_item(roi_item.label) # roi_item.set_style("plot", "shape/drag") roi_item.set_movable(False) roi_item.set_resizable(False) roi_item.set_selectable(False) items.append(roi_item) return items def __create_image_item( obj: ImageObj | np.ndarray, title: str | None = None, **kwargs ) -> MaskedImageItem | MaskedXYImageItem: """Create an image item from an ImageObj Args: obj: Image object or 2D numpy array title: Title for the image item **kwargs: Additional parameters for image display (e.g., interpolation, colormap) Returns: A `MaskedImageItem` or `MaskedXYImageItem` representing the image """ if isinstance(obj, ImageObj): data = obj.data mask = obj.maskdata title = obj.title or title or "" elif isinstance(obj, np.ndarray): data = obj mask = np.zeros_like(data, dtype=bool) title = title or "" else: raise TypeError(f"Unsupported image type: {type(obj)}") imparameters = IMAGE_PARAMETERS.copy() imparameters.update(kwargs) if isinstance(obj, ImageObj) and not obj.is_uniform_coords: x, y = obj.xcoords, obj.ycoords item = make.maskedxyimage( x, y, data, mask, title=title, show_mask=True, **imparameters ) else: item = make.maskedimage(data, mask, title=title, show_mask=True, **imparameters) if isinstance(obj, ImageObj): x0, y0, dx, dy = obj.x0, obj.y0, obj.dx, obj.dy item.param.xmin, item.param.xmax = x0, x0 + dx * data.shape[1] item.param.ymin, item.param.ymax = y0, y0 + dy * data.shape[0] item.param.update_item(item) return item def __create_image_roi_items(obj: ImageObj) -> list[AnnotatedShape]: """Create image ROI items from an ImageObj Args: obj: Image object Returns: A list of `AnnotatedShape` items representing the ROIs """ items = [] if obj.roi is not None and not obj.roi.is_empty(): for single_roi in obj.roi: if isinstance(single_roi, RectangularROI): x0, y0, x1, y1 = single_roi.get_bounding_box(obj) roi_item = make.annotated_rectangle(x0, y0, x1, y1, single_roi.title) elif isinstance(single_roi, CircularROI): xc, yc, r = single_roi.get_physical_coords(obj) x0, y0, x1, y1 = coordinates.circle_to_diameter(xc, yc, r) roi_item = make.annotated_circle(x0, y0, x1, y1, single_roi.title) elif isinstance(single_roi, PolygonalROI): coords = single_roi.get_physical_coords(obj) points = np.array(coords).reshape(-1, 2) roi_item = AnnotatedPolygon(points) roi_item.annotationparam.title = single_roi.title roi_item.set_style("plot", "shape/drag") roi_item.annotationparam.update_item(roi_item) items.append(roi_item) return items def __create_plot_items_from_geometry( result: GeometryResult, ) -> list[ AnnotatedPoint | Marker | AnnotatedRectangle | AnnotatedCircle | AnnotatedSegment | AnnotatedEllipse | AnnotatedPolygon ]: """Create plot items from a GeometryResult object Args: result: The GeometryResult object to convert Returns: A list of plot items corresponding to the geometry result """ items = [] for coords in result.coords: title = result.title or "" if result.kind == KindShape.POINT: x0, y0 = coords item = AnnotatedPoint(x0, y0) sparam: ShapeParam = item.shape.shapeparam sparam.symbol.marker = "Ellipse" sparam.symbol.size = 6 sparam.sel_symbol.marker = "Ellipse" sparam.sel_symbol.size = 6 aparam = item.annotationparam aparam.title = title sparam.update_item(item.shape) aparam.update_item(item) elif result.kind == KindShape.MARKER: x0, y0 = coords item = __make_marker_item(x0, y0, "%.3f", title) elif result.kind == KindShape.RECTANGLE: x0, y0, dx, dy = coords item = make.annotated_rectangle(x0, y0, x0 + dx, y0 + dy, title=title) elif result.kind == KindShape.CIRCLE: xc, yc, r = coords x0, y0, x1, y1 = coordinates.circle_to_diameter(xc, yc, r) item = make.annotated_circle(x0, y0, x1, y1, title=title) elif result.kind == KindShape.SEGMENT: x0, y0, x1, y1 = coords item = make.annotated_segment(x0, y0, x1, y1, title=title) elif result.kind == KindShape.ELLIPSE: xc, yc, a, b, t = coords coords = coordinates.ellipse_to_diameters(xc, yc, a, b, t) x0, y0, x1, y1, x2, y2, x3, y3 = coords item = make.annotated_ellipse(x0, y0, x1, y1, x2, y2, x3, y3, title=title) elif result.kind == KindShape.POLYGON: x, y = coords[::2], coords[1::2] item = make.polygon(x, y, title=title, closed=False) else: raise TypeError(f"Unsupported GeometryResult type: {type(result)}") item.set_movable(False) item.set_resizable(False) item.set_selectable(False) if isinstance(item, AnnotatedShape): shapeparam: ShapeParam = item.shape.shapeparam shapeparam.line.width = 2 shapeparam.update_item(item.shape) item.annotationparam.show_computations = False item.annotationparam.show_label = bool(title) item.annotationparam.update_item(item) item.label.labelparam.anchor = "T" item.label.labelparam.yc = 10 item.label.labelparam.update_item(item.label) items.append(item) return items def __generate_object_name(title: str, fallback: str) -> str: """Generate a valid object name from a title string Args: title: The title string to convert fallback: Fallback name to use if title is empty or invalid Returns: A valid object name derived from the title or the fallback name """ if title: obj_name = "".join(c if c.isalnum() else "_" for c in title) if obj_name: return obj_name return fallback
[docs] def view_curve_items( items: list[CurveItem], name: str | None = None, title: str | None = None, xlabel: str | None = None, ylabel: str | None = None, xunit: str | None = None, yunit: str | None = None, add_legend: bool = True, datetime_format: str | None = None, object_name: str = "", ) -> None: """Create a curve dialog and plot items Args: items: List of `CurveItem` objects to plot name: Name of the dialog, or None to use a default name title: Title of the dialog, or None to use a default title xlabel: Label for the x-axis, or None for no label ylabel: Label for the y-axis, or None for no label xunit: Unit for the x-axis, or None for no unit yunit: Unit for the y-axis, or None for no unit add_legend: Whether to add a legend to the plot, default is True datetime_format: Datetime format for x-axis if x data is datetime, or None object_name: Object name for the dialog (for screenshot functionality) """ __ensure_qapp() win = __create_curve_dialog( name=name, title=title, xlabel=xlabel, ylabel=ylabel, xunit=xunit, yunit=yunit ) win.setObjectName(object_name or __generate_object_name(title, "curve_dialog")) plot = win.get_plot() for item in items: plot.add_item(item) if add_legend: plot.add_item(make.legend()) if datetime_format is not None: plot.set_axis_datetime("bottom", format=datetime_format) __exec_dialog(win) make.style = __style_generator() # Reset style generator for next call
[docs] def view_curves( data_or_objs: list[SignalObj | np.ndarray | tuple[np.ndarray, np.ndarray]] | SignalObj | np.ndarray | tuple[np.ndarray, np.ndarray], name: str | None = None, title: str | None = None, xlabel: str | None = None, ylabel: str | None = None, xunit: str | None = None, yunit: str | None = None, show_roi: bool = True, object_name: str = "", ) -> None: """Create a curve dialog and plot curves Args: data_or_objs: Single `SignalObj` or `np.ndarray`, or a list/tuple of these, or a list/tuple of (xdata, ydata) pairs name: Name of the dialog, or None to use a default name title: Title of the dialog, or None to use a default title xlabel: Label for the x-axis, or None for no label ylabel: Label for the y-axis, or None for no label xunit: Unit for the x-axis, or None for no unit yunit: Unit for the y-axis, or None for no unit show_roi: Whether to show ROIs defined in `SignalObj` instances, default is True (ignored if `data_or_objs` is not a `SignalObj`) object_name: Object name for the dialog (for screenshot functionality) """ __ensure_qapp() if isinstance(data_or_objs, (tuple, list)): datalist = data_or_objs else: datalist = [data_or_objs] items = [] datetime_format = None for data_or_obj in datalist: if isinstance(data_or_obj, SignalObj): xlabel = xlabel or data_or_obj.xlabel or "" ylabel = ylabel or data_or_obj.ylabel or "" xunit = xunit or data_or_obj.xunit or "" yunit = yunit or data_or_obj.yunit or "" if data_or_obj.is_x_datetime(): datetime_format = data_or_obj.metadata.get("x_datetime_format") if datetime_format is None: unit = data_or_obj.xunit if data_or_obj.xunit else "s" if unit in ("ns", "us", "ms"): datetime_format = "%H:%M:%S.%f" else: datetime_format = "%H:%M:%S" item = __create_curve_item(data_or_obj) if isinstance(data_or_obj, SignalObj) and show_roi: items.extend(__create_curve_roi_items(data_or_obj)) items.append(item) view_curve_items( items, name=name, title=title, xlabel=xlabel, ylabel=ylabel, xunit=xunit, yunit=yunit, datetime_format=datetime_format, object_name=object_name, ) make.style = __style_generator() # Reset style generator for next call
def __create_image_dialog( name: str | None = None, title: str | None = None, xlabel: str | None = None, ylabel: str | None = None, zlabel: str | None = None, xunit: str | None = None, yunit: str | None = None, zunit: str | None = None, size: tuple[int, int] | None = None, object_name: str = "", ) -> PlotDialog: """Create Image Dialog Args: name: Name of the dialog, or None to use a default name title: Title of the dialog, or None to use a default title xlabel: Label for the x-axis, or None for no label ylabel: Label for the y-axis, or None for no label zlabel: Label for the z-axis (color scale), or None for no label xunit: Unit for the x-axis, or None for no unit yunit: Unit for the y-axis, or None for no unit zunit: Unit for the z-axis (color scale), or None for no unit size: Size of the dialog as a tuple (width, height), or None for default size object_name: Object name for the dialog (for screenshot functionality) Returns: A `PlotDialog` instance configured for image plotting """ __ensure_qapp() name, title = __generate_widget_name_title(name, title) win = PlotDialog( edit=False, toolbar=True, title=title, options=PlotOptions( title=title, type="image", xlabel=xlabel, ylabel=ylabel, zlabel=zlabel, xunit=xunit, yunit=yunit, zunit=zunit, ), size=(800, 600) if size is None else size, ) win.setObjectName(object_name or name) for toolklass in ( plotpy.tools.LabelTool, plotpy.tools.VCursorTool, plotpy.tools.HCursorTool, plotpy.tools.XCursorTool, plotpy.tools.AnnotatedRectangleTool, plotpy.tools.AnnotatedCircleTool, plotpy.tools.AnnotatedEllipseTool, plotpy.tools.AnnotatedSegmentTool, plotpy.tools.AnnotatedPointTool, ): win.get_manager().add_tool(toolklass, switch_to_default_tool=True) return win
[docs] def view_image_items( items: list[ImageItem], name: str | None = None, title: str | None = None, xlabel: str | None = None, ylabel: str | None = None, zlabel: str | None = None, xunit: str | None = None, yunit: str | None = None, zunit: str | None = None, show_itemlist: bool = False, object_name: str = "", ) -> None: """Create an image dialog and show items Args: items: List of `ImageItem` objects to display name: Name of the dialog, or None to use a default name title: Title of the dialog, or None to use a default title xlabel: Label for the x-axis, or None for no label ylabel: Label for the y-axis, or None for no label zlabel: Label for the z-axis (color scale), or None for no label xunit: Unit for the x-axis, or None for no unit yunit: Unit for the y-axis, or None for no unit zunit: Unit for the z-axis (color scale), or None for no unit show_itemlist: Whether to show the item list panel in the dialog, default is False object_name: Object name for the dialog (for screenshot functionality) """ __ensure_qapp() win = __create_image_dialog( name=name, title=title, xlabel=xlabel, ylabel=ylabel, zlabel=zlabel, xunit=xunit, yunit=yunit, zunit=zunit, object_name=object_name, ) if show_itemlist: win.manager.get_itemlist_panel().show() plot = win.get_plot() for item in items: plot.add_item(item) __exec_dialog(win)
# pylint: disable=too-many-positional-arguments
[docs] def view_images( data_or_objs: list[ImageObj | np.ndarray] | ImageObj | np.ndarray, name: str | None = None, title: str | None = None, xlabel: str | None = None, ylabel: str | None = None, zlabel: str | None = None, xunit: str | None = None, yunit: str | None = None, zunit: str | None = None, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, object_name: str = "", **kwargs, ) -> None: """Create an image dialog and show images Args: data_or_objs: Single `ImageObj` or `np.ndarray`, or a list/tuple of these name: Name of the dialog, or None to use a default name title: Title of the dialog, or None to use a default title xlabel: Label for the x-axis, or None for no label ylabel: Label for the y-axis, or None for no label zlabel: Label for the z-axis (color scale), or None for no label xunit: Unit for the x-axis, or None for no unit yunit: Unit for the y-axis, or None for no unit zunit: Unit for the z-axis (color scale), or None for no unit results: Single `GeometryResult` or list of these to overlay on images, or None if no overlay is needed. show_roi: Whether to show ROIs defined in `ImageObj` instances, default is True (ignored if `data_or_objs` is not a `ImageObj`) object_name: Object name for the dialog (for screenshot functionality) **kwargs: Additional keyword arguments to pass to `make.maskedimage()` """ __ensure_qapp() if isinstance(data_or_objs, (tuple, list)): datalist = data_or_objs else: datalist = [data_or_objs] imparameters = IMAGE_PARAMETERS.copy() imparameters.update(kwargs) items = [] image_title: str | None = None for data_or_obj in datalist: if isinstance(data_or_obj, ImageObj): data = data_or_obj.data if data_or_obj.title: image_title = data_or_obj.title if data_or_obj.xlabel and xlabel is None: xlabel = data_or_obj.xlabel if data_or_obj.ylabel and ylabel is None: ylabel = data_or_obj.ylabel if data_or_obj.zlabel and zlabel is None: zlabel = data_or_obj.zlabel if data_or_obj.xunit and xunit is None: xunit = data_or_obj.xunit if data_or_obj.yunit and yunit is None: yunit = data_or_obj.yunit if data_or_obj.zunit and zunit is None: zunit = data_or_obj.zunit elif isinstance(data_or_obj, np.ndarray): data = data_or_obj else: raise TypeError(f"Unsupported data type: {type(data_or_obj)}") # Display real and imaginary parts of complex images. assert data is not None if np.issubdtype(data.dtype, np.complexfloating): re_title = f"Re({image_title})" if image_title is not None else "Real" im_title = f"Im({image_title})" if image_title is not None else "Imaginary" items.append(__create_image_item(data.real, title=re_title, **imparameters)) items.append(__create_image_item(data.imag, title=im_title, **imparameters)) else: items.append( __create_image_item(data_or_obj, title=image_title, **imparameters) ) if isinstance(data_or_obj, ImageObj) and show_roi: items.extend(__create_image_roi_items(data_or_obj)) if results is not None: if isinstance(results, GeometryResult): results = [results] if not isinstance(results, (list, tuple)): raise TypeError(f"Unsupported results type: {type(results)}") for res in results: items.extend(__create_plot_items_from_geometry(res)) view_image_items( items, name=name, title=title, xlabel=xlabel, ylabel=ylabel, zlabel=zlabel, xunit=xunit, yunit=yunit, zunit=zunit, object_name=object_name, )
[docs] def view_curves_and_images( data_or_objs: list[SignalObj | np.ndarray | ImageObj | np.ndarray], name: str | None = None, title: str | None = None, xlabel: str | None = None, ylabel: str | None = None, zlabel: str | None = None, xunit: str | None = None, yunit: str | None = None, zunit: str | None = None, object_name: str = "", ) -> None: """View signals, then images in two successive dialogs Args: data_or_objs: List of `SignalObj`, `ImageObj`, `np.ndarray` or a mix of these name: Name of the dialog, or None to use a default name title: Title of the dialog, or None to use a default title xlabel: Label for the x-axis, or None for no label ylabel: Label for the y-axis, or None for no label zlabel: Label for the z-axis (color scale), or None for no label xunit: Unit for the x-axis, or None for no unit yunit: Unit for the y-axis, or None for no unit zunit: Unit for the z-axis (color scale), or None for no unit object_name: Object name for the dialog (for screenshot functionality) """ __ensure_qapp() if isinstance(data_or_objs, (tuple, list)): objs = data_or_objs else: objs = [data_or_objs] sig_objs = [obj for obj in objs if isinstance(obj, (SignalObj, np.ndarray))] if sig_objs: view_curves( sig_objs, name=name, title=title, xlabel=xlabel, ylabel=ylabel, xunit=xunit, yunit=yunit, object_name=f"{object_name}_curves", ) ima_objs = [obj for obj in objs if isinstance(obj, (ImageObj, np.ndarray))] if ima_objs: view_images( ima_objs, name=name, title=title, xlabel=xlabel, ylabel=ylabel, zlabel=zlabel, xunit=xunit, yunit=yunit, zunit=zunit, object_name=f"{object_name}_images", )
def __compute_grid( num_objects: int, max_cols: int = 4, fixed_num_rows: int | None = None ) -> tuple[int, int]: """Compute number of rows and columns for a grid of images Args: num_objects: Total number of objects to display max_cols: Maximum number of columns in the grid fixed_num_rows: Fixed number of rows, if specified Returns: A tuple (num_rows, num_cols) representing the grid dimensions """ num_cols = min(num_objects, max_cols) if fixed_num_rows is not None: num_rows = fixed_num_rows num_cols = (num_objects + num_rows - 1) // num_rows else: num_rows = (num_objects + num_cols - 1) // num_cols return num_rows, num_cols
[docs] def view_images_side_by_side( images: list[ImageItem | np.ndarray | ImageObj], titles: list[str] | None = None, share_axes: bool = True, rows: int | None = None, maximized: bool = False, title: str | None = None, results: list[GeometryResult] | GeometryResult | None = None, show_roi: bool = True, object_name: str = "", **kwargs, ) -> None: """Show sequence of images Args: images: List of `ImageItem`, `np.ndarray`, or `ImageObj` objects to display titles: List of titles for each image share_axes: Whether to share axes across plots, default is True rows: Fixed number of rows in the grid, or None to compute automatically maximized: Whether to show the dialog maximized, default is False title: Title of the dialog, or None for a default title results: Single `GeometryResult` or list of these to overlay on images, or None if no overlay is needed. show_roi: Whether to show ROIs defined in `ImageObj` instances, default is True (ignored if `images` do not contain `ImageObj` instances) object_name: Object name for the dialog widget (used for screenshot filename) **kwargs: Additional keyword arguments to pass to `make.maskedimage()` """ __ensure_qapp() # pylint: disable=too-many-nested-blocks rows, cols = __compute_grid(len(images), fixed_num_rows=rows, max_cols=4) dlg = SyncPlotDialog(title=title) dlg.setObjectName( object_name or __generate_object_name(title, "images_side_by_side") ) imparameters = IMAGE_PARAMETERS.copy() imparameters.update(kwargs) if not isinstance(titles, (list, tuple)): titles = [titles] * len(images) elif len(titles) != len(images): raise ValueError("Length of titles must match length of images") if not isinstance(results, (list, tuple)): results = [results] * len(images) elif len(results) != len(images): raise ValueError("Length of results must match length of images") for idx, (img, result, imtitle) in enumerate(zip(images, results, titles)): row = idx // cols col = idx % cols imtitle = img.title if isinstance(img, ImageObj) else imtitle plot = BasePlot(options=BasePlotOptions(title=imtitle)) other_items = [] if isinstance(img, (MaskedImageItem, ImageItem)): item = img else: item = __create_image_item(img, title=imtitle, **imparameters) if isinstance(img, ImageObj) and show_roi: other_items.extend(__create_image_roi_items(img)) plot.add_item(item) for other_item in other_items: plot.add_item(other_item) if result is not None: if not isinstance(result, GeometryResult): raise TypeError(f"Unsupported results type: {type(result)}") overlay_items = __create_plot_items_from_geometry(result) for overlay_item in overlay_items: plot.add_item(overlay_item) dlg.add_plot(row, col, plot, sync=share_axes) dlg.finalize_configuration() if maximized: dlg.showMaximized() elif os.environ.get("QT_QPA_PLATFORM") == "offscreen": # Set explicit size for proper rendering in headless mode # Qt size hints don't work reliably without a display dlg.resize(20 + 440 * cols, 20 + 400 * rows) __exec_dialog(dlg)