Driving DICOM Desk from your own code
Everything the web app's AI lanes do is available over HTTP. Send a task and one series' header dump, get back one JSON object. The DICOM Part 10 reader the browser runs for free — the series grouping, the slice normal crossed out of ImageOrientationPatient, every ImagePositionPatient projected onto it, the spacing the positions actually show, the gaps, the duplicates, the orientation drift and the PS3.15 Annex E identifier audit — is not recomputed server-side. If you drive the API directly you should send your own prescan facts, because that object is what the model is held accountable to.
Base URL and headers
| Thing | Value |
|---|---|
| Base URL | https://api.skillsafe.ai/v1/app-api |
| Auth | Authorization: Bearer <token> |
| Body | Content-Type: application/json. The body is the input object — there is no {"input": ...} wrapper, and wrapping it returns 200 while hiding task from the model |
| App identity | carried by the token. There is no X-App-Slug header. The one place the slug appears is the body of POST /guest, which is what binds the minted token to this app |
| Idempotency | Idempotency-Key: <string> on /run and /run-stream |
Handling somebody's patient headers
A DICOM header is not a config file. It routinely carries a name, a medical record number, a birth date, an accession number, a referring physician and a study description that reads like a diagnosis. Three things follow.
First, in the browser app nothing leaves the tab until a lane runs, and the values of identifying tags are stripped out of the run input even then: the model is told that (0010,0010) PatientName is present, that its VR is PN, that the profile's action for it is Z and that it holds 17 characters, and it is never told what those 17 characters say. If you drive this API directly, you are the one assembling both headers_text and prescan, so that redaction is now your decision rather than the app's. Sending a raw dcmdump sends the values in it.
Second, the run is stateless. No header you send is retained as clinical data beyond the run that used it, and continuity between lanes is something you pass in via prior_preflight.
Third, the deterministic half needs no API call at all. The Part 10 reader, the series grouping, the geometry, the identifier audit, the flags and all four CSV/JSON exports are JavaScript in the page. If all you want is the measurement, use the app and send nothing anywhere.
The response envelope
Every response, success or failure, is the same shape. Read ok before you touch data.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "insufficient_credits", "message": "...", "details": { ... }}}
Error codes
| HTTP | error.code | What it means and what to do |
|---|---|---|
| 400 | invalid_input | The body was not a JSON object, or task was not one of the four lanes. Fix the body; a retry will not help. |
| 400 | invalid_request | POST /guest without a slug. The slug is what binds the token to this app. |
| 401 | unauthorized | No token, or a token that has expired. Mint a new one (step 2). |
| 402 | insufficient_credits | The balance cannot cover min_credits. Check /estimate against /me before submitting, which is what the app does so this never fires. |
| 404 | not_found | A job id that does not exist, one belonging to another subject, or a slug that is not a deployed app. |
| 409 | idempotency_conflict | The same Idempotency-Key was reused with a different body. Keys must be derived from the body, not from a counter. |
| 429 | rate_limited | Back off and retry. Never tight-loop. |
| 500 | internal | Retry once with the same idempotency key, which is exactly what the key is for. |
Step 1 — a tiny client
Everything below uses this one helper. It does the single thing that matters: it reads the envelope and raises on ok: false, because an error response is still HTTP-shaped JSON and ignoring it turns a 402 into a confusing null three lines later.
# The whole client is two variables and curl.
BASE=https://api.skillsafe.ai/v1/app-api
TOKEN=YOUR_TOKEN
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE$1" -H "Authorization: Bearer $TOKEN"
fi
}
# Every response is {"ok":true,"data":{...}} or {"ok":false,"error":{...}}.
# Check ok before you read data, or a 402 becomes a confusing null three lines on.
check() { python3 -c '
import json,sys
p = json.load(sys.stdin)
if not p.get("ok"):
e = p.get("error") or {}
sys.exit(str(e.get("code")) + ": " + str(e.get("message")))
print(json.dumps(p["data"], indent=2))
'; }
import json
import urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://dicom-desk.skillsafe.ai/tokens.html
class ApiError(Exception):
def __init__(self, code, message, details=None):
super().__init__("%s: %s" % (code, message))
self.code, self.message, self.details = code, message, details or {}
def call(path, body=None, token=None, extra_headers=None):
"""POST when there is a body, GET when there is not. Raises on ok:false."""
headers = {"Authorization": "Bearer " + (token or TOKEN)}
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
headers.update(extra_headers or {})
req = urllib.request.Request(BASE + path, data=data, headers=headers)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e) # errors are JSON too - read them
if not payload.get("ok"):
err = payload.get("error") or {}
raise ApiError(err.get("code", "unknown"), err.get("message", ""), err.get("details"))
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://dicom-desk.skillsafe.ai/tokens.html
class ApiError extends Error {
constructor(code, message, details) {
super(`${code}: ${message}`);
this.code = code;
this.details = details || {};
}
}
async function call(path, body, token, extraHeaders) {
const headers = { Authorization: `Bearer ${token || TOKEN}`, ...(extraHeaders || {}) };
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(BASE + path, {
method: body === undefined ? "GET" : "POST",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json(); // an error response is JSON as well
if (!payload.ok) {
throw new ApiError(payload.error?.code, payload.error?.message, payload.error?.details);
}
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN" // from https://dicom-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
} `json:"error"`
}
// call POSTs when body is non-nil and GETs when it is nil. It returns the raw
// data member so each step can unmarshal into whatever shape it needs.
func call(path string, body any, hdr map[string]string) (json.RawMessage, error) {
var rdr io.Reader
method := http.MethodGet
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
method = http.MethodPost
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public final class DicomDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // dicom-desk.skillsafe.ai/tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static class ApiException extends RuntimeException {
ApiException(String m) { super(m); }
}
/** POST when body is non-null, GET otherwise. Throws on ok:false. */
static String call(String path, String jsonBody, Map<String, String> extra)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
if (extra != null) extra.forEach(b::header);
HttpResponse<String> res = HTTP.send(b.build(),
HttpResponse.BodyHandlers.ofString());
String body = res.body();
// Any real client parses this with Jackson or Gson; the point here is
// only that ok:false must be read before data is touched.
if (body.contains("\"ok\":false")) throw new ApiException(body);
return body;
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://dicom-desk.skillsafe.ai/tokens.html
class ApiError < StandardError
attr_reader :code, :details
def initialize(code, message, details = {})
super("#{code}: #{message}")
@code = code
@details = details
end
end
# POST when a body is given, GET when it is not. Raises on ok:false.
def call(path, body = nil, extra = {})
uri = URI(BASE + path)
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
extra.each { |k, v| req[k] = v }
unless body.nil?
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
unless payload["ok"]
e = payload["error"] || {}
raise ApiError.new(e["code"], e["message"], e["details"])
end
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://dicom-desk.skillsafe.ai/tokens.html
class ApiError extends Exception {
public $apiCode;
public $details;
public function __construct($code, $message, $details = []) {
parent::__construct("$code: $message");
$this->apiCode = $code;
$this->details = $details;
}
}
/** POST when $body is given, GET when it is null. Throws on ok:false. */
function call(string $path, $body = null, array $extra = []) {
$headers = array_merge(["Authorization: Bearer " . TOKEN], $extra);
$opts = ["http" => [
"method" => $body === null ? "GET" : "POST",
"ignore_errors" => true, // read the JSON body of a 4xx too
]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(BASE . $path, false, stream_context_create($opts));
$payload = json_decode($raw, true);
if (empty($payload["ok"])) {
$e = $payload["error"] ?? [];
throw new ApiError($e["code"] ?? "unknown", $e["message"] ?? "", $e["details"] ?? []);
}
return $payload["data"];
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class DicomDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // dicom-desk.skillsafe.ai/tokens.html
static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public string Code;
public ApiException(string code, string message) : base(code + ": " + message)
=> Code = code;
}
/// POST when body is non-null, GET otherwise. Throws on ok:false.
public static async Task<JsonElement> Call(
string path, object body = null, Dictionary<string, string> extra = null)
{
var req = new HttpRequestMessage(
body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
req.Headers.Add("Authorization", "Bearer " + Token);
if (extra != null)
foreach (var kv in extra) req.Headers.Add(kv.Key, kv.Value);
if (body != null)
req.Content = new StringContent(
JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new ApiException(e.GetProperty("code").GetString(),
e.GetProperty("message").GetString());
}
return payload.GetProperty("data");
}
}
Step 2 — get a token
The friendly route is the token page, which shows the token this browser already holds, reveals it, copies it, and can mint a fresh guest one. Programmatically, POST /guest is the whole story, and it is the one call whose body is not a lane input: it takes {"slug": "dicom-desk"}, and that slug is what binds the returned token to this app. Omit it and you get a 400 invalid_request; misspell it and you get a 404 not_found. Everything after this reads the app identity off the token, which is why no later call carries a slug and there is no X-App-Slug header anywhere.
A guest token is enough for /me and /estimate. Running a lane is metered and needs a personal token, which comes from signing in on the token page.
# mint a guest token - free, and enough for /me and /estimate
call /guest '{"slug": "dicom-desk"}' | check
# data.token is the bearer, data.guest_id identifies the wallet, and
# data.expires_at is roughly a month out. Put the token in TOKEN and carry on.
# mint a guest token - free, and enough for /me and /estimate
data = call("/guest", {"slug": "dicom-desk"})
print(data["token"], data["guest_id"], data["expires_at"])
# Running a lane is metered: swap in a personal token from
# https://dicom-desk.skillsafe.ai/tokens.html before step 6.
TOKEN = data["token"]
// mint a guest token - free, and enough for /me and /estimate
const guest = await call("/guest", { slug: "dicom-desk" });
console.log(guest.token, guest.guest_id, guest.expires_at);
// Running a lane is metered: use a personal token from
// https://dicom-desk.skillsafe.ai/tokens.html before step 6.
// mint a guest token - free, and enough for /me and /estimate
raw, err := call("/guest", map[string]any{"slug": "dicom-desk"}, nil)
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
}
json.Unmarshal(raw, &guest)
fmt.Println(guest.Token, guest.GuestID, guest.ExpiresAt)
// mint a guest token - free, and enough for /me and /estimate
String data = DicomDesk.call("/guest", "{\"slug\": \"dicom-desk\"}", null);
System.out.println(data); // token, guest_id, expires_at
// Running a lane is metered: use a personal token from
// https://dicom-desk.skillsafe.ai/tokens.html before step 6.
# mint a guest token - free, and enough for /me and /estimate
guest = call("/guest", { "slug" => "dicom-desk" })
puts guest["token"], guest["guest_id"], guest["expires_at"]
<?php
// mint a guest token - free, and enough for /me and /estimate
$guest = call("/guest", ["slug" => "dicom-desk"]);
echo $guest["token"], " ", $guest["guest_id"], " ", $guest["expires_at"], "\n";
// mint a guest token - free, and enough for /me and /estimate
var guest = await DicomDesk.Call("/guest", new { slug = "dicom-desk" });
Console.WriteLine(guest.GetProperty("token").GetString());
Console.WriteLine(guest.GetProperty("expires_at").GetString());
Step 3 — who am I, and can I afford this
GET /me returns the subject and the balance. Comparing it against /estimate before you submit is what turns a 402 from an error your user sees into a button you disabled. It is also how you tell a guest token from a personal one: subject_type reads guest on the first and user on the second, and only the second can run a metered lane.
# subject_type, username, credits
call /me | check
# subject_type, username, credits
data = call("/me")
print(data["subject_type"], data.get("username"), data.get("credits"))
// subject_type, username, credits
const me = await call("/me");
console.log(me.subject_type, me.username, me.credits);
// subject_type, username, credits
raw, err := call("/me", nil, nil)
if err != nil {
panic(err)
}
fmt.Println(string(raw))
// subject_type, username, credits
String data = DicomDesk.call("/me", null, null);
System.out.println(data);
# subject_type, username, credits
me = call("/me")
puts "#{me["subject_type"]} #{me["credits"]}"
<?php
// subject_type, username, credits
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"] ?? 0, "\n";
// subject_type, username, credits
var me = await DicomDesk.Call("/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Step 4 — the input object
This is the whole contract on the way in. It is what readForm() in the app's own app.js builds, field for field, so what the browser sends and what you send are the same object.
| Field | Type | Meaning |
|---|---|---|
task | string, required | Document this first, because it selects everything else. One of preflight, deid, volume, catalog. Absent or unrecognised, the model picks the closest lane, sets task_inferred to true and sets task to the lane it actually answered, rather than blending two contracts into one reply. |
headers_text | string, required | The DICOM header dump. A dcmdump, a gdcmdump, a pydicom print(ds), or the canonical dump the browser produces from dropped .dcm files — all four parse. The browser clips it at 60,000 characters on whole-line boundaries, taking lines out of the middle and keeping both ends; when it does, it also sends clip_note. |
intent | string | One of research-volume, archive-transfer, public-release, unspecified. It changes the bar, not the facts: research-volume weights geometry and calibration, archive-transfer weights references surviving and UIDs staying consistent, public-release applies the strictest identifier reading and treats an absent BurnedInAnnotation as a blocker rather than a note, unspecified means report everything and assume nothing. |
context_note | string, optional | Free text, up to 400 characters. What the series is for, or what went wrong with it. The single most useful optional field. |
prescan | object, optional but strongly recommended | The browser's own measurements. See below — this is the field that decides whether you get an audited answer or an unaudited one. |
clip_note | string, optional | Present only when headers_text was clipped. It says what was cut and states that every number in prescan was measured over the complete input, so the model treats the excerpt as a sample it may quote from rather than as the whole series. |
prior_preflight | object, optional | The handoff. {verdict, series_kind, usable_as, findings[]} carried over from a previous preflight run, where each entry of findings[] is {id, severity, location, title}. With it present, the later lane agrees with, refines or explicitly disagrees with the preflight instead of re-deriving it; without it, the four lanes read as four unrelated tools over one paste. |
retry_note | string, optional | Only when a previous reply failed to parse. The app puts the exact reformat instruction here and reuses the same idempotency key stem with the attempt counter bumped, so the retry is a deliberate second run rather than an accidental one. |
Sending your own prescan
Be clear about what this object is, because it is easy to mistake it for decoration. prescan is what a real DICOM Part 10 reader running in the user's own tab measured: the instance, series and study grouping; the slice normal crossed out of the two direction cosines in (0020,0037); every (0020,0032) projected onto that normal; the spacing those projections actually show, with its minimum, maximum and spread; the gaps and the duplicate positions; the orientation drift in degrees; the transfer syntax and pixel encoding; the rescale and window values; the identifier inventory over the PS3.15 Annex E tag list; and a numbered flags array.
Three consequences follow, and they are the reason to send it:
- The model is told the prescan wins. When its reading of the text and the prescan disagree on a count or a measurement, the prescan is right and the model must say so plainly rather than contradict it.
- Every
criticalorhighflag must be answered. The model owes exactly onereconciliationentry per required flag, naming itsflag_id. A required flag with no entry is displayed as unaccounted for, which is what makes a reply auditable rather than merely fluent.mediumandlowflags may be answered but need not be. - The values of identifying tags are deliberately excluded from it. For each entry of
identifiers.presentyou send the tag, its keyword, its VR, the profile's action code and the length of the value — never the value. The same withholding covers the free-text fields on the profile list (SeriesDescription,StudyDescription,ProtocolName) and the UIDs, which is why a series travels as a label likeseries 1 (CT #2)built only from non-identifying fields, and why the description and the UID appear as character counts. SendingPatientNameto a language model in order to be told thatPatientNameis identifying would be absurd, and it would put the identifier in the one place nobody audits.
So: omit prescan and the model works from your raw text alone. It will still answer, but nothing holds it to a number, no reconciliation is possible, and the app's own honesty machinery — the ungrounded-citation marking, the unanswered-flag count, the unhandled-tag list on the deid lane — has nothing to compare against. That is a weaker answer, and it is weaker in a way that is invisible unless you know to look for it.
And fabricating a prescan is worse than omitting it. You would not be decorating a prompt; you would be lying to a model that has been instructed to trust you over its own reading of the header, on exactly the numbers a dataset gets built on. If you did not measure it, do not assert it. If you measured it over a clipped dump, say so in clip_note. If the input did not parse at all, send the honest failure shape — {"readable": false, "reason": "...", "note": "..."} — and the model reports verdict: "unreadable" instead of inventing a series.
The full object the browser builds is large. Trimmed to the parts that carry weight:
{
"readable": true,
"intent": "research-volume",
"intent_label": "Research dataset - this becomes a training or analysis volume",
"counts": {"instances": 61, "series": 1, "studies": 1},
"lines": {"total": 1204, "recognised": 1198, "unrecognised": 6},
"volume_ready": false,
"release_ready": false,
"studies": [{"label": "study 1", "uid_present": true,
"description_chars": 18, "series_count": 1}],
"series": [{
"label": "series 1 (CT #2)", // cite locations EXACTLY like this
"modality": "CT", "series_number": "2",
// free text and UIDs are withheld exactly like a value: length only
"description_chars": 14, "study_description_chars": 18,
"protocol_name_chars": 12,
"sop_class": "1.2.840.10008.5.1.4.1.1.2",
"sop_class_name": "CT Image Storage",
"series_uid_present": true, "series_uid_chars": 54,
"instances": 61,
"transfer_syntax": "1.2.840.10008.1.2.1",
"transfer_syntax_name": "Explicit VR Little Endian",
"encapsulated": false, "lossy": false,
"rows": 512, "columns": 512, "samples_per_pixel": 1,
"photometric": "MONOCHROME2",
"bits_allocated": 16, "bits_stored": 12, "pixel_representation": 0,
"pixel_spacing_mm": [0.7031, 0.7031],
"slice_thickness_mm": 1, "spacing_between_slices_mm": null,
"number_of_frames": null,
"rescale_slope": 1, "rescale_intercept": -1024, "rescale_type": "HU",
"window_center": "40", "window_width": "400",
"gantry_tilt_deg": 0, "patient_position": "HFS",
"body_part": "CHEST", "manufacturer": "Acme", "model": "Scanner 64",
"image_types": ["ORIGINAL\\PRIMARY\\AXIAL"],
"distinct_frames_of_reference": 1,
"instance_numbers": {"count": 61, "min": 1, "max": 62,
"missing": [33], "duplicates": []},
"volume_shape_cols_rows_slices": [512, 512, 61],
"distinct_positions": 61,
"voxel_mm_x_y_z": [0.7031, 0.7031, 2.5],
"geometry": {
"have_orientation": true, "have_position": true,
"row_direction": [1, 0, 0], "column_direction": [0, 1, 0],
"slice_normal": [0, 0, 1], "plane": "axial",
"orientation_drift_deg": 0,
"slices_with_position": 61, "distinct_positions": 61,
"derived_spacing_mm": 2.5, // MEASURED, not SliceThickness
"spacing_min_mm": 2.5, "spacing_max_mm": 5,
"spacing_spread_mm": 2.5, "spacing_consistent": false,
"extent_mm": 152.5,
"first_position_mm": -240.5, "last_position_mm": -88,
"gaps": [{"after": "IM-0001-0032.dcm", "before": "IM-0001-0034.dcm",
"gap_mm": 5, "missing_slices": 1}],
"duplicate_positions": [],
"order_matches_instance_number": true,
"instance_number_direction": "ascending",
"positions_head": [{"name": "IM-0001-0001.dcm", "instance_number": 1,
"proj": -240.5, "ipp": [-166.5, -31.5, -240.5]}],
"positions_tail": [{"name": "IM-0001-0062.dcm", "instance_number": 62,
"proj": -88, "ipp": [-166.5, -31.5, -88]}],
"positions_omitted": 55
}
}],
"identifiers": {
"count_with_values": 14,
"person_detail_tags_with_values": 5,
"quasi_identifying_tags_with_values": 9,
"direct": ["(0010,0010) PatientName", "(0010,0020) PatientID"],
"quasi": ["(0008,0020) StudyDate", "(0010,1010) PatientAge"],
"present": [
// the tag, its VR, the profile's action code and the LENGTH - no value
{"tag": "(0010,0010)", "keyword": "PatientName", "vr": "PN",
"action": "Z", "value_length": 17, "nested": false},
{"tag": "(0010,0020)", "keyword": "PatientID", "vr": "LO",
"action": "Z", "value_length": 9, "nested": false},
{"tag": "(0008,0020)", "keyword": "StudyDate", "vr": "DA",
"action": "Z", "value_length": 8, "nested": false}
],
"empty_but_present": [{"tag": "(0010,2160)", "keyword": "EthnicGroup"}],
"free_text_with_values": ["(0008,1030) StudyDescription"],
"private_tags": ["(0043,1028)"],
"uid_tags_needing_remap": ["(0020,000D)", "(0020,000E)", "(0008,0018)"],
"date_tags_present": ["(0008,0020)", "(0008,0022)"],
"nested_person_names": [],
"burned_in_annotation": "(tag absent)",
"patient_identity_removed": "(tag absent)",
"deidentification_method": ""
},
"flags": [
{"id": "DD-01", "severity": "high", "location": "series 1 (CT #2)",
"label": "slice spacing is not constant",
"detail": "The positions step 2.5 mm to 5 mm - a spread of 2.5 mm over 61 slices."},
{"id": "DD-02", "severity": "high", "location": "series 1 (CT #2)",
"label": "1 gap(s) in the slice stack",
"detail": "After IM-0001-0032.dcm the next position is 5 mm away where the median step is 2.5 mm: 1 slice is missing."},
{"id": "DD-04", "severity": "critical", "location": "header",
"label": "5 person-detail tag(s) hold values",
"detail": "(0010,0010) PatientName, (0010,0020) PatientID, ..."}
// DD-03 and DD-05..DD-10 elided here: a missing InstanceNumber, the
// quasi-identifiers, the free-text field, the private tag, an absent
// BurnedInAnnotation, PatientIdentityRemoved not YES, and the UID remaps.
],
"value_policy": "The header VALUES of every tag on the PS3.15 Annex E list were deliberately withheld from this prescan object - the named identifiers, the free-text descriptions and the UIDs alike. You are told which tags are present and how long their values are, never what they say. Series are named by their prescan LABEL, not by their UID. Do not ask for the values and do not pretend to have read them."
}
Flag ids are DD-01, DD-02, ... in the order the browser raised them, and they are stable for a given input, which is what lets a reconciliation entry point at one. Any id scheme works as long as your own reconciliation matching agrees with it. severity is critical, high, medium or low; only the first two require an answer.
One more rule about location, because it governs the output too: a location the app cannot find in the input is kept and marked ungrounded rather than dropped. The strings it accepts are a series label written exactly as prescan writes it, a modality, an instance or file name from the prescan, a tag in (gggg,eeee) form, a standard keyword, a flag id, or one of header, pixels and input. So an invented tag is visible on screen to your users, not only to you.
Step 5 — estimate, free
/estimate creates no job and charges nothing. It is also the authoritative check that your input shape is valid and that the app is bound to the model you think it is: the reply carries model, model_alias and markup_bps alongside hold_credits, min_credits, sponsor_enabled and byok. Estimate per lane and per input: the four lanes have different prompts and different output caps, so one lane's hold is not another lane's price.
Three numbers, and they mean different things. hold_credits is the worst case that gets reserved — show it as RESERVED, never as the price. min_credits is the floor below which the run will not start at all. charged_credits comes back later, from the run, and is usually far lower than the hold because the hold priced the full output cap.
INPUT='{
"task": "preflight",
"headers_text": "(0008,0016) UI [1.2.840.10008.5.1.4.1.1.2] # SOPClassUID\n(0008,0060) CS [CT] # Modality\n(0020,0037) DS [1\\0\\0\\0\\1\\0] # ImageOrientationPatient\n(0020,0032) DS [-166.5\\-31.5\\-240.5] # ImagePositionPatient\n(0028,0030) DS [0.7031\\0.7031] # PixelSpacing\n(0018,0050) DS [1.0] # SliceThickness",
"intent": "research-volume",
"context_note": "61 instances pulled from the PACS for a segmentation set",
"prescan": {"readable": true, "counts": {"instances": 61, "series": 1, "studies": 1},
"...": "the rest of step 4 goes here"}
}'
# Free. No job is created and nothing is charged.
call /estimate "$INPUT" | check
# hold_credits - reserved worst case. Show it as RESERVED, never as the price.
# min_credits - below this the run will not start at all.
# model, model_alias, markup_bps - what you are actually bound to.
input_obj = {
"task": "preflight",
"headers_text": open("series.dcmdump").read(),
"intent": "research-volume",
"context_note": "61 instances pulled from the PACS for a segmentation set",
# prescan is what the model is held accountable to. Send yours - see step 4.
"prescan": {
"readable": True,
"counts": {"instances": 61, "series": 1, "studies": 1},
"series": [], # your measured series, geometry and all
"identifiers": {}, # tags, VRs, actions and lengths - never values
"flags": [], # each one the model must answer for
},
}
est = call("/estimate", input_obj) # free: no job, no charge
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserved:", est["hold_credits"], "minimum:", est["min_credits"])
# The hold prices the full output cap. Between min_credits and hold_credits the
# run still executes with a reduced cap and comes back "truncated": true.
import { readFileSync } from "node:fs";
const inputObj = {
task: "preflight",
headers_text: readFileSync("series.dcmdump", "utf8"),
intent: "research-volume",
context_note: "61 instances pulled from the PACS for a segmentation set",
// prescan is what the model is held accountable to. Send yours - see step 4.
prescan: {
readable: true,
counts: { instances: 61, series: 1, studies: 1 },
series: [],
identifiers: {},
flags: [],
},
};
const est = await call("/estimate", inputObj); // free: no job, no charge
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserved:", est.hold_credits, "minimum:", est.min_credits);
dump, _ := os.ReadFile("series.dcmdump")
inputObj := map[string]any{
"task": "preflight",
"headers_text": string(dump),
"intent": "research-volume",
"context_note": "61 instances pulled from the PACS for a segmentation set",
// prescan is what the model is held accountable to - see step 4.
"prescan": map[string]any{
"readable": true,
"counts": map[string]any{"instances": 61, "series": 1, "studies": 1},
"series": []any{},
"flags": []any{},
},
}
raw, err := call("/estimate", inputObj, nil) // free: no job, no charge
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(raw, &est)
fmt.Printf("%s (%s) reserve %d min %d\n",
est.Model, est.ModelAlias, est.HoldCredits, est.MinCredits)
String dump = Files.readString(Path.of("series.dcmdump"));
// Build this with Jackson in real code; a map literal is shown so the shape of
// the object is readable on one screen.
String inputJson = new ObjectMapper().writeValueAsString(Map.of(
"task", "preflight",
"headers_text", dump,
"intent", "research-volume",
"context_note", "61 instances pulled from the PACS for a segmentation set",
"prescan", Map.of("readable", true,
"counts", Map.of("instances", 61, "series", 1, "studies", 1),
"series", List.of(),
"flags", List.of())));
String est = DicomDesk.call("/estimate", inputJson, null); // free
System.out.println(est); // model, model_alias, markup_bps, hold_credits, min_credits
input_obj = {
"task" => "preflight",
"headers_text" => File.read("series.dcmdump"),
"intent" => "research-volume",
"context_note" => "61 instances pulled from the PACS for a segmentation set",
# prescan is what the model is held accountable to - see step 4.
"prescan" => { "readable" => true,
"counts" => { "instances" => 61, "series" => 1, "studies" => 1 },
"series" => [], "identifiers" => {}, "flags" => [] }
}
est = call("/estimate", input_obj) # free: no job, no charge
puts "#{est["model"]} (#{est["model_alias"]}) reserve #{est["hold_credits"]}"
<?php
$inputObj = [
"task" => "preflight",
"headers_text" => file_get_contents("series.dcmdump"),
"intent" => "research-volume",
"context_note" => "61 instances pulled from the PACS for a segmentation set",
// prescan is what the model is held accountable to - see step 4.
"prescan" => [
"readable" => true,
"counts" => ["instances" => 61, "series" => 1, "studies" => 1],
"series" => [],
"identifiers" => new stdClass(),
"flags" => [],
],
];
$est = call("/estimate", $inputObj); // free: no job, no charge
echo $est["model"], " reserve ", $est["hold_credits"], " min ", $est["min_credits"], "\n";
var inputObj = new Dictionary<string, object>
{
["task"] = "preflight",
["headers_text"] = File.ReadAllText("series.dcmdump"),
["intent"] = "research-volume",
["context_note"] = "61 instances pulled from the PACS for a segmentation set",
// prescan is what the model is held accountable to - see step 4.
["prescan"] = new Dictionary<string, object>
{
["readable"] = true,
["counts"] = new Dictionary<string, object>
{
["instances"] = 61, ["series"] = 1, ["studies"] = 1
},
["series"] = new object[0],
["flags"] = new object[0],
},
};
var est = await DicomDesk.Call("/estimate", inputObj); // free
Console.WriteLine($"{est.GetProperty("model")} reserve {est.GetProperty("hold_credits")}");
Step 6 — run and poll
Submit, then poll the job until it is terminal. data.output.output is a string holding the JSON object — parse it a second time. charged_credits is the real cost and is usually far below the hold, which priced the full output cap.
The Idempotency-Key is not optional in practice. The app derives it from the lane, the header text, the intent, the note and the attempt number, as dicom-desk:<lane>:<hash>:a<attempt>, so four lanes over one series are four runs that cannot collide on one key, while a retried POST of the same lane is free rather than a second charge. Any deterministic derivation works; a counter does not, because reusing one key with a different body is a 409.
# 1. submit. Attempt 1 of this lane over this header.
KEY="dicom-desk:preflight:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-32):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# 2. poll until terminal
while :; do
OUT=$(call "/jobs/$JOB")
ST=$(printf '%s' "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
[ "$ST" = "succeeded" ] || [ "$ST" = "failed" ] && break
sleep 2
done
printf '%s' "$OUT" | python3 -c '
import json,sys
d = json.load(sys.stdin)["data"]
print("charged", d.get("charged_credits"), "truncated", d.get("truncated"))
print(d["output"]["output"]) # the JSON object your renderer parses
'
import hashlib
import time
# The key covers the lane AND the header text AND the intent AND the note AND the
# attempt: preflight and volume over one series are two runs, not one.
raw = json.dumps([input_obj["task"], input_obj["headers_text"],
input_obj.get("intent", ""), input_obj.get("context_note", "")])
key = "dicom-desk:%s:%s:a1" % (input_obj["task"],
hashlib.sha256(raw.encode()).hexdigest()[:32])
job = call("/run", input_obj, extra_headers={"Idempotency-Key": key})
while True:
st = call("/jobs/" + job["job_id"])
if st["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if st["status"] == "failed":
raise SystemExit(st.get("error"))
print("charged", st.get("charged_credits"), "truncated", st.get("truncated"))
result = json.loads(st["output"]["output"]) # the one JSON object
print(result["verdict"], len(result["findings"]), "findings")
# Answer every required flag or you have not audited anything: one reconciliation
# entry per critical/high prescan flag id is the contract.
answered = {r["flag_id"] for r in result["reconciliation"]}
required = {f["id"] for f in input_obj["prescan"].get("flags", [])
if f["severity"] in ("critical", "high")}
print("unanswered flags:", sorted(required - answered))
import { createHash } from "node:crypto";
const raw = JSON.stringify([inputObj.task, inputObj.headers_text,
inputObj.intent || "", inputObj.context_note || ""]);
const key = `dicom-desk:${inputObj.task}:${createHash("sha256").update(raw)
.digest("hex").slice(0, 32)}:a1`;
const job = await call("/run", inputObj, undefined, { "Idempotency-Key": key });
let st;
for (;;) {
st = await call(`/jobs/${job.job_id}`);
if (st.status === "succeeded" || st.status === "failed") break;
await new Promise((r) => setTimeout(r, 2000));
}
if (st.status === "failed") throw new Error(JSON.stringify(st.error));
const result = JSON.parse(st.output.output);
console.log(result.verdict, result.findings.length, "findings");
// Which critical/high flags went unanswered - the audit that makes prescan worth
// sending in the first place.
const answered = new Set(result.reconciliation.map((r) => r.flag_id));
const missed = (inputObj.prescan.flags || [])
.filter((f) => f.severity === "critical" || f.severity === "high")
.filter((f) => !answered.has(f.id))
.map((f) => f.id);
console.log("unanswered flags:", missed);
b, _ := json.Marshal([]any{inputObj["task"], inputObj["headers_text"],
inputObj["intent"], inputObj["context_note"]})
sum := sha256.Sum256(b)
key := fmt.Sprintf("dicom-desk:%s:%x:a1", inputObj["task"], sum[:16])
raw, err := call("/run", inputObj, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var job struct{ JobID string `json:"job_id"` }
json.Unmarshal(raw, &job)
var st struct {
Status string `json:"status"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
for {
raw, err = call("/jobs/"+job.JobID, nil, nil)
if err != nil {
panic(err)
}
json.Unmarshal(raw, &st)
if st.Status == "succeeded" || st.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
fmt.Println("charged", st.Charged, "truncated", st.Truncated)
fmt.Println(st.Output.Output) // a string holding the JSON - unmarshal it again
String raw = inputJson; // hash the same fields app.js hashes
String key = "dicom-desk:preflight:"
+ java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(raw.getBytes())).substring(0, 32)
+ ":a1";
String job = DicomDesk.call("/run", inputJson, Map.of("Idempotency-Key", key));
String jobId = job.replaceAll(".*\"job_id\"\\s*:\\s*\"([^\"]+)\".*", "$1");
String st;
while (true) {
st = DicomDesk.call("/jobs/" + jobId, null, null);
if (st.contains("\"succeeded\"") || st.contains("\"failed\"")) break;
Thread.sleep(2000);
}
System.out.println(st); // data.output.output holds the one JSON object
require "digest"
raw = JSON.generate([input_obj["task"], input_obj["headers_text"],
input_obj["intent"], input_obj["context_note"]])
key = "dicom-desk:#{input_obj["task"]}:#{Digest::SHA256.hexdigest(raw)[0, 32]}:a1"
job = call("/run", input_obj, { "Idempotency-Key" => key })
st = nil
loop do
st = call("/jobs/#{job["job_id"]}")
break if %w[succeeded failed].include?(st["status"])
sleep 2
end
abort(st["error"].to_s) if st["status"] == "failed"
result = JSON.parse(st["output"]["output"])
puts "#{result["verdict"]} #{result["findings"].length} findings"
<?php
$raw = json_encode([$inputObj["task"], $inputObj["headers_text"],
$inputObj["intent"], $inputObj["context_note"]]);
$key = "dicom-desk:" . $inputObj["task"] . ":" . substr(hash("sha256", $raw), 0, 32) . ":a1";
$job = call("/run", $inputObj, ["Idempotency-Key: $key"]);
do {
sleep(2);
$st = call("/jobs/" . $job["job_id"]);
} while (!in_array($st["status"], ["succeeded", "failed"], true));
if ($st["status"] === "failed") { exit(1); }
$result = json_decode($st["output"]["output"], true);
echo $result["verdict"], " ", count($result["findings"]), " findings\n";
using System.Security.Cryptography;
var raw = JsonSerializer.Serialize(new object[] {
inputObj["task"], inputObj["headers_text"],
inputObj["intent"], inputObj["context_note"] });
var key = "dicom-desk:" + inputObj["task"] + ":" +
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))
.Substring(0, 32).ToLowerInvariant() + ":a1";
var job = await DicomDesk.Call("/run", inputObj,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
JsonElement st;
while (true)
{
st = await DicomDesk.Call("/jobs/" + job.GetProperty("job_id").GetString());
var status = st.GetProperty("status").GetString();
if (status == "succeeded" || status == "failed") break;
await Task.Delay(2000);
}
var result = JsonDocument.Parse(
st.GetProperty("output").GetProperty("output").GetString()).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
Step 7 — run-stream, for progress
The same run, reported as it happens, as server-sent events. This is what the web app uses, and the reason its progress card can name a real stage rather than spin: the appearance of "findings", then "body", then the lane's own keys in the accumulated delta text is a real signal about where the model is. The app watches five markers per lane — "findings", "body", "checks", "missing_data", "script" on preflight; "tag_actions" and "residual_risk" on deid; "geometry" and "pitfalls" on volume; "manifest_columns" and "quality_columns" on catalog. Accumulate the deltas and parse once at the end, because a partial JSON document is not parseable.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT"
# Server-sent events. The ones worth handling:
# event: job - accepted; the run is now billed against the hold
# event: delta - a chunk of the JSON document, in order
# event: done - terminal, with charged_credits and truncated
# event: error - terminal failure
# The app drives its progress card off the delta text: "findings", then "body",
# then the lane's own keys, is what advances a stage.
# The stream is the same run, reported as it happens. Accumulate the deltas and
# parse ONCE at the end - a partial JSON document is not parseable, and the app's
# reformat retry exists exactly because a stream can die mid-document.
req = urllib.request.Request(
BASE + "/run-stream",
data=json.dumps(input_obj).encode(),
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key})
MARKERS = ['"findings"', '"body"', '"checks"', '"missing_data"', '"script"']
buf = ""
with urllib.request.urlopen(req) as r:
event = None
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
buf += payload.get("text", "")
stage = sum(1 for m in MARKERS if m in buf)
# advance your progress display to `stage` here
elif event == "done":
print("charged", payload.get("charged_credits"))
elif event == "error":
raise SystemExit(payload)
result = json.loads(buf)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(inputObj),
});
const MARKERS = ['"findings"', '"body"', '"checks"', '"missing_data"', '"script"'];
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", frame = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
frame += dec.decode(value, { stream: true });
const lines = frame.split("\n");
frame = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) {
const p = JSON.parse(line.slice(6));
if (event === "delta") {
buf += p.text || "";
const stage = MARKERS.filter((m) => buf.includes(m)).length;
// paint `stage` - throttle it, the deltas are small and frequent
} else if (event === "done") console.log("charged", p.charged_credits);
else if (event === "error") throw new Error(JSON.stringify(p));
}
}
}
const result = JSON.parse(buf);
req, _ := http.NewRequest(http.MethodPost, base+"/run-stream",
bytes.NewReader(func() []byte { b, _ := json.Marshal(inputObj); return b }()))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var buf strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<22)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
var p struct {
Text string `json:"text"`
Charged int `json:"charged_credits"`
}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &p)
if event == "delta" {
buf.WriteString(p.Text)
} else if event == "done" {
fmt.Println("charged", p.Charged)
}
}
}
fmt.Println(buf.String())
HttpRequest req = HttpRequest.newBuilder(URI.create(DicomDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + DicomDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
StringBuilder buf = new StringBuilder();
String[] event = { null };
DicomDesk.HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body()
.forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7);
} else if (line.startsWith("data: ") && "delta".equals(event[0])) {
// parse {"text": "..."} with your JSON library and append it
buf.append(extractText(line.substring(6)));
}
});
System.out.println(buf);
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(input_obj)
buf = ""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ")
event = line[7..]
elsif line.start_with?("data: ")
p = JSON.parse(line[6..])
buf << (p["text"] || "") if event == "delta"
puts "charged #{p["charged_credits"]}" if event == "done"
end
end
end
end
end
result = JSON.parse(buf)
<?php
$ctx = stream_context_create(["http" => [
"method" => "POST",
"header" => implode("\r\n", [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: $key",
]),
"content" => json_encode($inputObj),
]]);
$fh = fopen(BASE . "/run-stream", "r", false, $ctx);
$buf = "";
$event = null;
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\r\n");
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ")) {
$p = json_decode(substr($line, 6), true);
if ($event === "delta") { $buf .= $p["text"] ?? ""; }
if ($event === "done") { echo "charged ", $p["charged_credits"], "\n"; }
}
}
fclose($fh);
$result = json_decode($buf, true);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(inputObj),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
string ev = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line.Substring(7);
else if (line.StartsWith("data: "))
{
var p = JsonDocument.Parse(line.Substring(6)).RootElement;
if (ev == "delta" && p.TryGetProperty("text", out var t))
buf.Append(t.GetString());
else if (ev == "done")
Console.WriteLine("charged " + p.GetProperty("charged_credits"));
}
}
var result = JsonDocument.Parse(buf.ToString()).RootElement;
The output contract, lane by lane
One JSON object, and nothing else — no prose before it, no code fence around it. The envelope is identical across all four lanes, which is what lets one renderer, one history writer and one export path serve every lane; only body differs. Every key is present on every reply, and every array is present even when it is empty: an omitted key never means "none".
{
"task": "preflight", // one of preflight | deid | volume | catalog
"task_inferred": false, // true when the lane was chosen, not given
"title": "short human name for this series",
"verdict": "ready | fix-first | blocked | unreadable",
"summary": "two to four sentences a colleague could act on",
"assumptions": ["what had to be assumed because the header did not say"],
"open_questions": ["what would be needed to answer this properly"],
"findings": [
{"id": "DDX-001",
"severity": "critical | high | medium | low",
"location": "series 1 (CT #2)",
"title": "one line",
"why": "what goes wrong downstream if this is left alone",
"fix": "the specific change, with the tag or the step named"}
],
"reconciliation": [
{"flag_id": "DD-01",
"status": "confirmed | noted | set-aside | superseded",
"note": "why, in one or two sentences"}
],
"next_lane": {"lane": "deid", "reason": "why this is the next thing to do"},
"body": { ... } // the lane-specific half, below
}
verdict is always about this lane's question: for preflight, whether the series can be used as it stands; for deid, whether the plan reaches the stated intent; for volume, whether a correct volume can be built; for catalog, whether a defensible manifest can be built. unreadable is only for input the prescan could not parse.
reconciliation[].status is the audit vocabulary and each word means something specific: confirmed agrees the flag is a real problem, noted agrees it is real but says it does not block this lane, set-aside says it does not apply here and gives the reason, superseded says something else in the reply makes it moot.
Two things the client does to this object before it renders, worth knowing if you are writing your own consumer. It normalizes: an unrecognised verdict becomes unreadable, an unrecognised severity becomes medium, an unrecognised reconciliation status becomes confirmed, a missing finding id becomes DDX-001 by position, a reconciliation entry with no flag_id is dropped, findings are re-sorted critical first, and a next_lane naming the lane that just ran is cleared. And it grounds: every location, tag, geometry source and column source is checked against the strings the pasted header actually contains, and a companion boolean (location_ungrounded, tag_ungrounded, source_ungrounded) is added next to it. Those booleans are client-side additions, not model output — do not expect them on the wire, and do add your own equivalent if you are reusing this contract, because an invented tag that nobody marks is an invented tag nobody notices.
task: "preflight" — Preflight the series (Inspect stage)
Can this series be used as it stands, and if not, what exactly is wrong with it. The @nvidia/dicom-series-preflight lane.
"body": {
"series_kind": "one line: what this series is, from its SOP class, modality and ImageType",
"usable_as": "one line: a volume, a stack of 2D images, or nothing",
"checks": [{"area": "slice geometry and spacing", "status": "pass|warn|fail",
"note": "..."}],
"geometry_note": "a paragraph on the stack: order, spacing, extent, and what a
naive tool would get wrong",
"missing_data": [{"item": "SpacingBetweenSlices", "impact": "...",
"how_to_get_it": "..."}],
"next_step_note": "one line",
"script": "runnable pydicom that re-runs these checks over a whole folder",
"script_note": "one or two sentences on what the script does NOT do"
}
checks carries exactly these eight areas, in this order: object type and SOP class, transfer syntax and pixel encoding, series and study separation, slice geometry and spacing, instance completeness, intensity calibration, acquisition consistency, identifiers present. status is pass, warn or fail, and a pass has to be earned by a fact in the input: when the header is silent on an area that is warn with the silence named, never pass.
Worked example
POST /run
{
"task": "preflight",
"headers_text": "(0008,0016) UI [1.2.840.10008.5.1.4.1.1.2] # SOPClassUID\n(0008,0060) CS [CT] # Modality\n(0008,0020) DA [20240418] # StudyDate\n(0010,0010) PN [<withheld>] # PatientName\n(0018,0050) DS [1.0] # SliceThickness\n(0020,0011) IS [2] # SeriesNumber\n(0020,0037) DS [1\\0\\0\\0\\1\\0] # ImageOrientationPatient\n(0020,0032) DS [-166.5\\-31.5\\-240.5] # ImagePositionPatient\n(0028,0030) DS [0.7031\\0.7031] # PixelSpacing\n(0028,1052) DS [-1024] # RescaleIntercept\n(0028,1053) DS [1] # RescaleSlope",
"intent": "research-volume",
"context_note": "61 instances pulled from the PACS for a segmentation set; SimpleITK reports an odd z spacing",
"prescan": { ... the object from step 4, verbatim ... }
}
{
"task": "preflight",
"task_inferred": false,
"title": "CT chest, 61 axial instances, 512x512, HU-calibrated",
"verdict": "fix-first",
"summary": "One axial CT series, uncompressed Explicit VR Little Endian, 512x512
at 0.7031 mm in plane, calibrated to HU by slope 1 and intercept
-1024. It is not yet a volume: the measured positions step 2.5 mm
everywhere except once, where they step 5 mm, so one slice is
missing and InstanceNumber 33 is absent to match. Five
person-detail tags still hold values, so it cannot leave either.",
"assumptions": ["That the dump covers every instance in the folder, because the
prescan counted 61 and the folder was not listed."],
"open_questions": ["Whether instance 33 exists in the PACS and was not exported,
or was never reconstructed."],
"findings": [
{"id": "DDX-001", "severity": "critical", "location": "series 1 (CT #2)",
"title": "one slice is missing from the middle of the stack",
"why": "A reader that stacks these 61 arrays in order produces a volume whose
z axis is wrong from slice 32 onward, and nothing in the array says
so. Every measurement taken through that region is off by 2.5 mm.",
"fix": "Re-export the series and confirm the instance count, or accept the
gap explicitly and record it in the manifest. Do not resample across
it: interpolating a slice that was never acquired invents tissue."},
{"id": "DDX-002", "severity": "high", "location": "header",
"title": "BurnedInAnnotation (0028,0301) is absent",
"why": "Nothing here says whether the pixels carry an identifier. Absent is
not the same as NO, and this app cannot read pixel data.",
"fix": "Check a sample of frames visually, then set the tag when you
de-identify so the next reader is not guessing."}
],
"reconciliation": [
{"flag_id": "DD-01", "status": "confirmed",
"note": "The spread is one 5 mm step among 2.5 mm steps, not scanner jitter."},
{"flag_id": "DD-02", "status": "confirmed",
"note": "Same defect as DD-01 seen from the other side: one slice missing."},
{"flag_id": "DD-04", "status": "confirmed",
"note": "Five person-detail tags hold values, so at this intent the series
cannot leave until the deid lane's plan is applied."}
],
"next_lane": {"lane": "volume",
"reason": "The geometry is the blocking question; settle the sort
order and the gap before cataloguing anything."},
"body": {
"series_kind": "A single-frame CT Image Storage series, ORIGINAL\\PRIMARY\\AXIAL,
one frame of reference, no localizer mixed in.",
"usable_as": "A stack of 61 correctly oriented 2D images. Not a uniformly
spaced volume until the gap is resolved.",
"checks": [
{"area": "object type and SOP class", "status": "pass",
"note": "1.2.840.10008.5.1.4.1.1.2, CT Image Storage, single frame."},
{"area": "transfer syntax and pixel encoding", "status": "pass",
"note": "Explicit VR Little Endian, not encapsulated, 16/12 bits, signed 0."},
{"area": "series and study separation", "status": "pass",
"note": "One study, one series, one frame of reference."},
{"area": "slice geometry and spacing", "status": "fail",
"note": "Median step 2.5 mm, one step of 5 mm, spread 2.5 mm."},
{"area": "instance completeness", "status": "fail",
"note": "61 instances, InstanceNumber 1-62 with 33 missing."},
{"area": "intensity calibration", "status": "pass",
"note": "Slope 1, intercept -1024, RescaleType HU."},
{"area": "acquisition consistency", "status": "pass",
"note": "No orientation drift, gantry tilt 0, one ImageType."},
{"area": "identifiers present", "status": "fail",
"note": "5 person-detail and 9 quasi-identifying tags hold values."}
],
"geometry_note": "The normal is the cross product of (1,0,0) and (0,1,0), so
(0,0,1) - a clean axial stack. Projecting every (0020,0032)
onto it gives -240.5 mm to -88 mm, an extent of 152.5 mm.
61 slices over 152.5 mm at 2.5 mm would span 150 mm, and the
extra 2.5 mm is the gap. A naive reader that trusts
SliceThickness of 1.0 mm gets a z axis 2.5x too small AND
misses the gap.",
"missing_data": [
{"item": "SpacingBetweenSlices (0018,0088)",
"impact": "Nothing is lost - the positions are the better source anyway -
but tools that read it will fall back to SliceThickness.",
"how_to_get_it": "Derive it from the positions, as the prescan did."}
],
"next_step_note": "Settle the gap before building anything on this stack.",
"script": "import pathlib\nimport numpy as np\nimport pydicom\n\nSERIES_DIR = pathlib.Path(\"/data/ct/series2\")\n\ndef main():\n ds_list = [pydicom.dcmread(p, stop_before_pixels=True)\n for p in sorted(SERIES_DIR.glob(\"*.dcm\"))]\n iop = np.array(ds_list[0].ImageOrientationPatient, dtype=float)\n normal = np.cross(iop[:3], iop[3:])\n proj = sorted(float(np.dot(np.array(d.ImagePositionPatient, dtype=float), normal))\n for d in ds_list)\n steps = np.diff(proj)\n print(\"instances\", len(ds_list), \"spread\", steps.max() - steps.min())\n print(\"gaps\", [(i, s) for i, s in enumerate(steps) if s > 1.5 * np.median(steps)])\n\nif __name__ == \"__main__\":\n main()\n",
"script_note": "It re-runs the geometry and completeness checks over the whole
folder and prints them. It does not read pixel data, does not
look for burned-in text, and does not write anything."
}
}
The reply's script is re-checked client-side: every DICOM tag it names, in either ds.Keyword or ds[0xGGGG, 0xEEEE] form, is looked up against the pasted header, and the count and the unknown ones are attached as script_tag_count and script_unknown_tags. Those two keys, like the *_ungrounded booleans, are added by the client and are not part of what the model returns.
task: "deid" — Plan the de-identification (Clear it to leave stage)
What has to happen to these headers before this series can leave, at the stated intent. The @k-dense-ai/pydicom lane.
"body": {
"profile": "which profile and which of its options this plan assumes",
"tag_actions": [{"tag": "(0010,0010)", "keyword": "PatientName",
"action": "remove|zero|replace|remap-uid|shift-date|clean-text|keep",
"why": "one line, tied to the intent"}],
"residual_risk": [{"risk": "...", "likelihood": "high|medium|low",
"mitigation": "..."}],
"pixel_note": "what the pixels themselves may still carry, and how to find out",
"script": "pydicom that reads, edits and writes to a SEPARATE output directory",
"script_note": "..."
}
action is one of remove, zero, replace, remap-uid, shift-date, clean-text, keep; anything else normalizes to remove. remap-uid is not a synonym for remove: it means one new UID per old UID, the same new one every time it appears, because deleting a UID breaks every reference to it. keep is legitimate and sometimes right — PatientWeight for an SUV calculation, PatientSex for a stratified split — but every keep must say what it is being kept for. residual_risk[].likelihood is high, medium or low.
The plan is expected to cover every tag the prescan listed under identifiers.present, plus every private tag, plus BurnedInAnnotation. The client then computes the difference: any tag the browser found that the plan does not mention is collected into unhandled_tags and displayed as a hole in the plan. That key is a client-side addition too, and it is the reason a plan cannot quietly skip a tag it found inconvenient.
Worked example
POST /run
{
"task": "deid",
"headers_text": "... the same dump ...",
"intent": "public-release",
"context_note": "going into an open challenge dataset",
"prescan": { ... the object from step 4 ... },
"prior_preflight": {
"verdict": "fix-first",
"series_kind": "A single-frame CT Image Storage series, ORIGINAL\\PRIMARY\\AXIAL.",
"usable_as": "A stack of 61 correctly oriented 2D images.",
"findings": [
{"id": "DDX-001", "severity": "critical", "location": "series 1 (CT #2)",
"title": "one slice is missing from the middle of the stack"},
{"id": "DDX-002", "severity": "high", "location": "header",
"title": "BurnedInAnnotation (0028,0301) is absent"}
]
}
}
{
"task": "deid",
"task_inferred": false,
"title": "De-identification plan for one CT chest series, public-release bar",
"verdict": "fix-first",
"summary": "At the public-release bar this series is not clearable as it stands.
The named identifiers and the dates are straightforward, the three
UIDs need remapping rather than deletion, and the private tag and
the free-text StudyDescription have to be read by a person before
they can be cleared. BurnedInAnnotation is absent, which at this
intent is a blocker rather than a note.",
"assumptions": ["That no image carries burned-in text, unverified - see
pixel_note."],
"open_questions": ["What (0043,1028) holds. Private tags are vendor-defined and
this one is not in any public dictionary."],
"findings": [
{"id": "DDX-001", "severity": "critical", "location": "(0043,1028)",
"title": "an undocumented private tag cannot be cleared by inspection",
"why": "Private tags have carried patient names, operator notes and raw
filenames. At public-release intent an uninspected private tag is an
unbounded risk, not a small one.",
"fix": "Remove it unless the vendor documents it and you have read the
value. Removing a private tag is safe: nothing standard refers to it."},
{"id": "DDX-002", "severity": "high", "location": "header",
"title": "BurnedInAnnotation absent blocks a public release",
"why": "The one identifier class this app cannot see is the one drawn into
the pixels, and the tag that would rule it out is not present.",
"fix": "Inspect a sample of frames, then write BurnedInAnnotation = NO
explicitly in the output."}
],
"reconciliation": [
{"flag_id": "DD-01", "status": "set-aside",
"note": "Real, and confirmed by the preflight, but spacing is not an
identifier question - it does not affect this plan."},
{"flag_id": "DD-02", "status": "set-aside",
"note": "Same: the gap is a geometry defect, tracked by DDX-001 of the
preflight rather than restated here."},
{"flag_id": "DD-04", "status": "confirmed",
"note": "All five are in tag_actions below, each with an action and a reason."}
],
"next_lane": {"lane": "catalog",
"reason": "Once the identifiers are settled, decide which of the
surviving fields become manifest columns."},
"body": {
"profile": "PS3.15 Annex E Basic Application Level Confidentiality Profile with
the Retain Longitudinal Temporal Information option applied as a
whole-study date shift, and no other retain option.",
"tag_actions": [
{"tag": "(0010,0010)", "keyword": "PatientName", "action": "zero",
"why": "Profile action Z. Zeroing keeps the tag present so a reader knows
it was handled rather than lost."},
{"tag": "(0010,0020)", "keyword": "PatientID", "action": "replace",
"why": "Replace with the study's own pseudonym so the cohort can be joined
back to its own manifest, never to the hospital record."},
{"tag": "(0008,0020)", "keyword": "StudyDate", "action": "shift-date",
"why": "One offset per patient, applied to every date tag, so intervals
between studies survive while the calendar date does not."},
{"tag": "(0020,000D)", "keyword": "StudyInstanceUID", "action": "remap-uid",
"why": "Profile action U. Delete it and every reference to this study
breaks; remap it consistently and the archive still resolves."},
{"tag": "(0008,1030)", "keyword": "StudyDescription", "action": "clean-text",
"why": "Free text on the profile list. Read it, keep the protocol words,
remove anything about the person. This one is manual by design."},
{"tag": "(0043,1028)", "keyword": "", "action": "remove",
"why": "Undocumented private tag at public-release intent - see DDX-001."},
{"tag": "(0010,1030)", "keyword": "PatientWeight", "action": "keep",
"why": "Kept for dose normalisation in the challenge metric. Weight alone
is not identifying at this cohort size."}
],
"residual_risk": [
{"risk": "Burned-in text in the pixel data",
"likelihood": "medium",
"mitigation": "Inspect a sample, or run an OCR pass over the corner
regions, before release. No header edit addresses this."},
{"risk": "Re-identification from the geometry and dates in combination",
"likelihood": "low",
"mitigation": "The date shift is per patient, so intervals leak nothing
about the calendar. Publish the shift policy, not the
offsets."}
],
"pixel_note": "This plan touches headers only. Nothing in it can find an
identifier drawn into the image, and BurnedInAnnotation is
absent rather than NO, so that question is open, not answered.",
"script": "import pathlib\nimport pydicom\nfrom pydicom.uid import generate_uid\n\nSRC = pathlib.Path(\"/data/ct/series2\")\nDST = pathlib.Path(\"/data/ct/series2_deid\") # never in place\nUID_MAP = {}\n\ndef remap(uid):\n return UID_MAP.setdefault(uid, generate_uid())\n\ndef main():\n DST.mkdir(parents=True, exist_ok=True)\n for path in sorted(SRC.glob(\"*.dcm\")):\n ds = pydicom.dcmread(path)\n ds.PatientName = \"\"\n ds.PatientID = \"SUBJ-0007\"\n ds.StudyInstanceUID = remap(ds.StudyInstanceUID)\n ds.SeriesInstanceUID = remap(ds.SeriesInstanceUID)\n ds.SOPInstanceUID = remap(ds.SOPInstanceUID)\n if \"StudyDescription\" in ds:\n ds.StudyDescription = \"REVIEW ME\"\n for tag in [(0x0043, 0x1028)]:\n if tag in ds:\n del ds[tag]\n ds.BurnedInAnnotation = \"NO\" # only after you have looked\n ds.save_as(DST / path.name)\n\nif __name__ == \"__main__\":\n main()\n",
"script_note": "It writes to a separate directory and maps UIDs through one
dictionary so the mapping is consistent across the series. It
does not shift the dates - that offset is yours to choose -
and it does not look at the pixels."
}
}
Note what the reconciliation does here: two of the three required flags are answered set-aside, with the reason, because they belong to another lane. That is a legitimate answer and it is not the same as silence. Silence is what gets counted and displayed.
task: "volume" — Plan the volume (Reconstruct stage)
How does this series become a correctly oriented, correctly spaced array, and what will go wrong if it is done naively. The @nvidia/dicom-series-to-volume lane.
"body": {
"basis": "one or two sentences on what this plan is derived from",
"sort_key": "exactly what to sort on, and why not InstanceNumber",
"geometry": [{"property": "voxel spacing (x, y, z) mm",
"value": "0.7031, 0.7031, 2.5",
"source": "(0028,0030) and the derived spacing from (0020,0032)"}],
"affine_note": "how the 4x4 patient-space affine is built from the direction
cosines and the origin",
"steps": [{"step": "...", "detail": "..."}],
"pitfalls": [{"pitfall": "...", "consequence": "...", "guard": "..."}],
"script": "pydicom (plus numpy, SimpleITK or nibabel) producing array and affine",
"script_note": "..."
}
Every entry in geometry names its source tag, and that source is grounded against the header like any other citation: a geometry value with no source tag is a guess wearing a number. steps is the ordered procedure — read, filter, sort, check, stack, calibrate, orient, write. And the lane is explicitly allowed to refuse: when the prescan says the geometry cannot support a volume (no orientation, no positions, inconsistent spacing, orientation drift, mixed frames of reference) the verdict is blocked or fix-first and the plan says what has to be fixed first, because a resample onto a uniform grid across a gap is inventing tissue and has to be named as such if it is offered at all.
Worked example
POST /run
{
"task": "volume",
"headers_text": "... the same dump ...",
"intent": "research-volume",
"context_note": "target is a 3D nnU-Net input; the loader currently reads z as 1.0 mm",
"prescan": { ... the object from step 4 ... },
"prior_preflight": {
"verdict": "fix-first",
"series_kind": "A single-frame CT Image Storage series, ORIGINAL\\PRIMARY\\AXIAL.",
"usable_as": "A stack of 61 correctly oriented 2D images.",
"findings": [{"id": "DDX-001", "severity": "critical",
"location": "series 1 (CT #2)",
"title": "one slice is missing from the middle of the stack"}]
}
}
{
"task": "volume",
"task_inferred": false,
"title": "Reconstruction plan for one axial CT chest stack with a 2.5 mm gap",
"verdict": "fix-first",
"summary": "The orientation is clean and the sort order is defensible, so most of
this is mechanical. What is not mechanical is the gap: 61 slices span
152.5 mm where 61 slices at 2.5 mm would span 150 mm. Build the array
and you get a volume that is silently wrong across one boundary. The
plan below stacks it, records the gap, and refuses to interpolate it.",
"assumptions": ["That the 61 instances in the folder are the 61 the prescan
measured."],
"open_questions": ["Whether the missing instance can be re-exported. If it can,
everything below becomes a clean uniform stack."],
"findings": [
{"id": "DDX-001", "severity": "critical", "location": "series 1 (CT #2)",
"title": "the stack is not uniformly spaced, so it is not a single volume",
"why": "Every voxel-based measurement downstream assumes a constant z step.
One 5 mm step among 2.5 mm steps makes the array geometrically wrong
from that boundary on, and no array metadata records it.",
"fix": "Re-export the missing instance. Failing that, either cut the stack at
the gap and reconstruct two volumes, or carry the gap explicitly.
Resampling across it fabricates a slice that was never acquired."},
{"id": "DDX-002", "severity": "medium", "location": "(0018,0050)",
"title": "SliceThickness disagrees with the measured spacing",
"why": "SliceThickness is 1.0 mm and the positions measure 2.5 mm. A loader
that trusts the tag builds a volume 2.5x too short in z.",
"fix": "Take z from the positions, as the plan does. Never from (0018,0050)."}
],
"reconciliation": [
{"flag_id": "DD-01", "status": "confirmed",
"note": "It is the whole subject of this plan."},
{"flag_id": "DD-02", "status": "confirmed",
"note": "The gap is where the spacing spread comes from - one defect, two
measurements of it."},
{"flag_id": "DD-04", "status": "set-aside",
"note": "Identifiers do not affect the reconstruction. They block the export,
which is the deid lane's question."}
],
"next_lane": {"lane": "catalog",
"reason": "The gap has to appear as a column, or the next reader
inherits it silently."},
"body": {
"basis": "The prescan's measured geometry over 61 instances: one frame of
reference, no orientation drift, positions from -240.5 mm to -88 mm
along the normal.",
"sort_key": "The dot product of ImagePositionPatient (0020,0032) with the slice
normal, ascending. Not InstanceNumber: it happens to agree here,
but it is a label the scanner wrote and it disagrees with the
geometry often enough that relying on it is a coin flip you do not
need to take.",
"geometry": [
{"property": "slice normal", "value": "0, 0, 1",
"source": "(0020,0037)"},
{"property": "in-plane voxel size (x, y) mm", "value": "0.7031, 0.7031",
"source": "(0028,0030)"},
{"property": "z spacing mm", "value": "2.5 (median of the position steps)",
"source": "(0020,0032)"},
{"property": "origin (patient space) mm", "value": "-166.5, -31.5, -240.5",
"source": "(0020,0032)"},
{"property": "array shape (cols, rows, slices)", "value": "512, 512, 61",
"source": "(0028,0011), (0028,0010) and the position count"},
{"property": "intensity calibration", "value": "HU = raw * 1 + (-1024)",
"source": "(0028,1053) and (0028,1052)"}
],
"affine_note": "Columns one and two of the 3x3 are the two direction cosines
from (0020,0037) scaled by the row and column spacing in
(0028,0030); column three is the slice normal scaled by the
2.5 mm derived spacing, NOT by SliceThickness; the translation
column is the (0020,0032) of the first slice in sorted order.
Getting the row/column order of (0028,0030) backwards is the
classic transposed-volume bug and it is invisible on isotropic
in-plane data.",
"steps": [
{"step": "read", "detail": "dcmread with stop_before_pixels=True first, so
the geometry is settled before any pixels load."},
{"step": "filter", "detail": "Keep one SeriesInstanceUID and one
FrameOfReferenceUID. Drop localizers by
ImageType."},
{"step": "sort", "detail": "By the projection onto the normal, ascending."},
{"step": "check", "detail": "Assert the step spread is under a tolerance you
choose, and fail loudly here rather than
producing an array. This is where this series
stops."},
{"step": "stack", "detail": "np.stack the pixel arrays in sorted order."},
{"step": "calibrate", "detail": "Apply slope and intercept, in float, before
any windowing."},
{"step": "orient", "detail": "Build the affine, then hand array plus affine
to SimpleITK or nibabel - never the array
alone."},
{"step": "write", "detail": "NIfTI or NRRD with the affine attached, plus a
sidecar recording the gap."}
],
"pitfalls": [
{"pitfall": "Sorting on InstanceNumber",
"consequence": "A silently mirrored volume when the scanner numbered
against the geometry.",
"guard": "Sort on the projection and assert the two orders agree."},
{"pitfall": "Taking z from SliceThickness or SpacingBetweenSlices",
"consequence": "Here, a volume 2.5x too short in z with a correct-looking
header.",
"guard": "Derive z from the positions and compare it to the tags; log the
difference rather than resolving it silently."},
{"pitfall": "Resampling onto a uniform grid to remove the gap",
"consequence": "An interpolated slice that no scanner acquired, and nothing
downstream can tell it from real data.",
"guard": "Refuse, or record it in the manifest and the sidecar."},
{"pitfall": "Windowing before calibrating",
"consequence": "Wrong HU everywhere, and the error looks like a contrast
setting.",
"guard": "Slope and intercept first, in float, then window for display only."}
],
"script": "import pathlib\nimport numpy as np\nimport pydicom\nimport SimpleITK as sitk\n\nSERIES_DIR = pathlib.Path(\"/data/ct/series2\")\nOUT = pathlib.Path(\"/data/ct/series2.nii.gz\")\nTOL_MM = 0.01\n\ndef main():\n heads = [(p, pydicom.dcmread(p, stop_before_pixels=True))\n for p in sorted(SERIES_DIR.glob(\"*.dcm\"))]\n iop = np.array(heads[0][1].ImageOrientationPatient, dtype=float)\n normal = np.cross(iop[:3], iop[3:])\n order = sorted(heads, key=lambda h: float(\n np.dot(np.array(h[1].ImagePositionPatient, dtype=float), normal)))\n proj = [float(np.dot(np.array(h[1].ImagePositionPatient, dtype=float), normal))\n for h in order]\n steps = np.diff(proj)\n if steps.max() - steps.min() > TOL_MM:\n raise SystemExit(\"non-uniform spacing: %.4f mm spread - refusing\"\n % (steps.max() - steps.min()))\n vol = np.stack([pydicom.dcmread(p).pixel_array for p, _ in order])\n ds0 = order[0][1]\n vol = vol * float(ds0.RescaleSlope) + float(ds0.RescaleIntercept)\n img = sitk.GetImageFromArray(vol.astype(np.float32))\n ps = [float(x) for x in ds0.PixelSpacing]\n img.SetSpacing((ps[1], ps[0], float(np.median(steps))))\n img.SetOrigin(tuple(float(x) for x in ds0.ImagePositionPatient))\n img.SetDirection(tuple(np.concatenate([iop[:3], iop[3:], normal]).tolist()))\n sitk.WriteImage(img, str(OUT))\n\nif __name__ == \"__main__\":\n main()\n",
"script_note": "It refuses on this series by design - the spacing check fails
before any pixel is read. It does not resample, does not handle
multi-frame instances, and assumes one series in the folder."
}
}
task: "catalog" — Design the manifest (Catalogue stage)
Which of these header fields become the columns of a research manifest, and what does each one mean. The @nvidia/dicom-metadata-extract lane.
"body": {
"dataset_note": "one or two sentences on what a manifest over series like this is for",
"manifest_columns": [{"column": "voxel_z_mm",
"type": "string|number|boolean|date",
"unit": "mm",
"source": "derived from (0020,0032) along the slice normal",
"note": "not SliceThickness - see the geometry"}],
"derived_fields": [{"field": "...", "formula": "...", "why": "..."}],
"quality_columns": [{"column": "...", "definition": "..."}],
"vocab": [{"field": "modality", "values": "CT, MR, PT, ...", "note": "..."}],
"csv_header": "one comma-separated row, same order as manifest_columns, no spaces",
"script": "pydicom that walks a folder and writes that CSV",
"script_note": "..."
}
manifest_columns[].type is string, number, boolean or date; anything else normalizes to string. Every column either names a source tag or appears in derived_fields with its formula — a column that is neither is not a column. quality_columns are the ones that let a later reader filter the cohort honestly, and a manifest without them hides the reason half the cohort is unusable.
csv_header and manifest_columns are two statements of the same thing, so the client checks them against each other rather than displaying both and leaving you to diff by eye: a differing count, or a divergence at position n, is reported in a client-side csv_header_mismatch string. If you are writing your own consumer, do the same check. It catches the single most common way a generated manifest goes quietly wrong.
Worked example
POST /run
{
"task": "catalog",
"headers_text": "... the same dump ...",
"intent": "research-volume",
"context_note": "manifest over ~400 CT series from three scanners, for a segmentation cohort",
"prescan": { ... the object from step 4 ... }
}
{
"task": "catalog",
"task_inferred": false,
"title": "Manifest design for a multi-scanner CT chest cohort",
"verdict": "ready",
"summary": "A defensible manifest over series like this needs eight columns, of
which three exist only to let a later reader throw series away for a
stated reason. Two columns are derived rather than read, and the z
spacing is one of them - taking it from SliceThickness would put a
wrong number in a column nobody re-checks.",
"assumptions": ["That every series in the cohort is single-frame. Multi-frame
instances would need per-frame geometry and a different
manifest."],
"open_questions": ["Whether subject_id should be the pseudonym from the deid
plan. It should, and the two lanes must agree on it."],
"findings": [
{"id": "DDX-001", "severity": "high", "location": "series 1 (CT #2)",
"title": "this series would be admitted by a manifest without quality columns",
"why": "It has a gap. A manifest of subject, modality and voxel size lists it
as a clean 512x512x61 volume, and the person training on it has no
way to know otherwise.",
"fix": "Make spacing_consistent and has_gaps required columns, and populate
them from the measured positions rather than from any tag."}
],
"reconciliation": [
{"flag_id": "DD-01", "status": "confirmed",
"note": "It is why spacing_consistent is in the manifest at all."},
{"flag_id": "DD-02", "status": "confirmed",
"note": "has_gaps carries it into the CSV so it survives this session."},
{"flag_id": "DD-04", "status": "noted",
"note": "Real, and it belongs to the deid lane; identifiers_present records
the state per series so the cohort can be filtered on it."}
],
"next_lane": {"lane": "deid",
"reason": "identifiers_present reads true for this series, so the
export question is still open."},
"body": {
"dataset_note": "A manifest over CT chest series from mixed scanners exists to
answer two questions before any training starts: which series
are geometrically comparable, and which are safe to use.",
"manifest_columns": [
{"column": "subject_id", "type": "string", "unit": "",
"source": "derived - see derived_fields",
"note": "The study pseudonym, never (0010,0020) as it stands."},
{"column": "series_index", "type": "number", "unit": "",
"source": "(0020,0011)",
"note": "SeriesNumber. Not unique across studies; pair it with subject_id."},
{"column": "modality", "type": "string", "unit": "",
"source": "(0008,0060)", "note": "Controlled vocabulary - see vocab."},
{"column": "voxel_x_mm", "type": "number", "unit": "mm",
"source": "(0028,0030)",
"note": "Column spacing, the SECOND element of PixelSpacing."},
{"column": "voxel_z_mm", "type": "number", "unit": "mm",
"source": "derived from (0020,0032) along the slice normal",
"note": "Not SliceThickness (0018,0050) and not SpacingBetweenSlices."},
{"column": "spacing_consistent", "type": "boolean", "unit": "",
"source": "derived from (0020,0032)",
"note": "False here: the steps spread 2.5 mm."},
{"column": "has_gaps", "type": "boolean", "unit": "",
"source": "derived from (0020,0032)",
"note": "True here: one step at 5 mm where the median is 2.5 mm."},
{"column": "identifiers_present", "type": "boolean", "unit": "",
"source": "derived from the PS3.15 Annex E tag audit",
"note": "True while any profile-listed tag still holds a value."}
],
"derived_fields": [
{"field": "subject_id",
"formula": "the study's own pseudonym table, keyed by the ORIGINAL
StudyInstanceUID before remapping",
"why": "The manifest has to join to the de-identified files, so it must use
the same pseudonym the deid lane wrote, not the hospital id."},
{"field": "voxel_z_mm",
"formula": "median of the consecutive differences of
dot(ImagePositionPatient, cross(iop[0:3], iop[3:6]))",
"why": "It is the only z number that is a measurement rather than a claim."}
],
"quality_columns": [
{"column": "spacing_consistent",
"definition": "True when the spread of the position steps is under 0.01 mm.
Anything else is a stack that is not one grid."},
{"column": "has_gaps",
"definition": "True when any step exceeds 1.5x the median step. One gap is
enough to make the series unusable for 3D work."},
{"column": "identifiers_present",
"definition": "True when any tag on the profile list still holds a value.
A cohort where this is true anywhere has not been cleared."}
],
"vocab": [
{"field": "modality", "values": "CT, MR, PT, NM, US, CR, DX, MG",
"note": "From (0008,0060), a DICOM defined term. Do not invent values; an
unexpected one is a series that does not belong in this cohort."},
{"field": "spacing_consistent / has_gaps / identifiers_present",
"values": "true, false",
"note": "Never blank. A blank quality column reads as pass to every tool
that filters on it."}
],
"csv_header": "subject_id,series_index,modality,voxel_x_mm,voxel_z_mm,spacing_consistent,has_gaps,identifiers_present",
"script": "import csv\nimport pathlib\nimport numpy as np\nimport pydicom\n\nROOT = pathlib.Path(\"/data/ct\")\nOUT = pathlib.Path(\"/data/ct/manifest.csv\")\nHEADER = [\"subject_id\", \"series_index\", \"modality\", \"voxel_x_mm\", \"voxel_z_mm\",\n \"spacing_consistent\", \"has_gaps\", \"identifiers_present\"]\nPROFILE_TAGS = [\"PatientName\", \"PatientID\", \"PatientBirthDate\", \"StudyDate\"]\n\ndef row_for(series_dir):\n heads = [pydicom.dcmread(p, stop_before_pixels=True)\n for p in sorted(series_dir.glob(\"*.dcm\"))]\n iop = np.array(heads[0].ImageOrientationPatient, dtype=float)\n normal = np.cross(iop[:3], iop[3:])\n proj = sorted(float(np.dot(np.array(d.ImagePositionPatient, dtype=float), normal))\n for d in heads)\n steps = np.diff(proj)\n med = float(np.median(steps)) if len(steps) else 0.0\n ps = [float(x) for x in heads[0].PixelSpacing]\n ids = any(str(getattr(heads[0], t, \"\")).strip() for t in PROFILE_TAGS)\n return [series_dir.name, int(heads[0].SeriesNumber), heads[0].Modality,\n ps[1], med, bool(len(steps)) and (steps.max() - steps.min()) <= 0.01,\n bool(len(steps)) and bool((steps > 1.5 * med).any()), ids]\n\ndef main():\n with OUT.open(\"w\", newline=\"\") as fh:\n w = csv.writer(fh)\n w.writerow(HEADER)\n for series_dir in sorted(p for p in ROOT.iterdir() if p.is_dir()):\n w.writerow(row_for(series_dir))\n\nif __name__ == \"__main__\":\n main()\n",
"script_note": "It uses the directory name as subject_id, which is a
placeholder - swap in the pseudonym table. It assumes one
series per directory and does not read pixel data."
}
}
Lane-specific rules worth knowing before you consume the output
- The four lanes are one sitting, not four tools.
next_lanenames what reads on from here, and passing the previouspreflightresult back in asprior_preflightis what makes the later lane build on it instead of restating it. Without the handoff you will get the same geometry finding written three more times. - Geometry is arithmetic, and the prompt holds the model to it. The slice normal is the cross product of the two direction cosines in
(0020,0037); a slice's position along the stack is the dot product of(0020,0032)with that normal; spacing is the difference between consecutive projections. It is notSliceThicknessand notSpacingBetweenSlices, both of which are claims about the acquisition that the positions routinely contradict. If the prescan reports no orientation or no positions, there is no defensible sort order at all and the lane says so rather than proposing one. - A remedy has a price and the reply has to name it. Re-exporting from the PACS, re-acquiring the patient, or dropping a series from the cohort are real costs, and the prompt forbids presenting "re-acquire with isotropic voxels" as though it were a config change.
intentmoves the bar, not the facts. The same header atpublic-releaseproduces a stricter identifier reading and treats an absentBurnedInAnnotationas a blocker; atresearch-volumethe geometry and the calibration carry the weight. Do not compare two runs at different intents and conclude the model changed its mind.- Estimate per lane.
hold_creditsdiffers between lanes because the prompts and the output caps differ. Never show one lane's hold for another lane's run. - Degenerate inputs have defined answers. One instance only is reported as one instance, and the
volumelane says there is no volume rather than describing a volume of depth one. No identifying tag holding a value is reported as such, and thedeidplan then covers the UID remaps, the private tags and the pixel question instead of manufacturing work.
What this API will not do
- It is not a de-identifier. It never rewrites a file and it never returns one. Every lane returns a plan and a script for you to run and review yourself, on your own copy, in your own environment.
- It does not read pixel data. Not in the browser and not here. Nothing in a reply can tell you whether an identifier is burned into an image; when that question is open, the reply says it is open rather than answering it.
- It is not a legal or clinical authority. The PS3.15 Annex E action codes are a transcription of one published baseline profile. Expect "the profile asks for X" and "your data-use agreement may ask for more or less", never "this is now compliant" and never "this is safe to publish".
- It does not fetch anything. No PACS query, no UID resolution, no vendor dictionary lookup for a private tag, no reference data of any kind. Everything in a reply comes from what you sent.
- It does not remember. Each run is independent. Continuity between lanes is something you pass in, and history is stored against your own account by the app, not by the model.