melisklc0 commited on
Commit
b02f059
·
1 Parent(s): 8c21bee

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 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
- FROM python:3.13.5-slim
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  WORKDIR /app
4
 
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- git \
9
- && rm -rf /var/lib/apt/lists/*
10
 
11
- COPY requirements.txt ./
12
- COPY src/ ./src/
 
 
13
 
14
- RUN pip3 install -r requirements.txt
 
15
 
 
16
  EXPOSE 8501
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
 
 
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
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

  • SHA256: c0a465c39eecf9dfe8fd13a63151cb61304e0c63aad0c298d1365d425e212ff4
  • Pointer size: 130 Bytes
  • Size of remote file: 44.3 kB
assets/sample_stroke.png ADDED

Git LFS Details

  • SHA256: 2c44b14cf8ab802c1d7d778c51ba546414fc45c7f19a282910e8f575e52b1fd3
  • Pointer size: 131 Bytes
  • Size of remote file: 220 kB
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 altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
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ıç &amp; 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