ROI annotations¶
Experimental. The ROI annotation schema and wire types may still change without notice.
A user draws 2-D ROIs (points, polygons, rectangles, ellipses, polylines) on
a tensor in the viewer SPA; they persist across restarts in a rois table
in the tensor server's DuckDB catalog, reached over a small Flight action
set and re-exposed by the HTTP sidecar.
Scope of this doc: the backend (store, wire types, API surfaces). The SPA's draw tooling is roi-annotations-ui.md.
Model¶
An annotation is a geometry plus the metadata that makes it findable. The
geometry is biopb.image.ROI (proto/biopb/image/roi.proto), reused as-is;
ROI carries geometry only (no id, no label), so the record around it,
RoiAnnotation, is new.
Only the 2-D vector arms are accepted: point, rectangle, ellipse,
polygon, polyline. A mask (a BinData bitmap, hundreds of KB, past
"annotation scale") or mesh (3-D, where plane pinning has no meaning) is
rejected but stays in the proto, so accepting one later needs no wire
change.
- One row per ROI, not one row per set — useful for
WHERE label = 'mitotic', per-field object counts, or a join againstsources. A layer's atomicity comes fromset_nameplus a batchedput_roisin one transaction, not from an opaque row. - Coordinates are level-0 pixels, floating point — a shape drawn on a downsampled level is scaled up client-side before the write.
- Plane pinning is a sparse map, keyed by wire axis index —
{2: 12}, not{"z": 12}. A missing key means "every index". - Annotations are not source metadata — not merged into
sourcestable, but live in a sibling table (rois) in the same catalog DB.
Reserved sets¶
A set_name starting with @ is server-owned: a cache of the source file,
filled by an adapter's get_embedded_rois and replaced wholesale on
re-registration (today's importer files OME-embedded ROIs into @ome). The
store refuses a client write addressed to a reserved set, by name or by one
of its ids, since an edit landing there would be silently destroyed at the
next re-import -- so editing is a clone, done client-side: read the set,
mint fresh ids, write them into a set of your own. list_rois without a
set_name returns only client-owned sets; naming a reserved one is the only
way to read it. A reserved set shares the sources lifecycle (cleared and
rewritten alongside a source's own row) and sits outside the orphan
machinery entirely, since pruning a cache of the source file would reclaim
nothing a re-import doesn't already rebuild; the import itself can never
fail a registration and is exempt from max_rois_per_tensor, since a user
should not be pushed against the cap by rows they did not author.
Schema¶
The rois table, in MetadataDatabase._create_schema():
CREATE TABLE rois (
roi_id TEXT NOT NULL, -- uuid4 hex; client-supplied or server-minted
array_id TEXT NOT NULL, -- the anchor, unversioned
source_id TEXT NOT NULL, -- array_id split on the first '/'; joins + authz
source_url TEXT, -- last-seen catalog URL; names an orphan report
-- and anchors re-attach. NULL until first seen
set_name TEXT NOT NULL DEFAULT 'default',
label TEXT, -- user class/name
shape_kind TEXT NOT NULL, -- point|rectangle|ellipse|polygon|polyline
plane MAP(UINTEGER, UINTEGER), -- axis -> index; absent key = all indices
bbox DOUBLE[4], -- [x0,y0,x1,y1], level-0 px, derived server-side
geometry TEXT NOT NULL, -- biopb.image.ROI as canonical proto3 JSON
props_json TEXT, -- free-form client JSON (color, score, author)
drawn_against_version TEXT, -- content_version at write time, or NULL
rev BIGINT NOT NULL, -- per-roi, monotonic
created_at TIMESTAMP,
updated_at TIMESTAMP,
last_seen_at TIMESTAMP, -- source's last observed-in-catalog time; NULL = never seen
PRIMARY KEY (array_id, roi_id)
);
CREATE INDEX idx_rois_array ON rois(array_id);
bbox is derived server-side from geometry and is what makes the SQL
surface useful ("annotations overlapping this region," per-label counts
over a plate) though the viewer never reads it, always fetching a tensor's
whole set instead. geometry is proto3 JSON text, not a serialized
blob, so the sidecar hands it to the SPA verbatim while keeping the row
legible to SQL. The primary key is composite because roi_id is unique
within a tensor only -- two tensors both choosing "roi-1" is ordinary
(a batch naming one twice is refused) -- and a client-supplied id is
length-bounded (128 bytes) and comma-free, since the sidecar's delete route
addresses ids by a comma-separated list.
rois is in MetadataDatabase.ALLOWED_TABLES, SELECT-only like every other
allowed table -- client.query(...) and the MCP catalog surface can
read it for analysis, but the viewer always uses the typed read below.
sync_source_removed() does not cascade into rois, since a rescan or a
transient unregister must not destroy a user's work: orphaned rows re-attach
if the same path is registered again, and deleting is always an explicit
call (see Staleness). put_rois wraps its batch in one transaction, since
DuckDB otherwise autocommits per statement and a failure partway through
would leave partial rows a concurrent list_rois could watch appear one at
a time. annotations.max_rois_per_tensor (default 5000) is the line
between "annotation store" and "object store" -- a million-object
segmentation belongs in a label tensor.
Wire types¶
RoiAnnotation (proto/biopb/image/annotation.proto, the biopb.image
package) carries the fields described above -- roi_id, array_id,
set_name, label, roi (the geometry), plane, props_json,
drawn_against_version, rev, and two timestamps; field 6 is reserved
(a dropped dim_label-keyed pin, never to be reused). It wraps
biopb.image.ROI directly rather than importing biopb.tensor for it,
since biopb.image already imports biopb.tensor and the reverse would
cycle. RoiPutResult/RoiDeleteResult/RoiListResult/RoiSetInfo carry a
batch's outcome; RoiPruneRequest/RoiUnseen/RoiPruneResult are the
roi_prune action's own types; biopb/tensor/ticket.proto adds
RoiRead/RoiPut/RoiDelete as the flight's addresses
(TensorTicket.roi_read, PutCommand.roi_put, PutCommand.roi_delete).
See the proto files for exact field numbers.
A read takes no plane or bbox filter, since the client filters the resident
set in memory; set_name scopes it (empty returns client-owned sets, a
reserved set only comes back when named), and sets always covers the
whole tensor by counting stored rows, so a client can see a reserved set
exists. Concurrency is per-ROI optimistic: the server bumps rev on every
write, and with check_rev set, a request whose rev doesn't match the
stored one is reported in conflicts and not applied while the rest of the
batch still lands -- a client that doesn't care leaves check_rev false
and gets last-writer-wins.
API surfaces¶
The roi flight is the authoritative surface — the HTTP sidecar is a
separate client that reaches the server over gRPC and cannot touch the
DuckDB catalog directly:
| Verb | Wire | Stream | Reply |
|---|---|---|---|
| DoGet | TensorTicket.roi_read {array_id, set_name} |
ROI rows | truncated + sets (JSON) in the stream's schema metadata |
| DoPut | PutCommand.roi_put {array_id, check_rev} |
ROI rows | RoiPutResult in the put's app_metadata |
| DoPut | PutCommand.roi_delete {array_id, set_name} |
one roi_id column, or empty |
RoiDeleteResult in the put's app_metadata |
One row schema serves both directions (biopb.tensor._roi_rows.ROI_ROW_SCHEMA)
-- what the server streams on a read is what it accepts on a write, and
TensorFlightClient.put_rois() / list_rois() / delete_rois() rebuild the
result messages from that row stream, which is also what gives
napari-via-MCP the same feature with no second store. Orphan cleanup is
separate: roi_prune runs against the catalog directly, not a tensor (an
orphaned row has no live source to authorize against), as a server-token
action reporting unless apply is set.
Why a flight and not the SQL surface. A read or write names one tensor
and is authorized on its source, exactly like a pixel read; a SQL query
names no source, so rois stays writable only through the flight (readable
via SQL for analysis). Each action authorizes on the source first: a
feature-level refusal (annotations disabled, no metadata DB) raises
FlightUnavailableError, a rejected request (bad geometry, cap breached)
raises FlightServerError -- the split is what lets the sidecar answer 501
vs 422 without parsing messages. None are gated on --writable, since an
annotation writes no pixels; the auth boundary is the token (required in
remote mode) plus annotations.enabled (default true).
HTTP sidecar (serving/http_server.py), the SPA's path via the control's
/data_plane/* proxy:
GET /api/rois/{array_id:path}?set=
POST /api/rois/{array_id:path} body: {"rois": [RoiAnnotation…], "check_rev": bool}
DELETE /api/rois/{array_id:path}?ids=a,b&set=nuclei
The mutating routes call ctx.check_token(request) then
_require_same_origin(request), and JSON bodies are canonical proto3 JSON
on both ends. Filling drawn_against_version is the caller's job (cheaper
than a server-side describe round trip per save), and the handler strips
the @version token wherever one can enter -- the path and each
annotation's own array_id -- since responses carry versioned ids and a
read-edit-write round trip hands one straight back.
Persistence¶
The whole catalog is file-backed (MetadataDatabase(store_path=...) opens a
DuckDB file instead of ":memory:", shared with sources and
decode_rates), but annotations are the only rows no rescan can reproduce.
Reloaded rows re-attach with no fixup, since array_id is deterministic
across restarts for file-backed sources (upload/scratch sources are the
exception, their URL synthesized per run), and a file that won't open, or
whose rois shape this build doesn't understand, is fatal
(AnnotationStoreError) after a short retry -- neither renaming it aside
nor falling back to an in-memory catalog is safe, since the latter would
keep the server up while every ROI drawn on it silently stops surviving a
restart. A person choosing catalog.persist = false gets a session-only
store on purpose (annotations_persisted: false in health, not an error).
rois alone carries a real schema-migration ladder -- sources is dropped
and recreated every open and decode_rates just drops and re-collects on a
version bump, but nothing can reproduce an annotation, so an older file is
migrated and a newer-build file refused rather than misread.
Staleness¶
Content staleness (pixels changed under the annotation) is handled by
drawn_against_version: a client compares it against the current
descriptor and warns. Nothing to prune.
Referential staleness -- the tensor is gone and the row points at
nothing -- needs a policy since there's no existence oracle: the catalog is
routinely an incomplete picture (discovery is progressive, an unresolved
cloud source is absent and entirely intact), and only file-backed sources
have a path to stat at all. So the rule is: never assert deletion, only
measure elapsed time since last observed presence -- mark_sources_seen()
stamps last_seen_at = now() for every ROI whose source is currently in the
catalog, run at the end of each full scan, so a source offline for a week
simply never advances it.
Deletion is a policy over age (annotations.prune_unseen_days, default 0
= never), not a claim about the world -- prune_unseen(before) applies it
and unseen_rois(before) reports what it would take using the same
predicate, so a dry run and the real thing can't drift, reachable against a
running server (biopb tensor prune-annotations --days N [--apply]) or
directly against a stopped one's catalog file. Auto-pruning stays opt-in:
these are hand-drawn rows, so the safe default is to surface orphans and let
a person confirm.
Known limitation: a move orphans annotations¶
Identity is path-derived, so mv gives a local file a new source_id, a
new array_id, and the tensor appears freshly unannotated while the old
rows go orphan. Re-attachment, not pruning, is the fix -- not yet
implemented, though the stored source_url plus drawn_against_version
(which survives a plain mv, since it's mtime_ns:size) is enough to offer
"these annotations were drawn on a file of the same name, size and mtime --
re-attach?" instead of silently losing them. A proxied source is the
opposite case by design: its id is built from (alias, upstream_source_id)
and carries no endpoint, so moving the upstream file leaves annotations
attached -- the equivalent event there is renaming the alias, see
remote-tensor-cache.md.