Container Service command that processes every active scan-map-<bundle>
custom form on a session. It reads explicitly configured scan-number
mappings, copies each mapped scan's complete NIFTI resource (including
.bval, .bvec, JSON sidecars, and nested files), and attaches
<bundle>.zip to the session for each active bundle.
custom-form.json— an examplescan-map-mapped_sessionsform. Its scan field keys include the bundle name, for examplescanMap_mapped_sessions_t2ScanNumber(see below).cmd/map-and-zip/main.go— the dependency-free runtime implementation. It:- Calls the documented XNAT Custom Fields API
(
GET /xapi/custom-fields/experiments/{session}/fields) using theXNAT_HOST/XNAT_USER/XNAT_PASScredentials the Container Service automatically injects.XNAT_API_HOST, when set, overrides the publicXNAT_HOSTwith a container-network-reachable URL. - This API returns a flat namespace of custom-field values with no
indication of which form they came from. Each scan field therefore uses
the key format
scanMap_<bundle>_<field-key>. The mapper finds every persisted field with that format and derives the bundle name and mapping field automatically — no hidden marker or manual bundle configuration is required. - Applies explicit, repeatable command-line rules:
--map formField:Destination. The supplied command definest2ScanNumber:T2, for example;-1/unset fields are skipped. - For each selected scan, copies every file under
SCANS/<scan>/.../NIFTI/. Root resource files whose name begins withniftihave only that prefix replaced by the map destination, sonifti_a.nii.gz,nifti_b.nii.gz, andnifti.bvalbecomeT2/T2_a.nii.gz,T2/T2_b.nii.gz, andT2/T2.bval. Other root files use the legacy mapping behavior (for example,image.nii.gzbecomesT2.nii.gzandimage.bvalbecomesT2.bval); nested paths are retained unchanged. - Receives the XNAT session label directly from the wrapper and writes
/output/<session-label>_<bundle>.zipfor every active bundle, e.g.101_MR_1_mapped_sessions.zip. Unsafe label characters become underscores.
- Calls the documented XNAT Custom Fields API
(
command.json— the Container Service command/wrapper definition. It mounts the session's files read-only, supplies the mapping rules, passes the session ID into the script, and uploads every produced zip back onto the session as a resource.src/map_and_zip.py— retained as a readable Python reference implementation; it is not included in the runtime image.Dockerfile— multi-stage Go build with a distroless runtime image.
Give each form a title beginning with scan-map-; the remaining title text is
the output bundle name. For example, scan-map-brain-mri creates
<session-label>_brain-mri.zip.
The Custom Fields API persists values in one flat namespace, so encode this bundle name into every scan field key. Use:
scanMap_<bundle>_<field-key>
For the scan-map-brain-mri form, configure its T2 field as:
{
"key": "scanMap_brain-mri_t2ScanNumber",
"type": "textfield",
"input": true,
"label": "T2 Scan Number",
"placeholder": "-1"
}The field-key suffix must equal the left side of an existing command mapping.
With --map t2ScanNumber:T2, the example field maps its selected scan to the
T2 directory in <session-label>_brain-mri.zip. Every bundle is discovered
from persisted scan field keys; no hidden marker or --bundle arguments are
needed.
The destination is a simple directory/base-name. Configure dcm2niix with
-f nifti (typically dcm2niix -z y -f nifti ...); then
--map flairScan:FLAIR changes nifti.nii.gz, nifti.bval, and any
collision names such as nifti_a.nii.gz to FLAIR/FLAIR.nii.gz,
FLAIR/FLAIR.bval, and FLAIR/FLAIR_a.nii.gz respectively.
To support forms with different fields, add all their map rules to the same command; fields absent from the saved values are simply skipped for that session.
The command may be launched repeatedly. Each zip contains a hidden
.xnat-scan-mapper.sha256 manifest calculated from the mapping fields and the
contents of every selected source NIFTI resource. Before rebuilding a bundle,
the mapper searches the session's existing resources for the same archive name
and reads this manifest.
An unchanged bundle is skipped without copying or re-zipping files. Adding, removing, renaming, or changing any source resource file, changing a mapped scan number, or changing a map destination changes the manifest and produces a replacement archive. This makes it safe to invoke the command from a broad session-update trigger or a scheduled reconciliation job.
cd /Users/mjbarrett/Code/xnat-scan-mapper
docker build -t xnat-scan-mapper:1.0 .The runtime image contains only the statically linked Go binary and its certificate bundle; it has no Python interpreter, pip packages, or shell.
Push it somewhere your XNAT Docker server/host can pull from (or load it directly on the same Docker host XNAT uses), then in XNAT:
-
Administer > Plugin Settings / Images > Docker Server — confirm the image is visible (
docker imageson the XNAT Docker host, or push to a registry XNAT can reach). -
**Administer > Plugin Settings > Docker Commands (Container Service) > New Command > Upload from JSON
** — uploadcommand.json`, or use:curl -u admin:<password> -X POST \ "https://<xnat-host>/xapi/commands" \ -H "Content-Type: application/json" \ --data @command.json
-
Enable the command for the project(s) that will use it: go to Project > Manage > Automations (or Administer > Automation site-wide) and enable the
map-and-zip-session-scans-wrappercommand for the project/site.
The Automation Service API does not register event listeners or upload scripts; it only manages the legacy internal-scripting setting. XNAT's Custom Fields API does not emit a configurable Event Service trigger on this installation, so a small XNAT-side listener is required for immediate runs when a form is edited.
scripts/launch-mapper.sh is the launch action for that listener. Given a
session REST URI, it calls Container Service's project wrapper-launch endpoint.
Configure these environment variables where the listener runs:
XNAT_HOST=https://xnat.example.org
XNAT_USER=<service-account>
XNAT_PASS=<service-account-password>
XNAT_PROJECT=<project-id>
XNAT_COMMAND_ID=<map-and-zip command ID>
XNAT_WRAPPER_NAME=map-and-zip-session-scans-wrapper
Then invoke it with the edited session's URI:
SESSION_URI=/experiments/XNAT_E05657 scripts/launch-mapper.shThe listener should run this script only after a session custom-field update
whose field key starts with scanMap_. This filter prevents unrelated session
edits from launching containers. The listener must be an XNAT plugin or a
site-specific event bridge that subscribes to the internal custom-field update;
the Automation Service REST API cannot supply that subscription itself.
the just-saved values straight from XNAT, creates each <bundle>.zip, and
The launched command discovers all saved scanMap_<bundle>_<field-key> values,
creates each <session-label>_<bundle>.zip, and attaches the results to the
session's resources.
- The exact event name for "custom form saved" varies by XNAT/Forms-plugin version — check what's available in the Automation event dropdown and pick the closest session-level "updated"/"archived" event.
XNAT_HOST/XNAT_USER/XNAT_PASSare injected automatically by the Container Service for REST callbacks. If the publicXNAT_HOSTis not resolvable inside containers, configureXNAT_API_HOSTin the Docker Server / Container Service environment to an internal URL that is, such ashttp://xnat:8080whenxnatis the Docker Compose service name. This is deployment-specific; verify it from the Docker network rather than assuming a particular hostname.- If your custom form fields aren't reflected in
/data/experiments/{id}?format=json, adjustfetch_custom_form_values()in src/map_and_zip.py to call the Forms plugin's dedicated values endpoint for your XNAT version instead.