Recipe 9.5: Python Implementation Example
Heads up: This is a deliberately simplified, illustrative implementation of the pseudocode walkthrough from Recipe 9.5. It shows one way you could translate those concepts into working Python code using boto3 and a few standard libraries. It is not production-ready. There's no PACS integration, no real DICOM routing, and the "model" here is a mock that returns synthetic predictions. Think of it as the sketchpad version: useful for understanding the shape of the solution, not something you'd deploy to a radiology department on Monday morning. Consider it a starting point, not a destination.
Setup
You'll need the AWS SDK for Python and a few image-handling libraries:
pip install boto3 numpy Pillow pydicom
pydicom handles DICOM file parsing (the standard format for medical images). Pillow and numpy handle image manipulation and array operations. In a real deployment, you'd also want a deep learning framework (PyTorch or TensorFlow) for local preprocessing, but for this example we keep it to standard libraries and let SageMaker handle the model.
Your environment needs credentials configured (via environment variables, an instance profile, or ~/.aws/credentials). The IAM role or user needs sagemaker:InvokeEndpoint, s3:GetObject, s3:PutObject, dynamodb:PutItem, and dynamodb:GetItem.
Config and Constants
Before we get to the steps, here's the configuration that drives the triage logic. These thresholds and weights are the most important tunable parameters in the system. In production, you'd calibrate them on your institution's validation data with radiologist input. The values below are reasonable starting points based on published literature, but your mileage will vary.
# FINDING_THRESHOLDS: probability thresholds per finding category. # If the model's predicted probability exceeds this threshold, the finding # is considered "triggered" and contributes to the triage priority. # # Lower threshold = more sensitive (fewer misses, more false alarms). # Higher threshold = more specific (fewer false alarms, more misses). # # These are NOT arbitrary. They should be calibrated on a validation set # from your institution, targeting a specific sensitivity/specificity tradeoff # that your radiologists agree is acceptable. FINDING_THRESHOLDS = { "pneumothorax": 0.60, # life-threatening; err on the side of alerting "tension_pneumo": 0.50, # immediately life-threatening; very low threshold "large_effusion": 0.70, # clinically significant but less emergent "pulmonary_edema": 0.70, # urgent but not immediately life-threatening "mass_or_nodule": 0.75, # important but not time-critical in minutes "cardiomegaly": 0.80, # relevant but rarely emergent } # SEVERITY_WEIGHTS: how much each finding contributes to the composite score. # Higher weight = more influence on the final priority decision. # A tension pneumothorax (weight 10) dominates the score; cardiomegaly (weight 2) # barely moves the needle unless combined with other findings. SEVERITY_WEIGHTS = { "tension_pneumo": 10, "pneumothorax": 8, "large_effusion": 6, "pulmonary_edema": 6, "mass_or_nodule": 4, "cardiomegaly": 2, } # Model input dimensions. The preprocessing step resizes all images to this size. # Must match what the model was trained on. Common choices: 224x224, 512x512. # Larger inputs preserve more detail but increase inference latency. MODEL_INPUT_SIZE = (512, 512) # SageMaker endpoint name. This identifies which deployed model to call. SAGEMAKER_ENDPOINT_NAME = "cxr-triage-model-v2" # DynamoDB table for storing triage results. DYNAMODB_TABLE_NAME = "cxr-triage-results" # S3 bucket for incoming DICOM files. DICOM_BUCKET = "radiology-dicom-inbox"
Step 1: Receive and Filter DICOM Studies
The pseudocode calls this route_study(bucket, key). It reads DICOM metadata from the file header and checks whether this study is a chest X-ray. Non-chest studies are skipped entirely.
import logging import boto3 import numpy as np from botocore.config import Config from io import BytesIO # pydicom reads DICOM files: the standard format for medical images. # DICOM files contain both metadata (patient info, study type, acquisition # parameters) and pixel data (the actual image). import pydicom logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) # PHI Safety: Never log patient identifiers, accession numbers, or pixel data. BOTO3_RETRY_CONFIG = Config(retries={"max_attempts": 3, "mode": "adaptive"}) s3_client = boto3.client("s3", config=BOTO3_RETRY_CONFIG) def load_dicom_from_s3(bucket: str, key: str) -> pydicom.Dataset: """ Download a DICOM file from S3 and parse it into a pydicom Dataset. Args: bucket: S3 bucket name key: S3 object key (path to the DICOM file) Returns: A pydicom Dataset containing both metadata and pixel data. """ response = s3_client.get_object(Bucket=bucket, Key=key) dicom_bytes = response["Body"].read() # pydicom.dcmread can read from a file-like object. # We wrap the bytes in BytesIO to give it a file interface. dataset = pydicom.dcmread(BytesIO(dicom_bytes)) return dataset def is_chest_xray(dataset: pydicom.Dataset) -> bool: """ Check DICOM metadata to determine if this study is a chest X-ray. We filter on two criteria: 1. Modality must be CR (Computed Radiography) or DX (Digital Radiography). These are the two modality codes used for plain X-rays. 2. BodyPartExamined must be CHEST (or a close variant). This prevents us from running knee X-rays, hand films, or CT scans through a model trained exclusively on chest radiographs. """ # DICOM tag (0008,0060) = Modality modality = getattr(dataset, "Modality", "").upper() # DICOM tag (0018,0015) = BodyPartExamined body_part = getattr(dataset, "BodyPartExamined", "").upper() is_xray = modality in ("CR", "DX") is_chest = body_part in ("CHEST", "THORAX") return is_xray and is_chest def route_study(bucket: str, key: str) -> dict | None: """ Load a DICOM file and decide whether it should be triaged. Returns: A dict with study metadata if this is a chest X-ray, or None if skipped. """ dataset = load_dicom_from_s3(bucket, key) if not is_chest_xray(dataset): logger.info("Skipping non-chest study: %s (Modality=%s, BodyPart=%s)", key, getattr(dataset, "Modality", "unknown"), getattr(dataset, "BodyPartExamined", "unknown")) return None # Extract metadata we'll need downstream. # StudyInstanceUID is the globally unique identifier for this imaging study. # AccessionNumber is the local identifier used by the radiology department. study_metadata = { "study_instance_uid": str(getattr(dataset, "StudyInstanceUID", "")), "accession_number": str(getattr(dataset, "AccessionNumber", "")), "patient_id": str(getattr(dataset, "PatientID", "")), "modality": str(getattr(dataset, "Modality", "")), "view_position": str(getattr(dataset, "ViewPosition", "")), "s3_bucket": bucket, "s3_key": key, } logger.info("Chest X-ray identified: accession=%s", study_metadata["accession_number"]) return study_metadata
Step 2: Preprocess the DICOM Image for Model Input
The pseudocode calls this preprocess_for_inference(bucket, key) and loads from S3 internally. Here we accept a pre-loaded pydicom.Dataset to avoid a redundant S3 read, since the full pipeline function already has the dataset in memory from Step 1. The preprocessing logic is identical.
from PIL import Image def preprocess_for_inference(dataset: pydicom.Dataset) -> np.ndarray: """ Convert raw DICOM pixel data into a normalized, resized array ready for the inference model. The preprocessing pipeline: 1. Extract pixel array from DICOM 2. Handle photometric interpretation (invert if MONOCHROME1) 3. Apply windowing if specified in DICOM headers 4. Normalize to [0, 1] range 5. Resize to model input dimensions This MUST match the preprocessing used during model training. Even small differences (different interpolation method, different normalization range) can degrade model accuracy significantly. """ # Extract the raw pixel array. This is typically a 2D numpy array # of integers (12-bit or 16-bit depending on the equipment). pixel_array = dataset.pixel_array.astype(np.float32) # Handle photometric interpretation. # MONOCHROME1: high pixel values = dark (air appears white, bone appears dark). # MONOCHROME2: high pixel values = bright (the standard for display and most models). # Most chest X-ray models expect MONOCHROME2 convention. photometric = getattr(dataset, "PhotometricInterpretation", "MONOCHROME2") if photometric == "MONOCHROME1": # Invert: subtract from max value so high = bright. pixel_array = pixel_array.max() - pixel_array # Apply windowing if the DICOM file specifies window center/width. # Windowing maps the full dynamic range to a clinically relevant subset. # This mimics what the radiologist sees on their display. window_center = getattr(dataset, "WindowCenter", None) window_width = getattr(dataset, "WindowWidth", None) if window_center is not None and window_width is not None: # Handle the case where these are stored as lists (multi-value). if isinstance(window_center, pydicom.multival.MultiValue): window_center = float(window_center[0]) window_width = float(window_width[0]) else: window_center = float(window_center) window_width = float(window_width) # Apply the window: clip values outside the window range. lower = window_center - window_width / 2 upper = window_center + window_width / 2 pixel_array = np.clip(pixel_array, lower, upper) # Normalize to [0, 1] range. # Neural networks expect inputs in a small, consistent numeric range. pmin, pmax = pixel_array.min(), pixel_array.max() if pmax > pmin: pixel_array = (pixel_array - pmin) / (pmax - pmin) else: # Completely uniform image (shouldn't happen with real data, but be safe). pixel_array = np.zeros_like(pixel_array) # Resize to model input dimensions using Pillow. # We convert to a PIL Image, resize with bilinear interpolation, # then convert back to numpy. Bilinear is the standard choice for # medical imaging (preserves detail better than nearest-neighbor, # less blurring than bicubic at these resolutions). img = Image.fromarray((pixel_array * 255).astype(np.uint8), mode="L") img_resized = img.resize(MODEL_INPUT_SIZE, resample=Image.BILINEAR) pixel_array = np.array(img_resized, dtype=np.float32) / 255.0 return pixel_array
Step 3: Run Inference on the SageMaker Endpoint
The pseudocode calls this run_inference(preprocessed_image, study_id). The preprocessed image is serialized and sent to the SageMaker real-time endpoint. The model returns probability scores for each finding category.
import json sagemaker_runtime = boto3.client("sagemaker-runtime", config=BOTO3_RETRY_CONFIG) def run_inference(preprocessed_image: np.ndarray, study_id: str) -> dict: """ Send the preprocessed image to the SageMaker endpoint and get predictions. The endpoint hosts a trained CNN (e.g., DenseNet-121 or EfficientNet) that outputs a probability score for each finding category. Args: preprocessed_image: A 2D numpy array, normalized to [0, 1], resized to MODEL_INPUT_SIZE. study_id: The StudyInstanceUID for logging/audit. Returns: A dict mapping finding names to probability scores. Example: {"pneumothorax": 0.92, "cardiomegaly": 0.06, ...} """ # Serialize the image as a numpy array in bytes format. # The SageMaker endpoint expects the raw bytes of a float32 array. # We add a batch dimension (1, H, W) because the model expects batched input # even for a single image. # Note: We use raw bytes with application/x-npy content type. If your endpoint's # inference container expects proper .npy format (with shape/dtype header), # serialize with np.save() to a BytesIO buffer instead. image_batch = preprocessed_image.reshape(1, *MODEL_INPUT_SIZE).astype(np.float32) payload = image_batch.tobytes() # Call the SageMaker real-time inference endpoint. # ContentType tells the endpoint how to deserialize our input. # Accept tells it what format we want the response in. response = sagemaker_runtime.invoke_endpoint( EndpointName=SAGEMAKER_ENDPOINT_NAME, ContentType="application/x-npy", Accept="application/json", Body=payload, ) # Parse the JSON response body. # Expected format: {"predictions": {"pneumothorax": 0.92, ...}} result = json.loads(response["Body"].read().decode("utf-8")) predictions = result.get("predictions", {}) logger.info("Inference complete for study %s: %d findings scored", study_id, len(predictions)) return predictions
Step 4: Calculate Priority Score
The pseudocode calls this calculate_priority(predictions). This converts raw probability scores into a clinical triage decision by applying finding-specific thresholds and severity weights.
def calculate_priority(predictions: dict) -> dict: """ Convert model predictions into a triage priority decision. Logic: 1. For each finding, check if its probability exceeds the threshold. 2. Triggered findings contribute to a composite score (probability * weight). 3. The composite score and the maximum severity of triggered findings determine the final priority level. Priority levels: - CRITICAL: Immediate radiologist attention (pneumothorax, tension) - URGENT: Move to top of worklist (significant composite score) - ELEVATED: Slight priority boost (minor findings flagged) - ROUTINE: Normal queue order (nothing above threshold) Returns: A dict with priority level, composite score, and triggered findings. """ triggered_findings = [] composite_score = 0.0 for finding, probability in predictions.items(): threshold = FINDING_THRESHOLDS.get(finding) if threshold is None: # Finding not in our threshold map; skip it. continue if probability >= threshold: severity = SEVERITY_WEIGHTS.get(finding, 1) triggered_findings.append({ "finding": finding, "probability": round(probability, 4), "severity": severity, }) composite_score += probability * severity # Determine priority level. max_severity = max((f["severity"] for f in triggered_findings), default=0) if max_severity >= 8: priority = "CRITICAL" elif composite_score >= 5.0: priority = "URGENT" elif len(triggered_findings) > 0: priority = "ELEVATED" else: priority = "ROUTINE" return { "priority": priority, "composite_score": round(composite_score, 2), "triggered_findings": triggered_findings, }
Step 5: Store Results in DynamoDB
The pseudocode calls this store_and_notify(study_id, accession_number, patient_id, priority_result). Every inference result is persisted for audit and compliance. Critical/urgent findings also trigger a worklist update.
import datetime from datetime import timezone from decimal import Decimal dynamodb = boto3.resource("dynamodb", config=BOTO3_RETRY_CONFIG) def store_triage_result( study_metadata: dict, predictions: dict, priority_result: dict, inference_latency_ms: float, ) -> dict: """ Write the triage result to DynamoDB for audit and downstream consumption. This record is the authoritative output of the pipeline for this study. It captures: what was analyzed, what the model predicted, what priority was assigned, how long inference took, and when. This supports both HIPAA audit requirements and FDA post-market surveillance. Args: study_metadata: Metadata from the DICOM file (study ID, accession, etc.) predictions: Raw model predictions (all finding probabilities) priority_result: Output of calculate_priority (priority level, score, findings) inference_latency_ms: How long the SageMaker call took Returns: The complete record that was written. """ table = dynamodb.Table(DYNAMODB_TABLE_NAME) # DynamoDB does not accept Python floats. You must wrap numeric values # in Decimal() or put_item will raise a TypeError. # Convert via string to avoid floating-point representation artifacts. def to_decimal(val): return Decimal(str(round(val, 4))) record = { "study_id": study_metadata["study_instance_uid"], "accession_number": study_metadata["accession_number"], # patient_id is stored for FDA post-market surveillance: correlating AI # findings with patient outcomes over time. Because this is PHI, the # DynamoDB table must have encryption at rest (CMK), restricted IAM # policies, and CloudTrail audit logging on all access. "patient_id": study_metadata["patient_id"], "s3_key": study_metadata["s3_key"], "model_version": "cxr-triage-v2.1", "inference_timestamp": datetime.datetime.now(timezone.utc).isoformat(), "inference_latency_ms": to_decimal(inference_latency_ms), "priority": priority_result["priority"], "composite_score": to_decimal(priority_result["composite_score"]), "triggered_findings": [ { "finding": f["finding"], "probability": to_decimal(f["probability"]), "severity": f["severity"], } for f in priority_result["triggered_findings"] ], "all_predictions": { finding: to_decimal(prob) for finding, prob in predictions.items() }, } table.put_item(Item=record) logger.info("Stored triage result: study=%s priority=%s", record["study_id"], record["priority"]) return record
Full Pipeline Function
This assembles all steps into a single callable function. In production, this would be triggered by an S3 event notification (new DICOM file lands in the inbox bucket) routed through Lambda.
import time def triage_chest_xray(bucket: str, key: str) -> dict | None: """ Full triage pipeline: receive DICOM, filter, preprocess, infer, score, store. Args: bucket: S3 bucket containing the DICOM file key: S3 object key for the DICOM file Returns: The triage result record if the study was processed, or None if skipped. """ print(f"[1/5] Loading and filtering study: s3://{bucket}/{key}") study_metadata = route_study(bucket, key) if study_metadata is None: print(" Not a chest X-ray. Skipping.") return None print(f" Chest X-ray confirmed. Accession: {study_metadata['accession_number']}") # Reload the full DICOM for pixel data (route_study already parsed it, # but in a Lambda architecture these would be separate invocations). dataset = load_dicom_from_s3(bucket, key) print("[2/5] Preprocessing DICOM image for model input...") preprocessed = preprocess_for_inference(dataset) print(f" Image resized to {MODEL_INPUT_SIZE}, normalized to [0,1]") print("[3/5] Running inference on SageMaker endpoint...") start_time = time.time() predictions = run_inference(preprocessed, study_metadata["study_instance_uid"]) inference_latency_ms = (time.time() - start_time) * 1000 print(f" Inference complete in {inference_latency_ms:.0f}ms") print(f" Predictions: {predictions}") print("[4/5] Calculating triage priority...") priority_result = calculate_priority(predictions) print(f" Priority: {priority_result['priority']} " f"(composite score: {priority_result['composite_score']})") if priority_result["triggered_findings"]: for f in priority_result["triggered_findings"]: print(f" -> {f['finding']}: {f['probability']} " f"(severity {f['severity']})") print("[5/5] Storing result to DynamoDB...") record = store_triage_result( study_metadata, predictions, priority_result, inference_latency_ms ) print(f" Stored. Study ID: {record['study_id']}") if priority_result["priority"] in ("CRITICAL", "URGENT"): print(f"\n *** ALERT: {priority_result['priority']} finding detected! ***") print(f" Worklist update would be sent for accession " f"{study_metadata['accession_number']}") return record # Example usage (you'd replace this with your actual S3 event trigger): if __name__ == "__main__": result = triage_chest_xray( bucket=DICOM_BUCKET, key="inbox/2026/03/15/study-048291.dcm", ) if result: print(f"\nFinal priority: {result['priority']}")
Synthetic Test Data
Since you can't (and shouldn't) use real patient DICOM files for development, here's how to generate a synthetic chest X-ray DICOM file for testing the pipeline. This creates a valid DICOM file with proper metadata but random pixel data. The model will produce meaningless predictions on random noise, but the pipeline mechanics (routing, preprocessing, endpoint invocation, storage) can all be validated.
import pydicom from pydicom.dataset import Dataset, FileDataset from pydicom.uid import generate_uid import tempfile import os def create_synthetic_chest_xray_dicom() -> str: """ Generate a synthetic DICOM file that looks like a chest X-ray to the pipeline. The pixel data is random noise (not a real chest X-ray), but the DICOM metadata is set correctly so the routing logic identifies it as a chest X-ray and the preprocessing handles it properly. Returns: Path to the temporary DICOM file. """ filename = os.path.join(tempfile.gettempdir(), "synthetic_cxr.dcm") # Create the DICOM file dataset. file_meta = pydicom.Dataset() file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.1.1" # Digital X-Ray file_meta.MediaStorageSOPInstanceUID = generate_uid() file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian ds = FileDataset(filename, {}, file_meta=file_meta, preamble=b"\x00" * 128) # Set metadata that our routing logic checks. ds.Modality = "DX" ds.BodyPartExamined = "CHEST" ds.ViewPosition = "PA" ds.PhotometricInterpretation = "MONOCHROME2" # Patient/study identifiers (synthetic, not real PHI). ds.PatientID = "SYNTH-001" ds.StudyInstanceUID = generate_uid() ds.AccessionNumber = "CXR-TEST-001" # Image dimensions and pixel data. ds.Rows = 1024 ds.Columns = 1024 ds.BitsAllocated = 16 ds.BitsStored = 12 ds.HighBit = 11 ds.PixelRepresentation = 0 ds.SamplesPerPixel = 1 # Random pixel data (noise, not a real image). ds.PixelData = np.random.randint(0, 4096, (1024, 1024), dtype=np.uint16).tobytes() ds.save_as(filename) return filename
Gap to Production
This example demonstrates the pipeline shape. Here's what you'd need to add before deploying to a radiology department:
Error handling and retries. The SageMaker endpoint can throttle, timeout, or return errors. Wrap the invoke_endpoint call in retry logic with exponential backoff. If inference fails, the study should fall back to normal queue ordering (never block a study from being read because the AI is down).
DICOM routing at scale. In production, you'd use a DICOM router (like Orthanc or a commercial solution) that forwards studies to S3 via a DICOM-to-S3 bridge. The S3 event notification triggers a Lambda function. This example skips the DICOM networking layer entirely.
PACS worklist integration. The hardest part of the real system. You need to send priority updates back to your PACS/RIS. This typically involves HL7 ORM messages via an interface engine (Mirth Connect, Rhapsody) or vendor-specific APIs. Every PACS installation is different. Budget 2-3 months for this integration alone.
Model serving. This example assumes a SageMaker endpoint is already deployed. In practice, you need: model packaging (container with inference code), endpoint configuration (instance type, auto-scaling policy), model registry (version tracking), and A/B testing infrastructure for model updates.
Input validation. Validate DICOM files before processing: check for corrupt pixel data, unsupported transfer syntaxes, missing required tags, and unexpected image dimensions. Malformed DICOM files should be quarantined, not crash the pipeline.
Structured logging. Replace print statements with structured JSON logging (using aws_lambda_powertools or similar). Log inference latency, prediction distributions, and error rates. Never log PHI (patient IDs, accession numbers should be hashed or omitted from logs in production).
IAM least-privilege. The example uses broad permissions. In production, scope IAM policies to specific resources: the exact S3 bucket and prefix, the exact DynamoDB table, the exact SageMaker endpoint ARN.
VPC and VPC endpoints. Lambda and SageMaker should run in a VPC with no internet access. Use VPC endpoints for S3, DynamoDB, SageMaker Runtime, and CloudWatch Logs. This prevents PHI from traversing the public internet.
KMS customer-managed keys. Use CMKs (not AWS-managed keys) for S3, DynamoDB, and SageMaker volume encryption. This gives you key rotation control and the ability to revoke access by disabling the key.
Model monitoring and drift detection. Track the distribution of prediction scores over time. If the model suddenly starts predicting pneumothorax at 2x the historical rate, something changed (new equipment, different patient population, model degradation). CloudWatch custom metrics and alarms catch this.
FDA compliance (if building in-house). If you're deploying this as a medical device (not buying a commercial product), you need: 510(k) submission, Quality Management System, design controls documentation, risk analysis (ISO 14971), software lifecycle documentation (IEC 62304), and a post-market surveillance plan. This is 12-18 months of regulatory work.
Radiologist feedback loop. Capture whether the radiologist agreed with the triage decision. Store agreement/disagreement in DynamoDB alongside the original prediction. Use disagreements to identify systematic errors and inform model retraining.
Testing. Unit tests for preprocessing (known DICOM inputs produce expected arrays). Integration tests against a SageMaker endpoint with known test images. End-to-end tests with synthetic DICOM files through the full pipeline. Performance tests to verify sub-5-second latency under load.
โ Recipe 9.5: Chest X-Ray Triage ยท Chapter 9 Index ยท Next: Recipe 9.6: Diabetic Retinopathy Screening โ