|
Documentation: Build, register and deploy models

Models

Build, register and deploy models

The container contract, the five files you author, the padding-mask rule, the deploy script and the admin API.

Container contract

Each model is a self-contained FastAPI container that implements the OpenAPI specification in model-template/docs/openapi.json (each model directory carries its own docs/openapi.json). The container exposes port 80 through its own nginx; the API sits on port 8000 behind the /api prefix, and a web app (for WEB_APP output) can use the /app prefix on a port in the 7000 to 7999 range. The container must join the same Docker network as api-pacs (pacs-net). After start, its own API docs are at http://<host>/docs.

Endpoint Purpose
POST /inference/predict Run inference. The body carries exactly one of seriesInstanceImages (series number, instance number, base64 image) or seriesInstanceMetadata (DICOM JSON tags), never both, plus optional additionalMetadata and the outputMode
GET /inference/model-info modelName, version, dicomTargetLevel (SERIES or STUDY), dicomUploadMin / dicomUploadMax, supportedDicomModalities, supportedDicomTags, supportedAdditionalMetadata, supportedOutputModes
GET /inference/model-facts The Model Facts label: summary, mechanism, validation and performance, uses and directions, warnings and limitations, changelog

The model version is declared in data/model_info.json and data/model_facts.json, and the Docker image tag should match it at release. The admin flow is: an admin whitelists a Docker image in the Admin Docker Inference page, api-pacs pulls the image and starts the container, manages it through the Docker Remote API, lists the available models to the viewer, and forwards prediction requests and results.

File layout (copy CathEF-CLIP as the template)

