Skip to content

Add and drag rectangles on an image via a toolbar

Keywords: image · rectangle · ActRectangle2D · split view · toolbar · icon · drag · cursor

Shows a synthetic RGB gradient in an ~pytanga.viz.ImageCanvas inside a ~pytanga.viz.SplitView. A ~pytanga.viz.ToolbarView with an icon-only button sits above the image. The canvas registers a left-mouse drag handler disabled; pressing the button toggles a mode flag that enables the handler and switches the scene cursor to crosshair. Dragging then draws a preview ~pytanga.geometry.Rectangle2D; on release it is finalized into an ~pytanga.viz.ActRectangle2D (corner handles resize, the centre handle translates) and stored in a list.

Run

uv run python py/examples/viz/image/rectangle_labeling.py

Source

viz/image/rectangle_labeling.py

Code

# SPDX-License-Identifier: Apache-2.0
# Copyright 2021 Christian Perwass

"""rectangle_labeling.py — Add and drag rectangles on an image via a toolbar.

Shows a synthetic RGB gradient in an :class:`~pytanga.viz.ImageCanvas` inside a
:class:`~pytanga.viz.SplitView`.  A :class:`~pytanga.viz.ToolbarView` with an
icon-only button sits above the image.  The canvas registers a left-mouse drag
handler **disabled**; pressing the button toggles a mode flag that enables the
handler and switches the scene cursor to ``crosshair``.  Dragging then draws a
preview :class:`~pytanga.geometry.Rectangle2D`; on release it is finalized into
an :class:`~pytanga.viz.ActRectangle2D` (corner handles resize, the centre
handle translates) and stored in a list.

Run with:  uv run python py/examples/viz/image/rectangle_labeling.py

Keywords: image, rectangle, ActRectangle2D, split view, toolbar, icon, drag, cursor
"""

import numpy as np
from pytanga.geometry import Point, Rectangle2D
from pytanga.viz import (
    ActRectangle2D,
    ButtonView,
    ControlEvent,
    DragBinding,
    DragEvent,
    EIconMaterial,
    ImageCanvas,
    ImageData,
    MouseButton,
    Rectangle2DStyle,
    Size,
    SplitView,
    SquarePointStyle,
    ToolbarView,
    Visualizer,
)

_WIDTH, _HEIGHT = 320, 200


def _gradient(width: int, height: int) -> np.ndarray:
    """A 3-channel RGB gradient of shape (H, W, 3), dtype uint8."""
    ys, xs = np.mgrid[0:height, 0:width]
    r = (xs / max(width - 1, 1) * 255).astype(np.uint8)
    g = (ys / max(height - 1, 1) * 255).astype(np.uint8)
    b = np.full_like(r, 128)
    return np.stack([r, g, b], axis=-1)


class RectangleLabeler:
    """Draws rectangles on an ``ImageCanvas`` by left-dragging (when armed)."""

    def __init__(self, viz: Visualizer) -> None:
        self.adding = False
        self.rectangles: list[ActRectangle2D] = []
        self._anchor: Point | None = None
        self._preview_id: str | None = None
        self._style = Rectangle2DStyle(color="#ff4444", fill=True, fill_opacity=0.15)
        # Registered disabled: left-drag does nothing until the mode is armed.
        self._binding = DragBinding(MouseButton.LEFT, self._on_drag, enabled=False)

        self.canvas = ImageCanvas(
            viz,
            drag_handlers=[self._binding],
            on_drag_start=self._on_drag_start,
            on_drag_end=self._on_drag_end,
        )
        self.canvas.set_image(ImageData("gradient", data=_gradient(_WIDTH, _HEIGHT)))

    def set_adding(self, adding: bool) -> None:
        """Arm/disarm draw mode: toggles the left-drag handler and the cursor."""
        self.adding = adding
        self._binding.enabled = adding
        self.canvas.refresh_interaction()
        self.canvas.set_cursor("crosshair" if adding else None)
        if not adding:
            self._discard_preview()

    async def _on_drag_start(self, event: DragEvent, _canvas: ImageCanvas) -> None:
        self._anchor = event.world_position

    async def _on_drag(self, event: DragEvent, _canvas: ImageCanvas) -> bool:
        if self._anchor is None:
            self._anchor = event.world_position
        rect = Rectangle2D.between(self._anchor, event.world_position)
        if self._preview_id is None:
            self._preview_id = self.canvas.handle.add(rect, style=self._style)
        else:
            self.canvas.handle.update_entity(self._preview_id, rect)
        self.canvas.handle.flush()
        return True

    async def _on_drag_end(self, event: DragEvent, _canvas: ImageCanvas) -> None:
        if self._anchor is None:
            return
        rect = Rectangle2D.between(self._anchor, event.world_position)
        self._anchor = None
        self._discard_preview()
        act = ActRectangle2D(
            center=rect.center,
            size=rect.size,
            handle_style=SquarePointStyle(size=1.0, thickness=2.0),
        )
        self.canvas.handle.add(act, style=self._style)
        self.rectangles.append(act)
        self.canvas.handle.flush()

    def _discard_preview(self) -> None:
        if self._preview_id is not None:
            self.canvas.handle.remove(self._preview_id)
            self._preview_id = None


def main() -> None:
    viz = Visualizer(add_default_axes=False, add_default_grid=False, space_dim=2)
    labeler = RectangleLabeler(viz)

    async def on_add(_value: None, _event: ControlEvent) -> None:
        labeler.set_adding(not labeler.adding)

    toolbar = ToolbarView(
        [
            ButtonView(
                "add_rect",
                icon=EIconMaterial.ADD,
                icon_only=True,
                tooltip="Add rectangle (toggle draw mode)",
                on_click=on_add,
            ),
        ],
        border=False,
    )

    layout = SplitView(
        "vertical",
        sizes=[Size.px(40), Size.fr(1)],
        children=[toolbar, labeler.canvas.scene_view()],
    )

    viz.show(layout=layout)
    viz.wait()


if __name__ == "__main__":
    main()