Skip to content

Architecture

File structure

docs/
├── architecture.md             # How it's built and how to extend it
├── CONTRIBUTING.md             # Contribution guidelines
├── frameworks.md               # Framework integration (YOLO, SAM, etc.)
├── getting-started.md          # Installation, first selection, controls
├── index.md                    # Home page
├── persistence.md              # Save, load, JSON schema
├── roadmap.md                  # What's coming next
└── selectors.md                # All properties and methods for every selection type
pixpick/
├── backends/
│   ├── base.py                 # BaseBackend — contract for all backends
│   └── cv2_backend.py          # CV2Backend (OpenCV window)
├── core/
│   ├── box.py                  # Box, Multibox
│   ├── line.py                 # Line, MultiLine
│   ├── point.py                # Point, MultiPoint
│   └── polygon.py              # Polygon, MultiPolygon
├── selectors/
│   ├── box_picker.py           # BoxSelector
│   ├── line_picker.py          # LineSelector
│   ├── point_picker.py         # PointSelector
│   └── polygon_picker.py       # PolygonSelector
├── __init__.py                 # box(), polygon(), line(), point(), load() — public API
└── utils.py                    # load_image(), image_size(), SelectionCancelled

How the layers relate

pixpick.box("frame.jpg")
    │
    ▼
BoxSelector.select(source)
    ├── utils.load_image(source)     → np.ndarray
    ├── CV2Backend.select_box(image) → [[x1, y1, x2, y2], ...] | None
    └── Box(...) or Multibox(...)    → returned to caller
                │
                ├── .xyxy / .xywh / .norm / ...   (properties)
                ├── .yolo_region                  (inline, no extra file)
                ├── .sam                          (inline, no extra file)
                └── .save() / .load()             (persistence)

Design decisions

Backends are the only abstraction. BaseBackend is the one interface worth keeping because adding a new environment (Jupyter, Gradio) means writing a new backend with zero changes to selectors or selection objects. Everything else is concrete.

Selectors are thin glue. A selector does three things: load the image, call the backend, wrap the result. No logic of its own.

pixpick.load() dispatches on the JSON "type" field. You save any selection and load it back with the same call. The dispatcher reads "type" and returns the right object — one of the eight types.

A Multi* type holds the singular objects, never raw coordinates. Multibox holds Box objects, MultiPolygon holds Polygon, MultiLine holds Line, MultiPoint holds Point. Each wrapper validates only collection-level rules (non-empty, all items share the container's image size) and delegates every coordinate property to the items, so per-item validation lives in one place.

Backends

A backend handles the UI — opening a window, capturing mouse input, and returning raw pixel coordinates. Backends know nothing about Selection objects or frameworks; that is the selector's job.

Available backends

Backend Class Environment Status
OpenCV window CV2Backend Local scripts ✅ v0.1.0
Matplotlib NotebookBackend Jupyter / Colab 🔜 v0.3.0
Gradio GradioBackend Headless / SSH 🔜 v0.3.0

CV2Backend (default)

Used automatically when no backend is specified. Opens a native OpenCV window.

Requirements: a display must be available (DISPLAY set on Linux, native on Windows/macOS).

region = pixpick.box("frame.jpg")               # CV2Backend used by default

If you draw multiple boxes, the selector returns a Multibox instead of a single Box.

Swapping backends

Pass a backend instance to any selector.

from pixpick.backends.cv2_backend import CV2Backend
from pixpick.selectors.box_picker import BoxSelector

selector = BoxSelector(backend=CV2Backend())
region   = selector.select("frame.jpg")

Once NotebookBackend and GradioBackend land in v0.3, swapping is the same:

from pixpick.backends.notebook import NotebookBackend

region = BoxSelector(backend=NotebookBackend()).select("frame.jpg")

Writing a custom backend

Subclass BaseBackend and implement every abstract method (select_box, select_polygon, select_line, select_point). The return types are strict — selectors rely on them.

from pixpick.backends.base import BaseBackend
import numpy as np


class MyBackend(BaseBackend):

    def select_box(
        self,
        image: np.ndarray,
        title: str = "pixpick",
    ) -> list[list[int]] | None:
        # open your UI, capture drags
        # return [[x1, y1, x2, y2], ...] or None if cancelled
        ...

    def select_polygon(
        self,
        image: np.ndarray,
        title: str = "pixpick",
    ) -> list[list[tuple[int, int]]] | None:
        # open your UI, capture clicks
        # return [[(x0,y0), (x1,y1), ...], ...]  — one list per polygon
        # or None if cancelled
        ...

    def select_line(
        self,
        image: np.ndarray,
        title: str = "pixpick",
    ) -> list[tuple[tuple[int, int], tuple[int, int]]] | None:
        # open your UI, capture clicks
        # return [((x0,y0), (x1,y1)), ...]  — one tuple per line
        # or None if cancelled
        ...

    def select_point(
        self,
        image: np.ndarray,
        title: str = "pixpick",
    ) -> tuple[list[tuple[int, int]], list[int]] | None:
        # open your UI, capture clicks
        # return ([(x0,y0), ...], [label, ...])  — 1 = foreground, 0 = background
        # or None if cancelled
        ...

Every method returns a list even for a single selection — the selector decides whether to wrap the result in a Multi* type. All four must return None on cancellation; selectors convert that into a SelectionCancelled exception.