Skip to content

[Feature]: Add MONAILabel to OHIF viewer plugin XNAT - #945

Draft
atriaybagur wants to merge 28 commits into
developfrom
55-feature-add-monailabel-to-ohif-viewer-plugin-xnat
Draft

[Feature]: Add MONAILabel to OHIF viewer plugin XNAT#945
atriaybagur wants to merge 28 commits into
developfrom
55-feature-add-monailabel-to-ohif-viewer-plugin-xnat

Conversation

@atriaybagur

@atriaybagur atriaybagur commented Aug 12, 2026

Copy link
Copy Markdown
Member

Description

Adds an optional MONAI Label server to the trust stack: AI-assisted annotation (interactive
SAM click-to-segment, DeepEdit multi-organ segmentation) in the XNAT OHIF viewer, reading DICOM
straight off the trust's XNAT archive and writing labels back as DICOM-SEG assessors. Ports the
exploration from the legacy nhsflame branch (255-feature-add-monailabel-to-ohif-viewer-plugin-xnat)
and the xnat_ohif_monailabel repo onto FLIP's current trust layout.

Off by default and opt-in per trust — it needs an NVIDIA GPU and a large image, so a trust
that does not enable it is byte-for-byte unaffected (verified: identical rendered compose without
the flag). Follows the existing GPU_OVERRIDE/TRUST_OVERRIDE overlay pattern rather than
introducing Compose profiles:

make up-trust KIT=<CODE> MONAI_LABEL=true    # or MONAI_LABEL=true in the kit file

