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: 1anddicomUploadMax: num_videos(CathEF-CLIP 4, DeepRV-CLIP 6); shorter studies are zero-padded. Only set the minimum tonum_videosif the model truly requires exactly N uploads. - Series ordering: sort by SeriesTime inside
logic.py, then take the firstdicomUploadMax. The Go backend sorts request UIDs by numeric UID suffix, not SeriesTime. - Step-2 variables page: a non-empty
supportedAdditionalMetadatamakes 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 themodel_info.jsonit was built with.
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 - Build the image from the model directory. The repository name is
<DOCKERHUB_USER>/<IMAGE_PREFIX>-<modelName>with the model name lowercased and characters outsidea-z0-9._-replaced by dashes (DeepCORO-CTObecomesdeepcoro-cto); the version defaults to.versionindata/model_info.json, and the image is tagged both:<version>and:latest. - Push both tags to Docker Hub.
- Register through the api-pacs REST API: sign in, look up the existing model by name and snapshot its
envs,outputModeand ingestion job configurations, ask for confirmation on the terminal before removing it (skip with--yes), remove it, add the new image, wait up toHEALTH_TIMEOUT_SECONDSfor/inference/model-infoto answer, then recreate the ingestion jobs against the new container. If the container never becomes healthy the script stops before recreating the jobs.
-
--allcannot be combined with a directory, a version or--name; it is not recursive, so the multi-variant directoriesDeepCoro_CLIPandECHO-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
jqanddockeron the PATH and dies ifscripts/.env.deployis missing. - Its login step signs in to Firebase Identity Toolkit
signInWithPasswordwithFIREBASE_API_KEY, then posts{tenantId, idToken}toPOST /v1/iam/login. On currentmasterthat route takes{tenantId, email, password, turnstileToken?}(see Admin access), so expect the registration phase to need the updated exchange;--build-onlyand--push-onlyare unaffected.
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
-
snapshot_downloadthe gated checkpoint with your token. - Copy
models/*.py,config.jsonandclass_mapping.jsonfrom the model directory; rebuildVideoEncoder+MultiInstanceLinearProbing+VideoMILWrapper, load["linear_probing"]withmodule.stripped, and expect 0 missing and 0 unexpected keys. - Select videos the way production does: sort by SeriesTime, apply the metadata filter only if the request carried
additionalMetadata, take the firstdicomUploadMax. - Preprocess exactly as
logic.py: read frames withstride, BGR to RGB,float32,[T, C, H, W], repeat the last frame orlinspace-sample to 16 frames, resize, normalize with the model mean and std, permute to[T, H, W, C], stack the clips, zero-pad tonum_videos,unsqueeze(0), and build thevideo_mask. - 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.