Instructions to use ghafil/AiroDx-API with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Keras
How to use ghafil/AiroDx-API with Keras:
# !pip install -U keras tensorflow huggingface_hub # Keras needs TensorFlow installed to read "hf://" paths, so the tensorflow backend is selected here; # "jax" and "torch" also work for computation once TensorFlow is installed. import os os.environ["KERAS_BACKEND"] = "tensorflow" import keras model = keras.saving.load_model("hf://ghafil/AiroDx-API") - Notebooks
- Google Colab
- Kaggle
Upload 3 files
Browse files- Dockerfile +32 -0
- app.py +178 -0
- requirements.txt +44 -0
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use official lightweight Python image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
ENV TF_CPP_MIN_LOG_LEVEL=2
|
| 8 |
+
ENV TF_ENABLE_ONEDNN_OPTS=0
|
| 9 |
+
|
| 10 |
+
# Create a working directory
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Install system dependencies
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
build-essential \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Install Python dependencies
|
| 19 |
+
COPY requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Copy the model files directly into the image (Since it's only ~92MB!)
|
| 23 |
+
COPY model/ ./model/
|
| 24 |
+
|
| 25 |
+
# Copy your application code
|
| 26 |
+
COPY app.py .
|
| 27 |
+
|
| 28 |
+
# Expose the port FastAPI runs on
|
| 29 |
+
EXPOSE 8000
|
| 30 |
+
|
| 31 |
+
# Command to run the application using uvicorn (Simpler for HuggingFace)
|
| 32 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
app.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import io
|
| 3 |
+
import cv2
|
| 4 |
+
import logging
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
# ==========================================
|
| 8 |
+
# 0. PRODUCTION LOGGING & ENVIRONMENT SETUP
|
| 9 |
+
# ==========================================
|
| 10 |
+
# Set TensorFlow variables BEFORE importing TF to suppress warnings
|
| 11 |
+
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
|
| 12 |
+
os.environ['TF_ENABLE_ONEDNN_OPTS'] = '0'
|
| 13 |
+
|
| 14 |
+
import tensorflow as tf
|
| 15 |
+
from tensorflow import keras
|
| 16 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 17 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 18 |
+
|
| 19 |
+
# Configure production logging
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 23 |
+
)
|
| 24 |
+
logger = logging.getLogger("AiroDx-API")
|
| 25 |
+
|
| 26 |
+
# ==========================================
|
| 27 |
+
# 1. CONFIGURATION & PATHS
|
| 28 |
+
# ==========================================
|
| 29 |
+
# Use Environment Variables for paths (Allows Docker to change them without changing code)
|
| 30 |
+
MODEL_PATH = os.getenv("MODEL_PATH", "./model/remedis_multiview_final.keras")
|
| 31 |
+
REMEDIS_BASE_PATH = os.getenv("REMEDIS_BASE_PATH", "./model/cxr-50x1-remedis-m")
|
| 32 |
+
|
| 33 |
+
IMAGE_SIZE = (448, 448)
|
| 34 |
+
TARGET_DISEASES = [
|
| 35 |
+
'Atelectasis', 'Cardiomegaly', 'Consolidation', 'Emphysema', 'Hernia',
|
| 36 |
+
'Infiltrates', 'Mass', 'Nodule', 'Pleural Effusion', 'Pleural Thickening',
|
| 37 |
+
'Pneumonia', 'Pneumothorax', 'Pulmonary Edema', 'Pulmonary Fibrosis'
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
# ==========================================
|
| 41 |
+
# 2. REDEFINE CUSTOM LAYER
|
| 42 |
+
# ==========================================
|
| 43 |
+
@tf.keras.utils.register_keras_serializable()
|
| 44 |
+
class REMEDISBackbone(keras.layers.Layer):
|
| 45 |
+
def __init__(self, model_path=None, **kwargs):
|
| 46 |
+
# Accept legacy serialized model_path, but force the local deployment path.
|
| 47 |
+
super().__init__(**kwargs)
|
| 48 |
+
self.model_path = REMEDIS_BASE_PATH
|
| 49 |
+
self._remedis_model = None
|
| 50 |
+
|
| 51 |
+
def build(self, input_shape):
|
| 52 |
+
if self._remedis_model is None:
|
| 53 |
+
if not os.path.exists(self.model_path):
|
| 54 |
+
logger.error(f"Base REMEDIS model not found at {self.model_path}")
|
| 55 |
+
raise FileNotFoundError(f"Base REMEDIS model not found at {self.model_path}")
|
| 56 |
+
self._remedis_model = tf.saved_model.load(self.model_path)
|
| 57 |
+
super().build(input_shape)
|
| 58 |
+
|
| 59 |
+
def call(self, inputs, training=None):
|
| 60 |
+
return self._remedis_model(inputs)
|
| 61 |
+
|
| 62 |
+
def compute_output_shape(self, input_shape):
|
| 63 |
+
return (input_shape[0], 14, 14, 2048)
|
| 64 |
+
|
| 65 |
+
def get_config(self):
|
| 66 |
+
config = super().get_config()
|
| 67 |
+
return config
|
| 68 |
+
|
| 69 |
+
@classmethod
|
| 70 |
+
def from_config(cls, config):
|
| 71 |
+
# Remove stale serialized path (e.g., Kaggle) before layer construction.
|
| 72 |
+
config.pop("model_path", None)
|
| 73 |
+
return cls(**config)
|
| 74 |
+
|
| 75 |
+
# ==========================================
|
| 76 |
+
# 3. INITIALIZE APP & LOAD MODEL
|
| 77 |
+
# ==========================================
|
| 78 |
+
app = FastAPI(
|
| 79 |
+
title="AiroDx Multi-View Diagnostic API",
|
| 80 |
+
description="Multimodal diagnostic support using Siamese REMEDIS network.",
|
| 81 |
+
version="1.0.0"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# CORS setup for production (Can be restricted via ENV variable)
|
| 85 |
+
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "*").split(",")
|
| 86 |
+
app.add_middleware(
|
| 87 |
+
CORSMiddleware,
|
| 88 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 89 |
+
allow_credentials=True,
|
| 90 |
+
allow_methods=["*"],
|
| 91 |
+
allow_headers=["*"],
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
logger.info(f"Loading weights from {MODEL_PATH}...")
|
| 95 |
+
try:
|
| 96 |
+
model = keras.models.load_model(
|
| 97 |
+
MODEL_PATH,
|
| 98 |
+
custom_objects={"REMEDISBackbone": REMEDISBackbone}
|
| 99 |
+
)
|
| 100 |
+
logger.info("Model loaded successfully into memory!")
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.critical(f"Failed to load model: {e}")
|
| 103 |
+
model = None
|
| 104 |
+
|
| 105 |
+
# ==========================================
|
| 106 |
+
# 4. HEALTH CHECK ENDPOINT (For Cloud Hosting)
|
| 107 |
+
# ==========================================
|
| 108 |
+
@app.get("/health")
|
| 109 |
+
async def health_check():
|
| 110 |
+
"""Endpoint used by load balancers and Docker to ensure API is alive."""
|
| 111 |
+
if model is None:
|
| 112 |
+
raise HTTPException(status_code=503, detail="Model failed to load.")
|
| 113 |
+
return {"status": "healthy", "model_ready": True}
|
| 114 |
+
|
| 115 |
+
# ==========================================
|
| 116 |
+
# 5. PREPROCESSING FUNCTION
|
| 117 |
+
# ==========================================
|
| 118 |
+
async def process_image(file: UploadFile) -> np.ndarray:
|
| 119 |
+
try:
|
| 120 |
+
# Read the file bytes
|
| 121 |
+
contents = await file.read()
|
| 122 |
+
nparr = np.frombuffer(contents, np.uint8)
|
| 123 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 124 |
+
|
| 125 |
+
if img is None:
|
| 126 |
+
raise ValueError(f"Invalid image file: {file.filename}")
|
| 127 |
+
|
| 128 |
+
# Exact same preprocessing used in training
|
| 129 |
+
img = cv2.resize(img, IMAGE_SIZE)
|
| 130 |
+
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 131 |
+
img = img.astype('float32') / 255.0
|
| 132 |
+
|
| 133 |
+
# Add batch dimension: (448, 448, 3) -> (1, 448, 448, 3)
|
| 134 |
+
return np.expand_dims(img, axis=0)
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.error(f"Image processing error: {str(e)}")
|
| 137 |
+
raise HTTPException(status_code=400, detail=f"Error processing image: {str(e)}")
|
| 138 |
+
|
| 139 |
+
# ==========================================
|
| 140 |
+
# 6. PREDICTION ENDPOINT
|
| 141 |
+
# ==========================================
|
| 142 |
+
@app.post("/predict")
|
| 143 |
+
async def predict(
|
| 144 |
+
frontal_image: UploadFile = File(..., description="The PA/AP Frontal X-ray"),
|
| 145 |
+
lateral_image: UploadFile = File(..., description="The Lateral X-ray")
|
| 146 |
+
):
|
| 147 |
+
logger.info(f"Received prediction request for files: {frontal_image.filename}, {lateral_image.filename}")
|
| 148 |
+
|
| 149 |
+
# 1. Process both images
|
| 150 |
+
img_pa = await process_image(frontal_image)
|
| 151 |
+
img_l = await process_image(lateral_image)
|
| 152 |
+
|
| 153 |
+
# 2. Run inference
|
| 154 |
+
predictions = model.predict({
|
| 155 |
+
"pa_image": img_pa,
|
| 156 |
+
"l_image": img_l
|
| 157 |
+
})
|
| 158 |
+
|
| 159 |
+
# 3. Format results
|
| 160 |
+
probabilities = predictions[0].tolist()
|
| 161 |
+
|
| 162 |
+
results = []
|
| 163 |
+
for disease, prob in zip(TARGET_DISEASES, probabilities):
|
| 164 |
+
results.append({
|
| 165 |
+
"disease": disease,
|
| 166 |
+
"probability": round(prob, 4),
|
| 167 |
+
"flagged": bool(prob > 0.5) # You can adjust this threshold later based on your F1 tuning
|
| 168 |
+
})
|
| 169 |
+
|
| 170 |
+
# Sort results by probability (highest first)
|
| 171 |
+
results.sort(key=lambda x: x["probability"], reverse=True)
|
| 172 |
+
|
| 173 |
+
logger.info("Prediction processed successfully.")
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"status": "success",
|
| 177 |
+
"predictions": results
|
| 178 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
absl-py==2.4.0
|
| 2 |
+
annotated-doc==0.0.4
|
| 3 |
+
annotated-types==0.7.0
|
| 4 |
+
anyio==4.13.0
|
| 5 |
+
astunparse==1.6.3
|
| 6 |
+
certifi==2026.2.25
|
| 7 |
+
charset-normalizer==3.4.6
|
| 8 |
+
click==8.3.1
|
| 9 |
+
colorama==0.4.6
|
| 10 |
+
fastapi==0.135.2
|
| 11 |
+
flatbuffers==25.12.19
|
| 12 |
+
gast==0.7.0
|
| 13 |
+
google-pasta==0.2.0
|
| 14 |
+
grpcio==1.78.0
|
| 15 |
+
h11==0.16.0
|
| 16 |
+
h5py==3.14.0
|
| 17 |
+
idna==3.11
|
| 18 |
+
keras==3.13.2
|
| 19 |
+
libclang==18.1.1
|
| 20 |
+
markdown-it-py==4.0.0
|
| 21 |
+
mdurl==0.1.2
|
| 22 |
+
ml_dtypes==0.5.4
|
| 23 |
+
namex==0.1.0
|
| 24 |
+
numpy==2.4.3
|
| 25 |
+
opencv-python-headless==4.13.0.92
|
| 26 |
+
opt_einsum==3.4.0
|
| 27 |
+
optree==0.19.0
|
| 28 |
+
packaging==26.0
|
| 29 |
+
protobuf==7.34.1
|
| 30 |
+
pydantic==2.12.5
|
| 31 |
+
pydantic_core==2.41.5
|
| 32 |
+
Pygments==2.19.2
|
| 33 |
+
python-multipart==0.0.22
|
| 34 |
+
requests==2.33.0
|
| 35 |
+
rich==14.3.3
|
| 36 |
+
six==1.17.0
|
| 37 |
+
starlette==1.0.0
|
| 38 |
+
tensorflow==2.21.0
|
| 39 |
+
termcolor==3.3.0
|
| 40 |
+
typing-inspection==0.4.2
|
| 41 |
+
typing_extensions==4.15.0
|
| 42 |
+
urllib3==2.6.3
|
| 43 |
+
uvicorn==0.42.0
|
| 44 |
+
wrapt==2.1.2
|