Spaces:
Sleeping
Sleeping
Deploy build-ae01367
Browse files- .github/workflows/deploy.yml +20 -1
- .gitignore +5 -1
- Dockerfile.railway +34 -0
- backend/main.py +121 -20
- railway.json +6 -0
- requirements-railway.txt +12 -0
.github/workflows/deploy.yml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
name: CI / Lint + Deploy
|
| 2 |
|
| 3 |
on:
|
| 4 |
push:
|
|
@@ -133,3 +133,22 @@ jobs:
|
|
| 133 |
)
|
| 134 |
print(f"Model artifacts uploaded β https://huggingface.co/models/{model_repo}")
|
| 135 |
PY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI / Lint + Deploy
|
| 2 |
|
| 3 |
on:
|
| 4 |
push:
|
|
|
|
| 133 |
)
|
| 134 |
print(f"Model artifacts uploaded β https://huggingface.co/models/{model_repo}")
|
| 135 |
PY
|
| 136 |
+
|
| 137 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
+
# 3. Deploy β push backend+frontend to Railway (on main branch only)
|
| 139 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 140 |
+
deploy-railway:
|
| 141 |
+
name: Deploy to Railway
|
| 142 |
+
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && github.event_name == 'push'
|
| 143 |
+
needs: ci
|
| 144 |
+
runs-on: ubuntu-latest
|
| 145 |
+
steps:
|
| 146 |
+
- uses: actions/checkout@v4
|
| 147 |
+
|
| 148 |
+
- name: Install Railway CLI
|
| 149 |
+
run: npm install -g @railway/cli
|
| 150 |
+
|
| 151 |
+
- name: Deploy to Railway
|
| 152 |
+
env:
|
| 153 |
+
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
|
| 154 |
+
run: railway up --service pneumoops-backend --detach
|
.gitignore
CHANGED
|
@@ -51,6 +51,10 @@ models/chestmnist_mobilenetv3/*.onnx
|
|
| 51 |
models/chestmnist_mobilenetv3/*.npz
|
| 52 |
models/chestmnist_mobilenetv3/plots/
|
| 53 |
|
| 54 |
-
# Keep directory structure in Git
|
|
|
|
|
|
|
| 55 |
!models/.gitkeep
|
| 56 |
!data/.gitkeep
|
|
|
|
|
|
|
|
|
| 51 |
models/chestmnist_mobilenetv3/*.npz
|
| 52 |
models/chestmnist_mobilenetv3/plots/
|
| 53 |
|
| 54 |
+
# Keep directory structure and metadata JSON files in Git.
|
| 55 |
+
# The JSON files (training_metrics.json, baseline_stats.json, onnx_export_report.json)
|
| 56 |
+
# are needed by the Railway proxy backend to resolve class names and thresholds at startup.
|
| 57 |
!models/.gitkeep
|
| 58 |
!data/.gitkeep
|
| 59 |
+
!models/chestmnist_mobilenetv3/*.json
|
| 60 |
+
!models/chestmnist_mobilenetv3/README.md
|
Dockerfile.railway
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get upgrade -y && \
|
| 6 |
+
apt-get install -y --no-install-recommends curl && \
|
| 7 |
+
rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
RUN groupadd -r pneumoops && useradd -r -g pneumoops appuser
|
| 10 |
+
|
| 11 |
+
COPY requirements-railway.txt .
|
| 12 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 13 |
+
pip install --no-cache-dir -r requirements-railway.txt
|
| 14 |
+
|
| 15 |
+
COPY --chown=appuser:pneumoops backend ./backend
|
| 16 |
+
COPY --chown=appuser:pneumoops frontend ./frontend
|
| 17 |
+
COPY --chown=appuser:pneumoops models ./models
|
| 18 |
+
COPY --chown=appuser:pneumoops data ./data
|
| 19 |
+
COPY --chown=appuser:pneumoops model_utils.py ./model_utils.py
|
| 20 |
+
COPY --chown=appuser:pneumoops README.md ./README.md
|
| 21 |
+
|
| 22 |
+
ENV PYTHONPATH=/app
|
| 23 |
+
ENV PORT=7860
|
| 24 |
+
ENV MPLCONFIGDIR=/tmp/matplotlib
|
| 25 |
+
ENV PNEUMOOPS_PROFILE=chestmnist
|
| 26 |
+
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
| 30 |
+
CMD curl -f http://127.0.0.1:7860/health || exit 1
|
| 31 |
+
|
| 32 |
+
USER appuser
|
| 33 |
+
|
| 34 |
+
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
backend/main.py
CHANGED
|
@@ -13,19 +13,25 @@ from pathlib import Path
|
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
import gradio as gr
|
|
|
|
| 16 |
import numpy as np
|
| 17 |
-
import onnxruntime as ort
|
| 18 |
-
import torch
|
| 19 |
from fastapi import BackgroundTasks, FastAPI, File, HTTPException, Request, Response, UploadFile
|
| 20 |
from fastapi.middleware.cors import CORSMiddleware
|
| 21 |
from fastapi.responses import PlainTextResponse
|
| 22 |
from PIL import Image
|
| 23 |
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
|
| 24 |
from scipy.stats import ks_2samp
|
| 25 |
-
from torchvision import transforms
|
| 26 |
-
from torchvision.models import mobilenet_v3_small, efficientnet_b0
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
BASE_DIR = Path(__file__).resolve().parents[1]
|
|
@@ -44,6 +50,9 @@ REQUEST_LOG_HISTORY = deque(maxlen=20)
|
|
| 44 |
API_KEY = os.getenv("PNEUMOOPS_API_KEY")
|
| 45 |
ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",") if origin.strip()]
|
| 46 |
TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
|
|
|
|
|
|
|
|
|
|
| 47 |
COLLECT_DATA = os.getenv("PNEUMOOPS_COLLECT_DATA", "true").lower() == "true"
|
| 48 |
COLLECT_DIR = BASE_DIR / "data" / "collected_images"
|
| 49 |
LOW_CONFIDENCE_THRESHOLD = float(os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60"))
|
|
@@ -147,6 +156,23 @@ RUNTIME_PATHS = resolve_runtime_paths(MODEL_DIR)
|
|
| 147 |
|
| 148 |
def load_checkpoint_metadata(checkpoint_path: Path | None) -> dict[str, Any]:
|
| 149 |
if checkpoint_path is None or not checkpoint_path.exists():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return {
|
| 151 |
"architecture": "mobilenet_v3_small",
|
| 152 |
"class_names": ["Normal", "Pneumonia"],
|
|
@@ -198,16 +224,20 @@ IMAGE_SIZE = int(MODEL_METADATA["image_size"])
|
|
| 198 |
THRESHOLDS = np.asarray(MODEL_METADATA["thresholds"], dtype=np.float32)
|
| 199 |
LOGIT_TEMPERATURE = float(MODEL_METADATA.get("logit_temperature", 1.0))
|
| 200 |
|
| 201 |
-
TRANSFORM =
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
)
|
| 212 |
|
| 213 |
BASELINE_STATS = load_json(
|
|
@@ -227,7 +257,7 @@ BASELINE_STATS = load_json(
|
|
| 227 |
)
|
| 228 |
|
| 229 |
|
| 230 |
-
def build_model() ->
|
| 231 |
checkpoint_path = RUNTIME_PATHS["checkpoint"]
|
| 232 |
if checkpoint_path is None or not checkpoint_path.exists():
|
| 233 |
return None
|
|
@@ -256,9 +286,9 @@ def build_onnx_session():
|
|
| 256 |
return session, onnx_path.name
|
| 257 |
|
| 258 |
|
| 259 |
-
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 260 |
-
PYTORCH_MODEL = build_model()
|
| 261 |
-
ONNX_SESSION, ACTIVE_ONNX_MODEL_NAME = build_onnx_session()
|
| 262 |
|
| 263 |
app = FastAPI(
|
| 264 |
title="PneumoOps API",
|
|
@@ -438,7 +468,8 @@ def run_onnx_inference(image: Image.Image) -> dict[str, Any]:
|
|
| 438 |
}
|
| 439 |
|
| 440 |
|
| 441 |
-
async def
|
|
|
|
| 442 |
async def safe_call(model_name: str, fn):
|
| 443 |
try:
|
| 444 |
result = await asyncio.to_thread(fn, image)
|
|
@@ -454,6 +485,45 @@ async def benchmark_both_models(image: Image.Image) -> dict[str, Any]:
|
|
| 454 |
return {"pytorch": pytorch_result, "onnx": onnx_result}
|
| 455 |
|
| 456 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
def build_recommendation(selected_result: dict[str, Any], drift_result: dict[str, Any], dual_results: dict[str, Any]) -> str:
|
| 458 |
if drift_result["drift_alert"] == "DRIFT_DETECTED":
|
| 459 |
return "Input distribution differs from the stored training baseline. Manual review is recommended before trusting this result."
|
|
@@ -499,6 +569,8 @@ def save_prediction_data(image: Image.Image, payload: dict[str, Any]) -> None:
|
|
| 499 |
def health():
|
| 500 |
return {
|
| 501 |
"status": "ok",
|
|
|
|
|
|
|
| 502 |
"profile": PROFILE,
|
| 503 |
"model_dir": str(MODEL_DIR),
|
| 504 |
"pytorch_model_loaded": PYTORCH_MODEL is not None,
|
|
@@ -672,6 +744,35 @@ async def predict(request: Request, background_tasks: BackgroundTasks, file: Upl
|
|
| 672 |
return response_payload
|
| 673 |
|
| 674 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
# βββ Mount Gradio UI into FastAPI (single-port for HF Spaces) βββββββββββββββ
|
| 676 |
# This allows the entire app (API + UI) to run on one port (7860).
|
| 677 |
# - FastAPI REST endpoints remain at /predict, /health, /metrics, etc.
|
|
|
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
+
import httpx
|
| 17 |
import numpy as np
|
|
|
|
|
|
|
| 18 |
from fastapi import BackgroundTasks, FastAPI, File, HTTPException, Request, Response, UploadFile
|
| 19 |
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
from fastapi.responses import PlainTextResponse
|
| 21 |
from PIL import Image
|
| 22 |
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
|
| 23 |
from scipy.stats import ks_2samp
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
# Heavy ML deps are optional β not installed on the Railway proxy node.
|
| 26 |
+
try:
|
| 27 |
+
import torch
|
| 28 |
+
import onnxruntime as ort
|
| 29 |
+
from torchvision import transforms
|
| 30 |
+
from torchvision.models import mobilenet_v3_small, efficientnet_b0
|
| 31 |
+
from model_utils import CalibratedModel
|
| 32 |
+
_TORCH_AVAILABLE = True
|
| 33 |
+
except ImportError:
|
| 34 |
+
_TORCH_AVAILABLE = False
|
| 35 |
|
| 36 |
|
| 37 |
BASE_DIR = Path(__file__).resolve().parents[1]
|
|
|
|
| 50 |
API_KEY = os.getenv("PNEUMOOPS_API_KEY")
|
| 51 |
ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",") if origin.strip()]
|
| 52 |
TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
|
| 53 |
+
# When set, all model inference is forwarded to this HF Spaces URL instead of local models.
|
| 54 |
+
# Example: https://prakhar54-byte-pneumoops.hf.space
|
| 55 |
+
HF_SPACES_URL = os.getenv("HF_SPACES_URL", "").rstrip("/")
|
| 56 |
COLLECT_DATA = os.getenv("PNEUMOOPS_COLLECT_DATA", "true").lower() == "true"
|
| 57 |
COLLECT_DIR = BASE_DIR / "data" / "collected_images"
|
| 58 |
LOW_CONFIDENCE_THRESHOLD = float(os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60"))
|
|
|
|
| 156 |
|
| 157 |
def load_checkpoint_metadata(checkpoint_path: Path | None) -> dict[str, Any]:
|
| 158 |
if checkpoint_path is None or not checkpoint_path.exists():
|
| 159 |
+
# Proxy/backend-only deployments have no model weights but do have
|
| 160 |
+
# training_metrics.json β use it so class names and thresholds are correct.
|
| 161 |
+
tm_path = MODEL_DIR / "training_metrics.json"
|
| 162 |
+
tm = load_json(tm_path)
|
| 163 |
+
if tm.get("class_names"):
|
| 164 |
+
class_names = tm["class_names"]
|
| 165 |
+
n = len(class_names)
|
| 166 |
+
return {
|
| 167 |
+
"architecture": tm.get("architecture", "mobilenet_v3_small"),
|
| 168 |
+
"class_names": class_names,
|
| 169 |
+
"image_size": int(tm.get("image_size", 224)),
|
| 170 |
+
"normalize_mean": [0.5, 0.5, 0.5],
|
| 171 |
+
"normalize_std": [0.5, 0.5, 0.5],
|
| 172 |
+
"thresholds": tm.get("thresholds", [0.5] * n),
|
| 173 |
+
"logit_temperature": 1.0,
|
| 174 |
+
"multi_label": bool(tm.get("multi_label", True)),
|
| 175 |
+
}
|
| 176 |
return {
|
| 177 |
"architecture": "mobilenet_v3_small",
|
| 178 |
"class_names": ["Normal", "Pneumonia"],
|
|
|
|
| 224 |
THRESHOLDS = np.asarray(MODEL_METADATA["thresholds"], dtype=np.float32)
|
| 225 |
LOGIT_TEMPERATURE = float(MODEL_METADATA.get("logit_temperature", 1.0))
|
| 226 |
|
| 227 |
+
TRANSFORM = (
|
| 228 |
+
transforms.Compose(
|
| 229 |
+
[
|
| 230 |
+
transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
|
| 231 |
+
transforms.Grayscale(num_output_channels=3),
|
| 232 |
+
transforms.ToTensor(),
|
| 233 |
+
transforms.Normalize(
|
| 234 |
+
mean=MODEL_METADATA["normalize_mean"],
|
| 235 |
+
std=MODEL_METADATA["normalize_std"],
|
| 236 |
+
),
|
| 237 |
+
]
|
| 238 |
+
)
|
| 239 |
+
if _TORCH_AVAILABLE
|
| 240 |
+
else None
|
| 241 |
)
|
| 242 |
|
| 243 |
BASELINE_STATS = load_json(
|
|
|
|
| 257 |
)
|
| 258 |
|
| 259 |
|
| 260 |
+
def build_model() -> Any:
|
| 261 |
checkpoint_path = RUNTIME_PATHS["checkpoint"]
|
| 262 |
if checkpoint_path is None or not checkpoint_path.exists():
|
| 263 |
return None
|
|
|
|
| 286 |
return session, onnx_path.name
|
| 287 |
|
| 288 |
|
| 289 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") if _TORCH_AVAILABLE else None
|
| 290 |
+
PYTORCH_MODEL = build_model() if _TORCH_AVAILABLE else None
|
| 291 |
+
ONNX_SESSION, ACTIVE_ONNX_MODEL_NAME = build_onnx_session() if _TORCH_AVAILABLE else (None, None)
|
| 292 |
|
| 293 |
app = FastAPI(
|
| 294 |
title="PneumoOps API",
|
|
|
|
| 468 |
}
|
| 469 |
|
| 470 |
|
| 471 |
+
async def _run_local_inference(image: Image.Image) -> dict[str, Any]:
|
| 472 |
+
"""Run both models in-process β used when model weights are available locally."""
|
| 473 |
async def safe_call(model_name: str, fn):
|
| 474 |
try:
|
| 475 |
result = await asyncio.to_thread(fn, image)
|
|
|
|
| 485 |
return {"pytorch": pytorch_result, "onnx": onnx_result}
|
| 486 |
|
| 487 |
|
| 488 |
+
async def _call_hf_infer(image: Image.Image) -> dict[str, Any]:
|
| 489 |
+
"""Forward dual-model inference to the HF Spaces node via /infer."""
|
| 490 |
+
buf = io.BytesIO()
|
| 491 |
+
image.save(buf, format="PNG")
|
| 492 |
+
buf.seek(0)
|
| 493 |
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
| 494 |
+
resp = await client.post(
|
| 495 |
+
f"{HF_SPACES_URL}/infer",
|
| 496 |
+
files={"file": ("xray.png", buf.read(), "image/png")},
|
| 497 |
+
)
|
| 498 |
+
resp.raise_for_status()
|
| 499 |
+
data = resp.json()
|
| 500 |
+
|
| 501 |
+
result: dict[str, Any] = {}
|
| 502 |
+
for key in ("pytorch", "onnx"):
|
| 503 |
+
raw = data.get(key) or {}
|
| 504 |
+
if "error" in raw:
|
| 505 |
+
result[key] = {"model_key": key, "error": raw["error"]}
|
| 506 |
+
else:
|
| 507 |
+
probs = np.asarray(raw.get("probabilities", []), dtype=np.float32)
|
| 508 |
+
latency = float(raw.get("latency_ms") or 0.0)
|
| 509 |
+
LATENCY_HISTOGRAM.labels(model=key).observe(latency)
|
| 510 |
+
result[key] = {
|
| 511 |
+
"model_key": key,
|
| 512 |
+
"model_used": "Baseline PyTorch" if key == "pytorch" else "Optimized ONNX",
|
| 513 |
+
"latency_ms": latency,
|
| 514 |
+
"probabilities": probs.tolist(),
|
| 515 |
+
**postprocess_probabilities(probs),
|
| 516 |
+
}
|
| 517 |
+
return result
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
async def benchmark_both_models(image: Image.Image) -> dict[str, Any]:
|
| 521 |
+
"""Route dual-model inference to HF Spaces (proxy mode) or local models."""
|
| 522 |
+
if HF_SPACES_URL:
|
| 523 |
+
return await _call_hf_infer(image)
|
| 524 |
+
return await _run_local_inference(image)
|
| 525 |
+
|
| 526 |
+
|
| 527 |
def build_recommendation(selected_result: dict[str, Any], drift_result: dict[str, Any], dual_results: dict[str, Any]) -> str:
|
| 528 |
if drift_result["drift_alert"] == "DRIFT_DETECTED":
|
| 529 |
return "Input distribution differs from the stored training baseline. Manual review is recommended before trusting this result."
|
|
|
|
| 569 |
def health():
|
| 570 |
return {
|
| 571 |
"status": "ok",
|
| 572 |
+
"deployment_mode": "proxy" if HF_SPACES_URL else "local",
|
| 573 |
+
"hf_spaces_url": HF_SPACES_URL or None,
|
| 574 |
"profile": PROFILE,
|
| 575 |
"model_dir": str(MODEL_DIR),
|
| 576 |
"pytorch_model_loaded": PYTORCH_MODEL is not None,
|
|
|
|
| 744 |
return response_payload
|
| 745 |
|
| 746 |
|
| 747 |
+
@app.post("/infer")
|
| 748 |
+
async def infer_raw(file: UploadFile = File(...)):
|
| 749 |
+
"""Raw dual-model inference for remote backend nodes.
|
| 750 |
+
Returns probabilities from both PyTorch and ONNX arms with no side-effects
|
| 751 |
+
(no metrics, no drift, no history). Only available on the HF Spaces inference node
|
| 752 |
+
(i.e. when HF_SPACES_URL is not set)."""
|
| 753 |
+
if HF_SPACES_URL:
|
| 754 |
+
raise HTTPException(
|
| 755 |
+
status_code=501,
|
| 756 |
+
detail="This node is a proxy backend; /infer is only served by the inference node.",
|
| 757 |
+
)
|
| 758 |
+
image = load_image_from_upload(file)
|
| 759 |
+
dual = await _run_local_inference(image)
|
| 760 |
+
out: dict[str, Any] = {}
|
| 761 |
+
for key in ("pytorch", "onnx"):
|
| 762 |
+
r = dual.get(key, {})
|
| 763 |
+
out[key] = (
|
| 764 |
+
{"probabilities": r.get("probabilities", []), "latency_ms": r.get("latency_ms")}
|
| 765 |
+
if "error" not in r
|
| 766 |
+
else {"error": r["error"]}
|
| 767 |
+
)
|
| 768 |
+
return {
|
| 769 |
+
**out,
|
| 770 |
+
"class_names": CLASS_NAMES,
|
| 771 |
+
"thresholds": THRESHOLDS.tolist(),
|
| 772 |
+
"multi_label": MULTI_LABEL,
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
|
| 776 |
# βββ Mount Gradio UI into FastAPI (single-port for HF Spaces) βββββββββββββββ
|
| 777 |
# This allows the entire app (API + UI) to run on one port (7860).
|
| 778 |
# - FastAPI REST endpoints remain at /predict, /health, /metrics, etc.
|
railway.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://railway.com/railway.schema.json",
|
| 3 |
+
"build": {
|
| 4 |
+
"dockerfilePath": "Dockerfile.railway"
|
| 5 |
+
}
|
| 6 |
+
}
|
requirements-railway.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.12
|
| 2 |
+
gradio==5.25.2
|
| 3 |
+
huggingface_hub==0.31.2
|
| 4 |
+
matplotlib==3.10.1
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
Pillow==11.1.0
|
| 7 |
+
prometheus-client==0.21.1
|
| 8 |
+
python-multipart==0.0.20
|
| 9 |
+
requests==2.32.3
|
| 10 |
+
scipy==1.15.2
|
| 11 |
+
uvicorn[standard]==0.34.1
|
| 12 |
+
httpx==0.28.1
|