Reference for the /curation HTTP API as shipped in v0.4.0. The generated
OpenAPI document contracts/openapi/curation.json is the source of truth for
routes and schemas. The JSON/TypeScript helpers in contracts/json/ and
contracts/ts/ are generated from the same code. This document adds the
semantics that a schema cannot carry: ordering, locking, error codes,
concurrency tokens and which route to call for which job.
The API is generic. Cropwright is one consumer. Nothing here is specific to a domain, a model vendor or a frontend.
- Conventions
- Error model
- Projects and lifecycle
- Settings, config store and capability discovery
- Keymap
- Prompt packs
- Region profiles
- VLM endpoints, catalog and activation
- Items and the item wire format
- Crops: browse, label, undo
- Regions: multi-box edits and queues
- Review tabs
- Classes
- Clusters
- VLM labeling and the auto-label pipeline
- Ingest
- Dataset import
- Reprocess
- Export
- Training, promote and models
- Bake-off
- Scores, selection, projection, probe, search, stats
- Images
- Events
- The lock rule
- Class identity
- Breaking wire changes
- Every path is relative to
CurationConfig.api_prefix(OP_API_PREFIX, default/curation). Clients should not hardcode the prefix. - Data and per-project configuration live under
/curation/projects/{project}/.... In the tables below these paths are written without that prefix. For example/cropsmeans/curation/projects/{project}/crops. - Deployment-wide routes are written in full:
/curation/health,/curation/events,/curation/projects,/curation/projects/combine*,/curation/vlm/*. - There is no unscoped alias of a project route and no vendor-named alias.
{project}is a slug (see Projects and lifecycle). A slug that does not name a project is404 project_not_found, never422.- A project is bound before the handler runs. A project that is
building,deletingorfailedcannot be bound (409). Anarchivedproject, or a project bound while the project registry is stale, is bound read-only: every non-GET method answers409 project_archivedor409 project_read_only.
Writes that replace a stored document carry the revision that the client
loaded. A stale token is 409.
| Token | Where | Stale answer |
|---|---|---|
expected_revision (int) |
project PATCH/archive/unarchive/clone_settings, pack/profile/VLM-endpoint PUT, DELETE ...?expected_revision=, keymap PUT/reset (also If-Match: "keymap:N"), model sharing |
409 revision_conflict (current_revision in the body) |
expected_active ({name, revision}) |
activate, rollback, deactivate of a pack, profile or VLM endpoint | 409 active_conflict (current in the body) |
expected_region_revision (int) |
per-item box writes (PUT /crops/{crop_id}/regions, PATCH /crops/{crop_id}/regions/{box_id}, POST /regions/batch_box_state via expected_region_revisions) |
409 region_conflict (current_region_revision, current_box_ids, item) |
expected_preview_sha |
POST /curation/projects/combine |
409 preview_stale |
expected_import_key |
POST /datasets/imports |
409 dataset_changed |
Every stored document also serves an etag. Single-document GET routes
send it as the ETag header.
Prompt packs, region profiles and VLM endpoints are immutable revisions:
every save is a new revision number, numbers are never reused, and
GET .../{name}/revisions[/{revision}] reads history. Documents have a
source: builtin, file, template, env, registered or stored. Only
stored documents can be saved or deleted; the others are read_only
(403 read_only). Clone one to get an editable copy.
- Lists page with
page(from 1) andpage_size. A page past the 10,000th result is422. - Every queue ends in a
crop_idascending tie-break, so pages are stable. - Read endpoints fail closed: a backend outage is
503, never an empty or zero answer.
- Request models that write region state use
extra='forbid': an unknown key is422. - A human class or region write never takes its provenance from the client
beyond the documented
label_source/region_label_sourcevalues. The server stampsclass_source: "human"itself. - Class names, slugs and pack/profile names follow the patterns served by the
relevant
schema/limitsfield; the pattern is part of the response, not a client constant.
Routes of the project, config-store, dataset, reprocess, combine, VLM and test surfaces raise a typed body:
{"detail": {"error": "revision_conflict", "message": "...", "current_revision": 4}}error is one of the codes below. Extra fields depend on the code (project,
current_revision, current, report, issues, unmapped, valid_ids,
projects, jobs, crop_ids, endpoint, activate_via, limit,
requested). report is a ValidationReport (ok, errors[], warnings[],
force_allowed; an issue has code, severity, message, field, detail,
bypassable).
Older routes (crops, classes, review, export, training, clusters) raise
{"detail": "<string>"} or {"detail": {"error": "<code>", ...}}. Validation
failures of a request body are the standard FastAPI 422 list.
| Status | Codes |
|---|---|
404 |
project_not_found, not_found, unknown_revision, model_not_found, crop_not_found, image_not_found, import_not_found, combine_not_found, an export artifact that is not whitelisted or not present, unknown_box_id |
403 |
read_only (a non-stored pack, profile or endpoint), region-detector models in DELETE /models/{model_name} |
409 |
project_archived, project_read_only, project_building, project_deleting, project_failed, project_busy, project_protected, slug_taken, slug_retired, last_active_project, shard_budget_exceeded, invalid_transition, target_not_empty, clone_source_not_ready, in_use, name_conflict, revision_conflict, active_conflict, no_previous, previous_deleted, no_active_profile, preview_stale, combine_not_resumable, import_busy, import_resumable, import_not_resumable, import_not_undoable, dataset_changed, reprocess_busy, class_hotkey_conflict, hotkey_taken, region_conflict, vlm_not_configured, vlm_endpoint_unavailable, no_local_vlm, finish_in_progress, no_classes |
413 |
upload_too_large, an upload or batch over its per-request limit |
422 |
validation_failed, slug_invalid, confirm_mismatch, combine_invalid, class_mapping_incomplete, class_mapping_invalid, import_blocked, format_undetected, dataset_path_not_allowed, archive_invalid, reprocess_targets_invalid, region_profile_required, unknown_pack, unknown_profile, unknown_vlm, vlm_external_not_acknowledged, unknown_catalog_id, vlm_catalog_does_not_fit, export_outside_project, pack_invalid, profile_invalid, no_box_to_verify, too_many_crops, too_many_crop_ids, too_many_boxes, region_text_disabled, box_id_in_batch, box_id_required |
429 |
probe_busy (VLM endpoint probe), test_busy (test-on-crop) |
500 |
internal_isolation_error (a cross-project access was refused), path_escape |
502 |
vlm_transport_error, segmenter_error, detector_error, a Triton load refusal on promote |
503 |
config_store_unavailable, an OpenSearch outage on a read, ingest unavailable before the encoder has loaded |
504 |
test_timeout |
The full code enumeration is ConfigErrorDetail.error in the OpenAPI
document. The validation issue codes are ValidationIssue.code.
A project is an isolated dataset: its own OpenSearch indexes, class
registry, files, config documents, jobs and promoted models. Nothing is
shared between projects except the deployment-wide VLM endpoint registry and
models a project explicitly shares. The default project always exists and
cannot be deleted.
Pattern ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$, 2 to 32 characters. Reserved:
combine, new, all, none, projects, global, settings, vlm,
health. A deleted project's slug is retired and cannot be reused
(409 slug_retired). GET /curation/projects serves the pattern, the
bounds, the reserved and retired slugs and the cloneable axes under limits.
status |
Meaning | writable |
selectable |
|---|---|---|---|
building |
being created or filled (a combine target) | no | no |
active |
normal | yes | yes |
archived |
read-only | no | yes |
deleting |
removal running in the background | no | no |
failed |
creation failed, error has code and message; delete it |
no | no |
deleted |
tombstone, not served | no | no |
GET /curation/projects lists active, building, failed and deleting
projects, plus archived ones with include_archived=true.
| Method | Path | Body or query | Response | Errors |
|---|---|---|---|---|
| GET | /curation/projects |
include_archived |
ProjectsResponse (default_slug, projects[] of ProjectSummary, capacity, limits, labels) |
|
| POST | /curation/projects |
CreateProjectRequest (slug, display_name, description, clone_settings_from, clone_axes) |
201 ProjectLifecycleResponse |
422 slug_invalid, 409 slug_taken, 409 slug_retired, 409 shard_budget_exceeded, 409 clone_source_not_ready, 422 validation_failed |
| GET | /curation/projects/{project} |
ProjectRecordResponse (summary plus resources, error) |
404 project_not_found |
|
| PATCH | /curation/projects/{project} |
PatchProjectRequest (display_name, description, expected_revision). The slug is immutable |
ProjectLifecycleResponse |
409 revision_conflict |
| POST | /curation/projects/{project}/archive |
{expected_revision} |
ProjectLifecycleResponse |
409 invalid_transition, 409 project_busy, 409 revision_conflict |
| POST | /curation/projects/{project}/unarchive |
{expected_revision} |
ProjectLifecycleResponse |
409 invalid_transition, 409 revision_conflict |
| DELETE | /curation/projects/{project} |
dry_run, confirm, force |
200 DeleteDryRunResponse for a dry run, otherwise 202 ProjectLifecycleResponse |
422 confirm_mismatch, 409 project_protected, 409 project_busy, 409 last_active_project, 409 in_use |
| POST | /curation/projects/{project}/clone_settings |
CloneSettingsRequest (from, axes, expected_revision) |
ProjectLifecycleResponse |
409 target_not_empty, 409 clone_source_not_ready, 422 validation_failed |
| GET | /stats |
ProjectStatsResponse (counts, indexes, disk, jobs, last_ingest_at) |
||
| GET | /pause |
PipelinePauseState (paused, paused_by, reason) |
||
| POST | /pause |
PipelinePauseState |
||
| POST | /resume |
PipelinePauseState |
The ProjectSummary carries slug, display_name, description, prefix
(the project's API base), status, writable, selectable, is_default,
deletable, archivable, unarchivable, revision, created_at,
updated_at, counts (images, items, validated), origin (set for a
combine target) and paused. A client enables buttons from the boolean flags
and does not recompute them from status.
Lifecycle responses carry warnings[] (for example shard_budget_high) and
keymap_clone_conflicts[] (keymap actions whose combo collides with a class
hotkey of the target; any conflict makes a keymap clone skip the whole keymap
axis and leave the target's keymap unchanged).
Creation runs: validate, check shard capacity, write the record as
building, create indexes and directories, optionally clone, then flip to
active. A failure leaves the record failed. Every refusal that can be
known up front (unknown axis, clone into itself, source not ready) is raised
before the first write, so a refused clone burns no slug.
clone_settings_from / POST .../clone_settings copy configuration, never
data. The cloneable axes (limits.cloneable_axes) are settings_defaults,
classes, activations, keymap, prompt_packs and vlm_activation. A
clone never copies an external-endpoint acknowledgement; an external source
endpoint refuses the clone (422 vlm_external_not_acknowledged). 409 target_not_empty when the target already has items (classes), stored
packs (prompt_packs) or an activation or pack name that the clone would
overwrite.
DELETE ...?dry_run=true writes nothing and reports indexes[], dirs[],
promoted_models[], mlflow_experiment, running_jobs[], referenced_by[]
and blocking[] with blocking_detail[]. A real delete needs
confirm=<slug> and answers 202 with the deleting record. Index and
directory removal, promoted-model unload and the tombstone run in the
background. Follow the project.deleted event on /curation/events, or poll
GET /curation/projects/{project} until it answers 404.
A delete is refused while the project has running jobs (project_busy), is
the last active project, or has promoted models shared with other projects
(in_use; force=true bypasses only this check). The in_use body names the
shared models in message, the projects whose active detection profile uses them
in projects[] and used_by[] ({project, profile}); the dry run lists the
same rows in referenced_by[]. A project that cannot be read makes the real
delete 503 config_store_unavailable (force=true bypasses). Repeating a DELETE for a
project that is already deleting retries the background work.
POST /pause writes a per-project pipeline pause flag. Workers stop picking
up new work for that project at their next quiesce point. paused_by lists
project and gpu_training (a training run holds the GPU for the whole
deployment). POST /resume clears the project flag and is idempotent.
A combine builds a new project from 1 to 8 source projects. Sources are only
read. The target is building while it fills, then active (or failed).
Deleting the target undoes the combine. A running combine marks its sources
and its target busy (project_busy).
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
| POST | /curation/projects/combine/preview |
CombineRequest |
CombinePreview |
|
| POST | /curation/projects/combine |
CombineStartRequest (CombineRequest plus expected_preview_sha) |
202 {job_id, target} |
409 preview_stale, 422 combine_invalid (with report), 409 slug_taken |
| GET | /curation/projects/combine/{job_id} |
CombineJobResponse |
404 combine_not_found |
|
| POST | /curation/projects/combine/{job_id}/cancel |
CombineJobResponse |
404 combine_not_found |
|
| POST | /curation/projects/combine/{job_id}/resume |
CombineJobResponse |
409 combine_not_resumable (only interrupted or cancelled), 404 combine_not_found |
CombineRequest:
| Field | Meaning |
|---|---|
target |
{slug, display_name, description} of the new project |
sources[] |
{project, include: {label_states: all or validated_only}}. Order is priority: the first source wins ties |
class_mapping |
{<source project>: [ClassMappingEntry]}, one row per source class (see Class identity) |
target_classes |
optional explicit target class names |
dedup |
content_hash (default) or none. Byte-identical images are copied once from the first-listed source; boxes of the duplicate merge by IoU (dedup_iou, default 0.9) and target class, with human over import over VLM over model |
holdout |
preserve_union (default), recompute (warns), none |
settings_from |
optional source project to copy settings from |
CombinePreview writes nothing and returns ok, errors[], warnings[]
(CombineIssue: code, severity, message, project, detail),
suggested_mapping, sources[], target, dedup, bytes and
preview_sha. Starting with a different body than the one previewed is
409 preview_stale. Warning codes: shard_budget_high, label_conflicts,
holdout_recompute_contamination, embedding_model_mismatch (a source's
stored vector dimension differs from the target encoder's; those vectors are
not copied and the items are deferred for re-embedding) and
region_profiles_differ (sources have different active region profiles).
CombineJobResponse: job_id, status (queued, running, completed,
failed, cancelled, interrupted), phase, done, total, report,
next_steps[], error, target, sources, started_at, finished_at.
Jobs are file-backed, chunked, marked interrupted after a restart and
resumable from the persisted plan.
Combined items carry origin_project, origin_item_id, origin_image_id,
origin_split, import_ids (the job), combine_conflict,
combine_conflict_origins and combine_merged_origins. A box that disagrees
on class keeps the priority label and is flagged combine_conflict; review
those with the combine_conflict filter. Nothing numbered in a source (class
ids, cluster ids, class history) crosses into the target.
| Method | Path | Response |
|---|---|---|
| GET | /curation/health |
GlobalHealthResponse: status (ok, degraded, down), triton, opensearch, vlm (the active endpoint), mlflow_public_url, version, api_version |
| GET | /health |
HealthResponse: the same facts plus project, registry and region_profile (name, display_name, display_name_singular, region_class_name, text_reader, reads_text, text_hint_enabled, limits.max_boxes_per_write; null when no profile is active) |
mlflow_public_url (OP_MLFLOW_PUBLIC_URL) is the browser-reachable MLflow
base to build run links from.
Per-project configuration is stored as documents in the project's config
index. Three axes are activated through the config store: the prompt pack
(prompt_pack), the region profile (detection_profile) and the VLM
endpoint (vlm). Activations are served to workers and applied at their next
quiesce point. A worker reports what it applies as applied[] on the
activation response. Each applied[] row always carries profile and pack
({name, revision}); vlm is null when the worker never reported a VLM axis
and {name: null, revision: null} when it reported no VLM configured.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /settings |
CurationSettingsResponse: defaults (map axis id to id), updated_at, updated_by (always null) |
|
| PUT | /settings |
{defaults: {<axis>: <id or null>}}, partial |
the updated CurationSettingsResponse |
defaults is an open map. A missing key means no override for the axis.
A project with no settings document answers 200 with defaults: {}.
Settable axes: cluster, sort, prompt_pack, detection_profile, vlm.
An unknown axis or an id that GET /methods does not advertise is 422.
null clears an override. A sort default that orders by a field no item
has is refused (422).
prompt_pack, detection_profile and vlm are not stored in the settings
document. A PUT activates them through the config store, in one call with
the same activation gate as the /activate routes. detection_profile
accepts off. vlm accepts an endpoint name, off, or null (the
deployment's env endpoint). The server resolves and gates every axis of one
request against the pending values of the others before writing any of them,
so a combined request cannot leave a half-applied pairing. Errors:
422 unknown_pack, 422 unknown_profile, 422 unknown_vlm,
422 validation_failed (with report), 409 active_conflict.
GET /methods derives each axis's default flag from the same resolver that
the endpoints use when a request omits the axis parameter. Setting a default
changes server behavior, not only the display.
GET /config/vocabulary (include_other_projects optional) is the one place
that serves every model choice a form needs:
| Field | Content |
|---|---|
detectors[] |
Triton detector models (name, source, ready, state, versions, job_id, promoted_at, choice) |
segmenters[] |
segmenter services (name, endpoint, status, masks, default_min_score, max_candidates) |
ocr |
available, det_models[], rec_models[], pipeline_models[] |
vlm |
active and endpoints[] from the registry |
text_reader_modes[] |
none, vlm, ocr, vlm_then_ocr, both, each with reads_text, needs_vlm, needs_ocr |
registry_classes[] |
the project's classes |
prompt_pack_calls[] |
the VLM calls a pack defines |
model_choices[] |
fixed roles with role, label, scope (per_request, per_run, region_profile, config_store, deployment), current, choices[], settable, settable_via, reason, dims |
labels |
display copy for served enums |
GET /methods returns {axes[], strategies[], flags}. Gate optional UI on
it instead of probing a write endpoint. strategies[] rows have axis
(cluster, score, sort, overlay, export, prompt_pack,
detection_profile, vlm), id, label, status (stable,
experimental, shadow, disabled), default, settable,
requires_field, field_coverage and field_coverage_total. A client shows
stable and experimental rows and hides a row whose field_coverage is
exactly 0.
The export axis lists the kinds POST /export/yolo and
POST /export/single_class can produce. The vlm axis lists every endpoint
plus off. Each vlm row adds endpoint_status (ready, unprobed,
probe_failed, unreachable), endpoint_status_label,
sends_images_externally, warning, default_ack_recorded and
per_run_ack_required (what a run enforces; see
VLM endpoints).
Per-project, configurable keyboard map. Action ids, contexts, the key grammar
and the locked and browser-reserved keys are served; a client does not
hardcode them. contracts/json/keymap_actions.json is the generated action
catalog.
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
| GET | /keymap |
KeymapGetResponse: project, revision, etag (keymap:N), is_default, updated_at, grammar, contexts[], actions[], overrides, reserved_hotkeys, issues[] |
||
| PUT | /keymap |
KeymapPutRequest: overrides (action id to combos), expected_revision, unbind_conflicting_class_hotkeys |
KeymapPutResponse (KeymapGetResponse plus unbound_class_hotkeys[]) |
409 revision_conflict, 409 class_hotkey_conflict, 422 validation_failed |
| POST | /keymap/validate |
{overrides} |
KeymapValidateResponse: ok, errors[], warnings[], force_allowed, resolved, reserved_hotkeys, class_conflicts[] |
|
| POST | /keymap/reset |
KeymapResetRequest: action_ids (all when omitted), expected_revision, unbind_conflicting_class_hotkeys |
KeymapPutResponse |
409 revision_conflict, 409 class_hotkey_conflict |
PUTreplaces the whole override map. An action absent fromoverridestakes its default.- The revision comes from
expected_revisionor anIf-Match: "keymap:N"header. If both are given they must agree. If neither is given the request is422. - A combo that collides with a class hotkey of the project is
409 class_hotkey_conflictwithclass_conflicts[]. Withunbind_conflicting_class_hotkeys: truethe server unbinds those class hotkeys and saves the keymap in one step, and rolls the registry back if the save fails.unbound_class_hotkeys[]reports what was unbound. - A write publishes
config.changedwithaxis: "keymap"on the project's event stream.
A prompt pack holds the prompts and reply contracts of every VLM call: classification, open-vocabulary classification, combined classify and verify, region verification, region visibility, per-class descriptions and synonyms. The built-in generic pack is read-only. Packs are per project.
| Method | Path | Body or query | Response | Errors |
|---|---|---|---|---|
| GET | /prompt_packs |
PromptPackList: packs[], templates[], active, config_revision, stale |
||
| POST | /prompt_packs |
{name, body, description} |
201 PromptPackDoc |
409 name_conflict, 422 validation_failed |
| GET | /prompt_packs/schema |
PromptPackSchema: calls[], fields[] (placeholders, expected reply keys), placeholders[], reply_key_contract |
||
| POST | /prompt_packs/validate |
{body, name}, query profile |
ValidationReport |
|
| POST | /prompt_packs/test |
PackTestRequest |
PackTestResponse |
see Test on crops |
| GET | /prompt_packs/active |
ActiveConfigResponse: axis, active, source (stored, env, off; with env, active.name is the env/file default in effect), activated_at, previous, applied[], config_revision, stale |
||
| POST | /prompt_packs/active/rollback |
{expected_active} |
ActiveConfigResponse |
409 no_previous, 409 previous_deleted, 409 active_conflict |
| GET | /prompt_packs/{name} |
PromptPackDoc (name, body, description, revision, source, read_only, active, cloned_from, etag) |
404 not_found |
|
| PUT | /prompt_packs/{name} |
{body, description, expected_revision} |
PromptPackDoc (a new revision) |
403 read_only, 409 revision_conflict, 422 validation_failed |
| DELETE | /prompt_packs/{name} |
query expected_revision (required) |
204 |
403 read_only, 409 in_use (the active pack), 409 revision_conflict |
| POST | /prompt_packs/{name}/activate |
{revision, expected_active, force} |
ActiveConfigResponse plus validation |
404 not_found, 409 active_conflict, 422 validation_failed |
| POST | /prompt_packs/{name}/clone |
{new_name, source, revision, from_project, description} |
201 PromptPackDoc |
409 name_conflict, 404 not_found |
| GET | /prompt_packs/{name}/revisions |
{name, revisions[]} |
404 not_found |
|
| GET | /prompt_packs/{name}/revisions/{revision} |
PromptPackDoc |
404 unknown_revision |
clonecopies from the built-in pack, a template file, a stored pack (source), or a stored pack of another project (from_project, read-only).- Validation checks names, required fields, placeholders, reply keys,
synonym and description targets, and example values. A pack that asks for
text with a text-free profile, or a single-box pack with a multi-box
profile (
max_regions_per_item > 1), is reported. Pairing errors that make the combination unusable are refused at activation and cannot be bypassed;forcebypasses only the bypassable ones. - Activating a pack validates it against the active region profile and VLM
endpoint. A per-run override is available as
?prompt_pack=<name>or<name>@<revision>on the labeling routes.
A region profile describes how the sub-regions of an item are found and read:
the optional detector leg, the optional segmenter leg (a text prompt), the
OCR and text reader, the parent classes it applies to, and
max_regions_per_item (1 to 64; 1 is the default and a single box).
| Method | Path | Body or query | Response | Errors |
|---|---|---|---|---|
| GET | /region_profiles |
include_templates |
RegionProfileList: profiles[], templates[], active, config_revision, stale |
|
| POST | /region_profiles |
{name, body, description} |
201 RegionProfileDoc |
409 name_conflict, 422 validation_failed |
| GET | /region_profiles/schema |
RegionProfileSchema: fields[], groups[]. A field type is one of string, int, float, bool, enum, string_list, int_list, float_pair, rgb. For type: enum the choices are in the sibling enum[] (static list); a dynamic list names its source in choices_from instead (detectors, segmenters, ocr_pipeline_models, ocr_rec_models, registry_classes, text_reader_modes) |
||
| POST | /region_profiles/validate |
{body, name}, query for_activation |
ValidationReport |
|
| POST | /region_profiles/validate_segmenter_prompt |
{text_prompt, sole_leg} |
ValidationReport |
|
| POST | /region_profiles/test |
RegionTestRequest |
RegionTestResponse |
see Test on crops |
| GET | /region_profiles/active |
ActiveConfigResponse (axis: detection_profile) |
||
| GET | /region_profiles/active/impact |
ActivationImpact |
||
| POST | /region_profiles/active/rollback |
{expected_active} |
ActiveConfigResponse |
409 no_previous, 409 previous_deleted, 409 active_conflict |
| POST | /region_profiles/deactivate |
{expected_active} |
ActiveConfigResponse (source: off) |
409 active_conflict |
| GET | /region_profiles/{name} |
RegionProfileDoc (body, effective, source, read_only, revision, active, etag) |
404 not_found |
|
| PUT | /region_profiles/{name} |
{body, description, expected_revision} |
RegionProfileDoc |
403 read_only, 404 not_found, 409 revision_conflict, 422 validation_failed |
| DELETE | /region_profiles/{name} |
query expected_revision (required) |
204 |
403 read_only, 409 in_use (the active profile), 409 revision_conflict |
| POST | /region_profiles/{name}/activate |
{revision, expected_active, force} |
ActiveConfigResponse plus impact and validation |
404 not_found, 409 active_conflict, 422 validation_failed |
| POST | /region_profiles/{name}/clone |
{new_name, ...} |
201 RegionProfileDoc |
409 name_conflict, 404 not_found |
| GET | /region_profiles/{name}/revisions |
{name, revisions[]} |
404 not_found |
|
| GET | /region_profiles/{name}/revisions/{revision} |
RegionProfileDoc |
404 unknown_revision |
RegionProfileDoc.effective reports what the profile actually does:
legs[], reads_text, segmenter_enabled, text_hint_active.
Activating a profile does not rewrite any item. ActivationImpact (returned
by /activate and by GET /region_profiles/active/impact) reports how many
items exist per profile and revision (by_profile[]), items_total,
validated_items, unseeded_items, pending_items, pending_not_matching
and stale_items. suggested_reprocess is a dry-run ReprocessRequest
that selects every unlocked, machine-written item that the active
profile and revision did not produce. Post it to POST /reprocess as is, or
change dry_run to apply it.
POST /region_profiles/active/rollback re-activates the previous activation.
The previous target must still exist (previous_deleted otherwise).
/region_profiles/deactivate turns the profile axis off. Rollback and
deactivate are activations, so they publish the same events and are subject
to expected_active.
Validation covers the detector model (it must exist, be ready, be owned by or
shared with this project, and its classes must map by name), the segmenter
prompt, OCR models and the text reader, parent_classes (known classes) and
the pairing with the active pack and endpoint.
POST /prompt_packs/test and POST /region_profiles/test run a draft or a
saved pack, profile or VLM endpoint against crops already in the project and
return what the worker would decide. They write nothing: no index update and no
enqueue.
POST /prompt_packs/test |
POST /region_profiles/test |
|
|---|---|---|
| Subject | pack_name, pack_revision or draft; call (combined, classify, open_classify, region_verify, region_visible, assist, attributes); crop_ids[]; use_region_box; class_names; profile_name |
one crop_id; profile_name, profile_revision or draft; segmenter_text_prompt; verify; prompt_pack_name, prompt_pack_revision or prompt_pack_draft |
| VLM source | vlm_name, vlm_revision or vlm_draft; acknowledge_external |
the same |
| Response | call, pack, vlm, prompt (exact request text), raw_reply, reasoning, latency_ms, parse_ok, parse_error, results[] (crop_id, box_id, parsed, preview_item, skipped), validation |
crop_id, profile, item_eligible, legs[] (leg: detector or segmenter; status; candidates[]), optional verify, preview_basis, preview_item, validation |
A test candidate (RegionTestCandidate) is a box wire plus candidate_index,
selected, drop_reason (below_min_score, nms, over_max), mask_iou,
bbox_in_parent, mask_polygon and mask_polygon_in_parent (at most 256
points). preview_basis is selection_accepted without a verify leg (every
selected box is shown as accepted) and vlm_verdicts with one.
The profile test does not run the OCR text-hint leg or text reading: a profile that reads text previews boxes only.
Limits: a crop id from another project is 404 crop_not_found; at most 4
concurrent segmenter calls and 2 concurrent VLM runs (429 test_busy); a 60
second bound (504 test_timeout); a crop cap (422 too_many_crops,
422 too_many_crop_ids); 422 no_box_to_verify; 422 pack_declares_no_attributes (assist or attributes on a pack with no item attributes); 422 pack_invalid and
422 profile_invalid carry report; upstream failures are
502 vlm_transport_error, 502 segmenter_error and 502 detector_error.
A VLM endpoint is an OpenAI-compatible chat endpoint with vision. The
endpoint registry is deployment-wide: an endpoint created in one project is
visible in all of them. Which endpoint a project uses is that project's
activation. The built-in env endpoint (OP_VLM_URL, OP_VLM_MODEL) is
listed first and is read-only. Clone it to get an editable copy. A project with no
activation calls no VLM (GET .../vlm/endpoints/active answers source: "off"); a
project opts into env by activating it like any endpoint.
| Method | Path | Body or query | Response | Errors |
|---|---|---|---|---|
| GET | /curation/vlm/endpoints |
VlmEndpointList: endpoints[] (VlmEndpointSummary), external_policy (ack or deny), secret_refs[], labels, config_revision, stale |
||
| POST | /curation/vlm/endpoints |
{name, body, description} |
201 VlmEndpointDoc |
409 name_conflict, 422 validation_failed |
| GET | /curation/vlm/endpoints/schema |
VlmEndpointSchema: form fields[] and groups[] (choices_from: secret_refs or vlm_catalog) |
||
| POST | /curation/vlm/endpoints/validate |
{body, name}, query probe |
VlmValidateResponse: validation, locality, sends_images_externally, optional probe. Always 200 |
|
| GET | /curation/vlm/endpoints/{name} |
VlmEndpointDoc |
404 not_found |
|
| PUT | /curation/vlm/endpoints/{name} |
{body, description, expected_revision} |
VlmEndpointDoc (a new revision) |
403 read_only, 409 revision_conflict, 422 validation_failed |
| DELETE | /curation/vlm/endpoints/{name} |
query expected_revision (required) |
204 |
403 read_only, 409 in_use (projects[]), 409 revision_conflict |
| POST | /curation/vlm/endpoints/{name}/clone |
{new_name, revision, description} |
201 VlmEndpointDoc |
409 name_conflict |
| POST | /curation/vlm/endpoints/{name}/probe |
VlmProbeResult |
429 probe_busy (more than 2 concurrent probes) |
|
| GET | /curation/vlm/endpoints/{name}/revisions |
{name, revisions[]} |
404 not_found |
|
| GET | /curation/vlm/endpoints/{name}/revisions/{revision} |
VlmEndpointDoc |
404 unknown_revision |
Names match ^[a-z0-9][a-z0-9_.-]{1,63}$. Reserved: env, off, none,
default, local, active, schema, validate, deactivate.
VlmEndpointBody: base_url, model, api_key_ref (secret:<slug>),
timeout_s, requests_per_second, max_images_per_call,
open_images_per_call, json_mode (auto, on, off), allow_external,
catalog_id.
- Keys are references only. The API serves
api_key_refandapi_key_present, never a key. Write a key on the host withopenprocessor vlm key set <slug>. - A probe sends synthetic images only. It records the served model root,
context length, image token cost, the server's image cap and JSON mode
support. A probe belongs to the revision and body it tested. A new revision
is
unprobeduntil probed. - URL policy: the stack's own services, link-local, metadata and unspecified addresses (in any notation, for the literal host and every resolved address) are refused. No request to an endpoint follows a redirect.
localityiscompose,host,private,externalorunknown. An endpoint outside this deployment (sends_images_externally) needs an acknowledgement.OP_VLM_EXTERNAL_POLICY=denyrefuses such endpoints outright. An acknowledgement is recorded per project and pername@revision.- Every registry write publishes the global event
vlm.changed.
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
| GET | /vlm/endpoints/active |
VlmActiveResponse (axis: vlm, active, source: stored, env or off, previous, applied[], config_revision, stale) |
||
| POST | /vlm/endpoints/{name}/activate |
{revision, expected_active, force, acknowledge_external} |
VlmActiveResponse plus validation |
404 not_found, 409 active_conflict, 422 validation_failed, 422 vlm_external_not_acknowledged (endpoint, activate_via) |
| POST | /vlm/endpoints/active/rollback |
{expected_active} |
VlmActiveResponse |
409 no_previous, 409 previous_deleted, 409 active_conflict |
| POST | /vlm/endpoints/deactivate |
{expected_active} |
VlmActiveResponse (source: off) |
409 active_conflict |
PUT /settings with defaults.vlm activates through the same gate. The
detection worker follows a project's VLM at its quiesce points. An
activation, a re-probe or a rollback swaps the labeler. A new revision of the
active endpoint changes nothing until it is activated.
Every path that selects or switches an endpoint calls one gate: activate,
rollback, PUT /settings, a clone's activation copy, a per-run ?vlm=, and
the draft tests. The gate checks:
- endpoint validation (URL, secret reference, external policy, and for an activation that a probe exists and did not fail);
- the external-images acknowledgement. Activation needs
acknowledge_externalor a recorded acknowledgement of that exactname@revision. Rollback, settings and clone need the recorded one. A one-off run needsacknowledge_externalunless the endpoint is the project's acknowledged default; - pairing of endpoint, prompt pack and region profile: an estimate of the context size of a labeler call, the server's own image cap, multi-box and text-reading verification, JSON mode.
force applies to activate only and bypasses only vlm_not_probed,
vlm_probe_failed and vlm_context_too_small. vlm_max_images_exceeds_server
is never bypassable.
The routes below take ?vlm=<name> or ?vlm=<name>@<revision> and
?acknowledge_external=true: POST /vlm/label_batch,
POST /vlm/verify_region_batch,
POST /vlm/region_visible_batch, POST /assist/runs,
POST /pipeline/auto_label and POST /pipeline/auto_label/start. An unknown
endpoint is 422 unknown_vlm (valid_ids). A project whose VLM is off is
409 vlm_not_configured. An endpoint that cannot be used is
409 vlm_endpoint_unavailable. /pipeline/auto_label/start pins the
resolved (name, revision) into the job.
Items record which endpoint answered: vlm_endpoint (name@revision),
vlm_model (the resolved model) and vlm_prompt_pack. region_verifier,
text_engine_version and the class detector/labeler provenance carry the
resolved model, not the configured name.
The catalog (examples/vlm/catalog.tsv) lists models that the in-compose
vLLM service can serve, with license, VRAM and disk needs, context and image
limits, and whether each is tested or to_verify.
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
| GET | /curation/vlm/catalog |
VlmCatalogResponse: entries[] (fits, serving, desired, vram_gb, disk_gb, max_model_len, max_images, license, gated, status), labels, local |
||
| GET | /curation/vlm/local |
VlmLocalStatus: configured, served, desired, restart_required, can_restart_from_api, gpu_total_gb, reason, poll_after_s |
||
| POST | /curation/vlm/local/select |
{catalog_id, force} |
202 VlmLocalStatus |
409 no_local_vlm, 422 unknown_catalog_id, 422 vlm_catalog_does_not_fit |
| DELETE | /curation/vlm/local/select |
VlmLocalStatus |
The API never restarts the VLM service. POST /curation/vlm/local/select
records the desired model and returns restart_required with the host
command. served changes only after that command ran and a probe recorded
the new model.
Host CLI: openprocessor vlm list, openprocessor vlm status,
openprocessor vlm use <id> [--force] [--yes], openprocessor vlm apply,
openprocessor vlm probe and openprocessor vlm key set <slug>. use checks
fit, handles the training lock and pause, rewrites the .env file (restoring
it on failure), waits for the new model, probes and unpauses.
An item is one detected object (a crop) in a source image. Every route that
returns an item returns the same shape, built by one serializer
(serialize_item in src/services/curation/wire.py). The wire names are
fixed. They do not change when a deployment overrides its storage field names
with OP_REGION_FIELD_*; the translation happens once at the boundary.
contracts/json/item_wire.json lists the exact key set (106 keys, all always
present, nullable where the schema says so) and contracts/ts/itemWire.ts is
the TypeScript form. tests/curation/test_wire_contract.py pins the key set.
| Group | Keys |
|---|---|
| Identity and geometry | id, crop_id, image_id, image_path, source_image_path, bbox_norm ([x1, y1, x2, y2] normalized to the source frame), thumbnail_url, source, updated_at, crop_rank_in_image, crop_area_norm, blur_lap_ratio |
| Class | class_id, class_name, class_source, confidence, class_confidence, class_confidence_source, label_source, label_validated, class_validated, class_detector, class_detector_version, class_labeled_at, class_labeler, label_locked, test_holdout, proposal_name, proposal_chain |
| Exclusion and review | class_excluded, excluded_reason, excluded_at, review_dismissed_at, needs_new_class, needs_new_class_note |
| VLM | vlm_confidence, vlm_prompt_pack, vlm_endpoint, vlm_model, vlm_class_attempted_at, vlm_class_empty_reason, vlm_raw_class, vlm_proposed_class_id, vlm_proposed_class_name, proposed_class_id, proposed_class_name |
| Cluster | cluster_id, cluster_kind, cluster_distance, cluster_similarity, cluster_is_core, cluster_nearest_id, cluster_subid |
| Probe and scores | probe_pred_class, probe_pred_class_id, probe_pred_entropy, probe_disagreement, probe_in_scope, probe_model_version, probe_actionable, mistakenness_score, mistakenness_method, mistakenness_version, mistakenness_scored_at, uniqueness_score, dup_group_id, dup_group_size, dup_is_representative |
| Region (item level) | region_status, region_reason, region_rejection_reason, region_validated, region_auto_confirmed, region_verified, region_verified_at, region_verifier, region_verifier_version, region_visible, region_detector_chain, region_detected_at, region_profile, region_profile_revision, region_class_id, region_label_source, region_pairing, region_skip_verify |
| Region boxes | region_boxes, region_count, region_rejected_count, region_max_score, region_set_complete, region_revision |
| Import and combine | import_ids, dataset_split, imported_at, proposed_by_import, on_negative_frame, import_standalone_region, origin_project, origin_item_id, origin_image_id, origin_split, combine_conflict, combine_conflict_origins, combine_merged_origins |
| Accuracy audit | audit_sample, audit_batch_id, audit_sampled_at, audit_label_name, audit_label_source, audit_outcome, audit_human_name (all null on an item never drawn into an audit) |
| Text | item_text_lines |
Endpoint-specific extra keys on top of the item:
| Endpoint | Items at | Extra keys |
|---|---|---|
GET /crops, GET /classes/{class_id}/crops |
crops[] |
none |
GET /crops/{crop_id} |
body | none |
GET /review/{tab} |
items[] |
reason |
GET /regions |
items[] |
region_box_id |
GET /regions/training_candidates |
items[] |
region_box_id, selection_reason |
GET /regions/suspected_false_positives |
items[] |
region_box_id, suspected_fp_distance, nearest_fp_subid |
GET /search/text |
items[] |
semantic_score |
label_validatedisclass_validatedorregion_validated.label_lockedis the server's evaluation of the lock rule for the whole item. A client disables nothing on its own reading of provenance fields; it reads this flag.confidenceis the detector or classifier score stored at ingest, whatever wrote the current label.class_confidenceandclass_confidence_sourceare the confidence of the writer that set the label. For a VLM source the VLM category (vlm_confidence) maps high0.92, medium0.70, low0.40with sourcevlm. For a classifier source it is the stored score with sourcemodel. Human, move, merge, import, cluster-vote and unclassified-proposal labels havenull.cluster_kindisclass,candidateorunassigned, fromcluster_id(see Clusters).cluster_similarityis1 - cluster_distance.cluster_is_coreis similarity at or abovecore_similarity_min(served onGET /clusters, default0.75). When the distance was measured against a cluster the item has since left, the three distance keys arenull.proposed_class_idandproposed_class_nameare the class a one-key confirm applies, on every item endpoint: the VLM suggestion when there is one, elseclass_idandvlm_raw_classorclass_nameor"".probe_actionableis the server's accept decision for the probe's top-1 class. It istrueonly whenprobe_in_scopeandprobe_disagreementaretrueand the probe's confidence is at leastOP_PROBE_ACTIONABLE_MIN_CONFIDENCE(default 0.5, echoed asactionable_min_confidenceonGET /probe/status). Offer "accept model class" only when it istrue.probe_disagreementisnullwhen the item's class is outside the probe's class set.region_validatedis human validation only. The detection worker's auto-confirm setsregion_auto_confirmedinstead: the region is accepted but unreviewed and stays in theregionsreview tab.thumbnail_urland each box'sthumbnail_urlare built from the configured prefix, soOP_API_PREFIXand a frontend proxy prefix must match.item_text_linesis every OCR line read on the item crop ({text, box_norm, confidence, rel_height}),[]when none. It is filled only when item text is enabled (OP_ITEM_TEXT_ENABLED; lines belowOP_ITEM_TEXT_MIN_CONFIDENCEare not stored). The normalized search tokens are storage-only and back theitem_textfilter ofGET /crops.
vlm_proposed_class_id and vlm_proposed_class_name are derived from the
stored document:
| Stored state | vlm_proposed_class_id |
vlm_proposed_class_name |
|---|---|---|
class_source is vlm or vlm_reclassified, class_validated false, class_id set |
class_id |
class_name |
class_source is vlm_new_class_pending, class_validated false |
null |
the proposed new class name (null if absent) |
anything else, including vlm_unmatched and every validated class |
null |
null |
vlm_class_attempted_at and vlm_class_empty_reason record when a VLM was
last asked and why it gave no class: no_answer, no_match, invalid_index
or unparseable; null when it answered. An empty answer leaves every class
field as it was. It is not vlm_unmatched, which means the VLM named a label
outside the registry (carried in vlm_raw_class). Such items stay out of the
VLM selectors for 24 hours.
Accepting a suggestion has no dedicated route:
- a registry class:
PUT /crops/{crop_id}/labelorPUT /crops/batch_labelwithclass_idset tovlm_proposed_class_id(label_source: "human_confirmed"marks an accepted suggestion); - a new class:
POST /classeswith{name}set tovlm_proposed_class_name(409if it exists), then label with the newclass_id. To resolve every item that proposes one name at once, usePOST /review/new_class_proposals/resolve.
GET /class_sources returns {class_sources: [{id, label, role, short_label}]}, every class_source value this deployment can write.
Ingest values come first and derive from the configured ingest profiles
(OP_INGEST_PRIMARY_*, OP_INGEST_SECONDARY_*). role is one of
proposal, low_conf, model, vlm, vlm_unmatched,
vlm_new_class_pending, vlm_reclassified, cluster, human, merge,
label_import. Label routes take a caller-chosen label_source, so a
client renders an id it does not know verbatim. contracts/ts/classSources.ts
is generated from the catalog.
Fixed ids include vlm, vlm_unmatched, vlm_new_class_pending,
vlm_reclassified, cluster_majority_agreement, human, human_move,
class_merge and external_label (a validated dataset import).
Regions are a list per item. One region is a list of one; there is no separate single-box shape. Each element:
| Key | Meaning |
|---|---|
box_id |
b<N>, stable within the item and never reused after a delete |
bbox_norm |
[x1, y1, x2, y2] in the source-image frame, always |
state |
proposed, accepted, rejected or false_positive |
score, detector, detector_version, source, detected_at |
provenance. A box a human draws or moves has detector set to the profile's human detector name, score 1.0 and source human |
bbox_correct, confidence |
the verifier's verdict on the geometry and its category; bbox_correct is null when no verdict was given |
rejection_reason |
why the box is rejected (see below) |
text, text_raw, text_source, text_engine_version, text_confidence, text_vlm, text_ocr, text_choice, text_vlm_invalid, text_disagreement |
per-box text, only when the region profile reads text |
cluster_id, cluster_subid, cluster_distance |
per-box region cluster placement |
locked |
server-derived: the lock rule for this box |
bbox_in_parent |
server-derived: the box in the item-crop frame, clamped to [0, 1]; null when the item has no usable bbox_norm. Draw it on the item thumbnail as is |
thumbnail_url |
server-derived: GET /crops/{crop_id}/region_thumbnail?box_id=<box_id> |
Item-level summaries: region_count counts accepted boxes,
region_rejected_count counts rejected ones, region_max_score is the best
score, region_set_complete says the worker finished the set, and
region_revision is the token for expected_region_revision. It advances on
every write that changes what an editor sees.
Embeddings per box live in a separate nested field (region_box_embeddings)
that is not on the wire. A box a human moved is recognized as stale and
re-embedded.
The item region_status is derived from the boxes in a fixed order:
detected if any box is accepted, else false_positive if any box is
false_positive, else pending_verification if any is proposed, else
verify_rejected if any is rejected, else the empty status. The item-level
region_rejection_reason mirrors the best rejected box only when the item
has no accepted or false_positive box.
A verifier-rejected box is kept with state: rejected. Its
rejection_reason is region_visible_elsewhere (the verifier says the
region is elsewhere), sanity_reject:<gate reason> (the geometry gate),
verifier_no_verdict (no verdict after the allowed attempts), or the
human reason. GET /regions/vocabulary serves these as rejection_reasons[]
(id, label, kind of model_verdict, automatic, needs_human or
human, match of exact or prefix, label_template). A rejected box is
never an accepted region: browse, clustering and export ignore it. A human
reverses a rejection with a confirm write; region undo restores it.
A box's text is the chosen reading. Which reader fills it is the region
profile's text_reader:
text_reader |
Region OCR runs | text |
|---|---|---|
none |
never | no text on the box |
vlm |
only when no VLM is configured | the VLM's reading |
ocr |
always | the OCR reading (the VLM's if OCR read nothing) |
vlm_then_ocr (default) |
when the VLM read nothing | the VLM's, else the OCR's |
both |
always | the VLM's, else the OCR's |
text_sourceisvlm,ocrorhuman.text_engine_versionis the VLM model id or the OCR detector and recognizer ids.text_confidenceis the VLM category mapped to 0.92, 0.70 or 0.40, or the minimum recognition score of the kept OCR lines.text_rawis every OCR line, unfiltered.text_vlmandtext_ocrare each reader's own reading.text_disagreementistrueorfalsewhen both valid readings exist, elsenull.text_choiceis why the chosen reading won:readers_agree,vlm_preferred,vlm_only,ocr_only,ocr_mode,vlm_invalid,no_valid_reading(textisnull) orhuman.text_vlm_invalidsays why a VLM reading is not text:placeholder,no_reading,sequence,charset,too_short,too_long,format.- The rules and the choice values are served as
text_rulesandtext_choicesonGET /regions/vocabulary(nullrules without a profile). A reading that is a "no reading" word, a placeholder from the active pack, a repeated or ascending sequence (when the profile rejects them), or outside the profile's charset, length or format is not text. - With no VLM configured the worker calls no VLM. Detector regions are
written
detectedwithregion_verifiedfalse (chain entry<src>:accepted_unverified) and their text comes from OCR. - The VLM's reply keys
region_bbox_correct,region_confidenceandregion_textare a fixed protocol of the prompt packs. They are stored as the box'sbbox_correct,confidenceandtext. scripts/curation/rederive_region_text.pyre-applies the rules to stored box text (dry run by default).
A list of strings, oldest first, each <actor>:<event> with no version or
timestamp, unique per document and capped at 16. <det> is the profile's
detector, <seg> its segmenter, <ocr> its OCR recognizer and <src>
whichever produced the candidate.
| Entry | Meaning |
|---|---|
<det>:hit, <det>:miss |
the detector found, or found no, candidate |
<seg>:hit, <seg>:miss |
the segmenter found, or found no, candidate |
vlm_visible:yes, vlm_visible:no |
the VLM pre-filter saw, or did not see, a region |
vlm_visible:no_verdict |
no verdict after the allowed attempts; the item goes on to detection |
<src>:combined_verify_ok |
the VLM confirmed the candidate (written detected) |
<src>:combined_verify_reject, ...:region_visible_elsewhere, ...:verifier_no_verdict |
the VLM rejected the candidate, with the reason |
<src>:vlm_reject:verifier_no_verdict |
the same, from the per-crop cascade |
<src>:combined_no_region_visible |
the VLM sees no region at all |
<src>:sanity_reject:<reason> |
the geometry gate refused the box |
<seg>:skip_vlm_verify |
a high-score segmenter box written without a VLM call |
<src>:accepted_unverified |
no VLM configured |
<ocr>:text_hint:hit, :miss, :no_region_shape, <seg>:text_hint:miss |
the OCR-hinted segmenter re-pass |
A VLM reply that sees a region but gives no box verdict is not a reject.
Nothing is written and the item stays pending for a retry. Retries are
bounded per item and stage by OP_REGION_WORKER_MAX_NO_VERDICT_ATTEMPTS
(default 3). At the cap the combined stage writes verify_rejected with
verifier_no_verdict and keeps the box rejected so a human can confirm it.
A transport failure (no reply) is retried and never counted.
Readers match whole entries with term queries. For example
GET /regions/training_candidates?mode=detector_blind_spots requires
<det>:miss.
GET /regions/statuses serves the lifecycle. The source is
src/config/region_state.py, also emitted to contracts/ts/regionStatus.ts.
{"statuses": [{"value": "no_region_visible", "label": "no region visible", "role": "absent",
"terminal": true, "human_writable": true, "clears_box": true, "wants_reason": true}],
"confirm_status": "detected", "reject_status": "no_region_visible",
"false_positive_status": "false_positive",
"box_states": [{"value": "accepted", "label": "accepted", "role": "accepted", "tone": "accepted",
"human_writable": true, "exported": true, "dashed": false, "dim": false, "badge": null}],
"box_state_routes": [{"route": "PUT /crops/{crop_id}/regions",
"states": ["proposed", "accepted", "rejected", "false_positive"],
"new_box_default": "accepted"}]}Item statuses: pending_detection, pending_verification, detected,
verify_rejected, no_region_box, no_region_visible, detection_failed,
false_positive. role is pending, positive, rejected, absent,
false_positive or failed. tone is a semantic meaning, not a color.
box_state_routes lists which box states each write route accepts and the
default state of a new box.
| Method | Path | Response |
|---|---|---|
| GET | /crops |
CropsPageResponse: crops[], total, page, page_size, method, version, n_pool |
| GET | /crops/{crop_id} |
item (404 when unknown) |
| GET | /crops/{crop_id}/context |
CropContextResponse: image (image_id, image_path, width, height, source, indexed_at; null when unknown) and items[]: every item on the frame, crop_rank_in_image ascending, at most 500. Items carry mask_polygon here (lists send it as null), so one call draws every sibling outline |
| GET | /crops/{crop_id}/history |
{crop_id, entries[]}: class_id_history oldest first |
| GET | /classes/{class_id}/crops |
CropsPageResponse (page, page_size, include_test) |
GET /crops query parameters:
| Parameter | Meaning |
|---|---|
page, page_size (1 to 500, default 50), limit |
paging. limit is an alias of page_size and wins when both are set |
sort |
<field>[:asc|desc], default updated_at:desc. Fields: updated_at, created_at, confidence, crop_rank_in_image, crop_area_norm, blur_lap_ratio, cluster_distance, mistakenness_score, uniqueness_score. Anything else is 400. Ignored by order=outliers and order=diverse |
cluster_id, cluster_subid, label_source, class_source, label_validated, source |
exact filters (cluster_subid is one AHC sub-cluster such as 10000b; it embeds the cluster id, so it works without cluster_id). A class is filtered by name (class_name, repeatable; the shared item filter): class_id is not a query parameter. GET /classes/{class_id}/crops is the by-id listing |
needs_new_class, review_dismissed |
boolean filters |
ids |
comma-separated, at most 500. Returns exactly those items in that order, drops missing ids and ignores every other filter |
include_test, include_excluded |
include frozen test-holdout and excluded items |
max_rank, min_blur_ratio, classifier_conf_lt, conf_min, conf_max |
numeric bands (400 if conf_min is above conf_max) |
order |
default, outliers, core_first, diverse. outliers and core_first need cluster_id and order by distance to the live centroid of the matched members. Under core_first each item's cluster distance keys are recomputed against that centroid. method reports the order that ran |
k |
1 to 10000, order=diverse only: the first k k-center-greedy picks; total is then k |
item_text |
at most 200 characters. Every letter or digit word must be a case-insensitive prefix of one of the item's text tokens. A query with no letter or digit is 400 |
import_id |
items whose label or proposal came from that dataset import |
dataset_split |
train, val or test as imported |
on_negative_frame |
true keeps only items on a reviewed-negative frame, false hides them |
proposed_by_import |
true keeps items an import proposed, false hides them |
Every write below is a recorded human write: it snapshots the item's
pre-write class state into class_id_history, so the undo routes can restore
it. Human writes are not blocked by the lock rule. The lock
applies to automated writers.
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| PUT | /crops/{crop_id}/label |
CropLabelRequest: class_id, label_source (human default, human_confirmed, new_class_proposal) |
{crop_id, class_id, class_name} |
400 for an unknown class_id, 404 for an unknown crop. The server always writes class_source: "human" and class_validated: true |
| PUT | /crops/batch_label |
CropBatchLabelRequest: crop_ids, class_id, label_source |
{updated, updated_ids, conflicts[]} |
conflicts is [{crop_id, current_source}], not written. updated_ids are the ids to pass to undo_batch |
| POST | /crops/move |
CropMoveRequest: crop_ids, cluster_id |
{updated, updated_ids, conflicts[]} |
a class cluster is also a relabel (class_source: human_move, validated); a candidate cluster is placement only (a human-owned class is cleared, a machine suggestion is kept, nothing is validated). 400 for an unassigned (negative) target, an unknown class id or a candidate with no members |
| POST | /crops/flag_new_class |
{crop_ids, note} |
{flagged, errors} |
sets needs_new_class |
| POST | /crops/batch_exclude |
{crop_ids, reason} or a selection; dry_run |
{excluded, updated_ids, errors} (dry run: {dry_run, selected}) |
sets class_excluded, moves the item to cluster -2, clears class_validated and records the prior state |
| POST | /crops/batch_unexclude |
{crop_ids} or a selection; dry_run |
{unexcluded, updated_ids, errors} |
updated_ids are the items actually restored (an item that was not excluded is in neither the count nor the ids). Restores the recorded state: a validated item returns to cluster_id == class_id, an unvalidated one to the residual pool. Items that are not excluded are untouched |
| POST | /crops/{crop_id}/discard |
{clear_class: true, dismiss_from_review: false}, 422 if both false |
the item | clears class, provenance and validation and drops the item to the residual pool, and/or hides it from every review tab. Recorded and undoable |
| POST | /crops/discard_batch |
{crop_ids, clear_class, dismiss_from_review} |
{items[], discarded, conflicts, not_found} |
|
| POST | /crops/{crop_id}/review_dismiss |
{crop_id, dismissed} |
one-way review hide, not recorded. Prefer discard with clear_class: false and dismiss_from_review: true |
|
| POST | /crops/{crop_id}/review_undismiss |
the item | clears review_dismissed_at |
|
| POST | /crops/{crop_id}/vlm_dismiss |
the item | rejects the VLM's class suggestion. While the VLM's suggestion is that one the suggestion keys are null. A different later suggestion shows again. 409 when there is no suggestion |
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
| POST | /crops/{crop_id}/label/undo |
the restored item | 404 unknown crop, 409 nothing to undo |
|
| POST | /crops/label/undo_batch |
{crop_ids} |
{items[], undone, nothing_to_undo, conflicts, not_found} |
409 when no crop had anything to undo |
| DELETE | /crops/{crop_id}/label |
{crop_id, reset} |
the same restore. With nothing on record it resets the crop to unlabeled instead of 409. It is itself an undo, so it is not recorded |
|
| POST | /crops/{crop_id}/region/undo |
the restored item | 404, 409 nothing to undo |
|
| POST | /crops/region/undo_batch |
{crop_ids} |
{items[], undone, nothing_to_undo, conflicts, not_found} |
409 when no crop had anything to undo |
| POST | /crops/{crop_id}/vlm_dismiss/undo |
the restored item | 409 no dismissal to undo |
- A class snapshot holds
class_id,class_name,class_source,label_source,confidence,class_detector,class_detector_version,class_labeler,class_labeled_at,class_validated,cluster_idandcluster_subid. Repeated undo steps back through successive human writes. A restored validated class sits in its class cluster (cluster_id == class_id); anything else returns to the cluster recorded before the write (nullis the residual pool). Undo on an excluded item keeps it excluded. - A region snapshot holds
region_boxeswith its summaries,region_status,region_verified*,region_verifier*,region_validated,region_label_source,region_detected_atandregion_rejection_reason. Class fields are untouched. - History entries (
GET /crops/{crop_id}/history) addwriter(for examplehuman:label_crop,human:discard_crop,class_merge,vlm_pipeline) andat. Keys that were not recorded arenull. - Which route to call: label or confirm with
PUT /crops/{crop_id}/label; hide or clear withPOST /crops/{crop_id}/discard; revert with the undo routes.
The region write routes, GET /regions, GET /regions/training_candidates, GET /regions/suspected_false_positives, POST /regions/cluster, POST /regions/fp_centroids/build, the region undo routes and the region
cluster card and refine routes need an active region profile. Without one they
answer 409 {detail: {error: "no_active_profile", message: "no region profile is configured"}} (published as RegionProfileUnavailableResponse on every such route).
These are the only human routes that write box geometry or per-box state. Every one builds its update through the same code, snapshots the pre-write region state for region undo, and returns the post-write item so a client adopts it instead of re-deriving it.
| Method | Path | Body | Response |
|---|---|---|---|
| PUT | /crops/{crop_id}/regions |
ItemRegionsRequest |
{crop_id, item} |
| PUT | /crops/batch_regions |
ItemBatchRegionsRequest |
{updated, conflicts[], invalid[], items[]} |
| PATCH | /crops/{crop_id}/regions/{box_id} |
BoxPatchRequest |
{crop_id, box_id, item} |
| POST | /regions/batch_box_state |
BatchBoxStateRequest |
{updated, conflicts[], invalid[], items[]} (rows: one per targeted box, with region_box_id) |
| PATCH | /crops/{crop_id}/region_meta |
ItemRegionMetaRequest |
{crop_id, updated_fields[], item} |
| POST | /regions/batch_status |
CropBatchStatusRequest |
{updated, conflicts[], invalid[], items[]} |
PUT /crops/{crop_id}/regions sets the full box list, in display order.
An element (BoxWriteElement: box_id, bbox_norm, state, text; extra
keys are 422) is:
| Element | Effect |
|---|---|
{box_id} alone |
the stored box is untouched |
{box_id, bbox_norm} |
a moved box. Within 1e-4 per coordinate of the stored box it is a confirmation: the stored coordinates and provenance are kept. A different box is human geometry: detector is the human detector, score is 1.0, source is human, the verdict keys are cleared. State and text stay |
{box_id, state} or {box_id, bbox_norm, state} |
also changes the state |
{box_id: null, bbox_norm, state?} |
a new box. The server assigns the next b<N>. The default state is accepted |
| a stored box that is omitted | deleted |
Other fields: frame (source default; parent is the item crop's own
frame, projected through the item's stored bbox_norm, 422 if the item has
none), region_status (a whole-set status applied to the built list in the
same write), region_label_source (default human) and
expected_region_revision. An empty list is the human "no region visible".
PUT /crops/batch_regions replaces each crop's list with the same new boxes
(typically []). Every element must have box_id: null (422 box_id_in_batch).
PATCH /crops/{crop_id}/regions/{box_id} takes state, text,
region_label_source and expected_region_revision. At least one of state
and text is required (400). Siblings are untouched. Reversing a rejection
of one box is {"state": "accepted"}, which keeps the box's provenance and
clears its rejection reason.
POST /regions/batch_box_state flips one state on named boxes across items
(targets: [{crop_id, box_id}], state, region_label_source,
expected_region_revisions keyed by crop id). Only the named boxes change.
PATCH /crops/{crop_id}/region_meta takes region_status,
region_rejection_reason and region_label_source; only provided fields are
written. POST /regions/batch_status takes crop_ids, region_status and
region_label_source and flips every box of each item. The deprecated
region_verified key is accepted and ignored: the server derives it.
Errors for the box routes:
| Status | Cause |
|---|---|
400 |
PATCH with neither state nor text; a region_status that is not human-writable on region_meta and batch_status |
404 |
unknown crop |
409 region_conflict |
stale expected_region_revision; the body carries current_region_revision, current_box_ids and the current item |
409 |
no region profile is active |
422 too_many_boxes |
more elements than limits.max_boxes_per_write (OP_REGION_MAX_BOXES_PER_WRITE, default 500; served on GET /health and GET /regions/vocabulary) |
422 region_text_disabled |
a text element on a profile whose text_reader is none |
422 |
a state not allowed on that route, an out-of-range or degenerate box, a new box without bbox_norm, a duplicate or unknown box_id, no_region_visible together with boxes, detected with no accepted or confirmable box |
In the batch routes a per-crop refusal is reported in invalid[] ({crop_id, detail}) and a stale revision in conflicts[]. The rest are written.
Every human region writer enforces, server-side:
- a status with
clears_box(no_region_visible) emptiesregion_boxes, whichever writer set it. An empty list written byPUTis that same status; region_verifiedistrueexactly whenregion_statusisdetected. It is never taken from the request;detectedon an item with no accepted or confirmable box is refused. A whole-set confirm settles theproposedboxes and never overrides a per-box decision. When nothing isproposedoracceptedit reopens only the boxes the verifier rejected. A human's own reject and a sanity-gate reject are never reopened by it;- human writes set
region_validated. A box write that leaves aproposedbox is a partial review and leaves it as stored; - a box that becomes
false_positiveis parked in the permanent FP cluster (id-100); one that leaves it is released; - a write that re-asserts the stored status re-derives nothing: cluster placement stays;
- a human reject stamps the box as human-owned; a human-typed text sets
text_sourcetohuman.
Writable item statuses are the human_writable rows of
GET /regions/statuses: detected, verify_rejected, no_region_visible
and false_positive.
| Method | Path | Notes |
|---|---|---|
| GET | /regions |
RegionRowPage. Filters: page, page_size (max 200), cluster_id (the item cluster), region_cluster_id, region_cluster_subid, sort_by_subid, max_rank, min_score, max_score, verified, detector, text, box_state, status, include_test |
| GET | /regions/statuses |
the lifecycle vocabulary (see above) |
| GET | /regions/vocabulary |
RegionVocabularyResponse: detectors[] (id, label, role, filterable), region_sources[], chain_actors[], rejection_reasons[], text_rules, text_choices, region_profile summary. Built from the active profile, the ingest profiles and the VLM registry, never from a fixed model id |
| GET | /regions/training_candidates |
RegionRowPage, mode required |
| GET | /regions/suspected_false_positives |
RegionRowPage. threshold (0 to 2) is optional; omitted, the server applies default_threshold (0.35) and serves both |
| POST | /regions/cluster |
job. Query max_rank, auto_fp_threshold (default 0.2), rebuild_fp_centroids (default true), force_repartition |
| GET | /regions/cluster/status |
job state |
| GET | /regions/clusters |
cluster cards (max_clusters, per_cluster, max_rank) |
| POST | /regions/clusters/refine/{cluster_id} |
AHC refine of one region cluster |
| POST | /regions/fp_centroids/build |
rebuild the false-positive sub-centroids from FP boxes |
| GET | /regions/fp_centroids/status |
|
| GET | /crops/{crop_id}/region_thumbnail |
box_id is required (422 box_id_required, 404 unknown_box_id), size 32 to 512. Renders that box, also a rejected one |
Rows. GET /regions, /regions/training_candidates,
/regions/suspected_false_positives, the representatives of
/regions/clusters and the items of the batch box routes are rows: the full
wire item plus region_box_id (null for an item-level row). total counts
items (hasMore = page * page_size < total), total_rows counts rows, and a
page returns every row of its items. rows_truncated is true when an item
on the page matched more boxes than index.max_inner_result_window reports,
so some of its rows are missing.
GET /regions filters. The box filters (detector, min_score,
max_score, text, region_cluster_id, region_cluster_subid,
box_state) all apply to the same box, and each matching box is its own row.
With no box filter and no status the rows are the accepted and
false_positive boxes. The item filters (status, cluster_id,
verified, max_rank) select items. status is any value from
GET /regions/statuses (400 otherwise) and lists every item in that status
as item rows. box_state is proposed, accepted, rejected or
false_positive (400 otherwise).
training_candidates modes:
| Mode | Unit | Selects |
|---|---|---|
detector_blind_spots |
box | the detector missed and the segmenter found it (<det>:miss in the chain) |
low_conf_correct |
box | accepted boxes with a low score |
false_positives |
box | boxes a human marked false positive |
disagreement |
item | detector and segmenter both fired (<det>:hit and <seg>:hit) |
human_corrected |
item | a human moved or drew the box over a machine proposal |
An unknown mode is 400. Each row's selection_reason states why it was
selected. GET /training_cohorts serves the same cohorts as links.
GET /regions/suspected_false_positives scores boxes: an accepted box with a
vector, of an item that is not test-holdout and not human-decided, and not
itself a human's or an import's box. Each row is one box with
suspected_fp_distance and nearest_fp_subid. It pages rows directly, so
total == total_rows. The matcher is double-layered: the build sub-types the
false-positive boxes into k sub-clusters and keeps one centroid per
sub-type. A candidate matches the nearest of them. Re-run the build after
marking a batch of false positives.
Region clustering is over boxes. A card's size is the number of items with
at least one box in the cluster and box_count is the number of boxes.
representatives are rows for the boxes nearest the centroid, next to
representative_crop_ids, representative_box_ids and
representative_thumb_urls. The permanent false-positive cluster (-100)
pins first. The count fields name their unit: the cluster job result has
n_boxes, n_boxes_changed and n_items_written; a region refine has
n_boxes and n_boxes_updated; an item refine has n_items and
n_items_updated; the centroid build has n_boxes; the automatic FP pull
has n_boxes_scanned and n_boxes_moved.
Automated region writers (worker, clustering, reprocess) never touch a locked box or a human-final item (see the lock rule).
GET /training_cohorts?class_id= returns {cohorts: [{id, label, description, cutoffs, endpoint, params, row_kind}]}. Fetch a cohort's rows
with GET {endpoint} and params. Core cohorts are validated,
needs_labeling, low_confidence (cutoffs.classifier_conf_lt, the
backend's review band) and model_disagreements (row_kind: crop). With a
region profile active, the /regions/training_candidates modes follow
(row_kind: region).
One definition (src/services/curation/dataset_thresholds.py) serves both
training preflight and every count display:
"thresholds": {"block_below": 20, "warn_below": 500, "min_test_per_class": 5,
"min_train_per_class": 1, "min_val_per_class": 1,
"aug_target_min": 500, "aug_target_max": 3000}adequacy is block below block_below validated items, warn below
warn_below, else ok. aug_target is the validated count clamped to
[aug_target_min, aug_target_max]; aug_gap is aug_target minus the
validated count. trainable is validated minus test-holdout minus excluded;
trainable_gap is max(0, block_below - trainable). These appear on
GET /classes (thresholds, per-class adequacy, trainable,
trainable_gap), GET /stats/classes (also aug_target, aug_gap),
POST /train/preflight and GET /test_holdout/stats
(min_test_per_class and per-class deficient).
GET /review/tabs serves the tab catalog:
{tabs: [{id, label, description, filters, filter_defaults, filter_specs}], empty_state}.
filters is the list of query parameters a tab honors (a parameter that is
not listed is accepted and ignored). filter_defaults is the value applied
when a parameter is omitted. filter_specs self-describes EVERY filter in
filters ({param, kind, label, options: [{value, label}], min, max, description}) so a client renders it generically. kind is enum (pick
one of options), multi_enum (repeatable parameter, any of options:
origin, embedding_state, review_status), class_names (repeatable
class NAME, no fixed options: the names come from GET /classes and the
detector labels of GET /ingest/config), bool, number / integer
(min, max) or text. empty_state is {has_probe_predictions, has_item_scores, has_imported_labels, has_unembedded_items, suggested_reprocess}: suggested_reprocess is the dry-run embed
ReprocessRequest exactly when has_unembedded_items, so an empty queue
can offer the embed action. The regions tab is offered only
while a region profile is active.
| Method | Path | Notes |
|---|---|---|
| GET | /review/tabs |
ReviewTabsResponse |
| GET | /review/{tab} |
ReviewQueuePage: items[] (item plus reason), total, page, page_size, sort_applied, sort_fallback_reason, empty_reason |
| GET | /review/{tab}/locate |
ReviewLocateResponse: where one item sits in a tab. crop_id required. Same filters and sort as the queue plus page_size |
| GET | /review/new_class_proposals/summary |
size, samples. Counts and terms behind the new_class_proposals tab |
| POST | /review/new_class_proposals/resolve |
bulk-resolve every item proposing one name. dry_run query |
| GET | /review/raw_label_clusters |
size, samples_per_cluster. Groups of unmatched raw VLM answers |
| GET | /review/unmatched_terms |
size. Most common unmatched answers |
Tabs:
| Tab | Selects |
|---|---|
all |
the unified queue, most uncertain first. Includes combine_conflict items |
mismatches |
the VLM's reply did not match any registry class. reason says why: no match, a named registry class at low confidence that was not applied, or no class answer |
vlm_low_conf |
a VLM-sourced label whose vlm_confidence is medium or low |
detector_disagreements |
unvalidated items whose VLM class differs from the class the detector gave them (detector_class_name); sorted by vlm_confidence from least to most confident (no recorded confidence first), then detector confidence high to low |
outliers |
far from the cluster centroid |
uncertainty |
high probe entropy |
model_disagreements |
validated items where the probe disagrees with the human label |
regions |
items with an accepted but unvalidated box, plus verifier-rejected items (see region_status) |
primary_low_conf, classifier_blind_spots |
low-confidence and blind-spot items of the primary subject. max_rank defaults to 2 |
new_class_proposals |
items flagged needs_new_class by a human, or class_source: vlm_new_class_pending. Excludes items whose last VLM attempt had no answer and items that already have a class |
imported |
validated labels a dataset import wrote (import_id, dataset_split) |
Common filters: include_test, max_rank, min_blur_ratio,
min_mistakenness, hide_near_duplicates, class_name and
exclude_class_name (repeatable), source, conf_min, conf_max, min_area, max_area,
origin, embedding_state, review_status (400 if conf_min is above conf_max),
combine_conflict, on_negative_frame, sort, page, page_size (max
200). Tab-only filters: text and region_status on regions; import_id
and dataset_split on imported.
region_status on the regions tab:
| Value | Items served |
|---|---|
all (default) |
an accepted but unvalidated box, plus verify_rejected items with at least one rejected box |
detected |
an accepted box only |
verify_rejected |
items with only rejected boxes |
has_rejected_box |
any rejected box, whatever the item status (region_rejected_count >= 1) |
false_positive and no_region_visible items never appear in the default
mode. Any other value is 400. A rejected candidate's reason names the
rejection (needs human review: ... for needs_human reasons, rejected: ... otherwise).
Sort. An explicit sort is honored even when its field has no coverage.
When omitted or default, the tab's own default applies (a deployment sort
default from PUT /settings applies only to a tab without one). If that
default orders by a field no item has, the queue falls back to the tab's next
covered sort, ending at recent. sort_applied names the sort that ran and
sort_fallback_reason says why the default was skipped. PUT /settings
refuses a sort default whose field has no coverage. A zero-result page
carries empty_reason, worded from live index state.
GET /review/{tab}/locate returns {crop_id, in_queue, rank, page, page_size, total, reason, sort_applied, sort_fallback_reason}. rank is
0-based and page is 1-based. Outside the queue both are null and reason
is not_found or filtered_out. It counts items sorting before the crop,
so it works at any queue depth. Use it for deep links.
GET /review/new_class_proposals/summary returns {total_pending, without_term, top_terms, flagged_terms, term_rules}. The summary, the queue
and the resolve route share one selection, so counts agree. Each term is
{label, count, sample_crop_ids, flag, class_id}. flag is:
flag |
Rule | Action |
|---|---|---|
null |
worth creating | create a class |
existing_class |
the normalized name is an active class (class_id set) |
resolve with class_id |
generic_parent |
the whole name is in OP_NEW_CLASS_GENERIC_TERMS, or is a registry group name |
assign a specific class |
non_object |
the name or one of its _ tokens matches OP_NEW_CLASS_NON_OBJECT_TERMS (supports prefix_* and *_suffix) |
discard or exclude |
Both term lists are empty by default. term_rules serves the active rules.
POST /review/new_class_proposals/resolve takes {label, class_id | create: {class_name, group, notes}, label_source}. Exactly one of class_id and
create (422 otherwise). An unknown class_id is 400; a duplicate
create.class_name is 409 with no item writes. It matches every pending
item proposing label (never a validated, review-dismissed, excluded or
test-holdout item), writes each item independently and re-checks at write
time. Response: {class_id, class_name, created, label, matched, matched_ids, updated, updated_ids, conflicts[], skipped}. dry_run=true reports the match
without writing or creating anything. Writes are undoable with
POST /crops/label/undo_batch on updated_ids. A resolve over more pending
items than its safety cap is 422.
A class has an integer id and a name inside one project. Names are unique among active classes of a project. See Class identity.
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
| GET | /classes |
ClassListResponse: classes[], thresholds, reserved_hotkeys |
503 |
|
| POST | /classes |
ClassCreateRequest: name, group, notes, hotkey_letter |
201 registry entry |
422 bad name, 409 name or hotkey taken, 400/422 hotkey rules |
| GET | /classes/{class_id} |
ClassEntry |
404 |
|
| PUT | /classes/{class_id} |
ClassUpdateRequest: name, group, hotkey_letter |
registry entry | 404, 409, 422 |
| POST | /classes/merge |
{source_id, target_id}, query dry_run |
merge report | 400, 409 |
| POST | /classes/{class_id}/deprecate |
registry entry | 404, 409 class_still_referenced |
|
| POST | /classes/{class_id}/restore |
registry entry | 404, 409, 409 class_merged |
|
| POST | /classes/sync_to_opensearch |
sync report | ||
| GET | /class_sources |
{class_sources[]} |
ClassEntry:class_id,class_name,group,sample_count,validated_count,cluster_size,deprecated,merged_into,hotkey_letter,adequacy,kind(itemorregion),trainable,trainable_gap,added_at. A class named like the active profile'sregion_class_namehaskind: region. The active region profile's class is seeded into the registry at project creation and API start, and it never labels a whole item.- Names match
^[a-z0-9_]+$on create and rename (422). - Hotkeys, on create and update: one character (
400), not reserved (422 hotkey_reserved), not bound to another active class (409 hotkey_taken).""on update clears.reserved_hotkeysare the single keys bound in contexts where class hotkeys are live in the project's effective keymap. A create that fails a hotkey rule writes nothing. - Merge relabels every non-holdout item of the source with
class_source: class_mergeand keepsclass_validated.dry_run=truereturns{dry_run, source_id, target_id, would_relabel, validations_carried_over, holdout_blocking, blocked}and writes nothing.blockedmeans the real merge would be409because of frozen test-holdout items. A self-merge or an unknown id is400. The merge is recorded inclass_id_history(writer: class_merge). A merged class cannot be restored (409 class_merged); relabel manually. - Deprecate retires a class that has no items and no confirmed labels. While
items reference it, it is
409 class_still_referencedwithitem_countandconfirmed_label_count. It is idempotent and clears the hotkey. - Restore is
409if an active class already holds the name.
Cluster ids partition into ranges:
cluster_id |
cluster_kind |
Meaning |
|---|---|---|
< 0 |
unassigned |
not clustered, noise, or excluded (-2) |
0 to the offset |
class |
equals the class id, for labeled items |
>= cluster_id_offset |
candidate |
a residual cluster that needs a class |
| Method | Path | Notes |
|---|---|---|
| GET | /clusters |
cluster cards. Query: per_cluster, max_clusters, kind (class, candidate, all), cluster_id, max_rank, min_blur_ratio, class_source, offset, limit |
| GET | /clusters/representatives |
per_cluster, max_clusters, class_id, offset |
| POST | /clusters/auto_promote |
min_purity (default 0.85), min_members (default 4), dry_run, promote_min_precision (default 0.95), force. Validates classifier-labelled members of pure clusters. Refused with 409 audit_required / audit_precision_low (and classes) unless every class it would promote has enough audited crops at the required detector precision, or force=true; a dry run is never gated |
| POST | /clusters/refine/{cluster_id} |
AHC refine of one cluster. distance_threshold, max_members. Writes cluster_subid |
| POST | /cluster/umap/rebuild |
refit the UMAP reducer on the residual pool and re-cluster it. Returns the clustering summary |
Cards. labelled_count counts members with any class_name. dominant_count
and label_purity describe the top class among them. For a candidate,
dominant_class_name is set only for a unique top class with at least 3
members and half of the labelled ones, else null, and dominant_class_id
is null.
label_agreement is the same value as label_purity (the top class's share
of the labelled members, null with none labelled) under the name a UI should
read for label quality; label_purity stays for existing callers.
purity is geometric and independent of labels: the share of members whose
nearest cluster centroid is their own cluster. purity_n is how many members
it covers and purity_basis is nearest_centroid; with purity_n of 0
both purity and purity_tier are null. purity_tier is pure, mixed
or noisy. promotable is the auto-promote gate on labels (enough members,
enough of them labelled, label_purity at or above pure_min); never true
for a class cluster. The response serves purity_thresholds (pure_min
0.85, mixed_min 0.6, promote_min_members 4, promote_min_labelled_share
0.5) and core_similarity_min (0.75).
Representatives are paged. Every card is returned with size, purity and
the rest, but representatives is filled only for cards in the
[offset, offset + limit) window of the kind-filtered, member-count-descending
list (limit default 50, max 500). Other cards carry representatives: [].
Each card carries n_subclusters (the exact count) and subcluster_ids (the sorted ids, at most 200), which the Cropwright sub-cluster tabs use. per_cluster=0 skips representatives. GET /clusters/representatives
pages the same way by offset and max_clusters.
Clustering runs inside the auto-label pipeline. Setting
recluster_unvalidated=true broadens the residual pool so candidate clusters
can fuse. The default pool is fresh and class-bucketed unvalidated items.
| Method | Path | Body or query | Response |
|---|---|---|---|
| POST | /vlm/label_batch |
{crop_ids} (at most 64), vlm, acknowledge_external |
label results |
| POST | /vlm/verify_region_batch |
{items: [{crop_id, region_image_b64, candidate_text}]} (at most 64) |
{results[]}: crop_id, is_region, confidence, reason, candidate_text |
| POST | /vlm/region_visible_batch |
{items: [{crop_id, image_b64}]} (at most 64) |
{visible: {crop_id: bool}} |
| POST | /pipeline/auto_label |
query parameters | runs the pipeline in the request and returns each stage's counts. Idempotent |
| POST | /pipeline/auto_label/start |
query parameters | starts a background job and returns at once; poll the status routes. One job at a time (409 otherwise) |
| GET | /pipeline/auto_label/status |
the current job | |
| GET | /pipeline/auto_label/status/{job_id} |
a job by id; 404 for an unknown id |
|
| POST | /pipeline/auto_label/cancel |
||
| GET | /pipeline/events |
SSE stream for a dashboard: snapshot on connect, then state on job changes and stats every 15 s |
- The VLM labeling routes honor the lock rule: an item whose class is locked
is never overwritten. A project with no classes is
409 no_classes. A call over 64 ids is400. - A reply that resolves to a registry class also sets
cluster_id = class_idand clearscluster_subid, unless the item is excluded. Undo restores the placement. - A crop the VLM gave no usable verdict for is omitted from a batch response, or left untouched by a single-crop call. It is never written as a reject.
/pipeline/auto_labeland/starttake:train_clusters,promote_min_purity,promote_min_members,vlm_batch_size,vlm_concurrency,max_vlm_crops,classifier_confidence_skip_vlm,clustering_method,run_vlm,recluster_unvalidated,reassign_only,run_auto_promote,gate_max_rank,gate_min_blur_ratio,n_clusters,class_id,cluster_id,prompt_pack(<name>or<name>@<revision>),vlm,acknowledge_externalandvlm_attributes(withrun_vlm, store the pack's item attributes in each item'sassistslot through the combinedassistcall;422 pack_declares_no_attributeswhen the pack cannot serve it). Adetection_profileparameter is422: no stage runs region detection. Region detection runs in the detection worker on the active profile.- Job state:
job_id,status(queued,running,completed,failed,cancelled,interrupted),stage,processed,total,started_at,finished_at,error,result,args, backend and VRAM telemetry,stage_durations,eta_seconds,elapsed_seconds. The newest 50 jobs are kept. A start while a job runs is409. ?cluster_id=on/pipeline/auto_label[/start]scopes the whole job to one cluster: it runs only the VLM stage over every unvalidated, non-holdout, non-excluded member, does no re-clustering or auto-promote and applies no cap. A sampled, per-sub-cluster or per-selection run is an assist run.
One request shape sends a scope to the VLM: a crop, a selection of crops, a
cluster, one sub-cluster or an item filter. The run writes the VLM's suggestions
and never a label: a class goes through the same guarded writer as the
auto-label VLM stage (a human- or import-locked, validated or holdout item keeps
its class), and the answers, including a locked item's, are kept in the item's
assist object.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /assist/runs |
AssistRunRequest |
AssistRunState; dry_run (the default) plans and writes nothing |
| GET | /assist/runs |
cluster_id, cluster_subid, limit (1 to 100, default 20) |
{runs: AssistRunState[]}, newest first: the runs whose target or context names that (sub-)cluster; all runs without cluster_id |
| GET | /assist/runs/{run_id} |
AssistRunState; 404 not_found for an unknown id |
|
| POST | /assist/runs/{run_id}/cancel |
AssistRunState; the run stops after its current chunk |
|
| POST | /assist/runs/{run_id}/apply |
AssistApplyRequest |
AssistApplyResponse |
AssistRunRequest (unknown keys are 422):
| Field | Meaning |
|---|---|
target |
exactly one kind. {kind: "crops", crop_ids: [1..5000], context?: {cluster_id, cluster_subid?}} is a single crop (one id) or a user selection. {kind: "boxes", boxes: [{crop_id, box_id}, 1..1000]} is chosen boxes (a single box is one pair); it takes verify and attributes only. {kind: "cluster", cluster_id, cluster_subid?} is a cluster or one sub-cluster by the stored ids. {kind: "filter", filter} is a non-empty item filter |
sample |
what the VLM sees. mode: all (every visible member; a crops target must use it) or representative (a seeded sample). size (1 to 256) overrides the default of max(8, min(64, ceil(2*sqrt(n)))); seed (default 0); stratify_by: auto (by cluster_subid when the scope has more than one), cluster_subid or none |
actions |
a non-empty subset of classify, attributes and verify. With classify and attributes, and a pack that declares item attributes, one assist call answers both. attributes on a boxes target reports the pack's box attributes for each box close-up. verify (crops and boxes targets) has the VLM judge each stored box that is still open to a machine verdict (proposed or accepted, never a human- or import-owned box) and writes the verdict onto the box; a box the VLM gave no usable answer for is left for a retry. Order inside a run: verify, then classify / attributes |
vlm, prompt_pack, acknowledge_external |
the endpoint and pack for this run only (name or name@revision); omitted, the project's active endpoint and effective default pack. The run pins both |
min_agreement |
0.5 to 1.0, default 0.6 |
dry_run |
default true |
segment (present exactly when actions has segment) is {verify: "vlm" | "none"}: how the
region worker verifies the boxes the re-segmentation makes (vlm: the project's active VLM,
so the run's endpoint must be that one, else 409 verify_endpoint_mismatch; none: accepted
without a VLM). With the verify action segment.verify must be none and the run then judges
the new boxes with its own endpoint (a crops target). segment takes a crops, cluster or
filter target and re-queues the resolved items for the region worker (a human-validated
region set is skipped). Refusals: 409 region_profile_required, 409 no_segmenter_leg (the
active profile has no segmenter_text_prompt), 409 region_stage_paused.
AssistRunState: run_id (null on a dry run), status (dry_run, queued,
running, waiting_region, completed, completed_with_errors, failed, cancelled,
interrupted), created_at, finished_at, error, request (as normalised),
resolved, progress (processed, total) and result. resolved has
scope_count (members after the visibility rules), scope_digest (sha256:
over the sorted ids), sampled, sample_seed, strata (key, size,
sampled; empty when not stratified), skipped (holdout, excluded,
not_found, box_not_found and box_locked for a boxes target, and class_locked: sampled members that are sent anyway and whose
class is never written), vlm (name, revision, model), prompt_pack
(name, revision) and estimated_vlm_calls. result has classify
(predicted, updated, failed, unreadable, locked_suggest_only) and
attributes (items_written, invalid_values), segment (queued, done, pending, failed), verify (boxes judged, accepted,
rejected, locked_skipped) for the actions that ran, and the proposal.
Proposal and scope check. result.proposal reads the sampled answers as a
label for the scope: class_name (the top class, or null), in_registry (checked
against the registry on every read), agreement (top votes over answered),
answered, min_agreement, passes_gate, distribution (class_name, votes),
new_class_proposals (name, votes: the slugs the VLM proposed when nothing fit,
counted by name and not votes for a class) and attribute_summary (per key the top
10 values with counts). The gate passes with at least 3 answers and agreement >= min_agreement; a locked item's answer counts as a vote, its class is never written.
A run with a cluster target, or a crops target with a context, keeps the members
of that (sub-)cluster, and every read carries scope_now: count (members now),
overlap, jaccard and changed. The assist layer reads only the stored
cluster_id and cluster_subid and the member ids, so a re-refine shows as a changed
scope and never as a silently different proposal.
POST /assist/runs/{run_id}/apply (AssistApplyRequest: class_name,
label_source: "human", allow_changed_scope default false, dry_run default
true) is the only path from a proposal to labels. It labels the scope's current
members as a human would (validated, trainable), through the writer behind
PUT /crops/batch_label, and leaves alone every member whose class is locked or
validated and the holdout and ignored ones. The response has selected (members the
class may be applied to), skipped_locked, updated, updated_ids, conflicts and
scope_now. Refusals: 409 scope_changed (under 90% of the scope is what the run saw;
allow_changed_scope overrides), 409 class_not_in_registry (class_name is matched by
name; create the class first), 409 run_has_no_scope (a bare selection or a filter run).
- The frozen test holdout and human-ignored items are never sent. A scope
larger than 5000 items with
sample.mode: "all"is422 assist_too_large(sample it, narrow it, or use the auto-label job). - Refusals:
409 vlm_not_configured(the project's VLM is off andvlmnames none),409 assist_busy(one run per project;run_idnames the live one),404 not_found(a cluster with no items),422 invalid_request(acropsorboxestarget with a representative sample,classifyon aboxestarget,verifyon a cluster or filter target, an empty filter, a repeated action),422 pack_declares_no_attributes,422 no_class_names(classify with no item classes in the registry),422 unknown_vlm. - The item's
assistobject (see the item wire):run_id,run_created_at,vlm_endpoint,vlm_model,prompt_pack,prompt_pack_revision,class_name,class_confidence,attributes[](key,value,confidence) andboxes[](box_id,attributes[]: the box attributes, never part of the box itself; a box id the item no longer has is not served). One slot per item; the newest run wins, and an older run never replaces a newer slot. A restart ends a live run asinterrupted; run it again.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /ingest/image |
IngestImageRequest: path, source (extra='forbid') |
IngestImageResponse |
| POST | /ingest/batch |
IngestBatchRequest: items: [{path, source}] (required, non-empty), ingest_run_id (optional tag, at most 64 characters; extra='forbid') |
BatchIngestResponse |
| POST | /ingest/upload |
multipart: images (files), image_paths (JSON list of identifiers, optional), source, run_id |
BatchIngestResponse |
| POST | /ingest/path_lookup |
{image_paths} (at most 10,000) |
{known_paths: {path: image_id}} |
| GET | /ingest/config |
limits | |
| GET | /ingest/status |
query run_id |
{total, by_source[], by_day[]} |
| GET | /ingest/region_drain |
drain state | |
| POST | /ingest/bulk |
BulkIngestRequest: exactly one of folder, manifest; source, extensions, batch_size, concurrency (extra='forbid') |
BulkIngestRun (202) |
| GET | /ingest/bulk |
query page, page_size |
BulkIngestList, newest first |
| GET | /ingest/bulk/config |
BulkIngestConfig (includes preview: the caps and the measured figures the estimate uses) |
|
| POST | /ingest/bulk/preview |
BulkPreviewRequest: exactly one of folder, manifest, s3, source_id; extensions (extra='forbid') |
BulkPreview (200): read-only; counts, bytes, extensions, size sample, estimate; partial with stop_reason when a cap or a client disconnect ended the walk |
| GET | /ingest/bulk/{run_id} |
BulkIngestRun |
|
| GET | /ingest/bulk/{run_id}/failures |
query offset, limit |
BulkIngestFailurePage |
| POST | /ingest/bulk/{run_id}/cancel |
BulkIngestRun |
IngestImageResponse:status(success,duplicate,failed),image_id,image_path,source_identifier,imohash,n_crops,n_regions,error,error_kind,secondary_detector_error(set when a configured secondary detector call failed; the image still ingests on the primary detector's output).error_kindis one ofempty,unservable_path,unsupported_type,decode_failed,detector_infer,bulk_index. It is present witherrorwhenstatusisfailed.BatchIngestResponse:status(success,partial,error),summary(successful,duplicates,failed,crops_indexed,secondary_detector_failures) andresults[].- Duplicates are detected by content hash (
imohash) against the index and within a batch. A byte-identical file later in the same request reportsduplicatewith the representative'simage_id, and exactly one item set is created. - Paths for
/ingest/imageand/ingest/batchmust be servable: under a configured source root. An unservable path is422(single) or a failed row witherror_kind: unservable_path(batch). An unreadable file is404(single). /ingest/uploadstores the bytes content-addressed under the project's upload root:<upload_root>/<imohash[:2]>/<imohash><ext>, written atomically, stored once per content.image_pathis the stored, servable path. The client's identifier issource_identifier.path_lookupmatches either field and keys its result by the one that matched.run_idis recorded asingest_run_idand scopesGET /ingest/status?run_id=.- Bulk ingest runs a folder or manifest (a path inside the API container, under a
source root;
422 bulk_ingest_invalid_sourceotherwise) as a background run that sends batches throughPOST /ingest/batch.BulkIngestRun:run_id,status(queued,running,completed,completed_with_errors,failed,cancelled,interrupted),resumed,source,options,progress(discovered,discovery_complete,skipped,ingested,duplicates,failed,processed,crops_indexed,n_embedded,n_not_embedded,n_embed_failed,in_flight,images_per_s,images_per_s_recent,eta_s,elapsed_s),stage_s,attempts,can_cancel,can_resume,cancel_requested,error,started_at,finished_at,poll_after_s(null once the run is over).POST /ingest/bulkstarts a run, or continues the interrupted, cancelled, failed or partly failed run of the same source (resumed: true);409 bulk_ingest_busy(withrun_id) while a run of the project is live. A run stops asfailedafter five batches in a row fail entirely.404 bulk_ingest_not_foundfor an unknown run id. GET /ingest/config:upload(enabled,max_images_per_request,max_bytes_per_request,accepted_extensions,persists_bytes),batch(enabled,max_items,source_roots),region_drain(poll_interval_s,stable_polls). Limits:OP_UPLOAD_MAX_IMAGES_PER_REQUEST,OP_UPLOAD_MAX_BYTES_PER_REQUEST,OP_UPLOAD_ACCEPTED_EXTENSIONS,OP_BATCH_MAX_ITEMS_PER_REQUEST. Over a limit is413; an extension outside the accepted list fails that item witherror_kind: unsupported_type.GET /ingest/region_drain:pending_detection,pending_verification,total_unfinished,drained,stable_for_s,observed_at,region_dependencies[](role,model,ready,unavailable_since,detail) andstall_reason.drainedistrueonly aftertotal_unfinishedhas read0forstable_polls(OP_REGION_DRAIN_STABLE_POLLS, default 3) consecutive polls.region_dependenciesis checked against Triton's repository index and is empty with no region profile.stall_reasonisnullwhen nothing is pending or every dependency is ready. Items staypending_detectionwhile a dependency is down; the worker never writes a terminal status on an infrastructure failure.GET /ingest/region_drainandGET /ingest/statusanswer503on a backend outage.- Ingest answers
503until the encoder has loaded.
Import an already-labeled dataset (YOLO, COCO or an OpenProcessor export) into a project. The importer is chunked, persisted, resumable and runs on the shared jobs volume. Labels are written through the same single class writer that human labels use, so the lock rule holds. A human edit made between planning and writing wins.
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
| GET | /datasets/formats |
DatasetFormatsResponse: formats[], issues[] (the issue catalog), mapping_actions[], match_kinds[], parents_modes[], processing_modes[], trust_levels[], status_labels, upload_limits |
||
| POST | /datasets/uploads |
multipart file (zip or tar) |
201 {upload_id, dataset_path, bytes, files} |
413 upload_too_large, 422 archive_invalid |
| POST | /datasets/preview |
DatasetPreviewRequest |
DatasetPreview |
422 dataset_path_not_allowed, 422 format_undetected |
| POST | /datasets/imports |
DatasetImportRequest |
202 DatasetImportJob (200 with reused: true for a completed repeat) |
422 class_mapping_incomplete, 422 class_mapping_invalid, 422 import_blocked, 409 import_busy, 409 import_resumable, 409 dataset_changed |
| GET | /datasets/imports |
page, page_size, status |
DatasetImportList |
|
| GET | /datasets/imports/{import_id} |
DatasetImportJob |
404 import_not_found |
|
| GET | /datasets/imports/{import_id}/issues |
code, page, page_size |
DatasetIssuePage |
|
| GET | /datasets/imports/{import_id}/entries |
split, label_state, status, page, page_size |
DatasetImportEntryPage |
|
| POST | /datasets/imports/{import_id}/cancel |
DatasetImportJob |
||
| POST | /datasets/imports/{import_id}/resume |
202 DatasetImportJob |
409 import_not_resumable (only interrupted, failed or cancelled), 409 import_busy, 409 dataset_changed |
|
| POST | /datasets/imports/{import_id}/undo |
DatasetUndoRequest |
200 DatasetUndoReportWire for a dry run, else 202 DatasetImportJob |
409 import_not_undoable, 409 import_busy |
DatasetSource: path, format (auto, yolo, coco,
openprocessor_export) and coco_annotations[] ({path, images_dir, split}). Auto-detection tries an OpenProcessor export, then YOLO (a data
YAML), then COCO (annotations/*.json with no labels/ directory, so
images/ next to annotations/ is COCO), then YOLO (an images/ directory,
or a child with one). The path must lie under a source root, the project's
upload root or the project's export root, after symlinks are resolved
(422 dataset_path_not_allowed). References inside a dataset must stay
inside it. Archive uploads accept regular files and directories only and
cap member count, bytes written and compression ratio.
DatasetImportOptions:
| Option | Values | Effect |
|---|---|---|
processing |
none (default), propose |
propose runs the detector on the imported images for proposals |
label_trust |
validated (default), suggestion |
validated writes locked labels. suggestion writes unvalidated labels (and proposed boxes) that the machine pipeline may still replace |
parents |
auto (default), labels, detect |
where the parent items of region labels come from |
freeze_test_split |
bool or unset | freeze the imported test split as the test holdout |
missing_label |
unlabeled (default), negative |
how a frame with an empty label file is treated |
region_negatives |
bool, default true | |
region_containment |
0.5 to 1.0, default 0.9 | how much of a region box must lie inside a parent |
name, source_tag |
strings | labels for the import and the source of created items |
force |
bool | bypass issues marked bypassable |
Every dataset class that has boxes needs a decision. The mapping is a list of
ClassMappingEntry:
| Field | Meaning |
|---|---|
dataset_class |
the class name in the dataset |
action |
map to an existing class, create a class, skip the boxes, or region to treat the boxes as sub-regions of the active region profile |
class_id |
for map |
new_class_name, new_class_group |
for create (and map by name) |
The preview serves a suggestion per dataset class (action, class_id,
class_name, match: exact, case-insensitive or region). With
accept_suggestions: true the server takes exact, case-insensitive and
region matches. Matching is always by name, never by index. A class with boxes
and no decision is 422 class_mapping_incomplete with unmapped[]. An
invalid row is 422 class_mapping_invalid. A region action needs an active
region profile. Without one the preview reports the blocking issue
region_profile_required and a start is 422 import_blocked.
DatasetPreview writes nothing: format, root, source_sha,
import_key, splits[] (per split: images, boxes, labeled,
unlabeled, negatives), classes[] (dataset_class, boxes, images,
suggestion, resolved, merged_from), totals, estimate, issues[],
blocking, force_allowed, region and op_export.
Issues carry code, severity (error, warning, info), blocking,
bypassable, count and samples[]. The catalog of codes is in
DatasetImportJob/DatasetIssueWire.code and GET /datasets/formats.
DatasetImportJob: import_id, import_key, name, project, status
(queued, running, paused_backpressure, completed,
completed_with_errors, failed, cancelled, interrupted, undoing,
undone), progress (images_total, images_done, images_failed,
chunks_done, chunks_total, images_per_s, eta_s), report, issues_summary[],
mapping[] (resolved, with created for classes the import made),
options, source, next_steps[], waiting_for, poll_after_s,
reused, timestamps and error.
report counts images_created, images_reused, images_skipped,
images_failed, items_created, items_updated, items_noop,
items_reconciled_removed, labels_written, boxes_written,
label_conflicts_locked (labels a human lock refused), negatives,
unlabeled, parents_detected, standalone_regions, proposals_created,
proposals_merged, holdout_frozen and disagreements.
import_keyhashes the project, the source content, the name-based mapping and the write-affecting options. Repeating a request is idempotent: a completed repeat answers200withreused: true. A prior interrupted import of the same dataset is409 import_resumable.expected_import_key(from the preview) guards against a changed dataset (409 dataset_changed).- One import runs per project at a time (
409 import_busy). - When the region worker's backlog exceeds
OP_DATASET_IMPORT_MAX_PENDINGthe job pauses (paused_backpressure). - Importing a different version of a dataset over images an earlier import labeled removes items the new version no longer has, unless a human or a holdout freeze touched them, a box the dataset still has matches them, or the frame has no label file. The whole document is kept in the new import's ledger row, and undoing that import reinstates it.
- Undo (
dry_rundefaulttrue) restores each class snapshot or region edit history, deletes items the import created, optionally removes created images (remove_images) and deprecates created classes (deprecate_created_classes). Items a human edited or that another import shares are kept. A second undo reports zeros. - Imported items carry
import_ids,dataset_split,imported_at,proposed_by_import,on_negative_frameandimport_standalone_region. Browse them with theimport_id,dataset_split,on_negative_frameandproposed_by_importfilters ofGET /cropsor theimportedreview tab.
One route re-runs pipeline stages. It replaces every ad hoc requeue, clear and retry path.
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
| POST | /reprocess |
ReprocessRequest |
ReprocessWireResponse |
422 reprocess_targets_invalid, 409 reprocess_busy |
| POST | /images/{image_id}/reprocess |
ReprocessOneRequest |
ReprocessWireResponse with the image's items |
404 image_not_found |
| POST | /crops/{crop_id}/reprocess |
ReprocessOneRequest |
ReprocessWireResponse with the item |
404 not_found |
| GET | /reprocess/jobs/{job_id} |
ReprocessJobInfo |
404 not_found |
|
| POST | /reprocess/jobs/{job_id}/cancel |
ReprocessJobInfo |
404 not_found |
ReprocessRequest:targets(exactly one ofimage_ids,crop_ids,filter; at most 5000 ids),scopes(detect,region,vlm,embed; at least one),region_mode(redetectdefault,reverify) anddry_run(defaulttrue). The per-image and per-item routes droptargets(the path is the target) and apply by default (dry_runfalse).filter(ReprocessFilter):region_status[],detector[],reason[],profile_not,profile_revision_below,include_detected(only valid with a profile selector),missing_status,missing_provenance,import_id,source,class_id,dataset_split. An empty filter is refused.- Response:
dry_run,scopes[](scope,selected,locked_skipped,queued,not_found,failed,breakdown[],detail),items[](not on a dry run) andjobfor work that runs in the background. - The lock rule applies: locked items and boxes are never written and are
counted as
locked_skipped. - A
detectorembedrun over more thanOP_REPROCESS_SYNC_MAXimages (default 20) runs as a file-backed job.job.statusisqueued,running,completed,completed_with_errors,cancelledorfailed. One job runs at a time (409 reprocess_busy). Cancel stops after the current chunk. suggested_reprocessin an activation impact is a readyReprocessRequest.
| Method | Path | Body or query | Response |
|---|---|---|---|
| POST | /export/yolo |
ExportYoloRequest |
export summary |
| POST | /export/single_class |
ExportSingleClassRequest |
export summary |
| GET | /export/status |
ExportStatusResponse |
|
| GET | /export/single_class/status |
profile_name (default single_class) |
status of the last run for that profile |
| GET | /export/datasets |
kind (yolo, single_class), profile_name |
versions on disk |
| GET | /export/registry/{artifact} |
a file download |
ExportYoloRequest: export_dir, version_tag, seed (42),
max_images, dedup_threshold, require_fully_labeled_images (false),
include_negative_frames (true), split_mode (keep_imported default,
recompute).
ExportSingleClassRequest: export_dir, version_tag, class_ids,
box_source (item or region), region_class_name, profile_name,
seed, skip_test_split, empty_bg_ratio, max_positive_images,
dedup_threshold, image_mode (whole_frame or item_crop),
img_max_side, copy_images, split_mode.
Both exports refuse with 422 nothing to export: <reason> when nothing is
exportable, write nothing and leave current on the previous export. Every
manifest records items_index: {index, uuid, created_at}.
One image file and one label file per source image. Validated items are
grouped by image_id. Each image is written once as
images/<split>/<image_id>.<ext> next to labels/<split>/<image_id>.txt
with one cls cx cy w h line per validated, non-excluded, non-dismissed
object. cls is the dense export_id. The box comes from the item's
bbox_norm, normalized to the full source frame and clamped to [0, 1].
Objects are ordered by item id so re-runs are byte-identical. An item with no
image_id, usable box or class is counted in skipped_items.
- Partial frames. An exported image can hold unlabeled objects (not
validated, validated on a class with no dense id, or without a usable box).
They stay in the pixels. By default the image is exported and the manifest
records
unlabeled_items_on_exported_imagesandimages_with_unlabeled_items, and training preflight warns. Withrequire_fully_labeled_images: truesuch images are left out (images_dropped_not_fully_labeled); if none remains the export is422. - Negative frames. A frame an import marked a reviewed negative is written as
an empty label file when
include_negative_framesis true and the frame'snegative_forcovers every class that has objects in the export. The manifest recordsnegative_imagesandnegative_frames_skipped_partial. - Order of operations: the partial-frame policy, then
dedup_threshold(near-duplicate images collapse; a frozen-holdout image is preferred as survivor), thenmax_images(an even round-robin over each image's rarest class). - Splits. The group is the source image (
group_key: image_id). An image with a frozentest_holdoutitem goes totestwith all its objects. Withsplit_mode: keep_imported, the split a dataset import filed each frame under is kept. Withrecompute, it is ignored. Remaining groups count toward their most common class and are ordered within a class bysha256(seed:class:group). A class with a frozen holdout item uses it as its test set and splits the rest 0.8 train to 0.1 val; a class without one splits 0.8, 0.1, 0.1. Every split with a positive ratio gets one group before any gets a second. The manifest recordssplit_modeand how many groups were pinned or overridden. - Manifest counts:
image_count,object_count,split_counts(images per split),split_object_counts,class_split_counts(rows withclass_id,export_id,class_name,train,val,test),class_count(the registry size written asnc),classes_with_objects,skipped_items,dedup,dataset_sha. dataset_shahashes the on-disk content: for everylabels/<split>/*.txtsorted by relative path, the path and the sha256 of the bytes, then the orderednames:list, so a class rename with no id change still changes it. The multi-class export records the full 64-hex digest and the single-class export the first 16.POST /export/single_classbuilds a narrowed dataset for one class or a subset. It addsfrozen_test_sha, which hashes the identity of the test split (which frames, not their content). Eachprofile_namehas its own output root and its owncurrentlink.box_source: regionexports the region boxes of the active profile, withimage_mode: item_cropcropping to the parent item. The region stratum key is the first accepted (positive) orfalse_positive(hard negative) box's cluster.GET /export/statusserves thecurrentmanifest:status(idle,unknown,success),path,export_dir,last_run,version_tag,dataset_sha,seed,group_key,image_count,object_count,class_count,classes_with_objects, the split and class counts and the unlabeled-frame counters. A field the manifest does not record isnull.idlenulls everything;unknown(manifest unreadable) sets only the path fields.GET /export/datasetsrows:kind,profile_name,export_dir,version_tag,image_count,object_count,split_counts,dataset_sha,exported_at,class_count,is_current.GET /export/registry/{artifact}serves a frozen artifact of the current export as a file:class_registry.json,data.yaml,manifest.jsonorlabel_stats.json. Any other name is404before the filesystem is touched. It is scoped to the multi-class export.
Training preflight adds export checks (export_readiness.py):
| Check | block when |
|---|---|
export_not_empty |
the manifest has 0 images |
export_splits_nonempty |
train or val has 0 images |
export_class_split_coverage |
a trained class has fewer than min_train_per_class train or min_val_per_class val objects. Not applicable to a single-class export |
export_unlabeled_objects |
never; warn when unlabeled objects remain on exported images |
export_generation |
the items index was rebuilt since the export (its items_index.uuid differs) |
Each is unknown when the manifest lacks the data.
Training runs in a separate trainer container. The API writes a job
(job.json), the trainer writes status.json, and the API serves both.
Training is project-scoped: the dataset is the project's export, the run is
tagged with the project, and a promoted model belongs to the project that
trained it.
| Method | Path | Body or query | Response |
|---|---|---|---|
| POST | /train/preflight |
TrainJobSpec |
PreflightReport: blocked, checks[] (name, severity of ok, warn, block, unknown, message, detail), summary, thresholds |
| POST | /train/start |
TrainJobSpec, query force |
201 {job_id, preflight} |
| POST | /train/start_campaign |
TrainCampaignSpec, query force |
201 {campaign_id, job_ids[]} |
| GET | /train/status |
the newest run's TrainJobStatus, or null |
|
| GET | /train/status/{job_id} |
TrainJobStatus |
|
| GET | /train/runs |
limit, offset |
{items[], total} |
| GET | /train/log/tail/{job_id} |
lines (1 to 5000, default 200) |
{job_id, lines[]} |
| POST | /train/cancel/{job_id} |
{cancelled, job_id} |
|
| POST | /train/cancel_campaign/{campaign_id} |
{cancelled, campaign_id} |
|
| GET | /train/profiles |
{profiles[]}: name, description, default hyperparameters |
|
| GET | /train/presets |
{class_subset_presets[]} |
|
| GET | /train/gpus |
TrainGpuOptionsResponse: options[] (value, label, gpu_ids, advisory, stops_containers, default), allowed_ids, unrestricted |
|
| GET | /train/augmentation_presets |
{presets[], default} |
|
| POST | /train/promote/{job_id} |
PromoteRequest |
PromoteResponse |
| POST | /train/reload_promoted |
{status, reloaded[], failed[]} |
|
| GET | /train/manifest/{job_id} |
the run's lineage envelope | |
| GET | /train/artifacts/{job_id}/{name} |
a whitelisted artifact file |
TrainJobSpecfields:dataset_export_dir(defaults to the current export; it must be under the project's export root,422 export_outside_project, not bypassable byforce),profile(probe,nano,small,medium,large,xlarge,custom),model_family(yolo26),model_size,hyperparameters,include_classes,single_cls,augmentation,stop_when,auto_promote_best,auto_quantize_bakeoff,cuda_visible_devices,mlflow_experiment,mlflow_run_name,submitted_by. Provenance fields (project,dataset_sha,frozen_test_sha,registry_snapshot_path, image revision) are filled by the server.- A blocked preflight is
422 {message: "preflight blocked", preflight}unlessforce=truebypasses a bypassable check. An unknown augmentation preset is422withfield: augmentation.presetandvalid_presets, before any GPU claim or job write, even withforce. A run that is already active is409. A GPU claim that cannot be satisfied is409. GET /train/augmentation_presetsserves the preset catalog (id,label,description,orientation_sensitive) and the default (balanced_default). A client renders it and never hardcodes ids.orientation_sensitivemeans horizontal flip is off for the whole run.- Preflight includes the export checks listed under
Export, the
thresholdsand the class adequacy.
TrainJobStatus: job_id, state (queued, starting, running,
exporting, finished, failed, cancelled, skipped, lost),
current_epoch, total_epochs, epoch_time_s, gpu[], heartbeat_at,
started_at, finished_at, error, checkpoint_path, campaign_id,
last_epoch_metric, best_checkpoint_metric, eval, compare,
mlflow_run_id, mlflow_experiment_id, mlflow_run_url.
last_epoch_metricis{epoch, map50, map50_95}of the true last training epoch.best_checkpoint_metricis the same shape for the best checkpoint's own re-validation, as one coherent row. Both arenullfor a status written before they existed.evalstates which split every number came from:
{"map50": 0.62, "map50_95": 0.41, "precision": 0.71, "recall": 0.55, "split": "test",
"val_last": {"map50": 0.9191, "map50_95": 0.742},
"per_class": [{"class_id": 0, "name": "widget", "precision": 0.8, "recall": 0.7,
"f1": 0.75, "ap50": 0.79, "support": 12}],
"confusion_matrix_url": "/curation/projects/example/train/artifacts/<job_id>/confusion_matrix.png"}split: "test" means the frozen test split was scored. When the test pass
fails or the export has no test split, the four overall keys carry the
training-time validation numbers, per_class is absent and
split: "val". A consumer must check split before reading map50 as
held-out performance. val_last is always present when a result row
exists. head: "end2end" records that the NMS-free one-to-one head was
scored.
confusion_matrix_urlpoints at/train/artifacts/{job_id}/{name}. Whitelisted names:confusion_matrix.png,confusion_matrix_normalized.png,results.png,results.csv,BoxP_curve.png,BoxR_curve.png,BoxF1_curve.png,BoxPR_curve.png. The server path never reaches the wire.mlflow_run_urlis rebuilt fromOP_MLFLOW_PUBLIC_URL, the run id and the experiment id. It isnullwhen the public base is not configured. The internal tracking host never reaches the wire.
POST /train/promote/{job_id} copies the run's ONNX export into the Triton
model repository, writes the config and labels.txt, and asks Triton to load
it. Request: triton_name, force, fp16, input_size, max_batch_size,
overwrite. force may also be given as the query parameter ?force=true
(either one bypasses the gate). Response: triton_name, onnx_path, config_path,
labels_path, triton_loaded, class_remap_source, force_used,
gate_report, lineage_stamped, cold_start_expected_on_first_inference.
- The model is stored as
<slug>__<triton_name>for every project exceptdefault.triton_namemay not contain__(422). An existing name is409unlessoverwrite. - Errors:
404unknown job or no ONNX export;422the run is notfinishedorexporting, the promote gate failed, or class identity cannot be proven;502Triton refused the load. - A
422body is{detail: {message, failures[], force_allowed, override, thresholds}}, each failure{code, message, class_name?}.force_allowedsays whetherforce=truecan pass that failure.forcebypasses only the score thresholds and a missing class remap; it cannot invent a finished run. - The gate reads
eval: a run whose test pass failed (split: "val", noper_class) fails the per-class check outright. - Class identity. A subset or single-class run's
class_remap.jsonis carried from the trainer into the checkpoint'sweights/directory. Promote resolves it (manifestlineage.class_remapfirst, then the weights directory file) before writinglabels.txt. With no resolvable remap, a subset or single-class run is422 class_remap_missing, and a full-class run whose registry has a gap or deprecated class is422 class_remap_missing_full_class. See Class identity.forcebypasses both, logged distinctly.
| Method | Path | Body or query | Response | Errors |
|---|---|---|---|---|
| GET | /models/status |
include_other_projects |
{models[]}: Triton models and the segmenter and VLM services. Exactly one kind: external segmenter row is always listed (named by the region profile's segmenter_name, else sam3) with status ready, unavailable or not_configured; the same fact is GET /open_vocab segmenter {configured, reachable}. Each Triton entry carries project, shared, owned, sharing_revision, class_mapping, optional. VLM rows are one per registered endpoint with kind: vlm, active and active_in (the bound project only) |
|
| GET | /models/{model_name}/class_mapping |
{model, model_project, project, entries[], unmapped[], not_covered[], labels} |
404 model_not_found |
|
| PUT | /models/{model_name}/sharing |
{shared, expected_revision}, query force |
{name, project, shared, revision, used_by[]} |
404 model_not_found, 409 revision_conflict, 409 in_use (another project's active detection profile uses the model; typed ModelSharingConflictResponse: detail.projects[] slugs and detail.used_by[] rows {project, profile}; force bypasses), 503 config_store_unavailable (typed ModelSharingUnavailableResponse; force bypasses) |
| DELETE | /models/{model_name} |
query force |
{triton_name, triton_unloaded, directory_removed, forced, warning} |
404, 400, 403, 409 |
- A model's classes reach another project by name only.
class_mappingmatches them onto the bound project's registry; an entry hasmatchofexact,case_insensitiveornone. - Only the owning project may share a model or delete it (
404for anyone else). Sharing is opt-in. status: not_installed(instead ofnot_ready) marks an optional detector that is absent from Triton's repository index.DELETE /models/{model_name}: an external-service model is400. A region-detector or OCR model of the active profile is403always. Other core pipeline models needforce=true(409without it).
Model comparison on frozen test sets. Models are in
src/schemas/bakeoff/; every route has a response model.
Evaluator-written files are validated on read. A file that is not schema
version 2 is 409 bake-off result <file> has an unsupported schema.
| Method | Path | Notes |
|---|---|---|
| GET | /bakeoff/eval_datasets |
source (export or external) -> {datasets[], count}. A dataset has id (export:<path> or external:<group>/<name>), dataset_kind (multi_class, single_class, external), nc, classes[], counts, frozen_test_sha, test_label_sha, sha_source, dataset_sha, is_current |
| GET | /bakeoff/trained_models |
dataset_id, limit -> {models[], count}. A model has run_id, display_name, model_family, model_size, imgsz, checkpoint_path, class_names, single_cls, trainer_map50, trainer_map50_split and, with dataset_id, for_dataset (same_export, same_frozen_test, n_classes_mapped, train_test_overlap) |
| GET | /bakeoff/profiles |
{profiles[], count, default_profile, default_error} |
| GET | /bakeoff/baseline_models |
profile -> {baselines[], count} |
| POST | /bakeoff/run |
BakeoffRunRequest -> BakeoffRunAccepted |
| GET | /bakeoff/runs |
{runs[]} |
| GET | /bakeoff/status/{job_id} |
BakeoffStatus (state: queued, running, done, error; progress, completed[], failed[]) |
| GET | /bakeoff/results/{job_id} |
dataset_id optional -> BakeoffComparison |
| GET | /bakeoff/matrix/{job_id} |
BakeoffMatrix (cells[model][dataset], best[dataset][metric] with every tied winner) |
BakeoffRunRequest (extra='forbid'): job_id, profile,
datasets: [{id}] (an id may be run:<job_id>), models[] and
quantize. A model is one of {source: "run", run_id, backend, mode},
{source: "baseline", name} or {source: "custom", name, backend, weights, triton_model, imgsz, mode, class_map, backend_options}. Errors: 400
(no models, no datasets, unknown dataset, run or baseline id, duplicate model
keys, unknown profile), 422 (a single-class run over several classes on a
multi-class dataset, unknown fields), 409 (the job id exists, or
GPU-resident containers could not be stopped).
BakeoffComparison: thresholds, dataset, eval_classes,
common_classes, rank_by, rank_scope (common or overall), models[]
(rank, overall, common, per_class[], coverage, class_mapping,
train_test_overlap, latency_ms, fps, size_mb, per_stratum),
failed[], warnings[], n_models. A model's classes map onto the eval
classes by name (class_mapping.method: explicit, run_class_remap,
registry_ids, names, single_class_fallback); unmapped and not-covered
classes are reported.
| Method | Path | Notes |
|---|---|---|
| POST | /scores/compute |
{scorers?}. Starts a scoring job. 400 when OP_SCORES_ENABLED is off or a scorer is unknown, 409 while one runs |
| GET | /scores/status |
job state |
| POST | /scores/cancel |
|
| GET | /scores/coverage |
{coverage}: how many items carry each score field. Read-only routes work with the flag off |
| POST | /select/diverse |
{k, scope: {cluster_id, filters, review_tab}, seed_crop_id}. Answers inline with {crop_ids, method, version, n_pool} (200) when the pool is small enough (OP_SELECT_SYNC_MAX_OPS), otherwise starts a background job (202). 400 when OP_SELECT_DIVERSE_ENABLED is off, 409 while a job runs |
| GET | /select/status |
job state. result (crop_ids, method, version, n_pool) is set once status is completed |
| POST | /select/cancel |
|
| GET | /viz/projection |
cluster_id, class_id, max_points (default 50000). 400 when OP_VIZ_PROJECTION_ENABLED is off |
| POST | /viz/projection/rebuild |
scope (residual default, or cluster with cluster_id). 202 |
| GET | /viz/projection/status |
|
| POST | /viz/projection/cancel |
|
| POST | /probe/run |
{job_id, architecture, gpu, resume}. Runs the active-learning probe from a finished training job (409 when that job is unknown, not finished, has no checkpoint, or the GPU cannot be claimed; one job at a time) |
| GET | /probe/status |
ProbeStatusResponse (status, job_id, train_job_id, model_path, gpu, updated_count, error, actionable_min_confidence) |
| POST | /probe/cancel |
|
| GET | /search/text |
q (required), page, page_size, cluster_id, tab, date_from, date_to, max_rank, min_blur_ratio, hide_near_duplicates, min_score, include_test. Items plus semantic_score. 400 when OP_SEMANTIC_SEARCH_ENABLED is off, 503 while the text encoder is not ready |
| GET | /stats |
project counts, indexes, disk, jobs |
| GET | /stats/classes |
per-class counts with thresholds, adequacy, trainable, trainable_gap, aug_target, aug_gap |
| GET | /stats/dataset |
the dashboard roll-up |
| POST | /test_holdout/freeze |
{percent} (1 to 50, default 10), query force |
| GET | /test_holdout/stats |
per-class holdout counts, min_test_per_class, deficient |
| GET | /training_cohorts |
see Cohorts |
POST /test_holdout/freeze is deterministic: per class, the items with the
smallest sha1(crop_id), max(min_per_class, round(n * percent / 100)) of
them, capped at the class size. There is no seed (a seed key is 422). A
second freeze without force=true is 409. A selection that would pick zero
items is 422. Response: n_frozen, n_classes_covered,
test_holdout_sha, per_class_counts, selection (sha1_per_class),
percent, min_per_class (5).
GET /stats/dataset:
labeled:by_human,by_vlm,by_classifier,by_import,other. Built only from items that carry aclass_id.unlabeled:pending_detection,pending_verification,no_label_source,vlm_no_class(a VLM answered or proposed but never landed a class) andby_proposal(the detector proposed it, nothing classified it). The last two are disjoint subsets ofno_label_source.regions:boxed,confirmed,total_detected,by_detector,by_segmenter,by_human_drew,by_import,verified_by_human,verified_by_vlm(every non-human, non-import verifier),verified_by_import,validated_by_human,validated_by_import. The detector and segmenter buckets match the active profile's names.embedding:{embedded, not_embedded, by_state};by_statealways has the keysembedded,not_selected,deferred,failedandunknown(items written beforeembedding_stateexisted; it is not a value of theembedding_statefilter).validated_by_import,as_of,total_crops,validated,test_holdout,by_source,in_progress(withregion_stall_reason, the same text asGET /ingest/region_drain).clusters:cluster_count(distinct non-noise clusters now),last_run_cluster_count,last_run_at,method,residual_count,noise_count.
| Method | Path | Notes |
|---|---|---|
| GET | /crops/{crop_id}/thumbnail |
JPEG, size 32 to 512 |
| GET | /crops/{crop_id}/image |
the clean source render: EXIF-transposed, RGB, optional max_dim (128 to 8192). It draws no overlay, so the bytes do not depend on any box. Draw boxes from GET /crops/{crop_id}/context |
| GET | /crops/{crop_id}/region_thumbnail |
see Regions |
| GET | /images/serve |
path (required). Streams a source image, choosing the configured root that matches the path |
| GET | /images/root/{alias} |
path (required, relative to the alias root). 404 for an unknown alias |
| GET | /images/cache/stats |
thumbnail cache counters |
The server serves clean images and database metadata. A client draws boxes, labels and styling itself.
Server-sent events. A scoped stream carries only its own project's events. The
global stream carries the families project.*, combine.* and vlm.*.
Events are advisory: each subscriber has a bounded queue and the oldest drop
on overflow.
| Method | Path | Notes |
|---|---|---|
| GET | /curation/events |
global stream; topic filter |
| GET | /events |
the bound project's stream; topic and class_id filters |
| POST | /events/publish |
used by the detection worker. _PublishEvent: type, crop_id, class_id, class_name, class_source, region_status, region_count, image_path, topic, extra (extra='forbid'). A global event type is 422; extra.project must be the bound project |
| GET | /events/stats |
bus, log_path, subscribers, events_published, events_dropped |
| GET | /pipeline/events |
auto-label dashboard stream |
Event types: project.created, project.updated, project.archived,
project.unarchived, project.deleted (global, with target, status and
revision), project.paused and project.resumed, combine.progress
(target, job_id, phase, done, total, status), vlm.changed
(axis: registry or local_vlm), config.changed (project, with axis
of prompt_pack, detection_profile, vlm, keymap, ...),
classes.changed, crop.created, crop.classified and
crop.region_verified.
crop.region_verified data: type, topic (region_status), crop_id,
region_status, region_count, ts. The event hub is cross-process by
default (OP_EVENT_BUS=file): every API worker tails a shared bounded log, so
a client connected to any worker sees events from any other and from the
detection worker.
A label or box that a human or a dataset import set is never touched by an
automated writer. The rule has one implementation (src/clients/occ_locks.py)
and is checked inside the write, not only before it, so an edit that lands
between planning and writing still wins.
| Level | Locked when |
|---|---|
| Class | a human set or confirmed it (class_source or label_source contains human); or it is a validated imported label (class_source: external_label and class_validated); or the item is in the frozen test holdout (test_holdout) |
| Box | a human created it, gave a verdict on it (a human reject, rejection_reason is the human reason) or typed its text (text_source: human); or it came from a dataset import (source: import) and is not a mere suggestion (state is not proposed) |
| Item | its class is locked, or any of its boxes is locked, or its box set is validated with region_verifier of human or import |
An import with label_trust: suggestion writes unvalidated labels and
proposed boxes, which the machine pipeline may still replace.
Automated writers that honor the rule: the detection worker (classification
gate, bulk writer and box merge), POST /vlm/label_batch, re-ingest, region
clustering and false-positive pulls, POST /reprocess (reported as
locked_skipped), the VLM stages of the auto-label pipeline, dataset import
and combine.
On the wire:
label_lockedon every item is the evaluation for the whole item. A client reads it instead of recomputing it.lockedon everyregion_boxes[]element is the evaluation for that box.- A dataset import reports labels it refused as
label_conflicts_locked. A combine flags a conflicting box withcombine_conflictand keeps the priority label. - Human routes are not blocked by the rule. A human can always overwrite a human, imported or machine label, and can undo it.
A class is identified by its name inside one project. The integer id belongs to a project's registry and never crosses a project boundary or a file format unchanged.
| Hop | How the class is matched |
|---|---|
| Dataset import | the dataset's class names are mapped (map, create, skip, region) onto the project's registry by name, never by index. Two datasets with the same names in a different order land on the same classes |
| Combine | each source class is mapped by name onto a target class. The target owns its ids. Class ids, class-id history and cluster ids of a source do not cross |
| Export | the dataset uses a dense export_id (0 to n-1) assigned in registry order. class_split_counts and class_registry.json map export_id to class_id and class_name. data.yaml names: lists the names |
| Training | a subset or single-class run records class_remap.json (original id to trained id) |
| Promote | labels.txt is written from the remap, so line i is the name of trained class i. Without a resolvable remap, promote is refused (class_remap_missing) |
| Another project using a promoted model | the model's class names are matched by name onto that project's registry (GET /models/{model_name}/class_mapping); unmatched names are reported, never guessed |
| Bake-off | a model's classes map onto the eval dataset's classes by name or an explicit map; unmapped and uncovered classes are reported |
Rules that follow from this:
- Class names match
^[a-z0-9_]+$and are unique among active classes. - Renaming a class changes its name everywhere the next time the name is read. A merge records the relabel; a restore of a merged class is refused.
ClassEntry.kindisregionfor the class named like the active region profile'sregion_class_name. That class labels a sub-box, not a whole item.tests/integration/test_class_identity_e2e.pyandtests/integration/test_class_identity_combine.pyassert the(class_id, class_name)pairing at every hop from import or combine through export, remap, promote and predict.
The public example detects wheels on cars. All data is public: COCO car
images. examples/region_profiles/vehicle_wheel.json is a text-free region
profile (no detector leg, a segmenter text prompt wheel, parent_classes
of car, max_regions_per_item: 4, text_reader: none).
examples/prompt_packs/vehicle_wheel.json is the matching prompt pack.
examples/bakeoff/vehicle_wheel/profile.json is the bake-off profile.
Fetch the images with make sample-coco-cars.
The walk, in order (it is what scripts/examples/wheel_example_live.py
runs against a live stack, and what
tests/integration/test_wheel_example_e2e.py runs offline with fakes at the
OpenSearch, Triton, segmenter and VLM boundaries):
| Step | Call |
|---|---|
| Create the project | POST /curation/projects with {slug, display_name} |
| Create the classes | POST /classes for car and wheel |
| Add the profile and pack | POST /region_profiles and POST /prompt_packs with the example bodies |
| Activate them | POST /region_profiles/{name}/activate and POST /prompt_packs/{name}/activate |
| Activate a VLM endpoint (optional) | POST /vlm/endpoints/{name}/activate |
| Ingest | POST /ingest/batch |
| Wait for the region worker | poll GET /ingest/region_drain until drained |
| Review | GET /review/{tab} with the regions tab, then PATCH /crops/{crop_id}/regions/{box_id} or PUT /crops/{crop_id}/regions |
| Export wheels cropped to their car | POST /export/single_class with box_source: region, region_class_name: wheel, image_mode: item_crop |
Screenshot pending: Cropwright (the wheel review screen with several region boxes on one car)
v0.4.0 is a fresh wire. Deployments re-ingest; there is no data migration. The table lists renamed or restructured wire surfaces.
| Surface | Now |
|---|---|
| Per-box item scalars (box geometry, score, detector, text, cluster, thumbnail, parent-frame box) | elements of region_boxes[]: bbox_norm, score, detector, text*, cluster_*, thumbnail_url, bbox_in_parent. The item carries only region_* set-level fields |
| Region attributes with a domain prefix | region_<attr>, fixed names independent of storage names |
Vendor-named VLM and classifier fields, class_source and label_source values, review tab and history writer ids |
vlm_* and classifier_*; ingest-profile-derived source ids ({primary}_proposal, {secondary}_model) |
| Domain-named statuses | no_region_box, no_region_visible |
| Single-box region writes | PUT /crops/{crop_id}/regions, PUT /crops/batch_regions, PATCH /crops/{crop_id}/regions/{box_id}, POST /regions/batch_box_state |
| Labeled-dataset import through ingest | /datasets/*. IngestBatchItem is {path, source} and BatchIngestSummaryResponse has no label-import counters |
| Requeue, clear-detection and retry paths | POST /reprocess |
| One configured VLM | the endpoint registry, a per-project vlm axis and per-run ?vlm=. GET /models/status VLM rows have kind: vlm |
region_bbox_correct, region_confidence, region_text as item keys |
box keys bbox_correct, confidence, text (the VLM reply keys keep their protocol names) |
| Region clustering counts | n_boxes, n_boxes_changed, n_items_written, n_boxes_updated, n_items, n_items_updated, n_boxes_scanned, n_boxes_moved; cluster cards add size (items) and box_count |
| Ingest response region count | n_regions |
GET /stats/dataset |
labeled.by_classifier, regions block (was domain-named), unlabeled.by_proposal, unlabeled.vlm_no_class, validated_by_import, labeled.by_import, regions.by_import |
| Training status metric pair | last_epoch_metric and best_checkpoint_metric |
| Class merge dry run | validations_carried_over (a merge keeps class_validated) |
GET /crops/{crop_id}/image |
the clean render, no overlay |
GET /clusters |
representatives filled only for the offset/limit window |
GET /crops |
classifier_conf_lt, limit, sort, conf_min, conf_max, k, item_text, import_id, dataset_split, on_negative_frame, proposed_by_import |
GET /health |
vlm (the active endpoint), region_profile, mlflow_public_url |
| Environment variables | VLM and segmenter connection variables are OP_VLM_* and OP_SEGMENTER_*; retired names are listed in src/config/retired_env.py |
| Item key count | 106 keys; the list in contracts/json/item_wire.json is authoritative |
- This document and the OpenAPI file are the shared contract. No wire change
ships without a matching change here.
tests/curation/test_wire_contract.pypins the item key set.scripts/docs/check_docs_vs_code.pychecks that every route written in the docs exists. - Gate optional UI on
GET /methods,GET /review/tabsandGET /regions/vocabulary. They serve labels, options and defaults, so a client does not hardcode vocabulary. - A backend outage is
503. An empty list always means no matching data. - Not on the wire: backend OpenSearch field names, the internal tracking host, API keys, and filesystem paths of training artifacts.