Spaces:
Sleeping
Sleeping
feat: Add initial project structure with Docker support and stroke classification model
Browse files- Created Dockerfile with multi-stage build for optimized image size.
- Added docker-compose.yml for service orchestration.
- Introduced Python 3.13 as the project version.
- Implemented model utilities for stroke classification using ONNX.
- Developed Streamlit app for user interface and model interaction.
- Included CSS for styling and assets for sample images.
- Removed requirements.txt in favor of pyproject.toml for dependency management.
- .gitattributes +1 -0
- .python-version +1 -0
- .streamlit/config.toml +20 -0
- Dockerfile +37 -11
- assets/sample_no_stroke.png +3 -0
- assets/sample_stroke.png +3 -0
- docker-compose.yml +19 -0
- pyproject.toml +13 -0
- requirements.txt +0 -3
- src/model_utils.py +58 -0
- src/static/app.css +388 -0
- src/streamlit_app.py +276 -38
- uv.lock +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/*.png filter=lfs diff=lfs merge=lfs -text
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.13
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Koyu tema: Streamlit’te tamamen kapatma anahtarı yok; iki katman kullanılıyor:
|
| 2 |
+
# 1) toolbarMode = "minimal" → çoğu deployment’da tema menüsü (⋮ Settings) çıkmaz / kısıtlı kalır.
|
| 3 |
+
# 2) [theme.dark] → biri “Dark” veya sistem koyusu seçse bile renkler AÇIK tema ile aynı (görsel koyu yok).
|
| 4 |
+
|
| 5 |
+
[client]
|
| 6 |
+
toolbarMode = "minimal"
|
| 7 |
+
|
| 8 |
+
[theme]
|
| 9 |
+
base = "light"
|
| 10 |
+
|
| 11 |
+
# Streamlit varsayılan LIGHT paleti — "dark" adı altında aynısı (koyu görünüm oluşmaz).
|
| 12 |
+
[theme.dark]
|
| 13 |
+
primaryColor = "#ff4b4b"
|
| 14 |
+
backgroundColor = "#ffffff"
|
| 15 |
+
secondaryBackgroundColor = "#f0f2f6"
|
| 16 |
+
textColor = "#31333F"
|
| 17 |
+
linkColor = "#0068c9"
|
| 18 |
+
codeTextColor = "#31333F"
|
| 19 |
+
borderColor = "#e6eaf1"
|
| 20 |
+
showWidgetBorder = true
|
Dockerfile
CHANGED
|
@@ -1,20 +1,46 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
curl \
|
| 8 |
-
git \
|
| 9 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
-
|
| 12 |
-
COPY
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
|
|
|
| 15 |
|
|
|
|
| 16 |
EXPOSE 8501
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
|
|
|
| 1 |
+
# ==========================================
|
| 2 |
+
# Builder Stage
|
| 3 |
+
# ==========================================
|
| 4 |
+
FROM python:3.13-slim AS builder
|
| 5 |
+
|
| 6 |
+
# Keep runtime behavior predictable and logs visible in container environments.
|
| 7 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 8 |
+
PYTHONUNBUFFERED=1 \
|
| 9 |
+
UV_COMPILE_BYTECODE=1
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Install uv for fast dependency management
|
| 14 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
| 15 |
+
|
| 16 |
+
COPY pyproject.toml uv.lock ./
|
| 17 |
+
|
| 18 |
+
RUN --mount=type=cache,target=/root/.cache \
|
| 19 |
+
uv sync --locked --no-dev --no-install-project
|
| 20 |
+
|
| 21 |
+
# ==========================================
|
| 22 |
+
# Runtime Stage
|
| 23 |
+
# ==========================================
|
| 24 |
+
FROM python:3.13-slim AS runtime
|
| 25 |
|
| 26 |
WORKDIR /app
|
| 27 |
|
| 28 |
+
# Runtime uses a non-root user.
|
| 29 |
+
RUN useradd --create-home --shell /usr/sbin/nologin app
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
# Copy dependencies and application code
|
| 32 |
+
COPY --from=builder --chown=app:app /app/.venv /app/.venv
|
| 33 |
+
COPY --chown=app:app src ./src
|
| 34 |
+
COPY --chown=app:app assets ./assets
|
| 35 |
|
| 36 |
+
# Ensure runtime entrypoints stay executable
|
| 37 |
+
RUN find /app/.venv/bin -type f -exec chmod 755 {} +
|
| 38 |
|
| 39 |
+
USER app
|
| 40 |
EXPOSE 8501
|
| 41 |
|
| 42 |
+
# Same endpoint as compose: verifies the server responds (slim has no curl).
|
| 43 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
|
| 44 |
+
CMD ["/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=5).read()"]
|
| 45 |
|
| 46 |
+
CMD ["/app/.venv/bin/python", "-m", "streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
assets/sample_no_stroke.png
ADDED
|
Git LFS Details
|
assets/sample_stroke.png
ADDED
|
Git LFS Details
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
app:
|
| 3 |
+
build: .
|
| 4 |
+
ports:
|
| 5 |
+
- "8501:8501"
|
| 6 |
+
environment:
|
| 7 |
+
- STREAMLIT_SERVER_PORT=8501
|
| 8 |
+
healthcheck:
|
| 9 |
+
test:
|
| 10 |
+
[
|
| 11 |
+
"CMD",
|
| 12 |
+
"/app/.venv/bin/python",
|
| 13 |
+
"-c",
|
| 14 |
+
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=5).read()",
|
| 15 |
+
]
|
| 16 |
+
interval: 15s
|
| 17 |
+
timeout: 5s
|
| 18 |
+
start_period: 45s
|
| 19 |
+
retries: 3
|
pyproject.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "stroke-classification"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"streamlit>=1.40.0",
|
| 9 |
+
"pillow>=10.0.0",
|
| 10 |
+
"numpy>=2.0.0",
|
| 11 |
+
"onnxruntime>=1.20.0",
|
| 12 |
+
"huggingface-hub>=0.26.0",
|
| 13 |
+
]
|
requirements.txt
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
altair
|
| 2 |
-
pandas
|
| 3 |
-
streamlit
|
|
|
|
|
|
|
|
|
|
|
|
src/model_utils.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import onnxruntime as ort
|
| 6 |
+
from huggingface_hub import hf_hub_download
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
IMAGE_SIZE = 299
|
| 10 |
+
CLASS_NAMES = ("No-Stroke", "Stroke")
|
| 11 |
+
REPO_ID = os.environ.get("STROKE_MODEL_REPO", "melisklc0/efficientnet-b0-stroke-distilled")
|
| 12 |
+
ONNX_FILENAME = "model.onnx"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _softmax(x: np.ndarray) -> np.ndarray:
|
| 16 |
+
x = x.astype(np.float64)
|
| 17 |
+
x = x - np.max(x, axis=-1, keepdims=True)
|
| 18 |
+
e = np.exp(x)
|
| 19 |
+
return (e / e.sum(axis=-1, keepdims=True)).astype(np.float32)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def preprocess_image(img: Image.Image, image_size: int = IMAGE_SIZE) -> np.ndarray:
|
| 23 |
+
"""RGB, resize, ImageNet normalize -> NCHW float32."""
|
| 24 |
+
rgb = img.convert("RGB").resize((image_size, image_size), Image.Resampling.BILINEAR)
|
| 25 |
+
arr = np.asarray(rgb, dtype=np.float32) / 255.0
|
| 26 |
+
arr = np.transpose(arr, (2, 0, 1))
|
| 27 |
+
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(3, 1, 1)
|
| 28 |
+
std = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1)
|
| 29 |
+
arr = (arr - mean) / std
|
| 30 |
+
return np.expand_dims(arr, axis=0)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_stroke_model():
|
| 34 |
+
"""Download ONNX from the model Hub repo and build an inference session."""
|
| 35 |
+
onnx_path = hf_hub_download(
|
| 36 |
+
repo_id=REPO_ID,
|
| 37 |
+
filename=ONNX_FILENAME,
|
| 38 |
+
repo_type="model",
|
| 39 |
+
)
|
| 40 |
+
providers: list[str] = ["CPUExecutionProvider"]
|
| 41 |
+
if ort.get_device() == "GPU":
|
| 42 |
+
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
| 43 |
+
session = ort.InferenceSession(onnx_path, providers=providers)
|
| 44 |
+
return session, preprocess_image
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def predict(session: ort.InferenceSession, preprocess: Any, img: Image.Image):
|
| 48 |
+
x = preprocess(img)
|
| 49 |
+
inp = session.get_inputs()[0].name
|
| 50 |
+
logits = session.run(None, {inp: x})[0]
|
| 51 |
+
probs = _softmax(logits[0])
|
| 52 |
+
|
| 53 |
+
results = {CLASS_NAMES[i]: float(probs[i]) for i in range(len(CLASS_NAMES))}
|
| 54 |
+
pred_idx = int(np.argmax(probs))
|
| 55 |
+
prediction = CLASS_NAMES[pred_idx]
|
| 56 |
+
confidence = float(probs[pred_idx])
|
| 57 |
+
|
| 58 |
+
return prediction, confidence, results
|
src/static/app.css
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 2 |
+
|
| 3 |
+
html, body, [class*="css"] {
|
| 4 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.stApp {
|
| 8 |
+
background-color: #f1f5f9;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.block-container {
|
| 12 |
+
padding-top: 1.5rem;
|
| 13 |
+
padding-bottom: 2rem;
|
| 14 |
+
max-width: 1100px;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
section[data-testid="stSidebar"] {
|
| 18 |
+
background-color: #ffffff;
|
| 19 |
+
border-right: 1px solid #e2e8f0;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
section[data-testid="stSidebar"] .block-container {
|
| 23 |
+
padding-top: 1.5rem;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.brand-block {
|
| 27 |
+
display: flex;
|
| 28 |
+
align-items: center;
|
| 29 |
+
gap: 12px;
|
| 30 |
+
margin-bottom: 1.5rem;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.brand-mark {
|
| 34 |
+
width: 44px;
|
| 35 |
+
height: 44px;
|
| 36 |
+
border-radius: 10px;
|
| 37 |
+
background: linear-gradient(145deg, #0f2b46, #1a5f8a);
|
| 38 |
+
color: #fff;
|
| 39 |
+
font-weight: 700;
|
| 40 |
+
font-size: 0.85rem;
|
| 41 |
+
display: flex;
|
| 42 |
+
align-items: center;
|
| 43 |
+
justify-content: center;
|
| 44 |
+
letter-spacing: -0.02em;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.brand-title {
|
| 48 |
+
font-size: 1rem;
|
| 49 |
+
font-weight: 700;
|
| 50 |
+
color: #0f172a;
|
| 51 |
+
line-height: 1.2;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.brand-sub {
|
| 55 |
+
font-size: 0.75rem;
|
| 56 |
+
color: #64748b;
|
| 57 |
+
margin-top: 2px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.info-card {
|
| 61 |
+
background: #f8fafc;
|
| 62 |
+
border: 1px solid #e2e8f0;
|
| 63 |
+
border-radius: 10px;
|
| 64 |
+
padding: 1rem;
|
| 65 |
+
margin-bottom: 1.25rem;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.info-card dt {
|
| 69 |
+
font-size: 0.7rem;
|
| 70 |
+
font-weight: 600;
|
| 71 |
+
text-transform: uppercase;
|
| 72 |
+
letter-spacing: 0.05em;
|
| 73 |
+
color: #64748b;
|
| 74 |
+
margin: 0.75rem 0 0.15rem;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.info-card dt:first-child {
|
| 78 |
+
margin-top: 0;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.info-card dd {
|
| 82 |
+
margin: 0;
|
| 83 |
+
font-size: 0.875rem;
|
| 84 |
+
color: #0f172a;
|
| 85 |
+
font-weight: 500;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.info-card .metrics-dd {
|
| 89 |
+
margin-top: 0.1rem;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.info-card .metrics-grid {
|
| 93 |
+
display: grid;
|
| 94 |
+
grid-template-columns: 1fr 1fr;
|
| 95 |
+
gap: 0.35rem 1rem;
|
| 96 |
+
margin: 0;
|
| 97 |
+
padding: 0;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.info-card .metrics-grid__cell {
|
| 101 |
+
display: flex;
|
| 102 |
+
justify-content: space-between;
|
| 103 |
+
align-items: baseline;
|
| 104 |
+
gap: 0.5rem;
|
| 105 |
+
font-size: 0.875rem;
|
| 106 |
+
line-height: 1.4;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.info-card .metrics-grid__label {
|
| 110 |
+
color: #64748b;
|
| 111 |
+
font-weight: 500;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.info-card .metrics-grid__value {
|
| 115 |
+
color: #0f172a;
|
| 116 |
+
font-weight: 600;
|
| 117 |
+
font-variant-numeric: tabular-nums;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.app-header {
|
| 121 |
+
background: linear-gradient(120deg, #0f2b46 0%, #164e73 55%, #1a5f8a 100%);
|
| 122 |
+
border-radius: 12px;
|
| 123 |
+
padding: 1.75rem 2rem;
|
| 124 |
+
margin-bottom: 1.5rem;
|
| 125 |
+
color: #ffffff;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.app-header h1 {
|
| 129 |
+
margin: 0;
|
| 130 |
+
font-size: 1.65rem;
|
| 131 |
+
font-weight: 700;
|
| 132 |
+
color: #ffffff !important;
|
| 133 |
+
letter-spacing: -0.02em;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.app-header p {
|
| 137 |
+
margin: 0.4rem 0 0;
|
| 138 |
+
color: #cbd5e1;
|
| 139 |
+
font-size: 0.95rem;
|
| 140 |
+
font-weight: 400;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.verdict-box {
|
| 144 |
+
border-radius: 10px;
|
| 145 |
+
padding: 1.25rem 1.5rem;
|
| 146 |
+
margin-bottom: 1rem;
|
| 147 |
+
text-align: center;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.verdict-box.stroke {
|
| 151 |
+
background: #fef2f2;
|
| 152 |
+
border: 1px solid #fecaca;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.verdict-box.normal {
|
| 156 |
+
background: #f0fdf4;
|
| 157 |
+
border: 1px solid #bbf7d0;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.verdict-label {
|
| 161 |
+
font-size: 0.7rem;
|
| 162 |
+
font-weight: 600;
|
| 163 |
+
text-transform: uppercase;
|
| 164 |
+
letter-spacing: 0.08em;
|
| 165 |
+
color: #64748b;
|
| 166 |
+
margin-bottom: 0.35rem;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.verdict-value {
|
| 170 |
+
font-size: 1.75rem;
|
| 171 |
+
font-weight: 700;
|
| 172 |
+
letter-spacing: 0.04em;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.verdict-box.stroke .verdict-value { color: #b91c1c; }
|
| 176 |
+
.verdict-box.normal .verdict-value { color: #15803d; }
|
| 177 |
+
|
| 178 |
+
.prob-chart {
|
| 179 |
+
margin-top: 0.25rem;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.prob-row {
|
| 183 |
+
margin-bottom: 0.85rem;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.prob-row:last-child {
|
| 187 |
+
margin-bottom: 0;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.prob-row__label {
|
| 191 |
+
margin: 0 0 0.4rem;
|
| 192 |
+
font-size: 0.875rem;
|
| 193 |
+
color: #334155;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.prob-row__label strong {
|
| 197 |
+
font-weight: 600;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.prob-track {
|
| 201 |
+
height: 10px;
|
| 202 |
+
background: #e2e8f0;
|
| 203 |
+
border-radius: 5px;
|
| 204 |
+
overflow: hidden;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.prob-fill {
|
| 208 |
+
height: 10px;
|
| 209 |
+
border-radius: 5px;
|
| 210 |
+
transition: width 0.25s ease;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.alert-box {
|
| 214 |
+
border-radius: 8px;
|
| 215 |
+
padding: 0.85rem 1rem;
|
| 216 |
+
font-size: 0.85rem;
|
| 217 |
+
line-height: 1.55;
|
| 218 |
+
margin: 0.75rem 0.15rem 1rem;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.alert-box--muted {
|
| 222 |
+
background: #f8fafc;
|
| 223 |
+
border: 1px solid #e2e8f0;
|
| 224 |
+
color: #475569;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.hint-box {
|
| 228 |
+
display: flex;
|
| 229 |
+
align-items: flex-start;
|
| 230 |
+
gap: 0.6rem;
|
| 231 |
+
background: #f8fafc;
|
| 232 |
+
border: 1px solid #e2e8f0;
|
| 233 |
+
border-radius: 8px;
|
| 234 |
+
padding: 0.7rem 0.9rem;
|
| 235 |
+
margin: 0.35rem 0 1.25rem;
|
| 236 |
+
font-size: 0.85rem;
|
| 237 |
+
line-height: 1.45;
|
| 238 |
+
color: #475569;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.hint-box--ready {
|
| 242 |
+
background: #f0fdf4;
|
| 243 |
+
border-color: #bbf7d0;
|
| 244 |
+
color: #166534;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.hint-box--loading {
|
| 248 |
+
background: #f8fafc;
|
| 249 |
+
border-color: #e2e8f0;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.hint-box__icon {
|
| 253 |
+
flex-shrink: 0;
|
| 254 |
+
font-size: 1rem;
|
| 255 |
+
line-height: 1.45;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.hint-box__sub {
|
| 259 |
+
display: block;
|
| 260 |
+
font-size: 0.78rem;
|
| 261 |
+
color: #64748b;
|
| 262 |
+
margin-top: 0.15rem;
|
| 263 |
+
font-weight: 400;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.hint-box--ready .hint-box__sub {
|
| 267 |
+
color: #15803d;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.hint-box strong {
|
| 271 |
+
font-weight: 600;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.welcome-panel {
|
| 275 |
+
background: #f8fafc;
|
| 276 |
+
border: 1px solid #e2e8f0;
|
| 277 |
+
border-radius: 8px;
|
| 278 |
+
padding: 1rem 1.2rem 1.4rem;
|
| 279 |
+
margin: 0.85rem 0.15rem 1rem;
|
| 280 |
+
font-size: 0.85rem;
|
| 281 |
+
color: #475569;
|
| 282 |
+
line-height: 1.5;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.welcome-panel__title {
|
| 286 |
+
display: block;
|
| 287 |
+
color: #0f2b46;
|
| 288 |
+
font-size: 0.9rem;
|
| 289 |
+
font-weight: 600;
|
| 290 |
+
margin-bottom: 0.5rem;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.welcome-panel__list {
|
| 294 |
+
margin: 0;
|
| 295 |
+
padding-left: 1.25rem;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.welcome-panel__list li {
|
| 299 |
+
margin-bottom: 0.25rem;
|
| 300 |
+
padding-left: 0.15rem;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.welcome-panel__list li:last-child {
|
| 304 |
+
margin-bottom: 0;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.empty-state {
|
| 308 |
+
color: #64748b;
|
| 309 |
+
font-size: 0.875rem;
|
| 310 |
+
line-height: 1.55;
|
| 311 |
+
padding: 0.75rem 0.5rem 1.35rem;
|
| 312 |
+
margin: 0;
|
| 313 |
+
text-align: center;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
div[data-testid="stVerticalBlockBorderWrapper"] {
|
| 317 |
+
padding-bottom: 1.75rem;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
div[data-testid="stVerticalBlockBorderWrapper"] [data-testid="stVerticalBlock"] {
|
| 321 |
+
gap: 0.75rem;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.sidebar-disclaimer {
|
| 325 |
+
background: #fffbeb;
|
| 326 |
+
border: 1px solid #fde68a;
|
| 327 |
+
border-radius: 8px;
|
| 328 |
+
padding: 0.85rem 0.95rem;
|
| 329 |
+
margin-top: 1.75rem;
|
| 330 |
+
font-size: 0.75rem;
|
| 331 |
+
line-height: 1.5;
|
| 332 |
+
color: #92400e;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.sidebar-disclaimer__label {
|
| 336 |
+
font-size: 0.65rem;
|
| 337 |
+
font-weight: 600;
|
| 338 |
+
text-transform: uppercase;
|
| 339 |
+
letter-spacing: 0.06em;
|
| 340 |
+
color: #b45309;
|
| 341 |
+
margin: 0 0 0.4rem;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.sidebar-disclaimer p:last-child {
|
| 345 |
+
margin: 0;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.footer-note {
|
| 349 |
+
text-align: center;
|
| 350 |
+
color: #94a3b8;
|
| 351 |
+
font-size: 0.75rem;
|
| 352 |
+
margin-top: 1.5rem;
|
| 353 |
+
line-height: 1.6;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
h2, h3 {
|
| 357 |
+
color: #0f172a !important;
|
| 358 |
+
font-weight: 600 !important;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
section[data-testid="stSidebar"] .stButton > button {
|
| 362 |
+
width: 100%;
|
| 363 |
+
background: #ffffff;
|
| 364 |
+
color: #1e4d6e;
|
| 365 |
+
border: 1px solid #cbd5e1;
|
| 366 |
+
border-radius: 8px;
|
| 367 |
+
font-weight: 500;
|
| 368 |
+
font-size: 0.8rem;
|
| 369 |
+
padding: 0.45rem 0.5rem;
|
| 370 |
+
transition: background 0.15s ease, border-color 0.15s ease;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
section[data-testid="stSidebar"] .stButton > button:hover {
|
| 374 |
+
background: #f8fafc;
|
| 375 |
+
border-color: #94a3b8;
|
| 376 |
+
color: #0f2b46;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
[data-testid="stFileUploader"] section {
|
| 380 |
+
border: 1px dashed #cbd5e1;
|
| 381 |
+
border-radius: 10px;
|
| 382 |
+
background: #f8fafc;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
[data-testid="stMetricValue"] {
|
| 386 |
+
font-size: 1.75rem !important;
|
| 387 |
+
color: #0f2b46 !important;
|
| 388 |
+
}
|
src/streamlit_app.py
CHANGED
|
@@ -1,40 +1,278 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
|
| 4 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
| 17 |
-
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
| 18 |
-
|
| 19 |
-
indices = np.linspace(0, 1, num_points)
|
| 20 |
-
theta = 2 * np.pi * num_turns * indices
|
| 21 |
-
radius = indices
|
| 22 |
-
|
| 23 |
-
x = radius * np.cos(theta)
|
| 24 |
-
y = radius * np.sin(theta)
|
| 25 |
-
|
| 26 |
-
df = pd.DataFrame({
|
| 27 |
-
"x": x,
|
| 28 |
-
"y": y,
|
| 29 |
-
"idx": indices,
|
| 30 |
-
"rand": np.random.randn(num_points),
|
| 31 |
-
})
|
| 32 |
-
|
| 33 |
-
st.altair_chart(alt.Chart(df, height=700, width=700)
|
| 34 |
-
.mark_point(filled=True)
|
| 35 |
-
.encode(
|
| 36 |
-
x=alt.X("x", axis=None),
|
| 37 |
-
y=alt.Y("y", axis=None),
|
| 38 |
-
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
| 39 |
-
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
| 40 |
-
))
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
import streamlit as st
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from model_utils import load_stroke_model, predict
|
| 8 |
+
|
| 9 |
+
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
| 10 |
+
_APP_CSS_PATH = _STATIC_DIR / "app.css"
|
| 11 |
+
|
| 12 |
+
# KD EfficientNet-B0 · 3-fold CV mean (thesis/results/kd/KD_Efficientnet_b0)
|
| 13 |
+
_MODEL_METRICS = {
|
| 14 |
+
"accuracy": 98.0,
|
| 15 |
+
"precision": 99.5,
|
| 16 |
+
"recall": 96.4,
|
| 17 |
+
"f1": 98.0,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
st.set_page_config(
|
| 21 |
+
page_title="Stroke Classification | Clinical Support",
|
| 22 |
+
page_icon="🩺",
|
| 23 |
+
layout="wide",
|
| 24 |
+
initial_sidebar_state="expanded",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@st.cache_data
|
| 29 |
+
def _load_app_css() -> str:
|
| 30 |
+
if not _APP_CSS_PATH.is_file():
|
| 31 |
+
raise FileNotFoundError(f"Stylesheet not found: {_APP_CSS_PATH}")
|
| 32 |
+
return _APP_CSS_PATH.read_text(encoding="utf-8")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def inject_app_styles() -> None:
|
| 36 |
+
st.markdown(f"<style>{_load_app_css()}</style>", unsafe_allow_html=True)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@st.cache_resource(show_spinner=False)
|
| 40 |
+
def get_model():
|
| 41 |
+
return load_stroke_model()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _resolve_input_image(file_source):
|
| 45 |
+
if file_source is not None:
|
| 46 |
+
st.session_state.pop("sample_path", None)
|
| 47 |
+
return Image.open(file_source).convert("RGB"), "upload"
|
| 48 |
+
|
| 49 |
+
sample_path = st.session_state.get("sample_path")
|
| 50 |
+
if sample_path and os.path.isfile(sample_path):
|
| 51 |
+
return Image.open(sample_path).convert("RGB"), "sample"
|
| 52 |
+
|
| 53 |
+
if sample_path:
|
| 54 |
+
st.session_state.pop("sample_path", None)
|
| 55 |
+
return None, None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _render_probability_bars(results: dict[str, float]) -> None:
|
| 59 |
+
colors = {"No-Stroke": "#15803d", "Stroke": "#b91c1c"}
|
| 60 |
+
rows = []
|
| 61 |
+
for cls in ("No-Stroke", "Stroke"):
|
| 62 |
+
prob = results.get(cls, 0.0)
|
| 63 |
+
pct = prob * 100
|
| 64 |
+
color = colors[cls]
|
| 65 |
+
rows.append(
|
| 66 |
+
f'<div class="prob-row">'
|
| 67 |
+
f'<p class="prob-row__label">{cls} — {prob:.1%}</p>'
|
| 68 |
+
f'<div class="prob-track">'
|
| 69 |
+
f'<div class="prob-fill" style="width:{pct:.1f}%;background:{color};"></div>'
|
| 70 |
+
f"</div></div>"
|
| 71 |
+
)
|
| 72 |
+
st.html(f'<div class="prob-chart">{"".join(rows)}</div>')
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _sidebar_specs_html() -> str:
|
| 76 |
+
m = _MODEL_METRICS
|
| 77 |
+
return f"""
|
| 78 |
+
<dl class="info-card">
|
| 79 |
+
<dt>Model</dt>
|
| 80 |
+
<dd>EfficientNet-B0 (Distilled)</dd>
|
| 81 |
+
<dt>Metrics</dt>
|
| 82 |
+
<dd class="metrics-dd">
|
| 83 |
+
<div class="metrics-grid" aria-label="Model metrics">
|
| 84 |
+
<div class="metrics-grid__cell">
|
| 85 |
+
<span class="metrics-grid__label">Accuracy</span>
|
| 86 |
+
<span class="metrics-grid__value">{m["accuracy"]:.1f}%</span>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="metrics-grid__cell">
|
| 89 |
+
<span class="metrics-grid__label">Precision</span>
|
| 90 |
+
<span class="metrics-grid__value">{m["precision"]:.1f}%</span>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="metrics-grid__cell">
|
| 93 |
+
<span class="metrics-grid__label">Recall</span>
|
| 94 |
+
<span class="metrics-grid__value">{m["recall"]:.1f}%</span>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="metrics-grid__cell">
|
| 97 |
+
<span class="metrics-grid__label">F1</span>
|
| 98 |
+
<span class="metrics-grid__value">{m["f1"]:.1f}%</span>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</dd>
|
| 102 |
+
<dt>Training data</dt>
|
| 103 |
+
<dd>MOH Turkey (15k Augmented Scans)</dd>
|
| 104 |
+
<dt>External validation</dt>
|
| 105 |
+
<dd>Kaggle hold-out set</dd>
|
| 106 |
+
</dl>
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _model_loading_html() -> str:
|
| 111 |
+
return """
|
| 112 |
+
<div class="hint-box hint-box--loading">
|
| 113 |
+
<span class="hint-box__icon">⏳</span>
|
| 114 |
+
<span>
|
| 115 |
+
<strong>Loading model</strong>
|
| 116 |
+
<span class="hint-box__sub">Downloading weights from Hugging Face…</span>
|
| 117 |
+
</span>
|
| 118 |
+
</div>
|
| 119 |
+
"""
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _model_ready_html() -> str:
|
| 123 |
+
return """
|
| 124 |
+
<div class="hint-box hint-box--ready">
|
| 125 |
+
<span class="hint-box__icon">✅</span>
|
| 126 |
+
<span><strong>Model ready</strong> — waiting for a scan</span>
|
| 127 |
+
</div>
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _ensure_model_loaded(status_slot):
|
| 132 |
+
if st.session_state.get("model_bundle") is not None:
|
| 133 |
+
status_slot.markdown(_model_ready_html(), unsafe_allow_html=True)
|
| 134 |
+
return st.session_state.model_bundle
|
| 135 |
+
|
| 136 |
+
status_slot.markdown(_model_loading_html(), unsafe_allow_html=True)
|
| 137 |
+
try:
|
| 138 |
+
bundle = get_model()
|
| 139 |
+
except Exception as e:
|
| 140 |
+
st.error(f"Model could not be loaded: {e}")
|
| 141 |
+
st.stop()
|
| 142 |
+
|
| 143 |
+
st.session_state.model_bundle = bundle
|
| 144 |
+
status_slot.markdown(_model_ready_html(), unsafe_allow_html=True)
|
| 145 |
+
return bundle
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
inject_app_styles()
|
| 149 |
+
|
| 150 |
+
# --- Sidebar ---
|
| 151 |
+
with st.sidebar:
|
| 152 |
+
st.markdown(
|
| 153 |
+
"""
|
| 154 |
+
<div class="brand-block">
|
| 155 |
+
<div class="brand-mark">SC</div>
|
| 156 |
+
<div>
|
| 157 |
+
<div class="brand-title">Stroke Classification</div>
|
| 158 |
+
<div class="brand-sub">Clinical decision support</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
""",
|
| 162 |
+
unsafe_allow_html=True,
|
| 163 |
+
)
|
| 164 |
+
st.markdown(_sidebar_specs_html(), unsafe_allow_html=True)
|
| 165 |
+
st.markdown("**Validation Samples**")
|
| 166 |
+
st.caption("Test cases from the external dataset.")
|
| 167 |
+
if st.button("Stroke", use_container_width=True):
|
| 168 |
+
st.session_state.sample_path = "assets/sample_stroke.png"
|
| 169 |
+
if st.button("No Stroke", use_container_width=True):
|
| 170 |
+
st.session_state.sample_path = "assets/sample_no_stroke.png"
|
| 171 |
+
|
| 172 |
+
st.markdown(
|
| 173 |
+
"""
|
| 174 |
+
<div class="sidebar-disclaimer">
|
| 175 |
+
<p class="sidebar-disclaimer__label">Disclaimer</p>
|
| 176 |
+
<p>For research and decision support only — not a standalone diagnostic device.
|
| 177 |
+
A qualified clinician must interpret all findings.</p>
|
| 178 |
+
</div>
|
| 179 |
+
""",
|
| 180 |
+
unsafe_allow_html=True,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# --- Header (render immediately so the page is not blank) ---
|
| 184 |
+
st.markdown(
|
| 185 |
+
"""
|
| 186 |
+
<div class="app-header">
|
| 187 |
+
<h1>Stroke Detection</h1>
|
| 188 |
+
<p>AI-assisted CT review powered by knowledge-distilled EfficientNet-B0</p>
|
| 189 |
+
</div>
|
| 190 |
+
""",
|
| 191 |
+
unsafe_allow_html=True,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
scan_col, result_col = st.columns([1, 1], gap="large")
|
| 195 |
+
|
| 196 |
+
with scan_col:
|
| 197 |
+
with st.container(border=True):
|
| 198 |
+
st.subheader("CT scan")
|
| 199 |
+
file_source = st.file_uploader(
|
| 200 |
+
"Upload a non-contrast or contrast-enhanced axial slice (PNG, JPG).",
|
| 201 |
+
type=["png", "jpg", "jpeg"],
|
| 202 |
+
label_visibility="collapsed",
|
| 203 |
+
)
|
| 204 |
+
input_image, source_kind = _resolve_input_image(file_source)
|
| 205 |
+
|
| 206 |
+
if input_image is not None:
|
| 207 |
+
caption = "Uploaded scan" if source_kind == "upload" else "Kaggle hold-out sample"
|
| 208 |
+
st.image(input_image, caption=caption, width="stretch")
|
| 209 |
+
else:
|
| 210 |
+
st.markdown(
|
| 211 |
+
"""
|
| 212 |
+
<div class="welcome-panel">
|
| 213 |
+
<span class="welcome-panel__title">Get started</span>
|
| 214 |
+
<ul class="welcome-panel__list">
|
| 215 |
+
<li>Upload a CT slice (PNG or JPG)</li>
|
| 216 |
+
<li>Pick a sample from the sidebar</li>
|
| 217 |
+
</ul>
|
| 218 |
+
</div>
|
| 219 |
+
""",
|
| 220 |
+
unsafe_allow_html=True,
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
with result_col:
|
| 224 |
+
with st.container(border=True):
|
| 225 |
+
st.subheader("Analysis")
|
| 226 |
+
|
| 227 |
+
model_status = st.empty()
|
| 228 |
+
model, transform = _ensure_model_loaded(model_status)
|
| 229 |
+
|
| 230 |
+
if input_image is None:
|
| 231 |
+
st.markdown(
|
| 232 |
+
'<p class="empty-state">'
|
| 233 |
+
"Results will appear here after you upload an image or select a sample."
|
| 234 |
+
"</p>",
|
| 235 |
+
unsafe_allow_html=True,
|
| 236 |
+
)
|
| 237 |
+
else:
|
| 238 |
+
model_status.empty()
|
| 239 |
+
with st.spinner("Running inference…"):
|
| 240 |
+
prediction, confidence, results = predict(model, transform, input_image)
|
| 241 |
+
|
| 242 |
+
is_stroke = prediction == "Stroke"
|
| 243 |
+
verdict_class = "stroke" if is_stroke else "normal"
|
| 244 |
+
verdict_text = "Stroke detected" if is_stroke else "No stroke detected"
|
| 245 |
+
|
| 246 |
+
st.markdown(
|
| 247 |
+
f"""
|
| 248 |
+
<div class="verdict-box {verdict_class}">
|
| 249 |
+
<div class="verdict-label">Classification</div>
|
| 250 |
+
<div class="verdict-value">{verdict_text.upper()}</div>
|
| 251 |
+
</div>
|
| 252 |
+
""",
|
| 253 |
+
unsafe_allow_html=True,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
st.metric("Model confidence", f"{confidence:.1%}")
|
| 257 |
+
st.markdown("**Class probabilities**")
|
| 258 |
+
_render_probability_bars(results)
|
| 259 |
+
|
| 260 |
+
note = (
|
| 261 |
+
"Pattern indicates hemorrhage or ischemia. Clinical review required."
|
| 262 |
+
if is_stroke
|
| 263 |
+
else "No stroke pattern detected. Clinical review required."
|
| 264 |
+
)
|
| 265 |
+
st.markdown(
|
| 266 |
+
f'<div class="alert-box alert-box--muted">{note}</div>',
|
| 267 |
+
unsafe_allow_html=True,
|
| 268 |
+
)
|
| 269 |
|
| 270 |
+
st.markdown(
|
| 271 |
+
"""
|
| 272 |
+
<p class="footer-note">
|
| 273 |
+
Stroke Classification System · Melis Kılıç & Esra Koç<br>
|
| 274 |
+
ONNX inference · Streamlit
|
| 275 |
+
</p>
|
| 276 |
+
""",
|
| 277 |
+
unsafe_allow_html=True,
|
| 278 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|