---
title: "Working with qupflowR: handoffs, control and training data"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Working with qupflowR: handoffs, control and training data}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
resource_files:
- figures/logo.svg
- figures/logo-dark.svg
---
```{r setup, include=FALSE}
# The example project is a PNG (read with magick); masks are written as TIFF.
has_raster <- requireNamespace("magick", quietly = TRUE) &&
requireNamespace("tiff", quietly = TRUE)
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = has_raster)
library(hyperannotatR)
```
hyperannotatR and the qupflowR package (a QuPath bridge, analysis and workflow
package) exchange annotations, masks, hyperspectral metadata and deep-learning
datasets through a small, versioned contract. qupflowR never reaches into
hyperannotatR internals, Shiny state, `inst/` files or the browser DOM; it uses the
public functions below, the files they write, or the optional local control
service. This vignette describes that contract from the hyperannotatR side.
## What this installation supports
```{r capabilities}
caps <- at_interop_capabilities()
caps
caps$profiles$I2[c("status", "implemented", "available", "reason")]
```
The profiles are:
| Profile | What it covers | Status rule |
|---|---|---|
| I0 | file interchange: handoff directories, QuPath GeoJSON, masks with legends | supported |
| I1 | public R API (`at_*` functions) | supported |
| I2 | loopback control service `annotatr-control-v1` | `planned` until qualified with a real qupflowR client; `unavailable` without httpuv/later |
| I3 | embeddable app object from `at_app()` | `planned` until qualified; `unavailable` without shiny/bslib/shinyjs/htmlwidgets |
A capability is never advertised as supported because it merely exists. The
report carries a `digest` (SHA-256 of its canonical JSON) that manifests and
handshakes repeat, so a partner can bind its expectations to exactly this set.
## Handoff directories
`at_export_qupflowr()` writes a new, self-contained directory of neutral files:
```{r export}
proj <- at_example_project()
dest <- file.path(tempdir(), "qupflowr-handoff")
receipt <- at_export_qupflowr(proj, dest, formats = c("qupath_geojson", "mask_tiff", "manifest"),
overwrite = TRUE)
receipt$files[, c("path", "size_bytes", "format")]
receipt$handoff_digest
```
- `annotations_qupath.geojson` uses the QuPath 0.4+ dialect with a stable UUID
per ROI and hyperannotatR's ROI id, layer, source and coordinate level in
`properties.metadata`.
- `masks/_labelled.tif` holds one integer value per ROI; its
`.legend.json` joins values to ROI ids, labels and colours.
- `image.json` and `manifest.json` identify images by content hash and file
basename only. No absolute path is used as identity.
- `integrity.json` inventories every other file (`path`, `size_bytes`,
`sha256`, sorted by path). The **handoff digest** is the SHA-256 of its
canonical JSON (keys sorted, no whitespace, integer sizes, no trailing
newline) — the same rule qupflowR uses for its bundle digest.
Files are written to a hidden staging directory and moved into place only when
complete. An existing destination is replaced only with `overwrite = TRUE`, and
only if it is itself a handoff directory.
A session export writes each queue entry under `entries//`. Entry ids
come from the session manifest's stable `entry_id` (falling back to
`entry-0001`, ... for older sessions), and the manifest's ROI, layer and file
records carry the `entry_id` they belong to, so two entries showing the same
image stay separate. Project masks have the ids `project:`.
### The manifest
```{r manifest}
man <- receipt$manifest
man[c("schema", "schema_version", "consumer", "object_kind")]
str(man$coordinate_convention)
man$images[[1]][c("image_id", "source_name", "width", "height", "n_bands", "transform_digest")]
man$annotations$annotation_revision
```
The coordinate convention is explicit: pixel units, origin at the top-left
corner, `y` increasing downward, planes indexed from zero, and the pixel
coverage rule for masks. QuPath object hierarchy and native ROI shapes, which
hyperannotatR does not model, are reported as `"unknown"` or `"approximated"`
(circles and ellipses are polygons) instead of being invented.
## Importing, staging and committing
`at_import_qupflowr()` reads only the neutral contract. It verifies the
inventory (missing, extra, duplicate, resized, altered and symlinked files), the
manifest schema and its major version, the id join between manifest and
GeoJSON, labels, mask dimensions, integer values and legend completeness. Any
failure is a classified error with a machine-readable `code` (for example
`DUPLICATE_ID`, `LEGEND_INCOMPLETE`, `MASK_INVALID`, `FORMAT_UNRECOGNISED` or
`INVALID_JSON`, which also covers duplicate JSON keys); nothing is written and
no analysis runs. Operating-system metadata files that are not in the inventory
(`.DS_Store`, `._*`, `Thumbs.db`, `desktop.ini`) are ignored with a warning.
The conversion table's `geometry_match` reports `"exact"` when the geometry is
byte-identical to its manifest record, `"equivalent"` when an export at another
pyramid level agrees with it within a relative `1e-9` after rescaling, and
`"changed"` otherwise.
```{r import}
report <- at_import_qupflowr(dest, expected_revision = at_annotation_revision(proj))
report$checks
```
Changes are applied in two explicit steps. Staging joins ROIs by id and
classifies each change. Reviewed or locked ROIs and locked layers, including a
locked target layer of a new or moved ROI, are never changed automatically but
reported as conflicts. ROIs whose ids were minted on import, because the file
carried none, are always staged as new ROIs:
```{r stage}
partner <- report$objects$layers$regions
partner <- at_layer_add(partner, at_roi_rect(10, 10, 60, 60, label = "stroma", id = "from-partner"))
patch <- at_stage_qupflowr(proj, partner, expected_revision = at_annotation_revision(proj))
patch$operations
```
Committing is idempotent: the same key and patch replay the original receipt,
while a reused key for a different patch is refused with
`IDEMPOTENCY_CONFLICT`. Within one R session, a key is also tied to its original
destination; an equivalent canonical path is accepted, but redirecting that
key to another destination is a conflict. When the patch targets a project
file, its annotation revision is checked before replacement, and replacing an
existing file requires `overwrite = TRUE`.
```{r commit}
receipt <- at_commit_qupflowr(patch, idempotency_key = "vignette-commit")
receipt$new_revision
at_commit_qupflowr(patch, idempotency_key = "vignette-commit")$replayed
```
For a saved commit, a `.commit.json` receipt records the key across R
sessions. Every replay of a saved commit re-reads the project and compares its
annotation revision with the receipt, including replays in the original R
session. Later annotation edits cause `REVISION_CONFLICT`; the call does not
return an old receipt paired with a different project or overwrite those edits.
An in-memory commit with no destination can replay its retained project.
The project and receipt are both staged before either destination is replaced.
If publication fails, their previous files are restored. If restoration also
fails, the error reports the retained backup paths for recovery. Callers must
serialize writers to the same destination: paired publication uses multiple
filesystem renames and does not guarantee an atomic result after a process or
machine crash.
Saved commits use the same image preparation as `at_save_project()`: a
hand-built in-memory image keeps its pixels, while a file-backed image keeps
the source descriptor needed to validate and reopen it. Annotation commits do
not discard the only copy of an in-memory image.
## Hyperspectral metadata
Spectral images keep the format-independent tile contract `[y, x, band]`.
Metadata travels with them explicitly:
```{r hsi, eval = TRUE}
cube <- at_example_image("cube")
head(at_bands(cube), 3)
at_hsi_meta(cube)[c("image_kind", "value_unit", "interleave", "byte_order", "header_bytes",
"calibration_digest", "window_read")]
```
Raw values, reflectance, radiance, intensity and absorbance are never mixed:
`at_convert_values()` implements the named conversions and refuses to guess a
missing calibration. ENVI and TIVITA cubes are read window by window, so
opening, printing or displaying a cube does not load it; `at_read_stats()`
reports the bytes read. A TIVITA `*_SpecCube.dat` is decoded only through a
declared `at_tivita_profile()` or a `.tivita.json` sidecar. Cubert
`.cu3s` files need `cuvis.r` and the CUVIS SDK; without them the backend reports
itself unavailable and an SDK-written ENVI export is the portable fallback.
Display products from `at_band_view()` (ratios, normalised differences, band
means) carry their parent digest and band list and are never used as analysis
input.
## Deep-learning training datasets
```{r training}
sess <- at_example_session(3)
for (i in seq_len(3)) sess$projects[[i]] <- at_example_project()
ds <- at_training_export(sess, file.path(tempdir(), "training-dataset"), tile_size = 128,
mask_type = "labelled", seed = 11, overwrite = TRUE)
ds$manifest$split_policy$algorithm
ds$manifest$counts[c("tiles", "tiles_by_split", "excluded")]
ds$checks
```
Splits are assigned per group, so tiles of one group never occur in two
splits. The default group is the image, identified by its content hash
(including any non-default series, plane or measurement). The three example
entries are copies of one picture, so they form a single image group and all
land in one split: tiles of one image never leak across splits. Distinct images
spread over the splits. Group by `sample_id` or `subject_id`, supplied in
`groups`, when several images come from one sample or one patient; entries
that show the same pixels must still share a split, and `at_training_check()`
reports an image found in two splits as leakage. The manifest records tile size, overlap, level, pixel origin, coverage
rule, class legend (background `0`), band list with wavelengths and FWHM,
value unit, calibration and transform digests, annotation revisions and review
status counts. `at_training_check()` re-verifies the inventory, split leakage,
mask dimensions, label range and a mask-to-ROI round trip.
Model predictions come back as staged annotations. They must match the image
dimensions and use only codes from the label map; reviewed annotations are
never overwritten:
```{r predictions}
pred <- matrix(0L, 512, 512)
pred[20:80, 30:120] <- 1L
staged <- at_training_import(proj, pred, label_map = c(tumour = 1L),
model = list(name = "example-unet", version = "0.1",
sha256 = strrep("0", 64)))
staged$summary
```
Training itself remains an external, optional workflow.
## The control service
A partner process can drive a running app through the loopback service. It is
off unless started, binds only to `127.0.0.1`, authenticates with a random
bearer token that expires, rejects browser-origin requests and oversized
bodies, validates requested file paths against its negotiated root, and offers
no endpoint that evaluates R, shell or Python code. Tokens come from the operating system's
random source (`/dev/urandom`) or, where that does not exist (Windows), from the
optional `openssl` package; without either, the service refuses to start.
The control root must be a directory. If startup fails after resources have
been created, the service removes that attempt's listener, expiry timer,
registry entries and newly created token file. Existing files and unrelated
listeners remain available.
Before loading an image or import, the service also checks the canonical paths
of known companion files, including ENVI headers and payloads, TIVITA profile
sidecars, mask legends and handoff members. Each control instance uses its own
negotiated root, independently of the app's `app_root` option. These checks do
not sandbox custom reader code or additional external references, and they do
not prevent concurrent filesystem changes between validation and reading.
Shared deployments need separate operating-system access controls.
```{r control, eval = has_raster && requireNamespace("httpuv", quietly = TRUE)}
handle <- at_control_start(at_example_session(2), serve = FALSE)
state <- at_control_state(handle)
state$state_revision
at_control_command(handle, list(operation = "context.goto", payload = list(queue_index = 2),
expected_revision = state$state_revision))$entry_id
vapply(at_control_events(handle)$events, `[[`, character(1), "type")
at_control_stop(handle)
```
In a real integration the app runs in its own process:
```{r control-process, eval = FALSE}
handle <- at_control_start(session, token_file = "/run/user/1000/annotatr.token")
shiny::runApp(at_app(control = handle))
# ... in the partner process:
remote <- at_control_connect(manifest_path, "/run/user/1000/annotatr.token")
```
Every mutation carries a `request_id`, the `expected_revision` it is based on,
an `idempotency_key` and the SHA-256 of its canonical payload. A stale revision
is a conflict; a lost response is resolved with
`at_control_request_status()`, and re-sending with the same key never applies
a command twice. Keys belong to a control instance: independent clients may
use the same key in different instances without sharing commit receipts.
Events are read with a cursor; a cursor from another instance
or older than the retained buffer yields `resync_required = TRUE`.
A few further rules matter to clients:
- `session.load` refuses with `UNSAVED_CHANGES` (409) while the current session
has unsaved or staged edits, unless the payload sets `discard_unsaved: true`.
A loaded session gets its own output directory under `/sessions/`,
reported as the relative `session_dir`.
- Re-sending an idempotency key whose record has already been evicted returns
`IDEMPOTENCY_EXPIRED` (409) instead of running the command again.
- `mask.preview` refuses masks larger than
`options(hyperannotatR.control_mask_max_pixels)` (default 4096^2 pixels) with
`MASK_TOO_LARGE` (413).
- `GET /v1/state` reports `image_id: null` for unvisited queue entries larger
than `options(hyperannotatR.control_hash_max_bytes)` (default 256 MiB) rather than
hashing them while the app waits.
- `close` is not revision-checked. Once the token has expired, the listener
shuts down.
- Error bodies carry the condition `class` alongside the `code`. Paths in errors
are reported relative to the root, never as absolute paths.
- The remote client ignores `http_proxy`/`https_proxy` and never follows
redirects, so the token only ever goes to the loopback address.
- On Windows the operating system does not enforce the token file's owner-only
permission; keep it in a directory only you can read.
The JSON Schemas for all documents ship with the package:
```{r schemas, eval = TRUE}
basename(list.files(system.file("schema", package = "hyperannotatR"), recursive = TRUE))
```
## Versions and migration
Schema versions are independent of the package version. Readers accept any
`1.x` version, reject an unknown major version and keep unknown optional data
only under `extensions`. hyperannotatR 0.2.0 introduced `annotatr-handoff-v1`,
`annotatr-control-v1`, `annotatr-training-v1` and `annotatr-hsi-v1`; they are
now at version `1.1`. The `integrity.json` format stays at `1.0`, because its
digest rule is shared with qupflowR. Existing 0.1 files remain readable: legacy
QuPath GeoJSON (`object_type`, `colorRGB`) is imported, and
`at_write_qupath(dialect = "legacy")` still writes it.