Source code for anyplotlib.widgets._widgets2d
"""
widgets/_widgets2d.py
=====================
Interactive overlay widgets for 2-D image panels (Plot2D / InsetAxes).
Every 2-D widget accepts ``show_handles`` (default ``True``). When ``False``
the widget body still draws and stays fully hit-testable / draggable, but the
small square grab-handle dots are omitted — a cleaner look for a finished
annotation. It rides along in ``_data`` so it serialises into the panel state's
``overlay_widgets`` list and reaches the JS renderer unchanged.
"""
from __future__ import annotations
from anyplotlib.widgets._base import Widget
[docs]
class RectangleWidget(Widget):
"""Draggable rectangle overlay widget for 2-D plots.
Parameters
----------
push_fn : Callable
Update callback.
x, y : float
Top-left corner position in pixel/data coordinates.
w, h : float
Width and height in pixel/data coordinates.
color : str, optional
CSS colour for the rectangle outline. Default ``"#00e5ff"``.
linewidth : float, optional
Outline stroke width in px. Default 2.
show_handles : bool, optional
Draw the corner grab handles. Default ``True``.
max_extent : float or (float, float), optional
Maximum width/height in the widget's coordinates. A scalar caps both
axes; a ``(max_w, max_h)`` pair caps them separately. When set, the
rectangle physically stops growing at the cap while dragging — the
dragged corner pins and the opposite corner stays put. ``None``
(default) leaves it unbounded.
Use this when the rectangle's area costs real work downstream — e.g. an
integrating ROI whose size is a number of frames to read.
"""
def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff",
linewidth=2, show_handles=True, max_extent=None):
if max_extent is None:
max_w = max_h = None
elif isinstance(max_extent, (tuple, list)):
max_w, max_h = (float(max_extent[0]), float(max_extent[1]))
else:
max_w = max_h = float(max_extent)
super().__init__("rectangle", push_fn,
x=float(x), y=float(y),
w=float(w), h=float(h), color=color,
linewidth=float(linewidth),
show_handles=bool(show_handles),
max_w=max_w, max_h=max_h)
[docs]
class CircleWidget(Widget):
"""Draggable circle overlay widget for 2-D plots.
Parameters
----------
push_fn : Callable
Update callback.
cx, cy : float
Center position in pixel/data coordinates.
r : float
Radius in pixel/data coordinates.
color : str, optional
CSS colour for the circle outline. Default ``"#00e5ff"``.
linewidth : float, optional
Outline stroke width in px. Default 2.
show_handles : bool, optional
Draw the radius grab handle. Default ``True``.
lock_center : bool, optional
Pin ``cx, cy`` and let only the radius change. Default ``False``.
The centre is refused at HIT-TEST time, not corrected afterwards: a
grab on the ring body is simply not a widget grab, so it falls through
to the plot's own pan and the hover cursor never promises a move.
Enforcing it from Python instead — snapping the centre back when the
drag settles — cannot work, because the JS drag recomputes the position
from its own grab-time snapshot on every frame; the ring tracks the
cursor for the whole drag and only jumps back on release.
Use it when the centre is not a free parameter — a ring on a power
spectrum is centred on the DC term, so a draggable centre is a control
that can only ever be wrong, and one nudged off-centre silently
corrupts every radius measured from it.
"""
def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff",
linewidth=2, show_handles=True, lock_center=False):
super().__init__("circle", push_fn,
cx=float(cx), cy=float(cy), r=float(r), color=color,
linewidth=float(linewidth),
show_handles=bool(show_handles),
lock_center=bool(lock_center))
[docs]
class AnnularWidget(Widget):
"""Draggable annular (ring) overlay widget for 2-D plots.
Parameters
----------
push_fn : Callable
Update callback.
cx, cy : float
Center position in pixel/data coordinates.
r_outer, r_inner : float
Outer and inner radii in pixel/data coordinates.
Inner radius must be less than outer radius.
color : str, optional
CSS colour for the ring outline. Default ``"#00e5ff"``.
linewidth : float, optional
Outline stroke width in px. Default 2.
show_handles : bool, optional
Draw the inner/outer radius grab handles. Default ``True``.
Raises
------
ValueError
If r_inner >= r_outer.
"""
def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff",
linewidth=2, show_handles=True):
if r_inner >= r_outer:
raise ValueError("r_inner must be < r_outer")
super().__init__("annular", push_fn,
cx=float(cx), cy=float(cy),
r_outer=float(r_outer), r_inner=float(r_inner),
color=color, linewidth=float(linewidth),
show_handles=bool(show_handles))
class LineWidget(Widget):
"""Draggable two-endpoint line segment overlay widget for 2-D plots.
A plain segment from ``(x1, y1)`` to ``(x2, y2)``: drag either endpoint
handle to move that end, or drag the shaft to translate the whole
segment. Unlike :class:`ArrowWidget` it has no head, and unlike
:class:`PolygonWidget` it does not close the path — this is the widget for
a line profile, a cross-section cut, or a two-point measurement.
Parameters
----------
push_fn : Callable
Update callback.
x1, y1 : float
First endpoint in pixel/data coordinates.
x2, y2 : float
Second endpoint in pixel/data coordinates.
color : str, optional
CSS colour for the segment. Default ``"#00e5ff"``.
linewidth : float, optional
Stroke width in px. Default 2.
show_handles : bool, optional
Draw the endpoint grab handles. Default ``True``.
"""
def __init__(self, push_fn, *, x1, y1, x2, y2, color="#00e5ff",
linewidth=2, show_handles=True):
super().__init__("line", push_fn,
x1=float(x1), y1=float(y1),
x2=float(x2), y2=float(y2),
color=color, linewidth=float(linewidth),
show_handles=bool(show_handles))
@property
def length(self) -> float:
"""Euclidean length of the segment in data coordinates."""
return float(
((self.x2 - self.x1) ** 2 + (self.y2 - self.y1) ** 2) ** 0.5
)
[docs]
class CrosshairWidget(Widget):
"""Draggable crosshair overlay widget for 2-D plots.
Parameters
----------
push_fn : Callable
Update callback.
cx, cy : float
Center position in pixel/data coordinates.
color : str, optional
CSS colour for the crosshair. Default ``"#00e5ff"``.
linewidth : float, optional
Line stroke width in px. Default 2.
show_handles : bool, optional
Draw the centre dot handle. Default ``True``.
"""
def __init__(self, push_fn, *, cx, cy, color="#00e5ff", linewidth=2,
show_handles=True):
super().__init__("crosshair", push_fn,
cx=float(cx), cy=float(cy), color=color,
linewidth=float(linewidth),
show_handles=bool(show_handles))
[docs]
class PolygonWidget(Widget):
"""Draggable polygon overlay widget for 2-D plots.
Parameters
----------
push_fn : Callable
Update callback.
vertices : list of tuple
Polygon vertices ``[(x0, y0), (x1, y1), ...]`` in pixel/data coordinates.
Must have at least 3 vertices.
color : str, optional
CSS colour for the polygon outline. Default ``"#00e5ff"``.
linewidth : float, optional
Outline stroke width in px. Default 2.
show_handles : bool, optional
Draw the per-vertex grab handles. Default ``True``.
Raises
------
ValueError
If fewer than 3 vertices provided.
"""
def __init__(self, push_fn, *, vertices, color="#00e5ff", linewidth=2,
show_handles=True):
verts = [[float(x), float(y)] for x, y in vertices]
if len(verts) < 3:
raise ValueError("polygon needs >= 3 vertices")
super().__init__("polygon", push_fn, vertices=verts, color=color,
linewidth=float(linewidth),
show_handles=bool(show_handles))
[docs]
class LabelWidget(Widget):
"""Text label overlay widget for 2-D plots.
Parameters
----------
push_fn : Callable
Update callback.
x, y : float
Label position in pixel/data coordinates.
text : str, optional
Label text. Default ``"Label"``.
fontsize : int, optional
Font size in points. Default 14.
color : str, optional
CSS colour for the text. Default ``"#00e5ff"``.
show_handles : bool, optional
Draw the anchor grab handle. Default ``True``.
"""
def __init__(self, push_fn, *, x, y, text="Label", fontsize=14,
color="#00e5ff", show_handles=True):
super().__init__("label", push_fn,
x=float(x), y=float(y),
text=str(text), fontsize=int(fontsize), color=color,
show_handles=bool(show_handles))
def _coerce_strokes(strokes) -> list[list[list[float]]]:
"""Validate/normalise a stroke list into plain ``[[[x, y], ...], ...]``.
Accepts anything iterable-of-iterable-of-pairs (lists, tuples, numpy rows)
and returns nested plain ``float`` lists so the result is JSON-serialisable
for the wire.
Raises
------
ValueError
If a stroke is empty or a point is not a 2-element ``(x, y)``.
"""
out: list[list[list[float]]] = []
for si, stroke in enumerate(strokes):
pts = []
for pt in stroke:
pair = list(pt)
if len(pair) != 2:
raise ValueError(
f"stroke {si}: every point must be (x, y); got {len(pair)} "
"values"
)
pts.append([float(pair[0]), float(pair[1])])
if not pts:
raise ValueError(f"stroke {si} is empty — a stroke needs >= 1 point")
out.append(pts)
return out
[docs]
class BrushWidget(Widget):
"""Freehand paint-brush overlay widget for 2-D plots.
Shift-drag on the image to paint a stroke; every stroke is a polyline of
image-pixel points, stroked with round caps and joins at a width of
``2 * radius`` image pixels. Built for labelling regions — painting
training scribbles for a pixel classifier, marking a defect, masking a
beam stop — where a polygon or a rectangle is the wrong shape.
Two gates govern the painting so a brush can coexist with pan / click /
other widgets on the same panel:
1. ``active`` — Python-side arming. ``False`` keeps the strokes drawn but
ignores all input, which is how you park the tool without losing work.
2. **Shift** — the drag modifier. A *bare* drag still pans the image and
still drags other widgets; only ``Shift`` + drag paints. A brush that
claimed a plain drag would hit-test as "anywhere in the image" and kill
panning and click-to-select outright.
Painting is *modal*: while a brush is armed, a Shift-press that starts over
the image is consumed by the brush and no longer produces a panel
``pointer_down``. If the host binds Shift-click to something else
(multi-select is the common one), give it a different modifier or set
``active=False`` while that mode is on. A Shift-drag beginning outside the
image — in the axis margin — is not a brush gesture and pans as usual.
While the stroke is being drawn the points accumulate **in the browser** and
only the finished stroke reaches Python, once, as a ``pointer_up`` event.
So ``pointer_move`` does **not** fire for a brush stroke — register on
``pointer_up``::
brush = plot.add_brush_widget(radius=6, colors=["#f44", "#4f4"])
@brush.add_event_handler("pointer_up")
def stroke_done(event):
update_labels(brush.strokes, brush.stroke_classes)
Parameters
----------
push_fn : Callable
Update callback.
radius : float, optional
Brush radius in image pixels — the painted band is ``2 * radius`` wide,
and an erase drag removes stroke points within this distance.
Default 8.
color : str, optional
CSS colour used when ``colors`` has no entry for a stroke's class.
Default ``"#00e5ff"``.
colors : list of str, optional
Per-class CSS colours, indexed by ``class_id``. Lets one brush carry
several label classes at once. Default ``None`` (every class draws in
``color``).
class_id : int, optional
Label class new strokes are tagged with. Default 0.
strokes : list, optional
Pre-existing strokes, ``[[[x, y], ...], ...]`` in image-pixel
coordinates. Default ``None`` (empty).
stroke_classes : list of int, optional
Class id per entry of ``strokes``; must be the same length. Default
``None`` (every seeded stroke takes ``class_id``).
alpha : float, optional
Stroke opacity in ``[0, 1]``. Default 0.6 — a scribble you can see
the image through, since the point is to label what is underneath.
active : bool, optional
Accept Shift-drag painting. Default ``True``.
erase : bool, optional
When ``True`` an armed drag *removes* stroke points within ``radius``
instead of painting. Default ``False``.
Attributes
----------
strokes : list
Painted strokes, ``[[[x, y], ...], ...]`` in image pixels. Read-only
in practice — mutating the list in place does not reach the renderer;
use :meth:`add_stroke` / :meth:`set_strokes` / :meth:`clear_strokes`.
stroke_classes : list of int
Class id of each stroke, parallel to ``strokes``.
Raises
------
ValueError
If ``radius <= 0``, ``class_id < 0``, ``alpha`` is outside ``[0, 1]``,
``colors`` is not a sequence of strings, a stroke is malformed, or
``stroke_classes`` does not match ``strokes`` in length.
See Also
--------
PolygonWidget : Closed straight-edged region with draggable vertices.
"""
def __init__(self, push_fn, *, radius=8.0, color="#00e5ff", colors=None,
class_id=0, strokes=None, stroke_classes=None, alpha=0.6,
active=True, erase=False):
radius = float(radius)
if not radius > 0:
raise ValueError(f"radius must be > 0, got {radius}")
class_id = int(class_id)
if class_id < 0:
raise ValueError(f"class_id must be >= 0, got {class_id}")
alpha = float(alpha)
if not 0.0 <= alpha <= 1.0:
raise ValueError(f"alpha must be in [0, 1], got {alpha}")
if colors is None:
cols: list[str] = []
elif isinstance(colors, str):
raise ValueError("colors must be a list of CSS colours, not a "
"single string — use color= for that")
else:
cols = [str(c) for c in colors]
strks = _coerce_strokes(strokes or [])
if stroke_classes is None:
classes = [class_id] * len(strks)
else:
classes = [int(c) for c in stroke_classes]
if len(classes) != len(strks):
raise ValueError(
f"stroke_classes has {len(classes)} entries but there are "
f"{len(strks)} strokes"
)
if any(c < 0 for c in classes):
raise ValueError("stroke_classes must all be >= 0")
super().__init__("brush", push_fn,
radius=radius, color=color, colors=cols,
class_id=class_id, strokes=strks,
stroke_classes=classes, alpha=alpha,
active=bool(active), erase=bool(erase))
# ── stroke management ─────────────────────────────────────────────────
@property
def n_strokes(self) -> int:
"""Number of painted strokes."""
return len(self._data["strokes"])
[docs]
def clear_strokes(self) -> None:
"""Discard every painted stroke. Does not change ``class_id``."""
self.set(strokes=[], stroke_classes=[])
[docs]
def add_stroke(self, points, class_id: int | None = None) -> None:
"""Append one stroke.
Parameters
----------
points : list of tuple
``[(x, y), ...]`` in image-pixel coordinates; at least one point.
class_id : int, optional
Label class for this stroke. Defaults to the widget's current
``class_id``.
Raises
------
ValueError
If ``points`` is empty or a point is not an ``(x, y)`` pair.
"""
(stroke,) = _coerce_strokes([points])
cid = int(self._data["class_id"] if class_id is None else class_id)
if cid < 0:
raise ValueError(f"class_id must be >= 0, got {cid}")
self.set(strokes=self._data["strokes"] + [stroke],
stroke_classes=self._data["stroke_classes"] + [cid])
[docs]
def set_strokes(self, strokes, classes=None) -> None:
"""Replace every stroke (and its class) in one push.
This is the sanctioned way to write ``strokes``: it keeps the parallel
``stroke_classes`` list in lockstep, which a bare
``brush.strokes = ...`` assignment cannot do.
Parameters
----------
strokes : list
``[[[x, y], ...], ...]`` in image-pixel coordinates.
classes : list of int, optional
Class id per stroke. Defaults to the widget's current
``class_id`` for every stroke.
Raises
------
ValueError
If a stroke is malformed or ``classes`` has the wrong length.
"""
strks = _coerce_strokes(strokes)
if classes is None:
cls = [int(self._data["class_id"])] * len(strks)
else:
cls = [int(c) for c in classes]
if len(cls) != len(strks):
raise ValueError(
f"classes has {len(cls)} entries but there are "
f"{len(strks)} strokes"
)
self.set(strokes=strks, stroke_classes=cls)
[docs]
def strokes_for_class(self, class_id: int) -> list:
"""Return only the strokes tagged with ``class_id``.
Parameters
----------
class_id : int
Label class to select.
Returns
-------
list
``[[[x, y], ...], ...]`` — the matching strokes, in paint order.
"""
cid = int(class_id)
return [s for s, c in zip(self._data["strokes"],
self._data["stroke_classes"]) if c == cid]
[docs]
class ArrowWidget(Widget):
"""Draggable arrow overlay widget for 2-D plots.
The arrow tail sits at ``(x, y)`` and the head at ``(x + u, y + v)``, all in
image-pixel coordinates. Dragging the body moves the whole arrow; dragging
the head handle re-aims it (updates ``u``/``v``).
Parameters
----------
push_fn : Callable
Update callback.
x, y : float
Tail position in pixel/data coordinates.
u, v : float
Arrow vector (head = tail + (u, v)) in pixel/data coordinates.
color : str, optional
CSS colour for the arrow. Default ``"#00e5ff"``.
linewidth : float, optional
Shaft line width in px. Default 2.
show_handles : bool, optional
Draw the tail/head grab handles. Default ``True``.
"""
def __init__(self, push_fn, *, x, y, u, v, color="#00e5ff",
linewidth=2, show_handles=True):
super().__init__("arrow", push_fn,
x=float(x), y=float(y),
u=float(u), v=float(v), color=color,
linewidth=float(linewidth),
show_handles=bool(show_handles))