model-examples/<ModelName>/
  logic.py                    # CustomPredictionService(BasePredictionService)  <- you write
  download_model.py           # snapshot_download(repo_id=...)                  <- you set repo_id
  main.py                     # FastAPI entrypoint, copy verbatim
  config.json                 # {"modelDirectory":"models","models":{}}, copy verbatim
  requirements.txt, Dockerfile, nginx.conf, supervisord.conf
  docs/openapi.json           # the container contract
  data/model_info.json        # PACS-AI UI and ingestion contract               <- you write
  data/model_facts.json       # Model Facts label ({"en": {...}})
  models/config.json          # architecture, checkpoint, normalization         <- you write
  models/class_mapping.json   # output heads                                    <- you write
  models/*.py                 # video_encoder, multi_instance_linear_probing, attention_pool, rope_3d, video_aggregator (copy)
  models/<checkpoint>.pt      # pulled from Hugging Face at build, never committed
  utils/genericLogic.py, http_utils.py   # BasePredictionService and helpers (copy); html_parser.py only if the model uses it

The five files you author

File What it declares
models/config.json VideoEncoder (mvit backbone, 16 frames, output 512), MultiInstanceLinearProbing (attention + CLS token pooling; the head names and dimensions are passed as head_structure = {head: head_dim}), VideoMILWrapper (num_videos, stride 2 for most models and 1 for DeepCORO-CTO, resize 224), ModelStateDict.model_path (the .pt filename inside the Hugging Face repo, which also holds config.json and training_config.yaml) and per-model dataset_mean / dataset_std that must match training (CathEF about 96.6 / 44.8, DeepRV about 134.0 / 27.3, DeepCoro stenosis about 122.1 / 28.8). Wrong statistics produce garbage.
models/class_mapping.json Each output head: head_dim, task (regression clamped to min / max, for example LVEF [0, 100] and J-CTO score [0, 4], or binary_classification through a sigmoid), name, optional threshold, unit. Head names must match checkpoint keys mil_model.module.heads.<name>.*. Older non-CLIP models such as DeepRV use a flat {threshold, normal, reduced} mapping instead.
data/model_info.json modelId, modelName, version, modality, domainName (Cardiology, Neurology, Chest X-Ray), stage (ai_inference, or preprocessing for the two view classifiers), dicomTargetLevel (SERIES for every shipped model except DeepCORO-SYNTAX, which is STUDY), dicomUploadMin / dicomUploadMax, supportedDicomModalities, supportedDicomTags (["*"] for most; CIED-AI and EchoPrime list explicit tags), supportedOutputModes, approveFeedbackQuestionnaires and rejectFeedbackQuestionnaires (DeepCORO-SYNTAX ships only the former), the scored onboardingModelQuestionnaires (items with id, type CHECKBOX or RADIO, questionEn / questionFr, answerOptionsEn / answerOptionsFr, correctAnswerIds), and supportedAdditionalMetadata.
logic.py Required and model-specific: load_model, _run_inference (including the padding video_mask), _postprocess, _handle_json_output, _handle_html_output, SeriesTime sort and truncation to dicomUploadMax, optional _filter_dicoms_with_metadata. Never ship a sibling model copy unchanged.
download_model.py Hard-code this model Hugging Face repo_id; a copied template still points at another model and downloads the wrong checkpoint.
  • Upload bounds: existing CLIP models use dicomUploadMin: 1 and dicomUploadMax: num_videos (CathEF-CLIP 4, DeepRV-CLIP 6); shorter studies are zero-padded. Only set the minimum to num_videos if the model truly requires exactly N uploads.
  • Series ordering: sort by SeriesTime inside logic.py, then take the first dicomUploadMax. The Go backend sorts request UIDs by numeric UID suffix, not SeriesTime.
  • Step-2 variables page: a non-empty supportedAdditionalMetadata makes the viewer show a second page asking the user to tag each DICOM before inference; [] shows no page. The CLIP docs write the entries as strings (["main_structure","status"]) while the template contract documents objects ({id, name, type, required}); every shipped model uses [], so check the viewer against the form you choose. After changing this field you must rebuild and redeploy the image, because a running container keeps the model_info.json it was built with.
Padding-mask contract (PR #242). CLIP models pad each study up to num_videos with zero videos. _run_inference must build video_mask = torch.zeros((1, max_videos), dtype=torch.bool) with video_mask[:, :num_real_videos] = True and pass it with the [1, num_videos, T, H, W, C] batch; VideoMILWrapper.forward applies it as the attention mask only when its length equals the instance count and otherwise falls back to all-valid so encoder modes that collapse the instance axis do not crash. Without the mask the constant zero embedding dominates attention and CLS pooling and collapses the head (EF saturates near 100%, RV mis-calls). Test: holding the real video fixed and changing the padding content must move the logit by exactly 0.0.

Build with gated weights

# Dockerfile: RUN --mount=type=secret,id=hf_token python download_model.py --token $(cat /run/secrets/hf_token)
docker build --secret id=hf_token,src=./hf_token.txt -t heartwisehub/<model>:<version> .
docker login
docker push heartwisehub/<model>:<version>

Keep hf_token.txt out of git. Eleven containers download weights through the build secret: CathEF-CLIP, DeepRV, DeepRV-CLIP, DeepCoro_CLIP (all three variants), DeepCORO-SYNTAX, DeepCORO-CTO, PanEcho, ECHO-PRIME view_classifier and med_gemma_1.5. The manual build above uses the heartwisehub/<model>:<version> naming from the model docs, while the deploy script below names images <DOCKERHUB_USER>/<IMAGE_PREFIX>-<model>; pick one scheme per deployment so a hand-built image and a script-registered one do not end up in two repositories. Pushing requires membership in the heartwisehub Docker Hub organization; denied: requested access to the resource is denied means you are not logged in or not a member (an org owner invites you under Organizations, Members). After pushing a new tag, repoint the deployment and redeploy the container from current master.

Deploy with scripts/deploy-model.sh

cp scripts/.env.deploy.example scripts/.env.deploy && chmod 600 scripts/.env.deploy
# required: DOCKERHUB_USER; for registration also API_BASE_URL, TENANT_ID, PACS_ADMIN_EMAIL,
#           PACS_ADMIN_PASSWORD, FIREBASE_API_KEY (same value as the viewer APP_FIREBASE_API_KEY)
# optional: IMAGE_PREFIX (default pacs-ai), HF_TOKEN_FILE (default <repo>/hf_token.txt),
#           DEFAULT_OUTPUT_MODE (default JSON), NEW_MODEL_ENVS (default []),
#           HEALTH_TIMEOUT_SECONDS (default 300); DEPLOY_ENV_FILE and MODELS_ROOT override paths

DEFAULT_OUTPUT_MODE=HTML ./scripts/deploy-model.sh model-examples/<ModelName> --hf-token-file hf_token.txt
./scripts/deploy-model.sh <dir> [version] [--name <name>] [--yes]
./scripts/deploy-model.sh <dir> --build-only      # phase 1 only
./scripts/deploy-model.sh <dir> --push-only       # phases 1 and 2
./scripts/deploy-model.sh <dir> --register-only   # phase 3 only, image already pushed
./scripts/deploy-model.sh --all                   # every top-level directory with a Dockerfile and data/model_info.json
./scripts/deploy-model.sh --help
  1. Build the image from the model directory. The repository name is <DOCKERHUB_USER>/<IMAGE_PREFIX>-<modelName> with the model name lowercased and characters outside a-z0-9._- replaced by dashes (DeepCORO-CTO becomes deepcoro-cto); the version defaults to .version in data/model_info.json, and the image is tagged both :<version> and :latest.
  2. Push both tags to Docker Hub.
  3. Register through the api-pacs REST API: sign in, look up the existing model by name and snapshot its envs, outputMode and ingestion job configurations, ask for confirmation on the terminal before removing it (skip with --yes), remove it, add the new image, wait up to HEALTH_TIMEOUT_SECONDS for /inference/model-info to answer, then recreate the ingestion jobs against the new container. If the container never becomes healthy the script stops before recreating the jobs.
  • --all cannot be combined with a directory, a version or --name; it is not recursive, so the multi-variant directories DeepCoro_CLIP and ECHO-PRIME (no top-level Dockerfile) are skipped and 12 of the 14 directories are covered; failures do not stop the batch, a Deployed / Skipped / Failed summary is printed, and the exit code is 1 if anything failed.
  • The script needs jq and docker on the PATH and dies if scripts/.env.deploy is missing.
  • Its login step signs in to Firebase Identity Toolkit signInWithPassword with FIREBASE_API_KEY, then posts {tenantId, idToken} to POST /v1/iam/login. On current master that route takes {tenantId, email, password, turnstileToken?} (see Admin access), so expect the registration phase to need the updated exchange; --build-only and --push-only are unaffected.
Removing a model cascade-deletes its ingestion jobs, candidates and processing history in Postgres (issue #243). The script recreates the job configurations against the new container, but candidate and processing history is lost, and studies still inside the recent window are re-discovered and re-processed.

DEFAULT_OUTPUT_MODE (JSON, OHIF_ANNOTATIONS, HTML, WEB_APP, PDF; default JSON) and NEW_MODEL_ENVS apply only when the model is new; redeploying an existing model keeps its registered outputMode and envs. supportedOutputModes in model_info.json lists what the container can serve; the mode api-pacs actually calls is the registered one. Change it in the admin UI or with PUT /v1/inference/model/{id}/update (outputMode, disallowedDICOMTags).

Register and operate containers through the API

Endpoint (/v1/inference/model) Purpose
POST /add Register a model: name, dockerImage, optional envs, outputMode
GET /list, GET /proxy/available Registered models and the models currently available to the tenant
GET /container/{containerID}/info, POST /container/{containerID}/start, stop, restart Container lifecycle through the Docker Remote API
GET /proxy/container/{containerID}/info, GET /proxy/container/{containerID}/facts, POST /proxy/container/{containerID}/predict Proxy the container model-info, model-facts and predict endpoints
PUT /{id}/update, DELETE /{id}/remove Update output mode and disallowed DICOM tags; remove the model (see the cascade warning above)

Reproduce a deployed prediction locally

  1. snapshot_download the gated checkpoint with your token.
  2. Copy models/*.py, config.json and class_mapping.json from the model directory; rebuild VideoEncoder + MultiInstanceLinearProbing + VideoMILWrapper, load ["linear_probing"] with module. stripped, and expect 0 missing and 0 unexpected keys.
  3. Select videos the way production does: sort by SeriesTime, apply the metadata filter only if the request carried additionalMetadata, take the first dicomUploadMax.
  4. Preprocess exactly as logic.py: read frames with stride, BGR to RGB, float32, [T, C, H, W], repeat the last frame or linspace-sample to 16 frames, resize, normalize with the model mean and std, permute to [T, H, W, C], stack the clips, zero-pad to num_videos, unsqueeze(0), and build the video_mask.
  5. Compare with the deployed number. A residual of about 0.01 is the DICOM-to-AVI versus mp4 decode path; a large gap means wrong video selection or wrong mean and std. Verified: CathEF-CLIP reproduced 28.5% / P 0.96 exactly; DeepRV-CLIP reproduced P 0.112 against a deployed 0.121.

Agent skill

The same guidance is machine-readable at .claude/skills/pacs-ai-model-mapping/SKILL.md (auto-loaded by Claude Code) and summarized in AGENTS.md for Codex, Cursor and other coding agents, so a model can be ported as a supervised generation-and-review task: the agent drafts the five files, a human reviews them and the benchmark predictions.

Model catalogue in the repository

Directory (variant) Registered name and version DICOM modalities Predicts Output
CATH-EF CathEF 1.6.1 XA LVEF from coronary angiogram video JSON, HTML
CathEF-CLIP CathEF-CLIP 1.0.0 XA LVEF, CLIP video multi-instance architecture (4 videos) HTML, JSON
DeepRV DeepRV 1.0.0 XA RV systolic function (flat threshold / normal / reduced mapping) HTML, JSON
DeepRV-CLIP DeepRV-CLIP 1.0.0 XA RV systolic function (Outcome, 6 videos) HTML, JSON
DeepCoro_CLIP (generic, cardio_syntax, procedure_view_classifier) DeepCoro_CLIP_generic, CardioSyntax, DeepCoro_CLIP_ProcedureViewClassifier 1.0.0 XA Coronary stenosis heads; SYNTAX scoring; procedure and view classification (preprocessing stage) HTML, JSON; view classifier JSON
DeepCORO-CTO DeepCORO-CTO 1.0.0 XA J-CTO score components and total HTML, JSON
DeepCORO-SYNTAX DeepCORO-SYNTAX 5.0.0 XA (STUDY level) SYNTAX score, right and left, category heads, 16-segment HTML, JSON
ECHO-PRIME (generic, view_classifier) EchoPrime 1.2.1, EchoPrime_ViewClassifier 1.0.0 US Echo foundation-model interpretation; standalone view classification (preprocessing stage) HTML, JSON; JSON
PanEcho PanEcho 1.0.0 US Multi-task echo interpretation; can run the echo view classifier internally when built with DOWNLOAD_VIEW_CLASSIFIER_WEIGHTS=true HTML, JSON
med_gemma_1.5 MedGemma_1.5 1.5.0 DX, CR Radiology report generation HTML, JSON
BrainGPT_v1 BrainGPT (Otter-Image) 1.0.0 CT Head-CT report generation HTML, JSON
Hemorrhage Hemorrhage 1.0.0 CT Intracranial hemorrhage segmentation (ICH, IVH, PHE) OHIF_ANNOTATIONS
TotalSegmentator TotalSegmentator 2.9.0 CT, MRI Multi-organ segmentation OHIF_ANNOTATIONS
CIED-AI CIED-AI 1.0.0 DX, CR Cardiac implantable electronic device identification HTML

Fourteen directories, seventeen registered variants. CathEF, EchoPrime and Hemorrhage ship scored, bilingual bias and fairness questionnaires (three items each) that a user must pass during onboarding before using the model. Four shipped models (CathEF, CIED-AI, Hemorrhage, TotalSegmentator) omit the modality and domainName fields; the modality column above is taken from supportedDicomModalities.

PACS AI Logo status Status Terms and Conditions Privacy Policy Privacy Impact Assessment Document (EFVP)

© 2026 HeartWise AI Lab, Montreal Heart Institute. All rights reserved.