Key decisions (details in trust/README.md#monai-label-optional and commit messages):

  • MONAI Label pinned to a git commit, not PyPI. The latest release (0.8.5) omits auth on
    XNAT datastore GETs — the viewer panel 500s over a 401 (Monailabel-Plugin in XNAT OHIF Viewer status code 500 Project-MONAI/MONAILabel#1837; fix
    #1838 merged Jun 2025, never released). The image build asserts the fix is present.
  • Slim image (10.4 GB vs ~20 GB NGC base): python:3.12-slim + torch from the cu128 wheel
    index (same approach and reasoning as fl-services/flower/fl-base; cu13 wheels need driver
    ≥ 580, trust hosts run 575.x). Build asserts CUDA 12.x so a wrong wheel fails the build, not
    first inference.
  • XNAT archive mounted read-only at the archive itself — the datastore resolves files as
    <asset path> + <URI minus /data/xnat/archive/>, so a parent path silently misses and falls
    back to per-scan HTTP downloads. Labels go back via the XNAT REST API, never the filesystem.
  • Registered server URL is configuration (MONAI_LABEL_PUBLIC_URL): XNAT's ohifaiaa xapi
    only stores the URL — the clinician's browser calls it directly, so it must be
    browser-resolvable (and same-origin/TLS-fronted on an HTTPS XNAT).
  • export_mask container-service command (site-wide event subscription): converts OHIF's
    DICOM-SEG assessors to NIfTI for training. configure-dcm2niix.sh's legacy cleanup previously
    deleted every site-wide subscription it found; now scoped by name to its own.
  • Guard rails: up-trust refuses MONAI_LABEL=true without a GPU; under PROD it
    proceeds with an explicit trust XNAT: bulk image-pull wedges permanently — Reactor EventBus ring-buffer livelock (OHIF listener) + restart strands directArchive #662 warning (see the absorbed follow-ups below).

Linked Issues

Fixes #55
Fixes #947
Fixes #948

Checklist

  • Follows the project's coding conventions and style guide
  • Updates documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

Type of Change

  • Non-breaking change (fix or new feature that would not break existing functionality).
  • Breaking change (fix or new feature that would cause existing functionality to change).
  • New tests added to cover the changes.
  • In-line docstrings updated.
  • Documentation updated, tested make -C docs/ docs.

Testing

No new automated tests — the change is compose/Makefile/shell config plus a new Docker image;
verification was live on the dev GSTT stack (XNAT 1.10.0, OHIF plugin 3.8.0, RTX 5090):

  • Default path regression: rendered compose without MONAI_LABEL=true is unchanged
    (docker compose config service-set comparison); both Make guards exercised (no-GPU refusal,
    PROD refusal).
  • Overlay path: compose parse matrix for the dev overlay; archive mount resolves to the
    per-trust XNAT archive (xnat/xnat-data-<CODE>/xnat-data/archive, read-only).
  • Live end-to-end: server registers with XNAT automatically; datastore sees 900 archive
    images off disk (Exists in asset store); deepedit_seg produced correct multi-organ masks
    on abdominal CT in the viewer; sam_2d/sam_3d returned valid masks server-side on every
    run (voxel-counted by replaying the exact infer calls).
  • Build-time asserts verified to fail on the two known-bad states (unpatched XNAT datastore,
    non-cu12 torch).

Additional Notes

Acceptance Criteria

From issue #55

From issue #947

Follow-up to #55 / PR #945.

From issue #948

  • helm lint and helm template pass with monailabel.enabled=true (CI values) and default values.
  • With monailabel.enabled=false (default) the rendered chart is unchanged.
  • On a GPU node with the trust chart deployed: server registers with XNAT, deepedit_seg and sam_2d produce masks in the OHIF viewer against archive data.

Follow-up to #55 / PR #945. Depends on the published image from the image-publishing issue.

Ports the MONAI Label / OHIF / XNAT work from the legacy nhsflame repo onto
FLIP's current trust layout, as an opt-in service.

MONAI Label adds AI-assisted annotation to the XNAT OHIF viewer. It needs an
NVIDIA GPU and a large image, so it must not be forced on every trust: it
ships as a compose overlay applied only when MONAI_LABEL=true, following the
existing GPU_OVERRIDE / TRUST_OVERRIDE pattern rather than introducing
Compose profiles. With the flag unset the rendered trust stack is byte-for-byte
unchanged.

Notes on the port:

- MONAI Label is pinned to git commit 3806289, not PyPI. The latest release
  (0.8.5) omits auth on XNAT datastore GETs, so the viewer's MONAI Label panel
  fails with HTTP 500 over a 401 (Project-MONAI/MONAILabel#1837). The fix
  (#1838) merged in June 2025 and is still unreleased. The build asserts the
  fix is present rather than trusting the pin.
- The XNAT archive is mounted read-only at the archive itself, not its parent:
  the datastore resolves files as <asset path> + <URI minus /data/xnat/archive/>,
  so a parent path makes every lookup miss and silently fall back to HTTP.
  MONAI_LABEL_XNAT_ARCHIVE_DIR anchors the kit's XNAT_DATA_DIR at xnat/, since
  that value is written relative to trust/xnat/.
- The URL registered with XNAT is configurable and defaults to localhost. XNAT's
  ohifaiaa xapi only stores the string — the clinician's browser calls it
  directly — so a Docker-internal name or 0.0.0.0 cannot work.
- The entrypoint polls XNAT, the service account and the server's own readiness
  instead of sleeping, and fails if registration is rejected; an unregistered
  server otherwise looks healthy while the viewer panel is simply missing.
- up-trust refuses MONAI_LABEL=true without a GPU, and under PROD, where no
  monailabel image is published yet.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
… setup (#55)

Second half of the MONAI Label port: the return leg that turns a segmentation
saved in the OHIF viewer into the NIfTI that FL training consumes, plus the
manual steps the viewer needs.

export_mask:

- Command and event JSON derived from the canonical definition in
  londonaicentre/xnat_ohif_monailabel rather than the legacy nhsflame copy.
- The subscription is site-wide: a DICOM-SEG can be created in any project and
  there is no per-project opt-in for it, unlike dcm2niix's dicom_to_nifti flag.
- Unlike dcm2niix, the converter talks to XNAT (pyxnat) to fetch the session's
  NIfTI resources and upload its output, so it needs a network. The trust
  network name is now exported into xnat-web and pinned onto the command;
  configure-export-mask.sh refuses to run without it, since the container would
  otherwise start with no route to XNAT and fail at upload time.

configure-dcm2niix.sh deleted EVERY site-wide event subscription it found, not
just its own. That silently removed anything else registered on the site — an
operator's own subscription, or export_mask's. It is now scoped by name to the
two historical dcm2niix spellings ("DICOM-NifTi Conversion" from the retired
site-wide JSON, "DICOM-NIfTI Conversion" from imaging-api).

Dockerfile: install torch from the cu128 index with --index-url, not
--extra-index-url. With PyPI still in the resolver its default torch is a CUDA
13 build that outranks the cu128 wheel, which pulled nvidia-*-cu13 and produced
an image that cannot run on the trust hosts' 575.x driver (cu13 needs >=580).
The resolved versions are frozen into a constraints file so MONAI Label's own
dependency tree cannot swap torch back, and the build now asserts CUDA 12.x —
sm_120 alone does not distinguish cu128 from cu13.

Docs: MONAI Label needs the OHIF viewer plugin, which FLIP deliberately does
not install (#662) — that tension is now stated up front. Registering the
server is automatic; the per-user feature flag is not, and cannot be defaulted
from the server, so the one-time per-browser step is documented along with where
the viewer persists it and why the alternatives were rejected.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
The previous version expanded the one-time viewer toggle into two sections
with a full rationale for why it cannot be defaulted server-side and a
four-item troubleshooting list. That detail belongs in the issue, not in a
setup README — the actionable content is the toggle itself, that it is once
per browser, and what to check first when the panel is missing.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Both found by actually building and running the image.

curl: entrypoint.sh probes XNAT, the service account and the server's own
readiness with curl, but python:slim does not ship it — the NVIDIA base this
image was originally built on did. The container started and then logged
"curl: command not found" on every probe.

The build assert used torch.cuda.get_arch_list() to require Blackwell (sm_120)
kernels. That call returns [] whenever torch.cuda.is_available() is false,
which it always is inside a GPU-less build, so it failed a perfectly good
image: the log read "torch 2.11.0+cu128 cuda 12.8 []". The wheel's own
metadata is the right build-time signal, so it now asserts CUDA 12.x and a
+cu local version tag instead, which is what actually distinguishes the cu128
wheel from PyPI's cu13 default.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
The radiology app registers sam_2d and sam_3d whenever the sam2 package is
importable (radiology/main.py gates on conf 'sam2', which defaults to true),
so MONAI_LABEL_MODELS does not describe the full set of loaded models and the
image cannot be slimmed by dropping sam2 without losing interactive
segmentation. Also notes the extra ~900MB checkpoint fetched on first start.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Diagnosed live on the dev GSTT stack: every DEEPGROW-type model (sam_2d,
deepgrow_*) returned 'Empty mask was returned by the model run' in a
long-lived viewer tab, across CR and CT data and regardless of segment
selection, while deepedit_seg rendered correctly. The server returned valid
non-empty masks for every failing run (verified by replaying the exact infer
calls and counting voxels: 139k on CR, 97k on CT).

The viewer's DEEPGROW path remaps the returned mask through the active
segment index ([0, I]); when the tab's segment state wedges, that index goes
bad and every voxel remaps to zero. SEGMENTATION-type models use the model's
own label map and are immune. A plain reload of the tab restores stock
behaviour — confirmed by console line-offsets proving the working run
executed the unmodified 3.8.0 bundle.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Characterised live: DeepGrow-type results (sam_2d/sam_3d/deepgrow_*) always
write into segment index 1 regardless of the selected segment; selection and
panel refresh do not change it. Same family as the stale-state empty-mask
failure — the viewer routes DEEPGROW masks through an active-segment index
that the segments panel does not drive in this build. deepedit_seg
(SEGMENTATION type) routes by the model's own label map and is unaffected.
Upstream (ICR viewer frontend 3.7.2); the 3.7.0 frontend predates the
segment-store rework and was the configuration the legacy repo validated.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
@atriaybagur atriaybagur linked an issue Aug 12, 2026 that may be closed by this pull request
3 tasks
@github-actions github-actions Bot changed the title Add optional MONAI Label server (AI-assisted annotation in the XNAT OHIF viewer) [Feature]: Add MONAILabel to OHIF viewer plugin XNAT Aug 12, 2026
@github-actions

Copy link
Copy Markdown

✅ Acceptance criteria have been automatically imported from the linked issue(s) and added to the PR description.

…mpose (#947)

docker_build_monailabel.yml follows the orthanc pattern (direct push +
workflow_dispatch + PR build, no test-workflow gate) with two additions: the
job reclaims ~25GB of preinstalled runner toolchains before building — the
image is ~10.4GB against ubuntu-latest's ~14GB free — and an import smoke
(monailabel/torch/sam2/XNATDatastore + curl) runs between build and push, so
a broken runtime stack never publishes. The Dockerfile's own build asserts
already cover the unpatched-XNAT-auth and non-CUDA-12-torch states.

compose_trust.production.monailabel.yml is image-only like the rest of the
production compose; the Makefile's $(__DCKR_SUFFIX) machinery picks it up
with no new wiring. The hard PROD refusal in require_monailabel_supported
becomes an explicit warning: enabling MONAI Label re-introduces the OHIF
viewer plugin the trust XNAT deliberately excludes (FLIP#662 bulk-import
livelock), which is a per-trust judgement call, not a build limitation —
intended for hybrid/on-prem annotation trusts. The GPU refusal stays hard: a
GPU-less host cannot run it at all.

Dev keeps building locally: pointing the dev overlay at ghcr :stag before a
first publish exists would fail every `make up-trust` on pull.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Mirrors the compose overlay: Deployment + Service + weights PVC, gated on
monailabel.enabled (default false — the default render is proven unchanged,
comment-only delta). The chart's XNAT already ships the ohif-viewer plugin in
its roster, so unlike compose there is no plugin step and no FLIP#662
exclusion to weigh here.

Chart-specific decisions:

- The XNAT archive is read straight off xnat-web's data PVC (read-only
  `archive` subPath). That PVC is ReadWriteOnce by default, so the pod is
  pinned to xnat-web's node via required podAffinity
  (monailabel.coScheduleWithXnat, default true); RWX storage classes can
  disable it.
- monailabel.publicUrl is `required` when enabled rather than defaulted: XNAT
  hands it to the OHIF viewer, which calls it from the clinician's browser —
  the chart cannot derive an outside-the-cluster URL, and a wrong default
  fails only at click time. The helm-template CI job asserts the guard fires.
- Exposed as NodePort (default 30030) plus a scoped NetworkPolicy opening
  exactly that port on exactly this pod through the namespace's default-deny
  ingress (service.allowExternalIngress).
- Startup probe allows 30 minutes: first start on a cold volume downloads
  pretrained weights including the ~900MB SAM checkpoint.
- Render coverage lives in the helm-template job, NOT ci/test-values.yaml:
  the kind install deploys those values and waits for pod readiness, which a
  ~10.4GB GPU image can never satisfy there.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…ment

[Feature]: MONAI Label deployment in the Kubernetes trust chart
…age-prod-compose

[Feature]: Publish monailabel image and support MONAI Label in production compose
a943c06 taught the cleanup to tell site-wide subscriptions from per-project ones.
Correct, but beside the point: nothing has created a site-wide dcm2niix
subscription since 88adb78 (2026-03-24), when dcm2niix moved to per-project
subscriptions and dcm2niix_event.json was deleted. Confirmed by grep — the only
remaining references were the cleanup's own name list — and by four running trusts,
none of which carries one.

So the step had nothing legitimate left to find, while retaining every ability to
delete the wrong thing. A cleanup for something nothing creates can only misfire;
keeping a smarter version of it just narrows the window.

Removed, with the reasoning left in place so it is not reintroduced, and the test
repointed from 'is the filter correct' to 'this script deletes no subscription at
all' — which is the property that actually matters and is far harder to get wrong.
Mutation-checked: re-adding a delete in any form fails two of its cases.

Net 202 lines lighter.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
atriaybagur added a commit that referenced this pull request Aug 17, 2026
…HCR image

Docker Hub's mutable xnat/dcm2niix:latest resolves to dcm2niix v1.0.20211006 —
a 2021 build upstream no longer updates — whose slice-ordering heuristic
mis-fires on some of the seeded spleen series ("Slices skipped: image position
not sequential" on clean, uniformly-spaced 0-based-InstanceNumber series) and
silently drops one slice. The converted input_*.nii.gz then has N-1 slices
while the enrichment label has N, so the segmentation tutorials' shape QC
rejects 6 of GSTT's 21 spleen studies. The current upstream release
(v1.0.20260724) converts the same series in full.

- New trust/xnat/dcm2niix/Dockerfile: debian-slim + the upstream release
  binary, pinned by version AND sha256, with a build-time version smoke check.
- New docker_build_xnat_dcm2niix.yml workflow publishing
  ghcr.io/londonaicentre/xnat-dcm2niix with the usual sha/branch/stag/prod
  tags plus the tool-version tag (read from the Dockerfile ARG) that all
  deploy configs reference — also resolves the PR #945 review note about the
  converter still coming from Docker Hub.
- dcm2niix_command.json (Compose), the K8s init-job's inline copy, and
  imaging-api (new Settings.DCM2NIIX_IMAGE, used by the per-project event
  subscription lookup) now reference the immutable version tag; command
  version bumped 1.6 -> 1.7.
- Verified: the built image converts the previously slice-dropping series
  (FAK76374103) 60/60 and a control series 92/92 with the Container Service's
  exact command line; imaging-api ruff+mypy+274 tests and trust/xnat's 174
  tests pass.

Closes #980

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…plugin-xnat

All four conflicts were independent additions colliding at the same anchor.

- trust/.env.example, .env.GSTT/.KCH.development.example: kept BOTH the MONAI
  Label block and develop's FL_SITE_PRIVACY_* block (FLIP#851). Both sit above
  the hub-shared sentinel that scripts/trust_kit_lib.py matches, which is intact.
- trust/Makefile: combined both edits — this branch's $(MONAILABEL_OVERRIDE)
  (load-bearing: the debug targets run 'up --remove-orphans', so omitting the
  overlay deletes a running monailabel container) with develop's rename of the
  debug override to deploy/compose_trust.development.debug.override.yml, which
  is the only such file that now exists.

Signed-off-by: Alex Triay Bagur <alexandre.triay_bagur@kcl.ac.uk>
Verified against real S3: ohif-viewer is not published under the version-keyed
prefix xnat-plugins-download syncs (s3://flipdev-artifacts/xnat-1.10.0/plugins/
holds only batch-launch, container-service and dicom-query-retrieve). The only
copy in the bucket is under the legacy xnat/plugins/ prefix at 3.7.1 — too old for
XNAT 1.10, which is why the k8s chart pins 3.8.0.

So MONAI_LABEL=true on compose now fails at plugin download rather than crash-
looping later in the monailabel container, which is the right place to fail, but
the message was a bare 'missing plugin family'. Say what is actually missing, that
it is a publishing gap rather than a mistake, and give the two commands that close
it — plus the escape hatch of MONAI_LABEL=false.

Also correct a comment: the XNAT config dir is baked into the image in stag/prod
only. docker-compose-stack.development.yml bind-mounts it from the host, so the
'script does not exist on an older image' case is a stag/prod one.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…plugin-xnat

One conflict, in trust/xnat/Makefile, right after the kit-file `export` block: this
branch adds the `MONAI_LABEL ?= false` default there (gates export_mask registration in
xnat-configure) and develop (#958, FLIP_INSTANCE) adds `include ../../deploy/instance.mk`
at the same spot for INSTANCE_PREFIX. Both kept, develop's include first: the
instance-scoped XNAT_STACK / XNAT_NETWORK / TRUST_PROJECT derivations and the
check_network_matches_trust guard land unchanged, and the MONAI Label default follows
them. The other four files the earlier probe flagged (trust/.env.example, the two dev kit
examples, trust/Makefile) merged cleanly once the branch was rebased onto its own
develop-synced tip.

Verified on the merged tree: trust/xnat unit suite (199 passed), the xnat-configure-
export-mask / xnat-plugins-download / create-xnat-network dry-runs in both MONAI_LABEL
states, up-trust and build dry-runs with the overlay, and `docker compose config` for the
dev and prod stacks with the monailabel overlay (service present in both).

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant