Skip to content

Commit 09cd030

Browse files
authored
Merge pull request #2 from AdvancedPhotonSource/hf_ui
Add inference UI
2 parents 0d0cd66 + cc2e677 commit 09cd030

27 files changed

Lines changed: 10140 additions & 0 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ __pycache__
77
/data
88
/original
99
og
10+
.vscode
1011

1112
# Non-Docker Training Outputs
1213
/src/trainer/outputs

compose.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,17 @@ services:
2525
user: "${UID}:${GID}"
2626
command: python -m simulator.diffraction_generator --config /app/configs/simulator.yaml
2727
restart: "no"
28+
29+
ui:
30+
build:
31+
context: .
32+
dockerfile: docker/ui.Dockerfile
33+
ports:
34+
- "7860:7860"
35+
volumes:
36+
- ./data/:/data/:ro
37+
- ./src/ui/models/:/app/models/:ro
38+
environment:
39+
- PYTHONUNBUFFERED=1
40+
- PORT=7860
41+
restart: unless-stopped

docker/ui.Dockerfile

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Multi-stage Dockerfile for XRD Analysis Tool
2+
# Stage 1: Build React frontend with Node.js
3+
FROM node:18-alpine AS frontend-builder
4+
5+
WORKDIR /app/frontend
6+
7+
COPY src/ui/frontend/package*.json ./
8+
9+
RUN npm install
10+
11+
COPY src/ui/frontend/ ./
12+
13+
RUN npm run build
14+
15+
# Stage 2: Python runtime with FastAPI
16+
FROM python:3.9-slim
17+
18+
WORKDIR /app
19+
20+
RUN apt-get update && apt-get install -y \
21+
&& rm -rf /var/lib/apt/lists/*
22+
23+
COPY src/ui/requirements.txt ./
24+
25+
RUN pip install --no-cache-dir -r requirements.txt
26+
27+
COPY src/ui/app/ ./app/
28+
29+
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
30+
31+
# Create non-root user for security (HF Spaces requirement)
32+
RUN useradd -m -u 1000 user
33+
RUN chown -R user:user /app
34+
USER user
35+
36+
# Expose port 7860 (Hugging Face Spaces default)
37+
EXPOSE 7860
38+
39+
ENV PYTHONUNBUFFERED=1
40+
ENV PORT=7860
41+
42+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]

src/ui/.gitignore

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Frontend build output
2+
dist/
3+
dist-ssr/
4+
*.local
5+
6+
# Dependencies
7+
node_modules/
8+
.pnp
9+
.pnp.js
10+
11+
# Python
12+
__pycache__/
13+
*.py[cod]
14+
*$py.class
15+
*.so
16+
.Python
17+
env/
18+
venv/
19+
ENV/
20+
build/
21+
develop-eggs/
22+
downloads/
23+
eggs/
24+
.eggs/
25+
lib/
26+
lib64/
27+
parts/
28+
sdist/
29+
var/
30+
wheels/
31+
*.egg-info/
32+
.installed.cfg
33+
*.egg
34+
35+
# Models
36+
models
37+
38+
# IDEs
39+
.vscode/
40+
.idea/
41+
*.swp
42+
*.swo
43+
*~
44+
.DS_Store
45+
46+
# Environment
47+
.env
48+
.env.local
49+
.env.development.local
50+
.env.test.local
51+
.env.production.local
52+
53+
# Logs
54+
npm-debug.log*
55+
yarn-debug.log*
56+
yarn-error.log*
57+
pnpm-debug.log*
58+
lerna-debug.log*
59+
*.log
60+
61+
# Testing
62+
coverage/
63+
.nyc_output/
64+
65+
# Editor directories and files
66+
.vscode/*
67+
!.vscode/extensions.json
68+
.idea
69+
*.suo
70+
*.ntvs*
71+
*.njsproj
72+
*.sln
73+
*.sw?

src/ui/app/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""XRD Analysis API application"""
2+
from .main import app
3+
4+
__all__ = ["app"]

src/ui/app/main.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""
2+
FastAPI main application for XRD Analysis Tool.
3+
Serves both the API endpoints and the static React frontend.
4+
"""
5+
from fastapi import FastAPI, UploadFile, File
6+
from fastapi.staticfiles import StaticFiles
7+
from fastapi.responses import FileResponse, JSONResponse
8+
from fastapi.middleware.cors import CORSMiddleware
9+
from pathlib import Path
10+
from typing import Dict, List
11+
import torch
12+
import numpy as np
13+
14+
from .model_inference import XRDModelInference
15+
16+
# Initialize FastAPI app
17+
app = FastAPI(
18+
title="XRD Analysis API",
19+
description="API for analyzing Powder XRD data",
20+
version="1.0.0"
21+
)
22+
23+
# Configure CORS for local development
24+
app.add_middleware(
25+
CORSMiddleware,
26+
allow_origins=["http://localhost:5173", "http://localhost:3000"], # Vite dev server
27+
allow_credentials=True,
28+
allow_methods=["*"],
29+
allow_headers=["*"],
30+
)
31+
32+
# Initialize model inference
33+
model_inference = XRDModelInference()
34+
35+
36+
@app.on_event("startup")
37+
async def startup_event():
38+
"""Load model on startup"""
39+
model_inference.load_model()
40+
41+
42+
@app.get("/api/health")
43+
async def health_check():
44+
"""Health check endpoint"""
45+
return {"status": "healthy", "model_loaded": model_inference.is_loaded()}
46+
47+
48+
@app.post("/api/predict")
49+
async def predict(data: dict):
50+
"""
51+
Predict XRD analysis from preprocessed data.
52+
53+
Expects JSON payload: {"x": [2theta values], "y": [intensity values], "metadata": {...}}
54+
Returns: {"predictions": [...], "confidence": float}
55+
"""
56+
import time
57+
request_start = time.time()
58+
59+
try:
60+
# Extract metadata if present
61+
metadata = data.get("metadata", {})
62+
request_id = metadata.get("timestamp", "unknown")
63+
filename = metadata.get("filename", "unknown")
64+
analysis_count = metadata.get("analysisCount", "unknown")
65+
66+
x = data.get("x", [])
67+
y = data.get("y", [])
68+
69+
if not x or not y:
70+
return JSONResponse(
71+
status_code=400,
72+
content={"error": "Missing x or y data"}
73+
)
74+
75+
if len(x) != len(y):
76+
return JSONResponse(
77+
status_code=400,
78+
content={"error": "x and y arrays must have the same length"}
79+
)
80+
81+
# Run inference
82+
results = model_inference.predict(x, y)
83+
84+
request_time = (time.time() - request_start) * 1000 # Convert to ms
85+
86+
# Add request tracking to response
87+
if isinstance(results, dict):
88+
results['request_metadata'] = {
89+
'request_id': request_id,
90+
'filename': filename,
91+
'analysis_count': analysis_count,
92+
'processing_time_ms': request_time
93+
}
94+
95+
# Return with anti-caching headers
96+
return JSONResponse(
97+
content=results,
98+
headers={
99+
'Cache-Control': 'no-cache, no-store, must-revalidate, private',
100+
'Pragma': 'no-cache',
101+
'Expires': '0',
102+
'X-Request-ID': str(request_id),
103+
}
104+
)
105+
106+
except Exception as e:
107+
return JSONResponse(
108+
status_code=500,
109+
content={"error": f"Prediction failed: {str(e)}"}
110+
)
111+
112+
113+
# Static files and SPA support
114+
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
115+
116+
if frontend_dist.exists():
117+
# Mount static assets
118+
app.mount("/assets", StaticFiles(directory=str(frontend_dist / "assets")), name="assets")
119+
120+
# Catch-all route for React Router (SPA)
121+
@app.get("/{path:path}")
122+
async def serve_spa(path: str):
123+
"""Serve React SPA"""
124+
# Check if file exists in dist
125+
file_path = frontend_dist / path
126+
if file_path.is_file():
127+
return FileResponse(file_path)
128+
# Otherwise serve index.html for client-side routing
129+
return FileResponse(frontend_dist / "index.html")
130+
else:
131+
@app.get("/")
132+
async def root():
133+
return {"message": "Frontend not built. Run 'npm run build' in frontend/"}

0 commit comments

Comments
 (0)