← DICOM Desk / API
Get a token

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

ThingValue
Base URLhttps://api.skillsafe.ai/v1/app-api
AuthAuthorization: Bearer <token>
BodyContent-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 identitycarried 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
IdempotencyIdempotency-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

HTTPerror.codeWhat it means and what to do
400invalid_inputThe body was not a JSON object, or task was not one of the four lanes. Fix the body; a retry will not help.
400invalid_requestPOST /guest without a slug. The slug is what binds the token to this app.
401unauthorizedNo token, or a token that has expired. Mint a new one (step 2).
402insufficient_creditsThe balance cannot cover min_credits. Check /estimate against /me before submitting, which is what the app does so this never fires.
404not_foundA job id that does not exist, one belonging to another subject, or a slug that is not a deployed app.
409idempotency_conflictThe same Idempotency-Key was reused with a different body. Keys must be derived from the body, not from a counter.
429rate_limitedBack off and retry. Never tight-loop.
500internalRetry 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.

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.

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.

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.

FieldTypeMeaning
taskstring, requiredDocument 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_textstring, requiredThe 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.
intentstringOne 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_notestring, optionalFree text, up to 400 characters. What the series is for, or what went wrong with it. The single most useful optional field.
prescanobject, optional but strongly recommendedThe browser's own measurements. See below — this is the field that decides whether you get an audited answer or an unaudited one.
clip_notestring, optionalPresent 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_preflightobject, optionalThe 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_notestring, optionalOnly 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:

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.

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.

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.

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

What this API will not do