The contract between the 3DScan server and its clients. Routes live under /api, on
http://127.0.0.1:9010 by default, with generated OpenAPI docs at /docs while sign-in is off.
ARCHITECTURE.md explains the system behind them.
- Authentication. A session cookie while sign-in is on, none while it is off. See Sign-in.
- Identifiers. Case, revision, job, session and upload IDs are 32 lowercase hex characters. Malformed is 400. Well-formed but unknown is 404.
- Coordinates. Patient RAS millimetres. Voxel arrays use nibabel order (i, j, k) with the full affine.
- Long work returns a Job. Poll
GET /api/jobs/{id}untilstatusis final. - Bodies. JSON bodies reject unknown fields and non-finite numbers. Times are UTC ISO 8601.
- Legacy names. The
ct-volumeroutes repeatimage-volumefor older clients.hu,kind: "ct"andX-CT-Framecover any scalar image, not only Hounsfield units: readscalar.unitsandmodality.
Errors return {"detail": string}. Schema errors use FastAPI's validation list. A rejected Host
or an unexpected 500 is plain text.
| Status | Meaning |
|---|---|
| 400 | ValueError: invalid input. Also a Host outside the allowlist |
| 401 | No valid session, or a failed sign-in |
| 403 | A cross-origin mutation, access this account lacks (SessionGate, errors.Forbidden), or a wrong password on Delete forever |
| 404 | Absent (errors.NotFound), in another case, in Trash, deleted forever, or an unfinished upload another account started |
| 406 | No acceptable GLB encoding |
| 409 | Stale state, so reload before retrying; work under way must finish first; or a People change that would leave no admin or link a disabled account: ReviewConflict, ImportConflict, OrthancChanged, ArchiveChanged, errors.Conflict |
| 410 | A set-password link no longer works |
| 411, 413 | A sign-in, set-password or captured-view body without a plain Content-Length, or over its limit. 413 also means an offline HTML export is too large (ExportTooLarge) |
| 422 | Schema validation failed |
| 429 | Too many password attempts, or no hashing slot within a second |
| 499 | Client left while a volume-mask or volume-labels union was being built |
| 500 | Any other exception, including a bare KeyError, logged without internal detail |
| 502 | The configured Orthanc or DICOMweb archive failed (OrthancError, ArchiveError) |
| 503 | A cached surface or viewer bundle is missing |
Schema errors on the license, sign-in, set-password and Delete forever routes use fixed messages, so a key, password or link code is never echoed.
- Origin. A non-GET request whose
Origindiffers fromHostgets 403. Hosts must appear inSCAN3D_ALLOWED_HOSTS. - Private responses.
/api/session,/api/session/redeem,/api/people,/api/people/*,/api/orthanc/*,/api/archive/*, the license route, Delete forever,.../dicom-seg,.../dicom-view,.../dicom-view-fileand a preparedsegment.dcmorview.dcmuseCache-Control: no-store, errors included. Any other/api/response without its own header usesprivate, no-store. - Viewer data. Volume chunks are gzip, keyed by content. Chunks, surfaces and
model.glbuseCache-Control: private, no-cachewith anETag, and a matchingIf-None-Matchgets a 304.slicer.ziphas the same policy but never answers 304. - Compression. JSON and viewer assets of at least 1 KiB use dynamic gzip. GLB routes negotiate
stored gzip files.
slicer.zip,export.html,segment.dcmandview.dcmare left alone and keepContent-Length.
With sign-in off, every request acts as one built-in local user, an
admin with archive access, and every rule below passes, except that a case in Trash still reads as
absent. In password mode /docs, /redoc and /openapi.json are gone, and a request is refused
before its route reads a body.
| Route | Result |
|---|---|
GET /api/session |
Always 200 with the view |
POST /api/session |
Takes {username, password, shared_device?}. Returns the view and Set-Cookie |
DELETE /api/session |
Ends any session, clears its cookie, sends Clear-Site-Data: "cache" and returns the view |
POST /api/session/sign-out-others |
Ends this account's other sessions. Returns {signed_out} |
POST /api/session/redeem |
Takes {code, password, shared_device?} from a People link, sets that person's password, and returns the view and Set-Cookie |
The view is {mode, user, setup_required}. mode is off or password. user is null or
{id, username, display_name, role, archive_access}, and always the local user with sign-in off.
setup_required is true in password mode with no accounts.
Refusals of sign-in and redeem:
| Status | detail |
When |
|---|---|---|
| 400 | Sign-in is off on this workstation |
The mode is off |
| 400 | A password needs 10 to 1,024 characters |
Redeem with a live link, which still works afterwards |
| 401 | Wrong username or password |
Every failed sign-in, known name or not |
| 403 | Sign in through the HTTPS address, or on redeem Set your password through the HTTPS address |
Origin is not https and Host is not loopback |
| 410 | This link has expired or was already used. Ask an administrator for a new one. |
Redeem with a link that is unknown, expired, used or replaced, for a disabled account, or made by someone no longer an active admin |
| 411 | A sign-in request must declare its Content-Length. |
No plain declared length, or chunked framing |
| 413 | The sign-in request is too large. |
Over 4 KiB |
| 422 | Invalid sign-in request., or on redeem Invalid set-password request. |
Schema failure |
| 429 | Too many attempts. Try again in N seconds. |
A throttled sign-in, or either route waiting over a second for a hashing slot (N is 1), with Retry-After |
- Each username gets three free failures, then waits of 1, 2, 4, 8 and 16 seconds, then 30 seconds. A wait refuses without checking the password and never locks the account. At most two password hashes run at once, shared by sign-in, redeem and Delete forever.
- Redeem refuses a dead link before hashing. A link works once, and of two uses at once the first to write wins. It signs the browser out of any other person's session.
- A new password, from a link or
scan3d users passwd, ends that person's sessions and every link for or by them.
Every refusal uses Cache-Control: no-store. Where two are shown, a request with no session gets
the 401, and a signed-in one the other.
| Rule | Routes | Refusal |
|---|---|---|
| Public | GET /api/health, GET, POST, DELETE /api/session, POST /api/session/redeem, the viewer files |
None |
| Signed in | Every other /api/ route with no case, job or upload ID, unless listed below |
401 Sign in to continue |
| Admin | POST /api/cases/import-local, GET /api/storage, POST /api/storage/clear-caches, PUT, DELETE /api/totalsegmentator/license, the People routes |
401, 403 Administrator access is required |
| Archive | /api/orthanc/* and /api/archive/* |
401, 403 This account cannot use the archive |
| Case | Every route with a case ID in its path, for a case not in Trash | 401, 404 Case not found |
| Case and archive | GET, POST of /api/cases/{id}/orthanc-series, .../archive-series and .../dicom-view |
401, 404 Case not found, 403 This account cannot use the archive |
| Trashed | POST /api/trash/{id}/restore |
401, 404 Case not found |
| Admin, in Trash | POST /api/trash/{id}/purge |
401, 403 Administrator access is required, 404 Case not found |
| Job | GET /api/jobs/{id}, GET .../measurements, POST .../cancel |
401, 404 Job not found |
| Upload | POST /api/imports/{id}/parts, .../finish, DELETE /api/imports/{id} |
401, 404 Upload not found |
- One shared library: every account opens every case, and case lists, sizes and agreement cover every case.
- A case in Trash reads as absent in both modes, with the route's own 404:
Case not found,Job not foundorUpload not found. With sign-in on, a malformed case, job or upload ID is also 404. POST .../dicom-segalso needs archive access unlessdeliveryisfile, or it is 403This account cannot use the archive.- A job follows its case, and so does a finished upload. An unfinished upload belongs to whoever started it, or, with no owner or started with sign-in off, to any admin.
- A session cookie on a request other than GET, HEAD or OPTIONS with no
Origingets 403Cross-origin mutations are not allowed. - A route with no rule gets 403
This route has no access rule, in either mode.
These exist only in password mode, for an admin. Each change is audited and returns the Person
unless noted. A change to what the person already has succeeds and writes nothing.
| Route | Change |
|---|---|
GET /api/people |
None. Every account, oldest first, as Person[] |
POST /api/people |
Takes {username, display_name?, role?} and adds a person, a member by default, with no password and archive access on. Returns {person, link} |
POST /api/people/{id}/link |
Returns a new link, invite before the person has a password and reset after, ending any earlier one |
POST /api/people/{id}/role |
Takes {role}, admin or member |
POST /api/people/{id}/archive |
Takes {archive_access} |
POST /api/people/{id}/disable |
Stops sign-in and ends the person's sessions and links |
POST /api/people/{id}/enable |
Allows sign-in again, bringing back no session or link |
POST /api/people/{id}/sign-out |
Ends the person's sessions. Returns {signed_out} |
- A
Personis{id, username, display_name, role, archive_access, state, created_at, last_seen_at, link_expires_at}.stateisactive,invitedbefore a password is set, ordisabled.last_seen_atis the last sign-in, set-password or session slide, andlink_expires_atis when the person's working link expires, or null if none. - A link is
{code, purpose, expires_at}and lasts 24 hours. The answer is the only placecodeappears. Redeem takes it. - A link works only while its maker is an active admin: the
adminrole, not disabled, with a password. Disabling or demoting the maker ends their links, and undoing that brings none back. - Work already queued or running finishes when archive access goes off or a person is disabled.
| Status | detail |
When |
|---|---|---|
| 400 | A username needs 1 to 64 letters, digits or . _ @ - |
Add |
| 400 | A display name needs 1 to 128 printable characters |
Add |
| 400 | That username is already in use |
Add, with a name taken in any case |
| 400 | You cannot change your own role or disable your own account |
Role or disable on your own account |
| 403 | Administrator access is required |
A member, or an admin demoted or disabled since the request started |
| 404 | Person not found |
No account has that ID |
| 409 | Keep at least one administrator who can sign in |
Demoting or disabling the last active admin |
| 409 | Enable this account before making a link |
A link for a disabled account |
- The name is
scan3d_session, or__Host-scan3d_sessionwithSecureon an httpsOrigin. Attributes areHttpOnly; SameSite=Strict; Path=/; Max-Age=2592000, with noDomain. - A session lasts 30 days and slides. Its expiry moves at most every 10 minutes, with a fresh
Set-Cookieunder the same name. - With
shared_device, the cookie has noMax-Age, the session never slides and it ends after 12 hours. Responses on non-public routes then useCache-Control: no-store.
Public records never contain filesystem paths or archive credentials. added_by and started_by
give the display name of whoever added the case or started the job or draft when that is someone
else, else null. With sign-in off they are always null.
{id, label, created_at, status, active_revision_id, selected_series_uid, phase,
series: Series[], error?, import_source?, synthetic?}
status is importing, imported, ready, import_failed or import_cancelled. Orthanc imports
set import_source: {kind, source_key, study_uid, series_uid, frame_of_reference_uid, snapshot, instance_count, content_digest, manifest_sha256, completeness, warnings}.
Only GET /api/cases adds opened_at, when this account last opened the case, or null. It and
GET /api/trash add added_by.
{uid, description, modality, study_uid, frame_of_reference_uid, sop_class_uids, slice_count,
spacing, warnings, supported, unsupported_reason}
MR series add acquisition metadata such as sequence_name and echo_time_ms. A disabled modality's
series reads supported: false until it is enabled.
An immutable source image.
{schema_version: 1, id, case_id, kind: "volume3d", modality, content_sha256,
source: {series_uid, study_uid?, frame_of_reference_uid?, sop_class_uids?,
source_archive_key?, source_content_sha256?, source_manifest_sha256?},
frame_selector, geometry: Geometry, scalar: {dtype, slope, intercept, units},
presentation: {units, background_value, default_window, default_level,
photometric_interpretation, preset_group, preview_quantization_step},
capabilities: ("mpr" | "volume_rendering" | "segmentation" | "voxel_measurements")[]}
Geometry is {shape, affine_ras, spacing, bounds_ras, center_ras}.
{id, case_id, parent_id, kind: "ct" | "image" | "segmentation", created_at, review_status, source,
operation, geometry, reference_image_id, image_asset, structures: Structure[], qc: string[],
acquisition, synthetic, model_url, model_manifest_url, surface_assets, mesh_statistics,
processing_timings, pipeline_version, pipeline_code_sha256,
model_provenance?, segmentation_settings?, segmentation_runs?}
review_status:unreviewed,reviewedorcorrected.source:dicom,totalsegmentator,nnunet_model,refinement,slicer_correction,include_all_outputs,dicom_seg_import,synthetic_demo,direct_edit_correction,direct_edit_new_structure,nninteractive_correction,nninteractive_new_structureorgrow_branch.acquisition:{series_uid, profile, phase, cardiac_phase?, rr_percent?}.
{id, name, task, group, color, volume_ml, voxel_count, centroid_ras, bounds_ras, qc: string[],
reference_image_id, relationship?, growable?}
growable is true when the segment's CAS-Net run stored its probability, so
Grow branch can use it. Later edits of the segment keep it.
Volumes are nonzero voxels × |det(affine[:3,:3])| / 1000 mL. centroid_ras and bounds_ras are
null for an empty mask. DICOM SEG structures also carry a dicom_seg provenance object.
{id, case_id, kind, status, stage, error, revision_id, created_at, started_at?, completed_at?,
progress?: {phase, completed, total, timings_seconds?, bytes?},
result?, review_session_id?, source_revision_id?, inference_path?: {id, label, identity},
delivery_started?}
status:queued,running,completed,failedorcancelled. A restart fails running jobs with stageInterrupted.stageis display text.kind:process,segment,refine,correction,include_all(only fromscan3d include-all),review_predict,review_accept,orthanc_import,archive_import(DICOMweb),orthanc_export(Orthanc SEG exports and every SEG file),orthanc_roi_export(OsiriX ROIs),orthanc_view_export,view_file_export,archive_seg_export,archive_view_export(DICOMweb),direct_seg_export,direct_view_export(C-STORE),dicom_seg_importorcoronary_analysis. ARCHITECTURE.md lists the lanes.revision_id: the new revision once committed, the exported one for an export, or the analysed one for a coronary analysis, which commits none.delivery_started:trueonce an export has started a send, through Orthanc or directly. Cancelling cannot then stop it.inference_path: the inference path frozen at queue time.identityisdefaultor<id>|<device>|<gpu_name>; compare it whole, never parse it.started_by: onGET /api/jobs/{id}andGET /api/cases/{id}/jobsonly.
inference_path appears on a segment job that named a path, default included, and on a
review_predict that runs the model and named a path or belongs to a draft bound to one. Other
jobs omit it, including direct and relationship-only review_predict.
{id, case_id, revision_id, structure_id, method, status, generation, prompts, options, batches,
relationship, relationship_suggestions, relationship_dirty, dirty, pending_count,
applied_prompt_count, job_id, candidate: Candidate | null, error, accepted_revision_id,
created_at, updated_at, new_structure, inference_path, started_by, target?, preview_revision_id?}
status:draft,predicting,ready,accepting,acceptedordiscarded.acceptedanddiscardedare closed. A failed or cancelled job returns todraft, orreadyif a candidate remains, witherrorset and prompts kept.targetandpreview_revision_idappear whennew_structureis true.inference_path: the path that made the current result, as{id, label, identity}. Null whilebatchesis empty, or if the publishing job named no path. Only a published model result changes it.
{generation, revision_id, summary, created_at, relationship, overlaps, warnings,
total_changed_voxels, relationship_changed, affected_structures, preview_3d_ready?,
model_manifest_url?, gpu_name?}
summary:before_voxels,after_voxels,changed_voxels,added_voxels,removed_voxels,voxel_volume_ml,before_volume_ml,after_volume_ml,volume_delta_ml.affected_structures:{id, name, role: "target" | "exclusive_sibling", summary, metadata_only, relationship}.overlaps:{id, name, voxel_count, volume_ml, policy: "removed" | "preserved", reason}.warnings: lines to show the reader, copied into the saved revision'sqc. An nnInteractive candidate adds one when it removes 5% or more of the saved mask's interior, voxels over 2 mm deep, more than 40 mm from every mark.gpu_name: the GPU the run measured, when the job named a path and the run reported one. A relationship-only Apply keeps the one it reuses.
| Method and path | Purpose |
|---|---|
/api/session, POST .../sign-out-others, POST .../redeem |
Sign-in |
/api/people, POST /api/people/{id}/... |
People |
GET /api/health, /api/imaging-capabilities, /api/segmentation-options, /api/interactive/status, GET, PUT, DELETE /api/totalsegmentator/license |
System routes |
GET /api/agreement |
Pooled agreement report |
GET /api/cases, GET, PATCH /api/cases/{id}, POST .../open, .../trash, GET /api/trash, POST /api/trash/{id}/..., GET /api/sizes, GET /api/storage, POST /api/storage/clear-caches |
Case records and the Library |
POST /api/cases/demo, .../import, .../import-local, /api/imports/... |
Imports |
/api/orthanc/..., /api/cases/{id}/orthanc-series |
Orthanc routes |
/api/archive/..., /api/cases/{id}/archive-series |
DICOMweb archive |
POST /api/cases/{id}/process, .../segment, .../refinements, .../activate |
Preparation and segmentation |
POST /api/cases/{id}/corrections, .../dicom-seg/imports |
Slicer correction, DICOM SEG import |
GET /api/cases/{id}/images, .../images/{image} |
Image assets |
GET /api/cases/{id}/jobs, /api/jobs/{id}/... |
A case's jobs, newest first; Jobs |
GET /api/cases/{id}/revisions, GET, PATCH .../revisions/{rid} |
Revision history, one revision, set review status |
/api/cases/{id}/reviews/... |
Correction drafts |
Revision routes below are relative to /api/cases/{id}/revisions/{rid}:
| Method and path | Purpose | Candidate alias |
|---|---|---|
GET .../image-volume, .../{key}/chunks/{index} |
Scalar descriptor and chunks (legacy ct-volume) |
Yes |
GET .../mask-volume, .../{key}/chunks/{index} |
Cropped mask and parent | Yes |
GET .../volume-mask, .../{key}/chunks/{index} |
Display union | Yes |
GET .../volume-labels, .../{key}/chunks/{index} |
Display label union | Yes |
GET .../review-frame |
Exact slice with binary overlays | Yes |
GET .../probe |
Voxel value at a RAS point | Yes |
GET .../model-manifest |
Per-structure surface URLs | If prepared |
GET .../surfaces/{key}.glb |
One structure's surface | If prepared |
GET .../model.glb |
Combined surface model | No |
GET .../agreement |
Agreement with model outputs | No |
GET, POST .../coronary-analysis |
Coronary analysis runs and start | No |
GET .../coronary-analysis/{analysis_id} |
One coronary analysis result | No |
GET .../cinematic-reference |
Cardiac HU references | No |
GET .../slicer.zip |
Slicer bundle | No |
GET, POST .../export.html |
Offline HTML viewer | No |
GET, POST .../dicom-seg |
SEG and OsiriX ROI export | No |
GET .../dicom-seg/{export_id}/segment.dcm |
Prepared SEG file | No |
GET, POST .../dicom-view |
3D view export status and start | No |
GET, POST .../dicom-view-file |
3D view file status and start | No |
GET .../dicom-view-file/{export_id}/view.dcm |
Prepared 3D view file | No |
GET /api/health returns {status, worker, lanes, modalities, gpu, version} and never loads
PyTorch. With sign-in on, gpu is left out for a request with no session.
lanesmapsgpuandcputo thread liveness, andworkeris true only when all are alive. Without the worker lock,lanesis empty andworkerfalse.modalities: what this installation offers.gpu: the default path only, withready,device(theSCAN3D_DEVICErequest),dcm2niixandreason.ready: a GPU verified by thescripts/check_gpu.pyproof, sofalseon a CPU installation. A proof addstorch_version, and a matching onehip_version,cuda_version,devicesandchecked_at.
GET /api/imaging-capabilities returns {schema_version, modalities: [{adapter, modality, kind, capabilities, presentation, enabled}]}.
GET /api/segmentation-options?modality=CT returns {engine_version, tasks, license_configured, defaults, remove_small_blobs_mm3, inference_paths}. The default task is total for CT and
total_mr for MR. Each task has id, label, modality, classes, class_count,
model_class_count, description, engine, license_required, license_available, available,
uses_organ_crop, robust_crop_default, robust_crop_supported, body_seg_supported,
remove_small_blobs_supported and high_order_resampling_supported. A pinned nnU-Net model adds engine_version and comparison: true,
and available is false until its weights are installed.
License. GET /api/totalsegmentator/license returns {configured, validated, validated_at}
offline. PUT takes {license_key}, checks its format, validates it with the TotalSegmentator
service and stores it. DELETE removes it, and both changes are 409 while any job is queued or
running.
GET /api/interactive/status returns {installed, version, model_available, available, reason, model_id, model_revision, license, capabilities, inference_paths}, from packages and files only.
capabilities are point, bbox2d, scribble, lasso and initial_label, with bbox3d false.
Listing paths describes inference_paths.
POST /api/cases/import takes multipart files (folders and ZIPs, at most 1,000 files) and
optional label, and returns a Case.
POST /api/cases/import-local takes {path, label?} for a directory or ZIP under
SCAN3D_IMPORT_ROOTS, and copies the files. Symlinks and anything inside the data directory are
refused.
Staged uploads are how the browser imports, up to 16 GiB and 100,000 files. Uploads idle for a day are removed.
POST /api/importswith{label?, total_files, total_bytes}returns{id, max_parts, received_files, total_files, received_bytes, total_bytes}.max_partsis 256.POST /api/imports/{id}/partswith multipartmanifest(JSON[{path, offset, size}]) and onefilespart per entry returns progress. Larger files arrive as ordered byte ranges. Resending matching bytes is idempotent, different bytes are a 400, and a gap is 409.POST /api/imports/{id}/finishreturns a Case, the same one if repeated, or 409 until every declared file and byte has arrived.DELETE /api/imports/{id}discards an unfinished upload and returns{id, status: "cancelled"}.
POST /api/cases/demo creates a synthetic phantom with masks, for tests. synthetic: true
keeps it out of real agreement statistics.
These mirror the Orthanc routes for the archive in ARCHIVES.md. UIDs travel in request bodies, never in paths.
| Route | Body | Returns |
|---|---|---|
GET /api/archive/status |
{configured, connected, name, wildcards?, error} |
|
POST /api/archive/studies/query |
As POST /api/orthanc/studies/query |
{studies, offset, limit, has_more} |
POST /api/archive/studies/series |
{study_uid} |
{study, series} |
POST /api/archive/imports |
{study_uid, series_uid, snapshot, label?, reimport?} |
An archive_import Job |
POST /api/archive/launch |
{study_uid, series_uid, source_key} |
{study, series, case_id, job} |
GET /api/cases/{id}/archive-series |
{study, series}, each with in_case |
|
POST /api/cases/{id}/archive-series |
{study_uid, series_uid, snapshot} |
An archive_import Job |
- A series row has
snapshot,stable,completeness(receivingorunknown) andready_in, the seconds until an unchanged series can be imported. Import before then is a 409. reimport: truemakes a new case even when this snapshot was imported before.- A case's
import_sourcehas the Orthanc keys, withkind: "dicomweb".
POST /api/cases/{id}/process takes {series_uid, profile, phase, cardiac_phase?, rr_percent?, expected_active_revision_id?} and returns a process job, which creates an image-only revision
with no inference. Unsupported series and disabled modalities are refused. The labels are metadata
only: they never select frames, change intensities, crop anatomy or choose a task.
profile:chest,abdomen,chest_abdomen(the default),corcta,cardiac,chest_abdomen_pelvis,abdomen_pelvis,pelvis,head_neck,breast,whole_body,otherorunknown.phase: required free text of up to 80 characters, for contrast timing.cardiac_phase(unknown,best_diastolic,best_systolic,diastolic,systolic,other) andrr_percent(0–100): only forcardiacandcorcta.expected_active_revision_id: the active revision the page shows. If another is active when the job saves, it fails withThe active revision changed; start from the current revision, keeping the newer revision. Without it, the new image becomes active regardless.
POST /api/cases/{id}/segment takes {revision_id, task?, robust_crop?, body_seg?, remove_small_blobs?, high_order_resampling?, inference_path?} and returns a segment job. revision_id must be active.
See Choosing a path.
taskmust be in the modality's catalog. Licensed tasks need a configured key.- MR refuses
robust_cropandbody_seg. Body cropping cannot combine with an organ-cropped task. - The run replaces its task's structures and same-named ones of other tasks; see ARCHITECTURE.md. Ambiguous names and conflicting relationships fail before publishing.
- A pinned nnU-Net model runs alone, with no crop or small-blob options, and needs its weights
installed. Its structures match only that model's.
imagecasx_cas_netis pinned the same way, though it is not an nnU-Net, and its run's source is alsonnunet_model. operationrecordsreplacement_policy,replaced_structure_idsandreplacement_id_map, plusruntimeif the run named a path.
POST /api/cases/{id}/refinements takes {revision_id, structure_id, operation} and returns a
refine job. operation is {type, seed_ras?, bounds_ras?}. A result that changes nothing or
empties the mask is refused. If revision_id is no longer active when the job saves, it fails with
The active revision changed; start from the current revision and the newer revision stays.
keep_componentandremove_componentneed a seed inside the mask. Components are six-connected.keep_regionanderase_regionneed bounds with positive extent, and select voxel centres inside world-axis-aligned bounds.
POST /api/cases/{id}/grow-branch takes {revision_id, structure_id, point_ras, threshold},
with threshold 0.1 (default) or 0.05, and returns a grow_branch job that saves the grown mask as
a revision with source grow_branch. The request grows at once, so a refusal answers it:
- The rule: voxels farther than 1 mm from the current mask, in millimetres through the full affine;
the 26-connected piece of CAS-Net probability ≥
thresholdthere holding the clicked voxel; if it misses, the allowed voxel with the highest probability within 1.5 mm seeds it. - Refusals: 400
This segment has no stored CAS-Net probability...for any other segment or a CAS-Net run from before this; 400CAS-Net saw nothing here; use nnInteractive or Direct edit.; 400Grow branch would add N mL, over its 0.5 mL limit...; 409 unlessrevision_idis active. operationrecordspoint_ras,seed_voxel,threshold,added_voxels,added_mlandprovenance, such asGrow branch, CAS-Net probability ≥ 0.1.
POST /api/cases/{id}/activate takes {revision_id, expected_active_revision_id?} and returns
the Case. This is undo and redo: masks, volumes and relationships return exactly. Reset to scan is
this route with the image-only revision.
- If
expected_active_revision_idis no longer active, it answers 409Someone else or another tab saved a newer version of this case. Show it, then try again.and changes nothing. Without it, the route activates the requested revision regardless.
PATCH /api/cases/{id}/revisions/{rid} takes {review_status} and returns the Revision.
Image-only revisions are refused.
GET /api/cases lists every case, the ones this account opened last first, then newest.
PATCH /api/cases/{id} takes {label}, trimmed, and returns the Case. Over 128 characters is
a 422, an empty name or a control character is 400 A case name must contain 1 to 128 printable characters, and a case still importing is 409 This case is still importing. Rename it when the import finishes.
POST /api/cases/{id}/open records an open for this account, putting the case first in its
list, and returns {id}.
POST /api/cases/{id}/trash hides the case, stops its jobs and returns {id, jobs_stopped, sends_continuing}, the last counting sends already started, which finish. Nothing on disk moves,
and Trash is never refused for a job, a draft or a stuck import.
GET /api/trash lists the cases in Trash, last trashed first, each a Case with trashed_at.
POST /api/trash/{id}/restore returns the Case, or 404 Case not found if it is not in Trash.
Stopped jobs stay cancelled, so an import in progress comes back as import_cancelled.
POST /api/trash/{id}/purge deletes a case in Trash forever and returns {id}. It takes
{password}, the admin's own, in password mode, and {confirm: true} with sign-in off.
- Every row of the case goes in one transaction, and its
trashrow and the audit log keep only its ID. A background janitor removes its files moments later. - A case that cannot go yet is refused before any password is checked. The password shares sign-in's throttle.
| Status | detail |
When |
|---|---|---|
| 400 | Enter your password to delete forever. |
Password mode, no password |
| 400 | Confirm to delete forever. |
Sign-in off, no confirm: true |
| 403 | Delete forever through the HTTPS address |
Password mode, Origin is not https and Host is not loopback |
| 403 | Administrator access is required |
A member, or an admin demoted or disabled while the password was checked |
| 403 | Wrong password. |
Never 401, which a browser reads as a lost session |
| 404 | Case not found |
Not in Trash, or already deleted |
| 409 | A send to Orthanc is still finishing. Try again in a few minutes. |
A send of the case to Orthanc is running |
| 409 | Work on this case is still stopping. Try again in a minute. |
Another job of the case is running |
| 409 | This case is still in use. Try again in a minute. |
A request on the case is in flight |
| 422 | Invalid delete request. |
Schema failure |
| 429 | Too many attempts. Try again in N seconds. |
As for sign-in |
GET /api/sizes returns {sizes}, allocated bytes by case ID for every case, Trash included,
measured on each request and leaving out shared caches.
GET /api/storage returns {cases, totals, disk}, in bytes:
cases: every case as{id, label, owner, created_at, trashed_at, size}, whereowneris the display name of whoever added it, ornull.totals:{cases, trash, shared_caches, other, rebuildable}.rebuildableis what Clear caches would free.disk:{total, free}for the data directory's volume.
POST /api/storage/clear-caches empties the caches that rebuild when next needed: every review
volume, the analysis and agreement caches and the surface contours. Saved surfaces, cases and
their coronary analysis results stay.
It returns {freed, storage}, the bytes freed and a fresh storage report, or one of these 409s:
detail |
When |
|---|---|
A backup, a deletion or another 3DScan command is running. Try again when it finishes. |
Something holds maintenance.lock |
Another 3DScan command was running when 3DScan started. Restart 3DScan, then try again. |
Another process held worker.lock at start |
Jobs are running. Try again when they finish. |
A job is running |
Images are still loading. Try again in a minute. |
Another request is in flight, or a review volume is being written |
3DScan cannot remove <name> in its caches. Check its owner and permissions, then try again. |
A cache file or folder the service cannot remove, such as one sudo wrote |
POST /api/jobs/{id}/cancel returns the Job. A queued job cancels at once, and a running job
stops at its next check. Once a DICOM SEG or view send has started, this stops only the local wait;
see DICOM SEG export. Moving a case to Trash cancels its jobs the same way.
GET /api/jobs/{id}/measurements returns {structures, completed, total} while surfaces build.
Ordinary polls leave them out.
GET /api/cases/{id}/reviews lists the case's correction sessions, newest first and closed ones
included, as ReviewSession[]. GET .../reviews/{sid} returns one.
POST /api/cases/{id}/reviews takes {revision_id, structure_id, relationship?, method?}.
method is direct or nninteractive, the API default. The revision must be active and contain
the structure, and starting a review changes no mask.
- An open draft for that revision, structure and method, the newest if two are open, is returned as
it stands.
relationshipis validated but not applied to it; usePATCH. Closed drafts are never returned. - Check
generationandpromptsbefore saving, becausePATCHreplaces every prompt.
POST /api/cases/{id}/reviews/new-structure takes {revision_id, name, group?, color?, relationship?, method?} and creates an empty draft target on the reference grid with a
server-generated custom:<id>. review-<sid>-0 exposes it on the read-only routes. Duplicate names
keep distinct IDs. The revision may be image-only, and saving then makes its first segmentation.
PATCH /api/cases/{id}/reviews/{sid} takes {expected_generation, prompts, options?, relationship?} and replaces the complete ordered prompt list, with no inference.
- Dropping the last prompt is undo. An empty list resets.
- Appending keeps the last candidate for preview and marks the draft dirty.
- Editing an applied prompt drops later boundaries and invalidates the candidate.
- Undoing to an exact applied snapshot restores identical bytes under a fresh alias.
POST .../reviews/{sid}/predict takes {generation, full_refine?, inference_path?, replay?, polarity_guard?}
and returns a review_predict job, which applies every pending mark and publishes a candidate, not
a revision. With nothing pending, it needs full_refine, a changed relationship or replay, or
marks with no candidate, whose applied steps it repeats. See Choosing a path.
- Queuing a run moves
generation, and publishing its result does not. A client that has not seen the run gets 409, so it can neither cancel the run nor replace its result. - A policy-only Apply reuses the previous prediction with no inference.
- After Undo, a failure or a restart, the worker restores the longest run of applied steps from its stored result with no forward pass, if an intact one matches its marks, options, sources, card and build, and runs only the steps after it. Otherwise, or for a schema-1 draft's pointwise steps, it replays from the original mask.
full_refine(nnInteractive only) refines the whole mask from the current candidate, and can take longer than a local correction. Earlier marks keep guiding later runs. If it fails or is cancelled with nothing pending, the earlier candidate returns under a fresh alias and can be accepted.polarity_guard(nnInteractive only) guards the step this run adds for pending marks: if they are all Include, no voxel is removed, and if all Exclude, none is added. Mixed marks and the whole-mask pass are unguarded, and replayed steps keep their own records.
POST .../reviews/{sid}/accept takes {generation} and returns a review_accept job, which
marks the new revision corrected. The candidate must be current and change masks or relationships,
on a still-active base. Only a direct edit of an existing segment may save empty, at 0 mL.
POST .../reviews/{sid}/discard takes {expected_generation}, closes the draft, invalidates
its aliases and cancels a pending run. A running review_accept refuses it with 409.
Stale generations, closed or busy sessions and a changed active base return 409. Prompts and completed candidates survive a restart.
id is unique within a draft: 1–80 letters, digits, underscores or hyphens. include is a boolean.
| Kind | Fields | Method |
|---|---|---|
point |
ras: [R, A, S] |
nnInteractive |
bbox |
axis, index, vertices: [[u0, v0], [u1, v1]] |
nnInteractive |
lasso |
axis, index, vertices, at least 3 with nonzero area |
nnInteractive |
scribble |
axis, index, vertices, at least 2; radius_mm 0.5–30, default 3 |
Both |
scissors |
operation, shape, vertices, ras_to_clip; include: false |
Direct |
- Limits. 128 prompts, 2048 vertices per gesture, 8192 vertices per draft.
- Points must lie inside the reference grid. An omitted
kindmeanspoint. - Drawings.
axisis native i, j or k (0, 1, 2), andindexan integer slice. u, v are the two remaining axes in increasing order, as continuous voxel-centre coordinates within[0, shape-1]. - Coverage. Saving a new
lassoorscribblethat selects no voxel centre is 400, naming the mark, as inLasso mark 2 covers no voxel centre on its slice. Draw it larger.One already in the draft, as from an older build, still saves, and a run fails naming it. - Boxes span exactly one slice. 3D boxes are not supported, and a patient-aligned rectangle on an oblique plane never becomes a native box.
- Direct edits accept
scribble(include: truepaints,falseerases) andscissorsonly. - Scissors.
operationiserase_insideorerase_outside.shapeisfreehand,rectangle(four ordered axis-aligned corners) orcircle. A cut covers the segment's whole depth. - Scissors geometry.
verticesare 3–2048 screen points within [−1, 1].ras_to_clipis an invertible orthographic 4×4 matrix; perspective is refused.
options defaults to {autozoom: true, start_empty: false}. start_empty changes the proposal's
initialization, not the saved mask. Changing options resets applied boundaries.
batches records each Apply as {end, autozoom, full_refine, legacy?, polarity_guard?}: end is
the cumulative prompt count, legacy marks older pointwise sessions, and polarity_guard is
present only on a guarded step, which every replay guards again.
A candidate's revision_id is review-<session>-<generation>. It works on the routes marked in the
route index, never appears in history and cannot supply exports.
GET .../revisions/{alias} returns a read-only view whose source is direct_edit_candidate or
nninteractive_candidate. Both kinds set preview_3d_ready and model_manifest_url.
Relationships describe voxel ownership. A draft stores
{kind, parent_id?, exclusive_with: string[]}.
| Kind | Requires | Effect |
|---|---|---|
independent |
No parent or peers | Keeps every other mask; overlap is reported |
subregion |
parent_id, no peers |
Preserves the parent; does not crop or prove containment |
subdivision |
At least one exclusive_with |
Named peers lose overlapping voxels; a parent is optional |
- For each selected peer B and target A,
B_candidate = B_original & ~A_candidate. - Parents, ancestors and descendants are protected from replacement. Peers with declared parents must share the target's parent.
- Exclusivity applies only to selected peers, never transitively. Cycles, self-references and foreign IDs fail.
- Names, colours, display groups and geometric intersection never authorize a removal. Exact
TotalSegmentator lung-lobe IDs give a
relationship_suggestionsentry, which still needs selecting. - Changing the policy invalidates acceptance of an older candidate.
- Save updates reciprocal peer links. Removing an exclusive link restores no earlier voxels; activating the parent revision does.
- An existing nonempty segment can save a relationship-only change.
An inference path is an interpreter plus a device that AI jobs run on. default is the API's own
interpreter with SCAN3D_DEVICE, and an added path is one proof file
(INSTALL.md, ARCHITECTURE.md).
With none named, a request runs on the default path, or on a draft's own path once it has one.
GET /api/segmentation-options and GET /api/interactive/status return the same inference_paths
rows, default first, then added paths by id. Neither starts a process, loads PyTorch or touches
a GPU. No row holds a local path or proof text.
| Field | Meaning |
|---|---|
id |
The value to send as inference_path |
label |
The GPU's name. default falls back to Default device, or CPU when SCAN3D_DEVICE is cpu. An added path whose proof names no GPU shows its id |
memory_gib |
The GPU's memory to the nearest GiB, or null if unknown |
shares_display |
True when a GPU of this vendor has a connected display now, so its free memory depends on the screen. False when unreadable |
available |
Whether a request may name this path: what its proof records is still installed, and its last check passed. Never means a scan will fit. Always true for default |
reason |
A fixed sentence, or null |
An added path with available: false gives the first of these that applies:
Its GPU check record cannot be read.Its last GPU check failed.Its last install did not finish.Its Python environment is missing.Its PyTorch or GPU runtime packages changed after the last GPU check.Its TotalSegmentator, nnU-Net or nnInteractive version differs from this workstation's.This also coversnninteractive-client.It has cupy or cucim installed, which would change resampling.The GPUs in this workstation changed after the last GPU check.
default gives null or the caution Its last GPU check failed or is out of date., which blocks
nothing. A proof taken before its vendor's cards changed also sets label to Default device and
memory_gib to null, which a job queued then freezes. identity stays default.
inference_path is optional on POST .../segment and POST .../reviews/{sid}/predict. It must
match ^[a-z0-9][a-z0-9-]{0,31}$, and never goes inside options or batches.
- On a segmentation, omitting it and sending
defaultrun alike and share saved results. scan3d segment --inference-path IDtakes the same IDs and refusals.scan3d doctorlists the rows.- A job's path is frozen at queue time. An added path is rechecked as the job starts, and never falls back to another path or the CPU.
defaultfollowsSCAN3D_DEVICE, so underautoit can still run on the CPU.
A draft is free until a model result publishes, then stays on that path while batches is
non-empty:
| Draft | Request | Result |
|---|---|---|
batches empty |
inference_path, or none for default |
Runs there, and a published result binds the draft |
batches set |
None, or the draft's own path | Runs on the draft's path |
batches set |
Another path, with replay false |
409 |
batches set |
None, and another card now holds the draft's path ID | 409, with replay too |
batches set |
replay: true |
Repeats the draft's steps on the named path, or on its own path if none is named |
replay repeats every recorded batch, in order, from the original mask, plus any pending marks
as one more batch. Another card or build can give a slightly different result.
- On its own path, a worker that holds those batches, or can rebuild them from their stored result, runs only pending marks.
- Nothing need be pending, and a pending relationship goes with it as a model run. With
batchesempty, it adds nothing. - Once queued, the new generation makes the old candidate
dirty, so it cannot be accepted. It stays incandidateuntil the new one publishes, which also changes the draft'sinference_path. - After a failed or cancelled re-run it stays, still
dirty, and the draft can re-run on any available path.
Direct Apply and a relationship-only Apply run no model. They ignore inference_path, name no path
and stay on the CPU lane. A direct draft ignores replay too.
A segment revision has operation.runtime only if its job named a path, default included.
| Key | Meaning |
|---|---|
inference_path |
The path's id |
gpu_name |
The GPU the child measured, or null on the CPU |
device |
The device it selected, such as gpu:0 or cpu |
torch_version |
The PyTorch version it measured |
hip_version |
The build's HIP version, or null |
cuda_version |
The build's CUDA version, or null |
reused |
True when this revision reused a saved result, so these values are that run's |
results_moved_to_cpu |
True when nnU-Net re-ran part of a task with results in system memory, usually because GPU memory ran out |
Only added paths check that last key, so on default, false means not detected. True also adds
Part of <task> re-ran with results in system memory on <label>, usually because GPU memory ran out. The run took longer. to the revision's qc.
A saved correction's operation.runtime records what made its candidate:
- nnInteractive. The same
device,gpu_name,torch_version,hip_versionandcuda_version, plusinference_pathif named.miopen_find_modeis null unless the build is HIP.restoredis{batches, mask_sha256}when the run rebuilt its first steps from a stored result, else null, andstored_result_refusedsays why one was not used. - nnInteractive replay check. When a refused stored result was still trusted,
replay_checkis{batches, changed_voxels}from comparing the replay with it at that step, and moved voxels add a candidate warning. - Direct edit.
device: "cpu"and no GPU or PyTorch keys. - Relationship-only. Its prediction's provenance, or
engine: "mask-relationship-processing"and none of these keys with no prediction to reuse.
A bad request is refused before any job exists:
| Status | When | detail |
|---|---|---|
| 422 | A malformed ID, or replay on a segment |
FastAPI's validation list |
| 400 | The path is not set up | This GPU is not set up on this workstation. |
| 400 | The path is unavailable | <label> is unavailable: <reason> |
| 400 | replay with full_refine |
A re-run repeats the draft's own steps, so it cannot add a whole-object refinement |
| 409 | Another path on a draft with batches, or none once another card holds its path ID |
This draft's AI results came from <label>. Re-run its steps to use another GPU. |
Once queued, a problem fails the job, never the route. The job has status: "failed" and keeps its
inference_path, and a review job's error also reaches the draft's error. On an added path:
- Removed path:
<label> is no longer available: This GPU is not set up on this workstation. Nothing ran and no other GPU was tried. - Unavailable path:
<label> is no longer available: <reason> Nothing ran and no other GPU was tried. - Different card or device under that ID:
<label> changed after this job was queued. Nothing ran and no other GPU was tried. Start it again. - PyTorch reports no GPU, in either engine:
GPU inference was requested but PyTorch cannot access a GPU; check the driver, then repeat this GPU's check (see Add a second GPU in docs/INSTALL.md). No other GPU was tried.SCAN3D_DEVICE=cpucannot change an added path. - A card this build cannot see, such as
gpu:1with one card in view:Requested GPU index is unavailable. This one names no card. - Any other device mismatch:
Expected <label>, but the GPU check found <name> (<device>, <build>). No result was kept and no other GPU was tried.<build>isCUDAorno CUDA. - A crop-model task such as
teethon a GPU index above 0:TotalSegmentator would run part of <task> on the first GPU, so it cannot run on <label>. Nothing ran and no other GPU was tried. - A stopped task:
TotalSegmentator <task> failed on <label>: <cause>. No other GPU was tried.<cause>isit ran out of GPU memory,the process was killed, usually because system memory ran outorexit <code>. - A report from another PyTorch:
TotalSegmentator <task> ran on PyTorch <version>, not the <version> that <label> was checked on. No result was kept and no other GPU was tried.
default gives none of these. On a job that named a path, default included, a child that will not
stop gives The AI process did not stop in time., and an absolute path in any other error becomes
<local path>. The service log keeps the detail.
quality is full (default) or preview.
{schema_version: 1, key, kind: "ct", dtype, quality, approximate, preview_quantization_hu,
reference_geometry, shape, affine_ras, geometry, slope, intercept, byte_length, chunk_depth: 16,
chunks: [{index, start_k, depth, byte_offset, byte_length, url}],
reference_image_id, modality, scalar: {units}, presentation, source}
Samples are little-endian in i-fastest order, in gzip slabs of 16 k-slices. key is a
content-derived SHA-256.
- Full: exact int16 or uint16 samples with
slopeandintercept, or float32 with scaling applied for other source types. - Preview: at most 192 per dimension, with
approximate: true.shapeandaffine_rasdiffer fromreference_geometry, but voxel faces match its field of view. CT uses int16 at a 1 HU step, and a zeropreview_quantization_stepkeeps float32 for MRI signal.
mask-volume takes structure_id, compare, quality and margin_mm (0–5), and returns
{schema_version, current, parent}. current and parent are each null or a cropped binary
descriptor with kind: "mask", dtype: "uint8", origin_ijk and empty.
compare adds the parent revision's structure. A nonzero margin_mm grows a display copy only.
Chunk URLs carry structure_id, parent, quality and margin_mm.
volume-mask takes repeated structure_ids and margin_mm, and returns {schema_version, source_id: "volume-mask-union:<key>", current}. An all-empty selection is a valid zero mask. An
empty selection is a 400.
current is one OR union, cropped to the selection's bounds and capped at 8 MiB, with
presentation_only: true, volume_mask_union: true, pooling: "foreground-centre-occupancy",
approximate, selected_structure_ids, native_crop_origin_ijk and native_crop_shape.
volume-labels takes repeated structure_ids in painting order, and returns the same shape
with source_id: "volume-label-union:<key>", volume_label_union: true, volume_mask_union: false
and labels. Value n marks the n-th listed structure, 0 is background, and later structures win
overlaps. At most 255 structures, and no margin.
Both unions are presentation artifacts. Never measure them or use them as masks.
review-frame returns one exact reference-grid slice, as gzip bytes with its metadata in the
X-CT-Frame header.
| Parameter | Meaning |
|---|---|
plane, position_mm |
axial, coronal or sagittal, at a patient position |
native_axis, native_index |
A source-index plane instead of a patient plane; send both |
structure_id |
One overlay, using the original response schema |
structure_ids (repeated) |
Up to 128 independent overlays |
compare |
Adds each structure from the parent revision |
The header holds {meta, dtype: "float32", hu_encoding: "byte-shuffle", pixels, current_offset, parent_offset, overlays?} plus the image context. meta has width, height, pixel_to_ras,
position_mm, plane, extent_mm, overlay_colors and, for native planes,
native: {axis, index}.
- The decompressed body starts with
pixels × 4bytes of unwindowed float32 values, their four byte planes grouped. Reconstruct by interleaving those planes. - Binary overlays follow at the listed byte offsets, one byte per pixel.
- With
structure_ids,overlayslists{id, current_offset, parent_offset}. A missing parent label has a null offset. - On a native plane
nativeis authoritative:planeechoes the request,position_mmholds the index, and pixels map back throughpixel_to_ras. - Native frames keep one pixel per voxel. A frame above 8 Mi pixels, or 256 MiB decoded with its overlays, fails rather than being reduced.
probe?x=&y=&z= returns {hu, value, units, voxel, reference_image_id} at a RAS point. hu
and value hold the same number.
model-manifest returns {schema_version: 1, structures: [{id, key, url}]}, and
surfaces/{key}.glb serves one structure. Reuse unchanged objects by key.
model.glb?detail=false serves the combined model. detail=true builds a detailed export on
first request, and never activates a revision or changes review status.
Coordinates. World coordinates, after node transforms, are metres: scene = (−R, S, A) / 1000, and RAS = (−x, z, y) × 1000. GLB nodes are named by structure ID.
Display encoding. Display GLBs need KHR_mesh_quantization. Use world-space results, never raw
position attributes.
- Positions: uint16, byte stride 8, on one absolute 2⁻¹⁵ m grid shared by all structures,
dequantized by the node's uniform
scaleandtranslation, within 0.0153 mm per axis (0.0264 mm in 3D). - Normals: normalized int8, byte stride 4, within 0.4°. Indices: uint16.
- Meshes above 65,535 vertices become consecutive primitives, repeating only seam vertices.
Detailed GLBs are float32 and uint32 with untransformed nodes. Older revisions keep float32 display
assets until scan3d optimize re-imports them.
Negotiation. These routes serve stored gzip by Accept-Encoding and vary on it, honouring
quality weights and explicit refusal. Forbidding every representation gets 406. ETags are per
representation, If-None-Match takes precedence over If-Modified-Since, and byte ranges use the
original GLB bytes with If-Range.
cinematic-reference measures median HU for a saved revision, for the Cinematic heart preset.
It returns {schema_version, myocardium_hu, blood_hu, myocardium_structure_ids, blood_structure_ids, myocardium_voxels, blood_voxels, blood_source, method}.
It pools native samples from myocardium-named and aorta-named masks, falling back to left heart, excluding values at or below −900 HU. Masks without 100 HU of separation return 400, with guidance to enter values manually.
Read-only reports comparing each saved structure with its model original: the nearest mask in the parent lineage that is a saved inference output with a matching ID.
GET .../revisions/{rid}/agreement returns:
{schema_version: 1, case_id, revision_id, review_status, synthetic, active,
aggregate_pool: "real" | "synthetic" | null, implementation, hd95_exact,
grid: {voxel_volume_ml, spacing_mm, sheared},
summary: {structures, compared, unchanged, changed, excluded, dice_mean, dice_median,
abs_volume_change_ml_mean, edit_kinds},
structures, excluded, cache: {hits, computed}}
Each structures row has id, name, task, group, origin: {kind, revision_id, run_id?, task?, engine_version?}, basis (same_file, same_digest or voxels), unchanged, dice,
hd95_mm, original and current voxels and volumes, volume_change_ml (accepted minus original),
added_voxels, removed_voxels, edit_kind and edits. Synthetic demo cases use their phantom
as the origin.
- Dice is
2|A∩B| / (|A| + |B|). - HD95 is the larger of the two directed 95th percentiles of distances between face-exposed surface voxel centres, in millimetres through the full affine. Null when either mask is empty.
editslists thesourceof each revision that saved a new mask since the original, oldest first. A new structure's save is an edit of each exclusive sibling it carved.edit_kindis null for an unchanged mask. Otherwise it isai_assistedif any edit isnninteractive_correctionornninteractive_new_structure,direct_editif every edit isdirect_edit_correctionordirect_edit_new_structure, andotherotherwise.- An AI-assisted save keeps the model's rewrite away from the marks, so its figures overstate the reader's change.
edit_kindsmaps each kind present to{changed, dice_mean, dice_median, abs_volume_change_ml_mean}.
excluded rows have {id, name, task, group, code, reason}, where code is reviewer_created,
no_model_output, model_output_unavailable, different_image or unreadable.
GET /api/agreement pools each case's active revision when its status is reviewed or
corrected:
{schema_version: 1, implementation, selection,
cases: {total, included, skipped: {no_active_revision, no_segmentation, unreviewed, unavailable}},
pools: {real: Pool, synthetic: Pool},
revisions: [{case_id, revision_id, review_status, pool}]}
A pool is {cases, excluded, summary, tasks: [{task, ...stats, structures: [{id, name, task, ...stats}]}]}. Stats are reviewed, unchanged, unchanged_percent, dice_mean, dice_median,
abs_volume_change_ml_mean and edit_kinds. Synthetic cases never enter the real pool.
Research use only, not for diagnostic use. A coronary_analysis job traces centrelines in a saved
revision's coronary lumen mask, measures lumen area along them and solves steady hyperaemic pressure
to give FFR: Poiseuille resistance along the vessels, and Young–Tsai viscous and expansion losses
across each narrowing. It reads the masks on their CT grid and commits no revision.
POST .../coronary-analysis takes:
| Field | Rule |
|---|---|
lumen |
Structure ID in the revision, required |
aorta |
Structure ID or null. Lumen within 1.5 mm of it gives one root per contact patch |
myocardium |
Structure ID or null. LV mass is its volume times 1.05 g/mL, and must be 20 to 1000 g |
lv_mass |
g, 20 to 1000. Required without myocardium, refused with it |
sbp, dbp |
mmHg, both or neither: SBP 60 to 300 above DBP 20 to 200. Neither means 120/80 |
heart_rate |
bpm, 20 to 250. Recorded only: the model does not use it, so it is never assumed |
share |
More than 0 and at most 1: the share of whole-heart flow in the trees solved |
ostia |
Up to four RAS points, each snapped to lumen within 3 mm, and dropped within 3 mm of another root |
It returns the Job. A queued, running or complete job for the same revision and inputs is returned
instead of a new one; a failed or cancelled one, or one being cancelled, is not. Field bounds fail
as 422. These are 400: neither aorta nor ostia, a structure ID not in the revision or used
twice, both or neither of myocardium and lv_mass, refused blood pressures, a non-CT image and a
candidate alias.
The job fails when the lumen is empty, an ostium point is more than 3 mm from the lumen, or no root
is found: The lumen does not touch the aorta; add an ostium point. It also fails on an ostium
point more than 5 mm from the aorta partway along lumen an aortic root already reaches, since that
would split the vessel. Its stages are Loading masks, Centrelines, Areas, Pressure and Saving, and its
result is {analysis_id, key, summary}.
GET .../coronary-analysis returns {analyses: [{analysis_id, state, stage, created_at, finished_at, error, inputs, summary}]}, this revision's runs, newest first. analysis_id is the job
ID. summary is {trees, branches, lesions, lowest_distal_ffr} once complete, else null.
GET .../coronary-analysis/{analysis_id} returns the result. It is 404 for a job of another
case, revision or kind, before it completes, and when its saved files fail their check.
{format: "scan3d-coronary-v1", notice, analysis_id, case_id, revision_id, key, created_at,
inputs: {lumen, aorta, myocardium, lv_mass, lv_mass_source: "myocardium mask" | "typed", sbp, dbp,
heart_rate, share, ostia},
constants: {perfusion, heart_factor, tcri, exponent, venous, viscosity, density, kt, spacing,
window, report_diameter, report_beyond},
band: {voxel, flow, tcri, exponent, cutoff},
aortic_pressure, assumed, flags,
roots: [{tree, point, source: "aorta" | "ostium point"}],
pieces: [{point, volume}],
branches: [{id, parent, tree, gap, length, flow, reason, distal: {ffr, low, high, indeterminate, station, note},
stations: {point, s, area, diameter, reference_diameter, ffr, ffr_low, ffr_high,
pressure, reported, held, junction, spur, contact}}],
lesions: [{id, branch, throat, start, end, length, mld, stenosis, held,
beyond: {ffr, low, high, indeterminate, branch, station, note},
before: {ffr, low, high, indeterminate, branch, station}, delta_ffr}]}
- Points are RAS mm. Lengths are mm, areas mm², flow mL/min, pressure mmHg. NaN is null.
stationsholds one array per field, one entry per station 0.5 mm apart.diameteris 2·√(area/π) andreference_diameterthe fitted healthy diameter.parentis -1 at a root.startandendare mm from the ostium along the tree, andstenosisis the diameter stenosis at the throat, 0 to 1.heldstations take the nearest trusted station's area: the first 1 mm of a root branch, the last 1.5 mm of an end, junction zones, and spur runs that touch them. A branch with no trusted station takes its reference area.- A lesion is a stretch under 0.7 of the reference for at least 1 mm. Stretches less than 1 mm apart are one lesion, with the deepest throat.
reasonsays why a branch's FFR is not reported, such as one tree with noshare, or a lumen gap.assumedis["blood pressure"]when neither pressure is given, else empty.piecesare lumen components no root reaches, not solved.ffr_lowandffr_high, andlowandhighbeside a reading'sffr, are the uncertainty band: the lowest and highest FFR over the nominal solve and 16 corners. The corners are every station's area scaled by (1 ± h/2r_eq)², all up or all down, with h = |det A|^⅓ (band.voxel, mm) and r_eq the station's equivalent radius, never below half of it; resting flow ×0.75 and ×1.25 (band.flow); TCRI 0.22 and 0.40; flow-split exponent 3 and 7/3. Each corner refits the reference and lesions.ffrstays the nominal value. The band is null whereverffris, and missing from results saved before it existed. If a corner cannot be solved the band is null and a flag says so.indeterminateislow <= 0.80 < high(band.cutoff) on the rounded values, so the band crosses 0.80; null with no band. Stations carry no such field; apply the same test toffr_lowandffr_high.flagslists assumed values, gaps, dropped ostium points, ashareapplied to more than one tree, unmeasured voxels and lumen that touches lumen far along the tree. Plausibility flags change no number and come as at most one line per kind, naming the branches: hyperaemic mean speed at a solved branch's start under 0.15 or over 1.5 m/s where the reference is at least 1.8 mm, and a parent's reference under 0.85 of its daughters' Murray size.
Reading rules:
- A station is
reportedwhen its FFR is solved and its reference diameter is at least 1.8 mm. - A lesion is listed only when its reference diameter at the throat is at least 1.8 mm, and listed lesions are numbered from 0. A thinner one still adds its loss to the solve.
distalis the branch's last reported station. With none,ffris null andnotesays why.beyondreads the station 20 mm past the lesion's end along the tree, taking the daughter with the larger median reference over its first 3 mm at each junction. If the vessel ends first, or that station is not reported, it reads the last reported station on the way, andnotesays how far past the lesion that is and why.beforeis the last reported station before the lesion starts, on its branch or a parent.delta_ffrisbefore.ffrminusbeyond.ffr.
The result is saved in cases/<case>/coronary/<key>/, keyed by the code, image, masks and inputs.
A later revision with the same masks reuses it.
GET .../slicer.zip returns reference.nii.gz, segmentation.seg.nrrd and mapping.json,
with overlapping masks in separate layers and stable IDs preserved.
POST /api/cases/{id}/corrections takes multipart revision_id and segmentation and returns
a correction job. It verifies geometry, segment identity and the source hash, handles cropped
labelmaps, and marks the new revision corrected. A segment whose voxels match its mask in
revision_id keeps that saved file. Like a refine, it fails if revision_id is no longer active
when it saves.
POST .../export.html takes {layers, state} and streams a self-contained HTML file. GET .../export.html returns a segmentation-only viewer with default settings.
layers is {segmentation, ct_volume, ct_plane}, with at least one true. Excluding segmentation
omits its GLB, and excluding both CT layers omits all voxel data. Masks are embedded only when plane
overlays or volume masking need them.
state carries the saved display snapshot:
display: per-structure{visible, opacity, color?}, plusselectedandsegmentation_opacity.volume:{preset, shift, density, quality, crop, cinematic, signal_window?}.presetis a bundled Slicer appearance ID. MR exports must useMR-SignalWindow.volume_mask:{enabled, mode: "inside" | "outside", ids, margin_mm}.plane:{plane, axis, index, window, level, overlays}, whereaxismust matchplane.camera:{position_ras, target_ras, up_ras, zoom, height_mm, name?}.
Structure references must belong to the saved revision. Limits are 512 MiB of CT samples, 192 MiB of selected masks, 128 MiB of surfaces, 512 MiB of HTML and 256 embedded masks, with 413 before streaming. Paths and acquisition identifiers stay out of the manifest.
POST /api/cases/{id}/dicom-seg/imports takes multipart revision_id and files (1–64 local
SEG objects, not ZIPs, bounded by 512 MiB and the upload limit) and returns a dicom_seg_import
job. The revision must be active with a prepared classic CT or classic single-frame MR and its
complete original series. It needs no AI segmentation or Orthanc connection, and sends nothing.
| Rule | Values |
|---|---|
| Accepted | Part 10 Segmentation Storage with BINARY pixels in uncompressed Explicit or Implicit VR Little Endian |
| Refused | Fractional, labelmap, concatenated, compressed, deflated and resampled SEG |
| Limits | 1000 segments, 100,000 frames, a 512 Mi voxel grid, 16 Mi pixels per frame, 64 MiB of metadata per object |
- Identity. Patient, study, frame-of-reference and referenced series and SOP identities must match the CT. Repeated assignments, unknown segments and foreign references are refused.
- Geometry. Patient functional groups must map every frame corner through the full affine to native integer voxels, within both 0.05 voxel per axis and 0.05 mm. Values are never thresholded or interpolated.
- Frames. Cropped, reordered and reflected frames and sparse planes are accepted if that holds.
- Result. Each segment gets a
dicom_seg:<SOP hash>:<SegmentNumber>identity, keeping its label, colour and coding provenance. Names never replace existing anatomy, and duplicates are refused. - Revision. Existing masks stay in the new unreviewed revision, with
source: "dicom_seg_import", which activates only while its parent remains active. Matching duplicate requests reuse the queued or running job.
| Route | Needs |
|---|---|
| Archive and send | A configured Orthanc, and a saved revision whose reference image is an original classic CT or MR imported from that archive |
| File | No Orthanc. A saved revision whose reference image was prepared from a classic CT or MR this case still holds, however it was imported |
SEG, OsiriX ROI and view exports from MR also need MR in SCAN3D_MODALITIES, and refuse Enhanced
MR.
SCAN3D_ORTHANC_EXPORT_DESTINATIONS allowlists configured Orthanc modality IDs. Empty allows
archive-only export.
GET .../dicom-seg returns {available, reason?, destinations: [{id, name, aet}], exports: Job[], encoding_version, file: {available, reason?}, osirix_roi: {encoding_version}}.
availableandreasondescribe archive and send, for SEG and OsiriX ROIs alike, andfilethe file route.exportslists this revision's SEG and OsiriX ROI jobs, newest first.- Without archive access it contacts no Orthanc:
availableis false,destinationsempty andreasonisYour account cannot use the archive. An administrator can turn it on in People.
POST .../dicom-seg takes {structure_ids?, destination?, resend?, overlap_priority?, delivery?, format?} and returns an orthanc_export job. Export never changes review status, adds
a revision or activates one.
- Omitted IDs select all nonempty masks. A null destination archives only.
deliverydefaults toorthanc.fileprepares the SEG here, takes nodestinationorresend, needs no archive access and never contacts an archive.formatdefaults toseg.osirix_roireturns anorthanc_roi_exportjob instead.
GET .../dicom-seg/{export_id}/segment.dcm returns the prepared application/dicom attachment
scan3d-seg-<revision>-<export>.dcm. The name holds no patient text, but the file carries the
source's patient and study identity. It is 404 without a completed file job of this revision and
export_id, and 400 if the bytes no longer match export.json.
Encoding. One binary SEG instance in a new series, with numbered segments identified on each frame, keeping original patient, study, frame and source references, names and colours. Overlaps are preserved.
- Each segment states its own mask's history, not the revision's
source. A mask that is still a saved model output is automatic in every revision that carries it, and any other mask is semiautomatic. - A corrected mask stays semiautomatic in later revisions that carry it, and a later run that replaces it is automatic again.
- A Slicer import keeps the file of each segment it did not change, so only an edited segment becomes semiautomatic. Earlier Slicer imports re-saved every segment, and those stay semiautomatic.
- The grid is never resampled. Source pixels must map to unique integer native voxels within the import tolerances, with complete slice coverage.
- The cap is 512 MiB and 100,000 frames, enforced before upload or download.
- The private coding scheme
99SCAN3DdefinesREGIONandSEGMENTATION, which can raise unknown-vocabulary validator warnings.
Overlap resolution. overlap_priority ranks every selected structure once, highest first, and
needs at least two. Each voxel stays only in the highest-ranked structure holding it, in this SEG
only. Saved masks, measurements and review status do not change.
- Segment numbers follow the rank,
SegmentsOverlapisNO, the series description endsoverlaps resolved, and a segment that lost voxels is described as semiautomatic. - A structure that would lose every voxel stops the export, naming its rank.
- Each ranking is its own artifact, so Send again needs the exact ranking.
export.jsonaddsoverlap_priority, andremoved_voxel_counton segments that lost voxels. Itsvoxel_countis the exported count, andmask_sha256still names the saved mask. The job'sdicom_segresult addsoverlap_priorityandremoved_voxels(keyed by structure ID).
Identity. Enqueue freezes mask file identities, and later changes are refused. export.json
pins the source, reference, mask and SEG hashes before any network write, and delivery.json holds
receipts. A CT not from Orthanc is identified by the SHA-256 of its series files in sorted path
order, with no source_snapshot or archive_key.
Result. result.dicom_seg has export_id, encoding_version, archived, sent,
segments_count, instance_count, structure_ids and optional destination and destination_id,
with no patient fields. sent is true only after an exact successful C-STORE count. A file job adds
file: true, with archived and sent false.
Retries and delivery.
- A duplicate request reuses the job and artifact. File and archive exports of the same selection and ranking share one artifact and SOP instance.
- A retry reuses the exact SOP bytes and checks any known remote job. If Orthanc no longer has that job, the outcome is unknown and the next retry sends again.
resend: trueneeds a destination and a known successful send, and makes a new delivery attempt with the same SOPs.- A new encoder version makes fresh SOPs on explicit export.
- Polling stops after 120 seconds with an unconfirmed outcome.
- Startup fails interrupted and queued exports locally and never contacts Orthanc.
- Cancelling before delivery stops the export, but a SEG already uploaded stays in Orthanc, and its
resultstill saysarchived. - After C-STORE has started, cancelling cannot stop the remote transfer, and the job says so.
format: "osirix_roi" writes the selected masks as native OsiriX MD brush ROIs instead of a SEG.
This format is experimental and takes the archive and send route only: any delivery other than
orthanc, and any overlap_priority, are 400.
- Encoding. One Basic Text SR, series description
OsiriX ROI SRand series number 5002, for each source slice any selected mask touches. It holds one brush ROI per mask on that slice, named and coloured as saved, and a mask's ROIs share one group across slices. - Pixels. Brushes cover the slice's exact source pixels, mapped as for a SEG and never resampled. Overlaps stay. Patient and study elements come from the source slice, in UTF-8.
- Names. OsiriX tells ROIs apart by name, so selected masks that share a name are 400.
- Limits, checked before upload: 1000 reports, 100,000 ROIs and 512 MiB.
Identity, retries and delivery follow DICOM SEG export, with their own encoding version, so a SEG send never counts as an ROI send. As for a view, Orthanc's patient ID for the source study must equal the reports', and every upload must land in the source study, or nothing is sent.
- Once the first report is uploaded, a cancel waits until every report is stored, then stops before sending.
- If an upload fails partway, the job fails, says how many reports reached Orthanc, and a retry stores the rest with the same instances.
Result. result.osirix_roi has export_id, encoding_version, archived, sent,
segments_count (masks), structure_ids and optional destination and destination_id, with no
patient fields. Once the masks are encoded it adds roi_count and instance_count (reports).
This turns one capture of the 3D view into a new series in the original study, archives it in Orthanc, and can send it to a configured destination. It needs the archive and send source of DICOM SEG export, and no masks.
GET .../dicom-view returns {available, reason?, destinations, exports: Job[], encoding_version}. exports lists view jobs only.
POST .../dicom-view takes multipart image (a PNG), reference_image_id, optional
destination and optional resend, and returns an orthanc_view_export job. Export never changes
review status, adds a revision or activates one.
- Leave
destinationout to archive only: an empty value is a 422, and the textnullis an unknown destination. reference_image_idmust be the reference image of the CT that drew the picture.
Limits.
- A plain
Content-Lengthand noTransfer-Encoding, or 411. - A PNG of at most 128 MiB or the upload limit, whichever is lower. A body more than 1 MiB over that gets 413 before it is read.
- A complete, still, 8-bit RGB or RGBA PNG of at most 16384 pixels per axis and 36 Mi pixels, not one uniform colour. Otherwise 400 with a fixed message, leaving nothing behind.
Encoding. One uncompressed Secondary Capture instance in a new series: Explicit VR Little
Endian, Modality OT, ConversionType WSD, ImageType DERIVED\SECONDARY\OTHER, SeriesNumber
501, InstanceNumber 1 and SeriesDescription 3DScan view <rev8>, where rev8 starts the
revision ID. Text is UTF-8, alpha blends over black, and nothing is burned in.
- Uncalibrated: no pixel spacing, image position, image orientation or frame of reference, and an
empty
PatientOrientation.ImageCommentssays it is not calibrated, has no orientation labels and is for research use only. DerivationDescriptionnames the revision and source series UID, with no source reference sequences.BurnedInAnnotationisYESif any source header says so.RecognizableVisualFeaturesis absent.
Identity.
- Patient and study elements, with
IssuerOfPatientIDandLateralitywhen present, are copied exactly from the source headers, which must agree on one patient, study and series. - The job fails if the revision, reference image, source manifest or PNG hash change after enqueue.
- Orthanc's patient ID for the source study must equal the instance's, or nothing is stored.
- After upload, Orthanc's parent study must be the source study, or the job fails before C-STORE and leaves the stored instance for inspection.
Retries. These follow DICOM SEG export, keyed on the revision, reference image, source manifest, PNG hash and encoding version.
- Identical bytes are the same export on one SOP. A failed job retries it. A completed one is
returned as it stands, delivering nothing, unless
resendis set. - A retry needs the same bytes from the client, so a job's
errornever offers one. - Different bytes make a new series, so if an earlier unconfirmed delivery did land, the receiver gets both.
Result. result.dicom_view has export_id, encoding_version, archived, sent,
instance_count and optional destination and destination_id, with no patient fields or PNG
hash.
The same capture becomes a file to download, with nothing archived or sent. It needs no archive access, and works for any CT or classic MR prepared from DICOM, folder and ZIP imports included.
GET .../dicom-view-filereturns{available, reason?, exports: Job[], encoding_version}.POST .../dicom-view-filetakes multipartimageandreference_image_id, with the limits above, and returns aview_file_exportjob. Identical bytes reuse one job and SOP.GET .../dicom-view-file/{export_id}/view.dcmserves a completed file's verified bytes, or 404.- The encoding and identity rules are the ones above, with no archive checks.
result.dicom_viewaddsfile: true.
For a case whose import_source.kind is dicomweb, the SEG and view routes above archive in the
DICOMweb archive instead of Orthanc. Files are unchanged.
GET .../dicom-segandGET .../dicom-viewaddarchive_nameand have nodestinations, anddicom-seghas noosirix_roi.availableandreasondescribe archiving there.POST .../dicom-segwithoutdelivery: "file"returns anarchive_seg_exportjob, andPOST .../dicom-viewanarchive_view_exportjob. Adestinationorresendis refused.- The job stores each instance with STOW-RS, then reads it back.
delivery-v2.json, notdelivery.json, records each stored SOP by archive, with any STOW warning codes andverified:bytes, orcanonicalfor the same identity, patient and decoded pixels. - The result adds
archive, the archive's name.
With SCAN3D_DICOM_DESTINATIONS_FILE set, a case whose import_source.kind is not orthanc can
also send its SEG or view to a configured node with C-STORE. A
case from Orthanc refuses it with 400.
GET .../dicom-segandGET .../dicom-viewadddirect: {available, reason?, destinations: [{id, name, aet, key}]}, absent when nothing is configured or the case came from Orthanc.exportsincludes these jobs.keynames the destination's address, asdestination_keydoes in a direct job's result, so a send to an earlier address of the sameidis not a send to this one.POST .../dicom-segwithdelivery: "direct"and adestinationreturns adirect_seg_exportjob, andPOST .../dicom-viewwith form fielddelivery=directadirect_view_exportjob.- The SEG is the same instance as a file or archived SEG of the same masks.
export_keynames the destination by AE title, host, port and TLS, so a moved destination is a new export, and a job queued before the move fails without sending. delivery-v2.jsonrecords per destination itsstate(starting,success,failure,refusedorunknown), its attempt, and each SOP's status and transfer syntax.startingis written before connecting, and a restart turns it intounknown, stageDelivery not confirmed. A retry ofunknownsends the same instance again.
These are off until SCAN3D_ORTHANC_URL is set. See ORTHANC.md.
GET /api/orthanc/status returns {configured, connected, name, error, version?, api_version?}, reporting an unconfigured or unreachable archive without upstream detail.
POST /api/orthanc/studies/query takes {patient_id?, patient_name?, accession_number?, study_date?, offset?, limit?} and returns {studies, offset, limit, has_more}. limit is 1–50,
and values use Orthanc's DICOM query syntax. Identity fields go in the body, never the URL.
A study is {id, study_uid, description, study_date, patient_id, patient_name, accession_number, series_count, modalities, stable}.
GET /api/orthanc/studies/{id}/series returns {study, series}. A series is {id, description, modality, series_uid, instance_count, expected_instances, stable, completeness, snapshot, warnings, supported, unsupported_reason}. completeness is receiving, incomplete, complete or
unknown.
POST /api/orthanc/imports takes {series_id, snapshot, label?} and returns an
orthanc_import job.
- Receiving and incomplete series, and any change since selection, return 409.
- A stable series with an unknown expected count imports with a warning.
- A completed case holds the originals with no revision yet.
processprepares it next. - Requests from anyone for the same snapshot return the same job. Anyone can retry a failed or cancelled import, which reuses its case, keeps who added it and never overwrites earlier bytes.
GET /api/cases/{id}/orthanc-series returns {study, series} for the case's own Orthanc study,
each series with in_case. POST takes {series_id, snapshot} and returns an orthanc_import
job that adds the series to the case, which keeps its status and open result. It is refused for a
case not imported from Orthanc, a series of another study, or a series the case already holds.
POST /api/orthanc/launch takes {series_id, source_key} and returns {study, series, case_id, job}, creating nothing and transferring no DICOM.
case_ididentifies the library's valid completed import, whoever made it,jobits pending one, otherwise both are null.source_keyis the SHA-256 of the normalized archive URL: an identity check, not a credential.- A different archive is refused before any network access. An import failing verification is 409.
The viewer accepts launch links as #orthanc-series=<id>&orthanc-source=<key>. New imports still
need an explicit action.