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 Box and Polygon
pixpick/
├── backends/
│ ├── base.py # AbstractBackend — contract for all backends
│ └── cv2_backend.py # CV2Backend (OpenCV window)
├── core/
│ ├── box.py # Box, Multibox
│ ├── line.py # Line
│ └── polygon.py # Polygon, Multipolygon
├── selectors/
│ ├── box_picker.py # BoxSelector
│ ├── line_picker.py # LineSelector
│ └── polygon_picker.py # PolygonSelector
├── __init__.py # box(), polygon(), load() — public API
└── utils.py # load_image(), image_size()
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 a Box or Polygon and load it back with the same call. The dispatcher reads "type" and returns the right object.
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 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 both methods. 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",
) -> tuple[int, int, int, int] | None:
# open your UI, capture drag
# return (x1, y1, x2, y2) or None if cancelled
...
def select_polygon(
self,
image: np.ndarray,
title: str = "pixpick",
) -> list[tuple[int, int]] | None:
# open your UI, capture clicks
# return [(x0,y0), (x1,y1), ...] or None if cancelled
...
def select_line(
self,
image: np.ndarray,
title: str = "pixpick",
) -> tuple[tuple[int, int], tuple[int, int]] | None:
# open your UI, capture clicks
# return ((x0,y0), (x1,y1)) or None if cancelled
...
Both methods must return None on cancellation — selectors convert that into a SelectionCancelled exception.