Harshit Ghosh commited on
Commit
b0bcfd5
Β·
0 Parent(s):

initialize

Browse files
.env.example ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Flask app
2
+ ICH_APP_DEBUG=1
3
+ ICH_APP_PORT=7860
4
+ ICH_SECRET_KEY=change-me-in-production
5
+
6
+ # Upload limits (MB)
7
+ ICH_MAX_UPLOAD_MB=2048
8
+
9
+ # Runtime model selection
10
+ # Values: ensemble | best | 0 | 1 | 2 | 3 | 4
11
+ ICH_FOLD_SELECTION=ensemble
12
+
13
+ # Local mode enables server-side directory scan route
14
+ ICH_LOCAL_MODE=1
15
+
16
+ # Logging level
17
+ # Values: DEBUG | INFO | WARNING | ERROR
18
+ ICH_LOG_LEVEL=INFO
.gitignore ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python bytecode and caches
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+ env/
13
+
14
+ # Build and packaging
15
+ build/
16
+ dist/
17
+ *.egg-info/
18
+
19
+ # Local runtime data
20
+ uploads/
21
+ logs/
22
+ *.log
23
+ *.tmp
24
+ *.temp
25
+
26
+ # Local environment files
27
+ .env
28
+ .env.*
29
+ !.env.example
30
+
31
+ # Local databases / coverage
32
+ *.db
33
+ *.sqlite3
34
+ .coverage
35
+ .coverage.*
36
+ htmlcov/
37
+ coverage.xml
38
+
39
+ # Generated inference artifacts
40
+ download_imp/*
41
+ download
42
+ # Local downloaded data
43
+ data/
44
+ datasets/
45
+
46
+ # Optional local artifact layout
47
+
48
+
49
+ # Jupyter
50
+ .ipynb_checkpoints/
51
+
52
+ # OS / editor files
53
+ .DS_Store
54
+ Thumbs.db
55
+ .vscode/
56
+ .idea/
README.md ADDED
@@ -0,0 +1,567 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Major Project Documentation
2
+
3
+ ## Project Structure and Setup
4
+
5
+ This repository is organized into clear functional sections:
6
+
7
+ - `app.py`: Flask web application for upload, inference, browsing reports, and logs
8
+ - `run_interface.py`: Compatibility adapter between the web app and model inference code
9
+ - `download_imp/`: Model artifacts and core inference implementation
10
+ - `templates/`: Jinja2 HTML templates for the web UI
11
+ - `static/`: CSS assets
12
+ - `logs/` and `uploads/`: Runtime folders created automatically
13
+
14
+ ### Quick Start
15
+
16
+ 1. Create and activate a Python virtual environment.
17
+ 1. Install dependencies:
18
+
19
+ ```bash
20
+ pip install -r requirements.txt
21
+ ```
22
+
23
+ 1. Create environment file:
24
+
25
+ ```bash
26
+ cp .env.example .env
27
+ ```
28
+
29
+ 1. Run the web app:
30
+
31
+ ```bash
32
+ python app.py
33
+ ```
34
+
35
+ 1. Open:
36
+
37
+ ```text
38
+ http://127.0.0.1:7860
39
+ ```
40
+
41
+ ### Notes
42
+
43
+ - Model and calibration files are expected under `download_imp/`.
44
+ - Generated reports are written to `download_imp/outputs/reports/`.
45
+ - `.gitignore` excludes runtime/generated files to keep version control clean.
46
+
47
+ ### Fold Selection
48
+
49
+ The web app supports configurable fold selection via `.env` / environment variable:
50
+
51
+ ```bash
52
+ ICH_FOLD_SELECTION=ensemble
53
+ ```
54
+
55
+ Supported values:
56
+
57
+ - `ensemble` (default): loads all available folds and averages predictions
58
+ - `best`: uses the best single fold from the performance report summary
59
+ - `0` to `4`: force a specific fold
60
+
61
+ Based on [B4_Performance_Report.md](B4_Performance_Report.md), per-fold any-AUC indicates fold `3` as the strongest single fold in that table.
62
+
63
+ ### GitHub Artifact Policy
64
+
65
+ Model checkpoints and heavy binary artifacts are intentionally ignored for GitHub:
66
+
67
+ - `download_imp/*.pth`
68
+ - `download_imp/*.pkl`
69
+ - `download_imp/outputs/`
70
+
71
+ This keeps the repository lightweight and reproducible while allowing local/model-private inference.
72
+
73
+ ## AI-Assisted CT-Based Intracranial Hemorrhage Detection with Explainability and Clinical Reporting
74
+
75
+ ---
76
+
77
+ ## 1. Introduction
78
+
79
+ **Intracranial hemorrhage (ICH)** is a life-threatening neurological condition caused by bleeding within the skull. It is one of the most critical forms of stroke and requires immediate medical attention. Delays in detection and intervention significantly increase the risk of mortality and long-term neurological damage.
80
+
81
+ **Computed Tomography (CT)** imaging is the primary diagnostic tool used in emergency settings for detecting intracranial hemorrhage due to its speed, availability, and high sensitivity to bleeding. However, accurate interpretation of CT scans requires experienced radiologists and must often be performed under time pressure, especially in emergency departments with high patient volumes.
82
+
83
+ Artificial intelligence has shown promise in assisting medical image interpretation. However, many AI-based solutions function as **black-box models** and focus solely on prediction accuracy, limiting their trustworthiness and clinical adoption. There is a need for an AI-assisted system that supports early screening while providing transparent and interpretable outputs to aid clinical decision-making.
84
+
85
+ ---
86
+
87
+ ## 2. Problem Statement
88
+
89
+ Intracranial hemorrhage detection from CT brain scans is a critical yet time-sensitive task in emergency medical care. Although CT imaging is effective for identifying hemorrhage, timely and accurate interpretation depends heavily on the availability of skilled radiologists. In high-pressure or resource-constrained environments, delays or misinterpretations can adversely affect patient outcomes.
90
+
91
+ Existing AI-based approaches for hemorrhage detection often emphasize binary predictions without sufficient explainability or clinical context. This lack of transparency makes it difficult for healthcare professionals to rely on AI outputs during screening and prioritization.
92
+
93
+ **The problem addressed in this project** is the absence of an explainable, AI-assisted screening system that can detect intracranial hemorrhage from CT scans while providing interpretable visual evidence and structured clinical explanations to support medical professionals.
94
+
95
+ > **Note:** The system is intended strictly as a screening and assistive tool, not as a diagnostic replacement for certified medical practitioners.
96
+
97
+ ---
98
+
99
+ ## 3. Objectives
100
+
101
+ ### Primary Objectives
102
+
103
+ - To develop an AI-based system capable of detecting intracranial hemorrhage from CT brain images
104
+ - To classify CT scans into clearly defined categories: **hemorrhage present** or **absent**
105
+ - To assist emergency screening by prioritizing high-risk cases
106
+
107
+ ### Secondary Objectives
108
+
109
+ - To integrate visual explainability techniques that highlight regions influencing the model's predictions
110
+ - To generate structured, human-readable clinical reports summarizing findings
111
+ - To evaluate model reliability with emphasis on **false-negative reduction**
112
+ - To ensure ethical deployment as a decision-support system rather than a diagnostic authority
113
+
114
+ ---
115
+
116
+ ## 4. Scope of the Project
117
+
118
+ ### Included
119
+
120
+ βœ… CT brain image preprocessing and normalization
121
+ βœ… Binary classification of intracranial hemorrhage presence
122
+ βœ… Explainability using activation-based heatmaps
123
+ βœ… Confidence-aware screening and structured reporting
124
+
125
+ ### Excluded
126
+
127
+ ❌ Stroke subtype classification beyond hemorrhage detection
128
+ ❌ Treatment recommendation or diagnosis
129
+ ❌ Real-time clinical deployment
130
+ ❌ Integration with hospital information systems
131
+
132
+ ---
133
+
134
+ ## 5. Dataset Description
135
+
136
+ The project utilizes publicly available CT brain imaging datasets from Kaggle, such as:
137
+
138
+ **Primary datasets:**
139
+ - RSNA Intracranial Hemorrhage Detection Dataset
140
+ - CT Brain Hemorrhage Dataset
141
+
142
+ These datasets contain labeled CT scan images indicating the presence or absence of intracranial hemorrhage, with some datasets also providing hemorrhage subtype annotations.
143
+
144
+ Using public datasets ensures reproducibility, ethical compliance, and feasibility within academic constraints.
145
+
146
+ ---
147
+
148
+ ## 6. Methodology
149
+
150
+ ### 6.1 Data Preprocessing
151
+
152
+ Preprocessing is essential to improve image quality and model performance:
153
+
154
+ - Conversion of DICOM images to standardized formats where required
155
+ - Image resizing to fixed resolution
156
+ - **CT windowing (brain window)** to enhance hemorrhage visibility
157
+ - Intensity normalization
158
+ - Noise reduction and artifact handling
159
+ - Data augmentation to improve generalization and mitigate class imbalance
160
+
161
+ ### 6.2 Model Development
162
+
163
+ A **convolutional neural network (CNN)** is implemented using transfer learning.
164
+
165
+ - Pretrained architectures such as **ResNet** or **EfficientNet** are adapted for CT image analysis
166
+ - The final classification layer is modified for **binary output**:
167
+ - **Class 0**: No intracranial hemorrhage
168
+ - **Class 1**: Intracranial hemorrhage present
169
+ - The model is trained using supervised learning with appropriate loss functions
170
+
171
+ ### 6.3 Evaluation Metrics
172
+
173
+ Model performance is evaluated using clinically relevant metrics:
174
+
175
+ - **Sensitivity (Recall)** – prioritized to reduce missed hemorrhage cases
176
+ - Specificity
177
+ - Precision
178
+ - Confusion matrix analysis
179
+ - Receiver Operating Characteristic (ROC) curve
180
+
181
+ > **Note:** Accuracy alone is not treated as a sufficient indicator of clinical usefulness.
182
+
183
+ ---
184
+
185
+ ## 7. Explainability Module
186
+
187
+ To ensure transparency and trustworthiness:
188
+
189
+ - **Gradient-weighted Class Activation Mapping (Grad-CAM)** will be applied
190
+ - Heatmaps are generated to visualize regions contributing to predictions
191
+ - Highlighted areas are analyzed in relation to known hemorrhage patterns in CT images
192
+
193
+ This module allows clinicians to visually verify AI decisions rather than relying solely on binary outputs.
194
+
195
+ ### 7.1 Explainability Quality Assurance
196
+
197
+ To ensure the reliability of explainability outputs:
198
+
199
+ - **Sanity checks** will be implemented (e.g., occlusion/perturbation tests) to verify Grad-CAM is not highlighting irrelevant borders, text markers, or artifacts
200
+ - Failure cases where heatmaps are misleading will be documented
201
+ - For a sample of True Positives, False Negatives, and False Positives, Grad-CAM overlays will include brief qualitative notes comparing:
202
+ - What the model highlights
203
+ - What a clinician would expect to see
204
+ - This ensures visual evidence aligns with clinical reasoning
205
+
206
+ ---
207
+
208
+ ## 8. Confidence-Aware Screening
209
+
210
+ Instead of a simple binary output, the system incorporates prediction confidence:
211
+
212
+ - **High-confidence hemorrhage detection** β†’ urgent attention
213
+ - **Low-confidence predictions** β†’ manual review recommendation
214
+
215
+ This approach reflects real-world screening workflows and reduces over-reliance on automated decisions.
216
+
217
+ ### 8.1 Confidence Calibration
218
+
219
+ To ensure clinicians can trust the confidence scores:
220
+
221
+ - **Calibration techniques** will be applied (e.g., temperature scaling or isotonic regression)
222
+ - Both **raw probability** and **calibrated confidence** will be reported
223
+ - Expected Calibration Error (ECE) will be evaluated
224
+ - Three confidence bands will be defined:
225
+ - **High confidence**: urgent attention required
226
+ - **Medium confidence**: standard review
227
+ - **Low confidence**: manual review recommended
228
+ - Error rates will be analyzed across each confidence band to support triage decisions
229
+
230
+ ---
231
+
232
+ ## 9. Clinical Report Generation
233
+
234
+ A structured report generation module converts model outputs into human-readable explanations. Each report includes:
235
+
236
+ - Screening outcome summary
237
+ - Prediction confidence
238
+ - Visual explainability reference
239
+ - Clinical interpretation phrased as decision support
240
+
241
+ The report avoids diagnostic claims and emphasizes assistive screening.
242
+
243
+ ### 9.1 Report Schema and Specifications
244
+
245
+ To prevent diagnostic claims and ensure consistency:
246
+
247
+ - A **fixed schema** will be defined with specific fields and allowed phrases
248
+ - Reports will be locked down with rules to ensure they never make diagnostic claims
249
+ - Each report field will have:
250
+ - **Screening outcome**: "Hemorrhage detected" or "No hemorrhage detected" (not "diagnosed")
251
+ - **Confidence level**: Numeric probability + calibrated confidence band
252
+ - **Visual evidence**: Reference to Grad-CAM heatmap image
253
+ - **Recommended action**: "Urgent radiologist review recommended" or "Standard review workflow"
254
+ - **System disclaimer**: Clear statement that this is a screening tool, not a diagnostic device
255
+ - Standardized phrasing ensures clinical safety and legal compliance
256
+
257
+ ---
258
+
259
+ ## 10. System Architecture Overview
260
+
261
+ ```
262
+ 1. CT Brain Image Input
263
+ ↓
264
+ 2. Image Preprocessing Module
265
+ ↓
266
+ 3. CNN-Based Hemorrhage Detection Model
267
+ ↓
268
+ 4. Explainability Module (Grad-CAM)
269
+ ↓
270
+ 5. Confidence Assessment
271
+ ↓
272
+ 6. Structured Clinical Report Generator
273
+ ↓
274
+ 7. Output for Medical Review
275
+ ```
276
+
277
+ ---
278
+
279
+ ## 11. Technology Stack
280
+
281
+ ### Programming Language
282
+ - **Python**
283
+
284
+ ### Libraries and Frameworks
285
+ - **TensorFlow** or **PyTorch** (Deep Learning)
286
+ - **OpenCV** (Image Processing)
287
+ - **NumPy**, **Pandas** (Data Handling)
288
+ - **Matplotlib** (Visualization)
289
+
290
+ ### Development Platform
291
+ - **Kaggle Notebooks**
292
+ - Jupyter Notebook
293
+
294
+ ---
295
+
296
+ ## 12. Feasibility and Resources
297
+
298
+ The project is **fully feasible** using free computational resources:
299
+
300
+ - Kaggle provides free GPU access suitable for CNN training
301
+ - Transfer learning minimizes training time
302
+ - All tools used are open-source
303
+ - No specialized hardware is required locally
304
+
305
+ **Kaggle provides:**
306
+ - Free GPU access (time-limited but sufficient)
307
+ - Adequate RAM and storage for medical imaging datasets
308
+ - Stable notebook environment for training and evaluation
309
+
310
+ **Constraints:**
311
+ - Training time per session is limited
312
+ - Efficient model selection and batch sizing are necessary
313
+
314
+ These constraints align well with transfer learning-based approaches.
315
+
316
+ ---
317
+
318
+ ## 13. Ethical Considerations
319
+
320
+ - The system is designed strictly as a **screening and decision-support tool**
321
+ - It does not provide diagnosis or treatment recommendations
322
+ - Limitations and potential biases are explicitly documented
323
+ - Human oversight is required for all clinical decisions
324
+ - Dataset usage complies with public research licenses
325
+ - Model limitations and potential biases will be documented
326
+
327
+ ---
328
+
329
+ ## 14. Assumptions and Risks
330
+
331
+ ### Assumptions
332
+ - Public datasets are representative of real-world CT brain images
333
+ - Hemorrhage labels are clinically reliable and accurately annotated
334
+
335
+ ### Potential Risks
336
+ - Class imbalance may bias predictions toward non-hemorrhage cases
337
+ - Overfitting due to dataset limitations
338
+ - Misinterpretation of AI outputs by non-expert users
339
+ - False negatives could delay critical interventions
340
+
341
+ ### Clinical Risk Evaluation Protocol
342
+
343
+ To address these risks systematically:
344
+
345
+ - **Target operating points** will be defined (e.g., "maximize sensitivity subject to acceptable specificity")
346
+ - **False negative analysis**: Pre-commit to reviewing all false negative cases with detailed inspection:
347
+ - Inspect FN scans with Grad-CAM overlays
348
+ - Document typical failure patterns (e.g., small bleeds, beam-hardening artifacts, post-operative changes)
349
+ - Use findings to refine preprocessing or model architecture
350
+ - Each component of the system architecture will be evaluated using the framework:
351
+ - **Inputs** β†’ **Outputs** β†’ **Metrics** β†’ **Failure Modes** β†’ **Mitigations**
352
+ - This structured approach ensures risks are systematically addressed before deployment
353
+
354
+ Mitigation strategies will be implemented during development and documented in evaluation.
355
+
356
+ ---
357
+
358
+ ## 15. Proposed Experiments and Ablation Studies
359
+
360
+ To validate design decisions and optimize performance, the following experiments will be conducted:
361
+
362
+ ### 15.1 Preprocessing Ablations
363
+
364
+ **Goal**: Justify the preprocessing pipeline choices
365
+
366
+ **Experiments**:
367
+ - Brain windowing: ON vs OFF
368
+ - Different normalization strategies (min-max, z-score, percentile-based)
369
+ - Data augmentation: ON vs OFF
370
+
371
+ **Evaluation metrics**:
372
+ - Sensitivity (primary)
373
+ - ROC-AUC
374
+ - Expected Calibration Error (ECE)
375
+
376
+ **Outcome**: Select preprocessing configuration that maximizes sensitivity while maintaining calibration
377
+
378
+ ### 15.2 Model Architecture Comparison
379
+
380
+ **Goal**: Choose the optimal backbone architecture
381
+
382
+ **Experiments**:
383
+ - ResNet-50 vs EfficientNet-B0
384
+ - Same train/validation split for fair comparison
385
+ - Evaluate with fixed hyperparameters
386
+
387
+ **Selection criteria**:
388
+ - Sensitivity at fixed specificity (e.g., 95% specificity)
389
+ - Inference time (important for screening/prioritization)
390
+ - Model size and computational requirements
391
+
392
+ **Outcome**: Select architecture based on clinical utility and deployment feasibility
393
+
394
+ ### 15.3 Confidence-Aware Triage Study
395
+
396
+ **Goal**: Validate the three-band confidence system
397
+
398
+ **Experiments**:
399
+ - Define thresholds for high/medium/low confidence bands
400
+ - Analyze case distribution across bands
401
+ - Compute error rates (sensitivity, specificity, FN rate) per band
402
+
403
+ **Metrics**:
404
+ - Percentage of cases in each band
405
+ - False negative rate by confidence level
406
+ - Positive predictive value by confidence level
407
+
408
+ **Outcome**: Demonstrate that high-confidence predictions are more reliable and support triage workflow
409
+
410
+ ### 15.4 Explainability Evaluation
411
+
412
+ **Goal**: Validate that Grad-CAM provides clinically useful visualizations
413
+
414
+ **Experiments**:
415
+ - Generate Grad-CAM overlays for sample cases:
416
+ - True Positives (correct hemorrhage detection)
417
+ - False Negatives (missed hemorrhages)
418
+ - False Positives (false alarms)
419
+ - Qualitative analysis comparing:
420
+ - What the model highlights
421
+ - What clinicians would expect to see
422
+ - Document cases where heatmaps are misleading or incorrect
423
+
424
+ **Outcome**: Ensure explainability module provides trustworthy visual evidence
425
+
426
+ ### 15.5 Calibration Study
427
+
428
+ **Goal**: Improve confidence reliability
429
+
430
+ **Experiments**:
431
+ - Train baseline model and measure calibration (ECE, reliability diagram)
432
+ - Apply temperature scaling and isotonic regression
433
+ - Compare raw probabilities vs calibrated confidence
434
+
435
+ **Outcome**: Deploy calibrated confidence scores that clinicians can trust
436
+
437
+ ---
438
+
439
+ ## 16. Expected Outcomes and Deliverables
440
+
441
+ ### 16.1 Expected Outcomes
442
+
443
+ **Model Performance**:
444
+ - A trained CNN model achieving high sensitivity (target: >95%) for intracranial hemorrhage detection
445
+ - Specificity maintained at clinically acceptable levels (target: >85%)
446
+ - Calibrated confidence scores with low Expected Calibration Error (ECE < 0.05)
447
+ - Comprehensive performance evaluation across all metrics (sensitivity, specificity, precision, F1-score, ROC-AUC)
448
+
449
+ **Explainability**:
450
+ - Reliable Grad-CAM visualizations highlighting hemorrhage regions
451
+ - Validated explainability outputs through sanity checks
452
+ - Documented failure modes and typical misclassification patterns
453
+
454
+ **Clinical Utility**:
455
+ - Confidence-based triage system validated across three bands (high/medium/low)
456
+ - Demonstrated reduction in false negatives through systematic review
457
+ - Structured reports that support clinical decision-making without making diagnostic claims
458
+
459
+ **Research Insights**:
460
+ - Evidence-based justification for preprocessing choices through ablation studies
461
+ - Model architecture comparison showing optimal choice for screening scenarios
462
+ - Understanding of model limitations and failure patterns
463
+
464
+ ### 16.2 Project Deliverables
465
+
466
+ **1. Trained Model**
467
+ - Final CNN model weights saved in standard format (`.h5` or `.pth`)
468
+ - Model configuration file documenting architecture and hyperparameters
469
+ - Training history and learning curves
470
+
471
+ **2. Preprocessing Pipeline**
472
+ - Complete data preprocessing module for CT scan normalization
473
+ - DICOM handling utilities where applicable
474
+ - Data augmentation scripts
475
+
476
+ **3. Explainability Module**
477
+ - Grad-CAM implementation integrated with the model
478
+ - Visualization generation scripts
479
+ - Sanity check utilities for validation
480
+
481
+ **4. Confidence Calibration Module**
482
+ - Calibration implementation (temperature scaling/isotonic regression)
483
+ - Scripts for computing calibrated confidence scores
484
+ - Calibration evaluation metrics
485
+
486
+ **5. Clinical Report Generator**
487
+ - Structured report generation system with fixed schema
488
+ - Template-based reporting following clinical safety guidelines
489
+ - Sample reports demonstrating different scenarios
490
+
491
+ **6. Evaluation Framework**
492
+ - Complete evaluation scripts for all metrics
493
+ - Confusion matrix and ROC curve generation
494
+ - Ablation study implementation and results
495
+
496
+ **7. Documentation**
497
+ - Project report (this README and extended documentation)
498
+ - Code documentation and inline comments
499
+ - User guide for running the system
500
+ - Dataset description and preprocessing details
501
+
502
+ **8. Jupyter Notebooks**
503
+ - Data exploration and preprocessing notebook
504
+ - Model training and evaluation notebook
505
+ - Explainability demonstration notebook
506
+ - Report generation examples notebook
507
+
508
+ **9. Results and Analysis**
509
+ - Performance metrics across all experiments
510
+ - Ablation study results with visualizations
511
+ - Failure case analysis with Grad-CAM overlays
512
+ - Calibration plots and reliability diagrams
513
+
514
+ **10. Presentation Materials**
515
+ - Project presentation slides
516
+ - Demo video (optional)
517
+ - Key visualizations and results summary
518
+
519
+ ---
520
+
521
+ ## 17. Limitations and Future Work
522
+
523
+ ### Current Limitations
524
+
525
+ - **Dataset Scope**: Limited to publicly available datasets which may not fully represent all clinical scenarios
526
+ - **Binary Classification**: Does not classify hemorrhage subtypes (epidural, subdural, subarachnoid, etc.)
527
+ - **Single Slice Analysis**: May not utilize full 3D volumetric information from CT scans
528
+ - **No Real-Time Deployment**: System is designed for research and demonstration, not clinical deployment
529
+ - **Limited Clinical Validation**: Evaluation based on dataset labels, not verified by multiple radiologists
530
+
531
+ ### Future Enhancements
532
+
533
+ **Clinical Extensions**:
534
+ - Multi-class classification for hemorrhage subtypes
535
+ - Integration of volumetric (3D) analysis using 3D CNNs
536
+ - Temporal analysis for follow-up scan comparison
537
+ - Integration with radiology workflow systems (PACS)
538
+
539
+ **Technical Improvements**:
540
+ - Ensemble models combining multiple architectures
541
+ - Uncertainty quantification using Bayesian deep learning
542
+ - Active learning for continuous model improvement
543
+ - Real-time inference optimization for clinical deployment
544
+
545
+ **Validation and Deployment**:
546
+ - Prospective clinical validation with radiologist verification
547
+ - Multi-center evaluation for generalizability
548
+ - Regulatory compliance pathway (FDA/CE marking)
549
+ - Production-ready deployment with monitoring
550
+
551
+ **Enhanced Explainability**:
552
+ - Multiple explainability methods comparison (Grad-CAM++, SHAP, attention mechanisms)
553
+ - Interactive visualization tools for clinicians
554
+ - Textual explanation generation describing detected features
555
+
556
+ ---
557
+
558
+ ## 18. Conclusion
559
+
560
+ This project aims to develop an AI-assisted screening system for intracranial hemorrhage detection that prioritizes **clinical utility**, **explainability**, and **ethical deployment**. By combining deep learning with transparency mechanisms, confidence calibration, and structured reporting, the system is designed to supportβ€”not replaceβ€”clinical decision-making.
561
+
562
+ The systematic approach to risk evaluation, comprehensive ablation studies, and focus on false negative reduction ensure that the system addresses real clinical needs while acknowledging its limitations as a screening tool.
563
+
564
+ Through this project, we demonstrate that responsible AI in healthcare requires not just prediction accuracy, but also interpretability, calibration, careful validation, and explicit acknowledgment of system boundaries.
565
+
566
+ ---
567
+
app.py ADDED
@@ -0,0 +1,1119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ICH Screening Web Application
3
+ ==============================
4
+ Features:
5
+ 1. Upload a .dcm file -> run AI model -> display screening report
6
+ 2. Browse past screening reports with date, outcome, band, urgency filters
7
+ 3. View execution logs from inference runs
8
+
9
+ Run:
10
+ python webapp/app.py
11
+ Open http://127.0.0.1:7860
12
+ """
13
+
14
+ from __future__ import annotations
15
+ import run_interface as ri
16
+ import csv
17
+ import datetime
18
+ import json
19
+ import math
20
+ import logging
21
+ import os
22
+ import shutil
23
+ import sys
24
+ import tempfile
25
+ import threading
26
+ import time
27
+ import uuid
28
+ import zipfile
29
+ from collections import Counter
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ try:
35
+ from dotenv import load_dotenv
36
+ except Exception:
37
+ load_dotenv = None
38
+
39
+ try:
40
+ import blackbox_recorder as bbr
41
+ except Exception:
42
+ class _NoopRecorder:
43
+ def configure(self, **_kwargs):
44
+ return None
45
+
46
+ def start(self):
47
+ return None
48
+
49
+ def stop(self):
50
+ return None
51
+
52
+ def save_report(self, _path: str):
53
+ return None
54
+
55
+ def save_json(self, _path: str):
56
+ return None
57
+
58
+ bbr = _NoopRecorder()
59
+
60
+ from flask import (
61
+ Flask, abort, flash, g, jsonify, redirect,
62
+ render_template, request, send_from_directory, url_for,
63
+ )
64
+ from werkzeug.utils import secure_filename
65
+
66
+
67
+ # ══════════════════════════════════════════════════════════════════════════
68
+ # PATH CONFIGURATION
69
+ # ══════════════════════════════════════════════════════════════════════════
70
+
71
+ BASE_DIR = Path(__file__).resolve().parent # webapp/
72
+ PROJECT_DIR = BASE_DIR # project root
73
+ TEST_DIR = BASE_DIR
74
+ MODEL_DIR = BASE_DIR / "download_imp"
75
+ OUTPUT_DIR = MODEL_DIR / "outputs"
76
+ REPORTS_DIR = OUTPUT_DIR / "reports"
77
+ SUMMARY_CSV = OUTPUT_DIR / "report_summary.csv"
78
+ CALIB_JSON = MODEL_DIR / "calibration_params.json"
79
+ NORM_JSON = MODEL_DIR / "normalization_stats.json"
80
+ MODEL_PATH = MODEL_DIR / "best_model_fold4.pth"
81
+ UPLOAD_DIR = BASE_DIR / "uploads"
82
+ LOGS_DIR = BASE_DIR / "logs"
83
+
84
+
85
+ def _env_bool(name: str, default: bool) -> bool:
86
+ raw = os.environ.get(name)
87
+ if raw is None:
88
+ return default
89
+ return raw.strip().lower() in ("1", "true", "yes", "on")
90
+
91
+
92
+ def _env_int(name: str, default: int, *, minimum: int | None = None) -> int:
93
+ raw = os.environ.get(name)
94
+ if raw is None:
95
+ return default
96
+ try:
97
+ value = int(raw)
98
+ except ValueError:
99
+ return default
100
+ if minimum is not None and value < minimum:
101
+ return default
102
+ return value
103
+
104
+
105
+ # ══════════════════════════════════════════════════════════════════════════
106
+ # FLASK SETUP
107
+ # ══════════════════════════════════════════════════════════════════════════
108
+
109
+ if load_dotenv is not None:
110
+ load_dotenv(BASE_DIR / ".env")
111
+
112
+ APP_DEBUG = _env_bool("ICH_APP_DEBUG", True)
113
+ APP_PORT = _env_int("ICH_APP_PORT", 7860, minimum=1)
114
+ MAX_UPLOAD_MB = _env_int("ICH_MAX_UPLOAD_MB", 2048, minimum=1)
115
+ LOG_LEVEL_NAME = os.environ.get("ICH_LOG_LEVEL", "INFO").strip().upper()
116
+ LOG_LEVEL = getattr(logging, LOG_LEVEL_NAME, logging.INFO)
117
+ SECRET_KEY = os.environ.get("ICH_SECRET_KEY", "").strip()
118
+
119
+ app = Flask(__name__, template_folder="templates", static_folder="static")
120
+ app.secret_key = SECRET_KEY or os.urandom(24)
121
+ app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
122
+
123
+ # Local mode: enables server-side directory scanning.
124
+ # Auto-detected (running from source) or forced via env var.
125
+ LOCAL_MODE = _env_bool("ICH_LOCAL_MODE", True)
126
+
127
+ logging.basicConfig(
128
+ level=LOG_LEVEL,
129
+ format="%(asctime)s | %(levelname)s | %(message)s",
130
+ )
131
+ logger = logging.getLogger("ich_app")
132
+
133
+
134
+ # ══════════════════════════════════════════════════════════════════════════
135
+ # BLACKBOX RECORDER β€” traces inference function calls
136
+ #
137
+ # We configure it once at module level. start()/stop() bracket each
138
+ # inference run. After each run, the trace is saved to logs/ as both a
139
+ # human-readable .txt and a structured .json.
140
+ # ══════════════════════════════════════════════════════════════════════════
141
+
142
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
143
+
144
+ bbr.configure(
145
+ include=["run_interface", "app"],
146
+ capture_args=True,
147
+ capture_returns=True,
148
+ sampling_rate=1.0,
149
+ )
150
+
151
+
152
+ def _save_trace(image_id: str) -> dict:
153
+ """
154
+ Save the current blackbox trace to logs/ and return metadata about it.
155
+ Called immediately after bbr.stop().
156
+ """
157
+ ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
158
+ base = f"{ts}_{image_id}"
159
+ txt_path = LOGS_DIR / f"{base}.txt"
160
+ json_path = LOGS_DIR / f"{base}.json"
161
+
162
+ try:
163
+ bbr.save_report(str(txt_path))
164
+ except Exception:
165
+ logger.warning("Could not save text trace for %s", image_id)
166
+
167
+ try:
168
+ bbr.save_json(str(json_path))
169
+ except Exception:
170
+ logger.warning("Could not save JSON trace for %s", image_id)
171
+
172
+ return {
173
+ "timestamp": ts,
174
+ "image_id": image_id,
175
+ "txt_file": txt_path.name if txt_path.exists() else None,
176
+ "json_file": json_path.name if json_path.exists() else None,
177
+ }
178
+
179
+
180
+ # ══════════════════════════════════════════════════════════════════════════
181
+ # BATCH PROCESSING STATE
182
+ #
183
+ # Each batch job is a background thread processing a list of .dcm paths.
184
+ # The UI polls /batch/status/<id> for live progress.
185
+ # ══════════════════════════════════════════════════════════════════════════
186
+
187
+ _BATCHES: dict[str, dict[str, Any]] = {}
188
+ _BATCHES_LOCK = threading.Lock()
189
+
190
+
191
+ def _new_batch(total: int, temp_dir: str | None = None) -> str:
192
+ """Create a fresh batch record and return its unique ID."""
193
+ batch_id = uuid.uuid4().hex[:12]
194
+ with _BATCHES_LOCK:
195
+ _BATCHES[batch_id] = {
196
+ "status": "running", # running | completed | failed
197
+ "total": total,
198
+ "processed": 0,
199
+ "succeeded": 0,
200
+ "failed_ids": [],
201
+ "current_file": "",
202
+ "image_ids": [], # successfully processed IDs
203
+ "started_at": datetime.datetime.now().isoformat(),
204
+ "finished_at": None,
205
+ "error": None,
206
+ "temp_dir": temp_dir, # cleaned up after completion
207
+ }
208
+ return batch_id
209
+
210
+
211
+ def _batch_update(batch_id: str, **kw):
212
+ """Thread-safe update of a batch record."""
213
+ with _BATCHES_LOCK:
214
+ if batch_id in _BATCHES:
215
+ _BATCHES[batch_id].update(kw)
216
+
217
+
218
+ def _run_batch_worker(batch_id: str, dcm_paths: list[Path]):
219
+ """
220
+ Background thread: process a list of .dcm files sequentially.
221
+ Updates the batch record after each file for real-time UI feedback.
222
+ """
223
+ succeeded_ids: list[str] = []
224
+ failed_ids: list[str] = []
225
+
226
+ for i, path in enumerate(dcm_paths, 1):
227
+ image_id = path.stem
228
+ _batch_update(batch_id, current_file=image_id, processed=i - 1)
229
+
230
+ try:
231
+ report, _trace = _run_inference_on_dcm(path)
232
+ if report is not None:
233
+ succeeded_ids.append(image_id)
234
+ else:
235
+ failed_ids.append(image_id)
236
+ except Exception as e:
237
+ logger.error("Batch %s: failed %s β€” %s", batch_id, image_id, e)
238
+ failed_ids.append(image_id)
239
+
240
+ _batch_update(
241
+ batch_id,
242
+ processed=i,
243
+ succeeded=len(succeeded_ids),
244
+ image_ids=list(succeeded_ids),
245
+ failed_ids=list(failed_ids),
246
+ )
247
+
248
+ # Clean up temp directory if one was used (ZIP extraction)
249
+ with _BATCHES_LOCK:
250
+ b = _BATCHES.get(batch_id, {})
251
+ td = b.get("temp_dir")
252
+ if td and Path(td).exists():
253
+ shutil.rmtree(td, ignore_errors=True)
254
+
255
+ _batch_update(
256
+ batch_id,
257
+ status="completed",
258
+ current_file="",
259
+ finished_at=datetime.datetime.now().isoformat(),
260
+ )
261
+ # Force cache reload on next page view
262
+ _CACHE["data_signature"] = None
263
+ logger.info(
264
+ "Batch %s complete: %d/%d succeeded, %d failed",
265
+ batch_id, len(succeeded_ids), len(dcm_paths), len(failed_ids),
266
+ )
267
+
268
+
269
+ def _start_batch(dcm_paths: list[Path], temp_dir: str | None = None) -> str:
270
+ """Create a batch job & launch its worker thread. Returns batch_id."""
271
+ batch_id = _new_batch(total=len(dcm_paths), temp_dir=temp_dir)
272
+ t = threading.Thread(
273
+ target=_run_batch_worker,
274
+ args=(batch_id, dcm_paths),
275
+ daemon=True,
276
+ name=f"batch-{batch_id}",
277
+ )
278
+ t.start()
279
+ return batch_id
280
+
281
+
282
+ # ══════════════════════════════════════════════════════════════════════════
283
+ # IN-MEMORY CACHE
284
+ # ══════════════════════════════════════════════════════════════════════════
285
+
286
+ _CACHE: dict[str, Any] = {
287
+ "data_signature": None,
288
+ "cases": {},
289
+ "rows_sorted": [],
290
+ "data_last_refresh_ms": None,
291
+ "data_last_cache_hit": False,
292
+ "calib_signature": None,
293
+ "calib": {},
294
+ "norm_signature": None,
295
+ "norm": {},
296
+ }
297
+
298
+
299
+ # ══════════════════════════════════════════════════════════════════════════
300
+ # MODEL STATE β€” lazy-loaded on first upload
301
+ # ══════════════════════════════════════════════════════════════════════════
302
+
303
+ _MODEL: dict[str, Any] = {
304
+ "loaded": False,
305
+ "model": None,
306
+ "grad_cam": None,
307
+ "loaded_folds": [],
308
+ "transform": None,
309
+ "device": None,
310
+ "temperature": None,
311
+ "calib_cfg": None,
312
+ "inference_mod": None,
313
+ }
314
+
315
+
316
+ def _ensure_model_loaded() -> bool:
317
+ """Lazy-load the ML model on first inference request."""
318
+ if _MODEL["loaded"]:
319
+ return True
320
+
321
+ try:
322
+ import torch
323
+
324
+ sys.path.insert(0, str(BASE_DIR))
325
+
326
+ device = "cuda" if torch.cuda.is_available() else "cpu"
327
+ fold_selection = os.environ.get("ICH_FOLD_SELECTION", "ensemble")
328
+
329
+ with open(CALIB_JSON) as f:
330
+ calib_cfg = json.load(f)
331
+
332
+ if NORM_JSON.exists():
333
+ with open(NORM_JSON) as f:
334
+ norm = json.load(f)
335
+ mean = norm.get("mean_3ch", [0.162136, 0.141483, 0.183675])
336
+ std = norm.get("std_3ch", [0.312067, 0.283885, 0.305968])
337
+ else:
338
+ mean, std = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]
339
+
340
+ models, grad_cams, loaded_folds = ri.load_runtime_models(device, fold_selection)
341
+ if not models:
342
+ logger.error("No fold checkpoints could be loaded from %s", MODEL_DIR)
343
+ return False
344
+
345
+ transform = ri.T.Compose([
346
+ ri.T.ToPILImage(),
347
+ ri.T.ToTensor(),
348
+ ri.T.Normalize(mean=mean, std=std),
349
+ ])
350
+
351
+ _MODEL.update({
352
+ "loaded": True,
353
+ "model": models,
354
+ "grad_cam": grad_cams,
355
+ "loaded_folds": loaded_folds,
356
+ "transform": transform,
357
+ "device": device,
358
+ "temperature": float(calib_cfg.get("temperature", 1.0)),
359
+ "calib_cfg": calib_cfg,
360
+ "inference_mod": ri,
361
+ })
362
+ logger.info(
363
+ "Model loaded (device=%s, fold_selection=%s, folds=%s)",
364
+ device,
365
+ fold_selection,
366
+ loaded_folds,
367
+ )
368
+ return True
369
+
370
+ except Exception as e:
371
+ logger.error("Model loading failed: %s", e, exc_info=True)
372
+ return False
373
+
374
+
375
+ def _run_inference_on_dcm(dcm_path: Path) -> tuple[dict | None, dict | None]:
376
+ """
377
+ Run inference on one .dcm file, with blackbox tracing.
378
+ Returns (report_dict, trace_metadata) or (None, None) on failure.
379
+ """
380
+ if not _ensure_model_loaded():
381
+ return None, None
382
+
383
+ ri = _MODEL["inference_mod"]
384
+ image_id = dcm_path.stem
385
+
386
+ # Start tracing this inference run
387
+ bbr.start()
388
+
389
+ try:
390
+ img_rgb = ri.dicom_to_rgb(str(dcm_path), size=ri.IMG_SIZE)
391
+
392
+ inference = ri.infer_single(
393
+ img_rgb,
394
+ _MODEL["model"],
395
+ _MODEL["grad_cam"],
396
+ _MODEL["transform"],
397
+ _MODEL["device"],
398
+ _MODEL["temperature"],
399
+ )
400
+
401
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
402
+ report = ri.build_report(
403
+ image_id, inference, _MODEL["calib_cfg"],
404
+ REPORTS_DIR, img_rgb, true_label=None,
405
+ )
406
+ pred = report.get("prediction", {})
407
+ pred.setdefault("raw_probability", inference.get("raw_prob_any"))
408
+ pred.setdefault("calibrated_probability", inference.get("cal_prob_any"))
409
+ pred.setdefault("decision_threshold", pred.get("decision_threshold_any"))
410
+ report["prediction"] = pred
411
+
412
+ report_path = REPORTS_DIR / f"{image_id}_report.json"
413
+ with open(report_path, "w") as f:
414
+ json.dump(report, f, indent=2)
415
+
416
+ _append_to_summary_csv(image_id, report)
417
+ _CACHE["data_signature"] = None
418
+
419
+ except Exception:
420
+ bbr.stop()
421
+ raise
422
+
423
+ # Stop tracing and save the execution log
424
+ bbr.stop()
425
+ trace_meta = _save_trace(image_id)
426
+
427
+ return report, trace_meta
428
+
429
+
430
+ def _append_to_summary_csv(image_id: str, report: dict):
431
+ """Append one report row to the summary CSV."""
432
+ pred = report["prediction"]
433
+ row = {
434
+ "image_id": image_id,
435
+ "true_label": "",
436
+ "screening_outcome": pred["screening_outcome"],
437
+ "raw_prob": pred["raw_probability"],
438
+ "cal_prob": pred["calibrated_probability"],
439
+ "confidence_band": pred["confidence_band"],
440
+ "triage_action": report["triage"]["action"],
441
+ "urgency": report["triage"]["urgency"],
442
+ "generated_at": report.get("generated_at", ""),
443
+ }
444
+
445
+ file_exists = SUMMARY_CSV.exists()
446
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
447
+
448
+ with open(SUMMARY_CSV, "a", newline="", encoding="utf-8") as f:
449
+ writer = csv.DictWriter(f, fieldnames=row.keys())
450
+ if not file_exists:
451
+ writer.writeheader()
452
+ writer.writerow(row)
453
+
454
+
455
+ # ══════════════════════════════════════════════════════════════════════════
456
+ # DATA MODEL
457
+ # ══════════════════════════════════════════════════════════════════════════
458
+
459
+ @dataclass
460
+ class CaseRow:
461
+ image_id: str = ""
462
+ outcome: str = "Unknown"
463
+ raw_prob: float|None = None
464
+ cal_prob: float|None = None
465
+ band: str = "N/A"
466
+ triage: str = "N/A"
467
+ urgency: str = "N/A"
468
+ true_label: str = ""
469
+ generated_at: str = "" # ISO timestamp from report JSON
470
+ report_file: str|None = None
471
+ gradcam_file: str|None = None
472
+
473
+ @property
474
+ def date_display(self) -> str:
475
+ """Format the ISO timestamp as a short readable date."""
476
+ if not self.generated_at:
477
+ return "β€”"
478
+ try:
479
+ dt = datetime.datetime.fromisoformat(self.generated_at)
480
+ return dt.strftime("%Y-%m-%d %H:%M")
481
+ except (ValueError, TypeError):
482
+ return self.generated_at[:16]
483
+
484
+ @property
485
+ def is_positive(self) -> bool:
486
+ return "no hemorrhage" not in self.outcome.lower()
487
+
488
+
489
+ # ══════════════════════════════════════════════════════════════════════════
490
+ # UTILITIES
491
+ # ══════════════════════════════════════════════════════════════════════════
492
+
493
+ def _to_float(value: Any) -> float | None:
494
+ try:
495
+ return float(value) if value not in (None, "") else None
496
+ except (TypeError, ValueError):
497
+ return None
498
+
499
+
500
+ def _file_mtime(path: Path) -> int:
501
+ try:
502
+ return path.stat().st_mtime_ns if path.exists() else -1
503
+ except OSError:
504
+ return -1
505
+
506
+
507
+ def _data_signature() -> tuple[int, int]:
508
+ return _file_mtime(REPORTS_DIR), _file_mtime(SUMMARY_CSV)
509
+
510
+
511
+ def _parse_positive_int(value: str | None, default: int) -> int:
512
+ try:
513
+ n = int(value or default)
514
+ return n if n > 0 else default
515
+ except (TypeError, ValueError):
516
+ return default
517
+
518
+
519
+ # ══════════════════════════════════════════════════════════════════════════
520
+ # DATA LOADING
521
+ # ══════════════════════════════════════════════════════════════════════════
522
+
523
+ def _load_summary_csv() -> dict[str, dict[str, Any]]:
524
+ """Read report_summary.csv into memory, keyed by image_id."""
525
+ if not SUMMARY_CSV.exists():
526
+ return {}
527
+ rows: dict[str, dict[str, Any]] = {}
528
+ with SUMMARY_CSV.open("r", encoding="utf-8") as f:
529
+ for row in csv.DictReader(f):
530
+ iid = (row.get("image_id") or "").strip()
531
+ if not iid:
532
+ continue
533
+ rows[iid] = {
534
+ "image_id": iid,
535
+ "outcome": row.get("screening_outcome", "Unknown"),
536
+ "raw_prob": _to_float(row.get("raw_prob")),
537
+ "cal_prob": _to_float(row.get("cal_prob")),
538
+ "band": row.get("confidence_band") or "N/A",
539
+ "triage": row.get("triage_action") or "N/A",
540
+ "urgency": row.get("urgency") or "N/A",
541
+ "true_label": row.get("true_label", ""),
542
+ "generated_at": row.get("generated_at", ""),
543
+ }
544
+ return rows
545
+
546
+
547
+ def _scan_report_assets() -> tuple[set[str], set[str]]:
548
+ """One dir walk to find which image IDs have JSON and PNG files."""
549
+ report_ids: set[str] = set()
550
+ gradcam_ids: set[str] = set()
551
+ if not REPORTS_DIR.exists():
552
+ return report_ids, gradcam_ids
553
+ for path in REPORTS_DIR.iterdir():
554
+ if not path.is_file():
555
+ continue
556
+ if path.name.endswith("_report.json"):
557
+ report_ids.add(path.name[:-12])
558
+ elif path.name.endswith("_gradcam.png"):
559
+ gradcam_ids.add(path.name[:-12])
560
+ return report_ids, gradcam_ids
561
+
562
+
563
+ def _read_generated_at(image_id: str) -> str:
564
+ """Read the generated_at timestamp from a report JSON file."""
565
+ path = REPORTS_DIR / f"{image_id}_report.json"
566
+ if not path.exists():
567
+ return ""
568
+ try:
569
+ data = json.loads(path.read_text("utf-8"))
570
+ return data.get("generated_at", "")
571
+ except (json.JSONDecodeError, OSError):
572
+ return ""
573
+
574
+
575
+ def _load_cases_from_json() -> dict[str, CaseRow]:
576
+ """Fallback: read each *_report.json when CSV is unavailable."""
577
+ summary = _load_summary_csv()
578
+ cases: dict[str, CaseRow] = {}
579
+ for rp in sorted(REPORTS_DIR.glob("*_report.json")):
580
+ try:
581
+ payload = json.loads(rp.read_text("utf-8"))
582
+ except (json.JSONDecodeError, OSError):
583
+ continue
584
+ iid = str(payload.get("image_id", rp.stem.replace("_report", ""))).strip()
585
+ pred = payload.get("prediction", {})
586
+ tri = payload.get("triage", {})
587
+ expl = payload.get("explainability", {})
588
+ sr = summary.get(iid, {})
589
+ gc = Path(str(expl.get("heatmap_path", ""))).name or None
590
+ cases[iid] = CaseRow(
591
+ image_id=iid,
592
+ outcome=pred.get("screening_outcome", sr.get("outcome", "Unknown")),
593
+ raw_prob=_to_float(pred.get("raw_probability", sr.get("raw_prob"))),
594
+ cal_prob=_to_float(pred.get("calibrated_probability", sr.get("cal_prob"))),
595
+ band=pred.get("confidence_band", sr.get("band", "N/A")),
596
+ triage=tri.get("action", sr.get("triage", "N/A")),
597
+ urgency=tri.get("urgency", sr.get("urgency", "N/A")),
598
+ true_label=str(payload.get("ground_truth_label", sr.get("true_label", ""))),
599
+ generated_at=payload.get("generated_at", ""),
600
+ report_file=rp.name,
601
+ gradcam_file=gc,
602
+ )
603
+ return cases
604
+
605
+
606
+ def load_cases_cached() -> dict[str, CaseRow]:
607
+ """Return all cases, re-reading from disk only when files change."""
608
+ sig = _data_signature()
609
+ if _CACHE["data_signature"] == sig:
610
+ _CACHE["data_last_cache_hit"] = True
611
+ return _CACHE["cases"]
612
+
613
+ start = time.perf_counter()
614
+ summary = _load_summary_csv()
615
+
616
+ if summary:
617
+ report_ids, gradcam_ids = _scan_report_assets()
618
+ cases = {}
619
+ for iid, sr in summary.items():
620
+ # Resolve generated_at: prefer CSV value, fall back to JSON file
621
+ gen_at = sr.get("generated_at", "")
622
+ if not gen_at and iid in report_ids:
623
+ gen_at = _read_generated_at(iid)
624
+
625
+ cases[iid] = CaseRow(
626
+ image_id=iid,
627
+ outcome=sr.get("outcome", "Unknown"),
628
+ raw_prob=_to_float(sr.get("raw_prob")),
629
+ cal_prob=_to_float(sr.get("cal_prob")),
630
+ band=sr.get("band", "N/A"),
631
+ triage=sr.get("triage", "N/A"),
632
+ urgency=sr.get("urgency", "N/A"),
633
+ true_label=sr.get("true_label", ""),
634
+ generated_at=gen_at,
635
+ report_file=f"{iid}_report.json" if iid in report_ids else None,
636
+ gradcam_file=f"{iid}_gradcam.png" if iid in gradcam_ids else None,
637
+ )
638
+ elif REPORTS_DIR.exists():
639
+ cases = _load_cases_from_json()
640
+ else:
641
+ cases = {}
642
+
643
+ elapsed_ms = (time.perf_counter() - start) * 1000
644
+ _CACHE.update({
645
+ "data_signature": sig,
646
+ "cases": cases,
647
+ "rows_sorted": sorted(cases.values(), key=lambda c: c.image_id),
648
+ "data_last_refresh_ms": elapsed_ms,
649
+ "data_last_cache_hit": False,
650
+ })
651
+ logger.info("Cache refresh: %d cases in %.1f ms", len(cases), elapsed_ms)
652
+ return cases
653
+
654
+
655
+ def load_case_payload(image_id: str) -> dict[str, Any] | None:
656
+ """Load full JSON report for one case (Raw JSON button)."""
657
+ path = REPORTS_DIR / f"{image_id}_report.json"
658
+ if not path.exists():
659
+ return None
660
+ try:
661
+ return json.loads(path.read_text("utf-8"))
662
+ except (json.JSONDecodeError, OSError):
663
+ return None
664
+
665
+
666
+ def compute_stats(rows: list[CaseRow]) -> dict[str, Any]:
667
+ """Compute summary statistics for the dashboard cards."""
668
+ total = len(rows)
669
+ positive = sum(1 for r in rows if r.is_positive)
670
+ urgent = sum(1 for r in rows if r.urgency.upper() == "URGENT")
671
+ heatmaps = sum(1 for r in rows if r.gradcam_file)
672
+ cal_probs = [r.cal_prob for r in rows if r.cal_prob is not None]
673
+ avg_cal = sum(cal_probs) / len(cal_probs) if cal_probs else 0.0
674
+ pos_rate = (positive / total * 100) if total else 0.0
675
+
676
+ # Date range
677
+ dates = sorted(r.generated_at for r in rows if r.generated_at)
678
+ newest = dates[-1] if dates else ""
679
+ oldest = dates[0] if dates else ""
680
+
681
+ return {
682
+ "total": total,
683
+ "positive": positive,
684
+ "negative": total - positive,
685
+ "urgent": urgent,
686
+ "heatmaps": heatmaps,
687
+ "avg_cal_prob": avg_cal,
688
+ "pos_rate": pos_rate,
689
+ "band_counts": dict(Counter(r.band.upper() for r in rows)),
690
+ "urgency_counts": dict(Counter(r.urgency.upper() for r in rows)),
691
+ "newest_date": newest,
692
+ "oldest_date": oldest,
693
+ }
694
+
695
+
696
+ def _load_json_cached(path: Path, sig_key: str, data_key: str, label: str) -> dict[str, Any]:
697
+ """Mtime-based JSON cache loader for calibration/normalization."""
698
+ sig = _file_mtime(path)
699
+ if _CACHE[sig_key] == sig:
700
+ return _CACHE[data_key]
701
+ data: dict[str, Any] = {}
702
+ if path.exists():
703
+ try:
704
+ data = json.loads(path.read_text("utf-8"))
705
+ except (json.JSONDecodeError, OSError):
706
+ logger.warning("Could not read %s", path)
707
+ _CACHE[sig_key] = sig
708
+ _CACHE[data_key] = data
709
+ return data
710
+
711
+
712
+ def load_calibration() -> dict[str, Any]:
713
+ calib = _load_json_cached(CALIB_JSON, "calib_signature", "calib", "Calibration")
714
+ if not calib:
715
+ return {}
716
+ # Backward-compatible aliases expected by templates.
717
+ return {
718
+ **calib,
719
+ "method": calib.get("method", calib.get("best_method", "N/A")),
720
+ "temperature": calib.get("temperature", 1.0),
721
+ "raw_ece": calib.get("ece_raw", 0.0),
722
+ "cal_ece": calib.get("ece_isotonic", calib.get("ece_temp", 0.0)),
723
+ "raw_brier": calib.get("brier_raw", 0.0),
724
+ "cal_brier": calib.get("brier_isotonic", calib.get("brier_temp", 0.0)),
725
+ "calibrated_threshold": calib.get("threshold_at_spec90", 0.5),
726
+ "base_threshold": calib.get("base_threshold", 0.5),
727
+ "high_threshold": calib.get("high_threshold", calib.get("triage_high_thresh", 0.7)),
728
+ "low_threshold": calib.get("low_threshold", calib.get("triage_low_thresh", 0.3)),
729
+ }
730
+
731
+
732
+ def load_normalization() -> dict[str, Any]:
733
+ return _load_json_cached(NORM_JSON, "norm_signature", "norm", "Normalization")
734
+
735
+
736
+ def filter_cases(
737
+ rows: list[CaseRow],
738
+ q: str,
739
+ band: str,
740
+ urgency: str,
741
+ outcome: str,
742
+ sort_by: str,
743
+ ) -> list[CaseRow]:
744
+ """Apply text search, dropdown filters, and sorting."""
745
+ if q:
746
+ ql = q.lower()
747
+ rows = [r for r in rows if ql in r.image_id.lower() or ql in r.outcome.lower()]
748
+ if band:
749
+ rows = [r for r in rows if r.band.upper() == band.upper()]
750
+ if urgency:
751
+ rows = [r for r in rows if r.urgency.upper() == urgency.upper()]
752
+ if outcome == "POSITIVE":
753
+ rows = [r for r in rows if r.is_positive]
754
+ elif outcome == "NEGATIVE":
755
+ rows = [r for r in rows if not r.is_positive]
756
+
757
+ if sort_by == "date_desc":
758
+ rows = sorted(rows, key=lambda r: r.generated_at or "", reverse=True)
759
+ elif sort_by == "date_asc":
760
+ rows = sorted(rows, key=lambda r: r.generated_at or "")
761
+ elif sort_by == "prob_desc":
762
+ rows = sorted(rows, key=lambda r: r.cal_prob or 0, reverse=True)
763
+ elif sort_by == "prob_asc":
764
+ rows = sorted(rows, key=lambda r: r.cal_prob or 0)
765
+ # default: sorted by image_id (already the case from cache)
766
+
767
+ return rows
768
+
769
+
770
+ def load_logs() -> list[dict]:
771
+ """Scan the logs/ directory and return metadata for each trace."""
772
+ if not LOGS_DIR.exists():
773
+ return []
774
+
775
+ log_files: dict[str, dict] = {} # base_name -> {txt_file, json_file, ...}
776
+
777
+ for path in sorted(LOGS_DIR.iterdir(), reverse=True):
778
+ if not path.is_file():
779
+ continue
780
+ stem = path.stem # e.g. "20260228_153000_ID_abc123"
781
+ if path.suffix == ".txt":
782
+ log_files.setdefault(stem, {})["txt_file"] = path.name
783
+ # Parse out timestamp and image_id from filename
784
+ parts = stem.split("_", 2)
785
+ if len(parts) >= 3:
786
+ log_files[stem]["timestamp"] = f"{parts[0]}_{parts[1]}"
787
+ log_files[stem]["image_id"] = parts[2]
788
+ log_files[stem]["size_kb"] = round(path.stat().st_size / 1024, 1)
789
+ elif path.suffix == ".json":
790
+ log_files.setdefault(stem, {})["json_file"] = path.name
791
+
792
+ entries = []
793
+ for stem in sorted(log_files, reverse=True):
794
+ info = log_files[stem]
795
+ ts_raw = info.get("timestamp", "")
796
+ try:
797
+ dt = datetime.datetime.strptime(ts_raw, "%Y%m%d_%H%M%S")
798
+ display = dt.strftime("%Y-%m-%d %H:%M:%S")
799
+ except ValueError:
800
+ display = ts_raw
801
+ entries.append({
802
+ "stem": stem,
803
+ "timestamp": display,
804
+ "image_id": info.get("image_id", ""),
805
+ "txt_file": info.get("txt_file"),
806
+ "json_file": info.get("json_file"),
807
+ "size_kb": info.get("size_kb", 0),
808
+ })
809
+
810
+ return entries
811
+
812
+
813
+ # ═══════════════════���══════════════════════════════════════════════════════
814
+ # MIDDLEWARE
815
+ # ══════════════════════════════════════════════════════════════════════════
816
+
817
+ @app.before_request
818
+ def _start_timer():
819
+ g._start_time = time.perf_counter()
820
+
821
+
822
+ @app.after_request
823
+ def _log_timing(response):
824
+ elapsed = (time.perf_counter() - getattr(g, "_start_time", time.perf_counter())) * 1000
825
+ logger.info("%s %s -> %s (%.1f ms)", request.method, request.path, response.status_code, elapsed)
826
+ return response
827
+
828
+
829
+ # ══════════════════════════════════════════════════════════════════════════
830
+ # ROUTES
831
+ # ══════════════════════════════════════════════════════════════════════════
832
+
833
+ @app.route("/")
834
+ def home():
835
+ """Landing page with quick stats and navigation."""
836
+ load_cases_cached()
837
+ all_rows = _CACHE["rows_sorted"]
838
+ stats = compute_stats(all_rows)
839
+ log_count = len(list(LOGS_DIR.glob("*.txt"))) if LOGS_DIR.exists() else 0
840
+ return render_template("home.html", stats=stats, log_count=log_count)
841
+
842
+
843
+ @app.route("/upload")
844
+ def upload():
845
+ return render_template("upload.html", local_mode=LOCAL_MODE)
846
+
847
+
848
+ @app.route("/analyze", methods=["POST"])
849
+ def analyze():
850
+ """
851
+ Accept one or more .dcm files (or a .zip) and run inference.
852
+
853
+ Single file β†’ synchronous, redirect straight to the report.
854
+ Multiple β†’ asynchronous batch, redirect to progress page.
855
+ """
856
+ files = request.files.getlist("file")
857
+ files = [f for f in files if f.filename]
858
+
859
+ if not files:
860
+ flash("No files were uploaded.", "error")
861
+ return redirect(url_for("upload"))
862
+
863
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
864
+
865
+ # ── Collect all .dcm paths (expand .zip archives) ────────────────
866
+ dcm_paths: list[Path] = []
867
+ temp_dir: str | None = None # set if a zip needed extraction
868
+
869
+ for f in files:
870
+ fname = f.filename.lower()
871
+
872
+ if fname.endswith(".zip"):
873
+ temp_dir = tempfile.mkdtemp(prefix="ich_zip_")
874
+ zip_save = Path(temp_dir) / secure_filename(f.filename)
875
+ f.save(str(zip_save))
876
+ try:
877
+ with zipfile.ZipFile(zip_save, "r") as zf:
878
+ zf.extractall(temp_dir)
879
+ except zipfile.BadZipFile:
880
+ shutil.rmtree(temp_dir, ignore_errors=True)
881
+ flash("The uploaded ZIP file is corrupted.", "error")
882
+ return redirect(url_for("upload"))
883
+ # Recursively find .dcm inside extracted tree
884
+ dcm_paths.extend(sorted(Path(temp_dir).rglob("*.dcm")))
885
+
886
+ elif fname.endswith(".dcm"):
887
+ safe = secure_filename(f.filename)
888
+ save_path = UPLOAD_DIR / safe
889
+ f.save(str(save_path))
890
+ dcm_paths.append(save_path)
891
+
892
+ else:
893
+ # skip non-dcm / non-zip silently
894
+ continue
895
+
896
+ if not dcm_paths:
897
+ flash("No .dcm files found in the upload.", "error")
898
+ if temp_dir:
899
+ shutil.rmtree(temp_dir, ignore_errors=True)
900
+ return redirect(url_for("upload"))
901
+
902
+ # ── Single file β†’ synchronous (fast path) ────────────────────────
903
+ if len(dcm_paths) == 1 and temp_dir is None:
904
+ single_path = dcm_paths[0]
905
+ try:
906
+ report, trace = _run_inference_on_dcm(single_path)
907
+ if report is None:
908
+ flash("Model failed to load. Check server logs.", "error")
909
+ return redirect(url_for("upload"))
910
+ return redirect(url_for("case_detail", image_id=single_path.stem))
911
+ except Exception as e:
912
+ logger.error("Analysis failed for %s: %s", single_path.name, e, exc_info=True)
913
+ flash(f"Analysis failed: {e}", "error")
914
+ return redirect(url_for("upload"))
915
+ finally:
916
+ if single_path.exists() and single_path.parent == UPLOAD_DIR:
917
+ single_path.unlink()
918
+
919
+ # ── Multiple files β†’ asynchronous batch ──────────────────────────
920
+ batch_id = _start_batch(dcm_paths, temp_dir=temp_dir)
921
+ logger.info("Batch %s started: %d files", batch_id, len(dcm_paths))
922
+ return redirect(url_for("batch_progress", batch_id=batch_id))
923
+
924
+
925
+ @app.route("/analyze/directory", methods=["POST"])
926
+ def analyze_directory():
927
+ """
928
+ Local-only route: scan a server-side directory for .dcm files and
929
+ start a batch job. Disabled when LOCAL_MODE is off.
930
+ """
931
+ if not LOCAL_MODE:
932
+ abort(403)
933
+
934
+ dir_path_str = request.form.get("dir_path", "").strip()
935
+ if not dir_path_str:
936
+ flash("Please enter a directory path.", "error")
937
+ return redirect(url_for("upload"))
938
+
939
+ scan_dir = Path(dir_path_str)
940
+ if not scan_dir.is_dir():
941
+ flash(f"Directory not found: {dir_path_str}", "error")
942
+ return redirect(url_for("upload"))
943
+
944
+ dcm_paths = sorted(scan_dir.rglob("*.dcm"))
945
+ if not dcm_paths:
946
+ flash(f"No .dcm files found in: {dir_path_str}", "error")
947
+ return redirect(url_for("upload"))
948
+
949
+ batch_id = _start_batch(dcm_paths)
950
+ logger.info("Directory batch %s started: %d files from %s", batch_id, len(dcm_paths), dir_path_str)
951
+ return redirect(url_for("batch_progress", batch_id=batch_id))
952
+
953
+
954
+ @app.route("/batch/progress/<batch_id>")
955
+ def batch_progress(batch_id: str):
956
+ """Batch progress page β€” polls /batch/status/<id> via JS."""
957
+ with _BATCHES_LOCK:
958
+ batch = _BATCHES.get(batch_id)
959
+ if not batch:
960
+ abort(404)
961
+ return render_template("batch_progress.html", batch_id=batch_id, batch=batch)
962
+
963
+
964
+ @app.route("/batch/status/<batch_id>")
965
+ def batch_status(batch_id: str):
966
+ """JSON endpoint polled by the progress page for live updates."""
967
+ with _BATCHES_LOCK:
968
+ batch = _BATCHES.get(batch_id)
969
+ if not batch:
970
+ return jsonify({"error": "not found"}), 404
971
+ # Return a safe copy (no Path objects)
972
+ return jsonify({
973
+ "status": batch["status"],
974
+ "total": batch["total"],
975
+ "processed": batch["processed"],
976
+ "succeeded": batch["succeeded"],
977
+ "failed_count": len(batch["failed_ids"]),
978
+ "failed_ids": batch["failed_ids"][:20], # cap for payload size
979
+ "current_file": batch["current_file"],
980
+ "image_ids": batch["image_ids"][-5:], # last 5 for display
981
+ "started_at": batch["started_at"],
982
+ "finished_at": batch["finished_at"],
983
+ })
984
+
985
+
986
+ @app.route("/reports")
987
+ def reports():
988
+ """Past reports page with filtering, sorting, and pagination."""
989
+ route_start = time.perf_counter()
990
+
991
+ load_cases_cached()
992
+ all_rows = _CACHE["rows_sorted"]
993
+
994
+ # Read all filter/sort/pagination params from query string
995
+ q = request.args.get("q", "").strip()
996
+ band = request.args.get("band", "").strip()
997
+ urgency = request.args.get("urgency", "").strip()
998
+ outcome = request.args.get("outcome", "").strip()
999
+ sort_by = request.args.get("sort", "").strip()
1000
+ page = _parse_positive_int(request.args.get("page"), 1)
1001
+ page_size = _parse_positive_int(request.args.get("page_size"), 50)
1002
+ if page_size not in (10, 50, 100):
1003
+ page_size = 50
1004
+
1005
+ filtered = filter_cases(all_rows, q, band, urgency, outcome, sort_by)
1006
+ stats = compute_stats(filtered)
1007
+ total = len(filtered)
1008
+ total_pages = max(1, math.ceil(total / page_size))
1009
+ page = min(page, total_pages)
1010
+ start_idx = (page - 1) * page_size
1011
+ rows = filtered[start_idx: start_idx + page_size]
1012
+ route_ms = (time.perf_counter() - route_start) * 1000
1013
+
1014
+ return render_template(
1015
+ "reports.html",
1016
+ rows=rows,
1017
+ stats=stats,
1018
+ calib=load_calibration(),
1019
+ q=q, band=band, urgency=urgency, outcome=outcome, sort=sort_by,
1020
+ page=page,
1021
+ page_size=page_size,
1022
+ page_start=start_idx,
1023
+ total_pages=total_pages,
1024
+ total_items=total,
1025
+ total_cases=len(all_rows),
1026
+ route_compute_ms=route_ms,
1027
+ data_refresh_ms=_CACHE["data_last_refresh_ms"],
1028
+ data_cache_hit=_CACHE["data_last_cache_hit"],
1029
+ )
1030
+
1031
+
1032
+ @app.route("/case/<image_id>")
1033
+ def case_detail(image_id: str):
1034
+ """Individual case report page."""
1035
+ cases = load_cases_cached()
1036
+ row = cases.get(image_id)
1037
+ if not row:
1038
+ abort(404)
1039
+ payload = load_case_payload(image_id)
1040
+ return render_template("detail.html", row=row, payload=payload)
1041
+
1042
+
1043
+ @app.route("/logs")
1044
+ def logs_page():
1045
+ """Execution logs page."""
1046
+ entries = load_logs()
1047
+ return render_template("logs.html", logs=entries)
1048
+
1049
+
1050
+ @app.route("/logs/view/<path:filename>")
1051
+ def serve_log(filename: str):
1052
+ """Serve a log file (txt or json) for viewing."""
1053
+ if not LOGS_DIR.exists():
1054
+ abort(404)
1055
+ return send_from_directory(LOGS_DIR, filename)
1056
+
1057
+
1058
+ @app.route("/evaluation")
1059
+ def evaluation():
1060
+ load_cases_cached()
1061
+ all_rows = _CACHE["rows_sorted"]
1062
+
1063
+ cal_probs = [r.cal_prob for r in all_rows if r.cal_prob is not None]
1064
+ bins = [0] * 10
1065
+ for p in cal_probs:
1066
+ bins[min(int(p * 10), 9)] += 1
1067
+
1068
+ band_data = {}
1069
+ for bnd in ("HIGH", "MEDIUM", "LOW"):
1070
+ subset = [r for r in all_rows if r.band.upper() == bnd]
1071
+ positive = sum(1 for r in subset if r.is_positive)
1072
+ band_data[bnd] = {
1073
+ "total": len(subset),
1074
+ "positive": positive,
1075
+ "negative": len(subset) - positive,
1076
+ }
1077
+
1078
+ return render_template(
1079
+ "evaluation.html",
1080
+ stats=compute_stats(all_rows),
1081
+ calib=load_calibration(),
1082
+ norm=load_normalization(),
1083
+ bins=bins,
1084
+ band_data=band_data,
1085
+ total=len(all_rows),
1086
+ )
1087
+
1088
+
1089
+ @app.route("/about")
1090
+ def about():
1091
+ return render_template("about.html", calib=load_calibration())
1092
+
1093
+
1094
+ @app.route("/gradcam/<path:filename>")
1095
+ def serve_gradcam(filename: str):
1096
+ if not REPORTS_DIR.exists():
1097
+ abort(404)
1098
+ return send_from_directory(REPORTS_DIR, filename)
1099
+
1100
+
1101
+ @app.route("/report-json/<path:filename>")
1102
+ def serve_report_json(filename: str):
1103
+ if not REPORTS_DIR.exists():
1104
+ abort(404)
1105
+ return send_from_directory(REPORTS_DIR, filename)
1106
+
1107
+
1108
+ # ══════════════════════════════════════════════════════════════════════════
1109
+ # ENTRY POINT
1110
+ # ══════════════════════════════════════════════════════════════════════════
1111
+
1112
+ if __name__ == "__main__":
1113
+ print("=" * 60)
1114
+ print(" ICH Screening Web Application")
1115
+ print(f" Data -> {OUTPUT_DIR}")
1116
+ print(f" Logs -> {LOGS_DIR}")
1117
+ print(f" Open -> http://127.0.0.1:{APP_PORT}")
1118
+ print("=" * 60)
1119
+ app.run(debug=APP_DEBUG, port=APP_PORT)
download_imp/__init__.py ADDED
File without changes
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ werkzeug
3
+
4
+ numpy
5
+ pandas
6
+ opencv-python
7
+ pydicom
8
+
9
+ torch
10
+ timm
11
+ scikit-learn
12
+
13
+ blackbox-recorder
14
+ python-dotenv
15
+
16
+
run_interface.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compatibility adapter for the web app inference API.
2
+
3
+ This module bridges the Flask app's expected interface to the improved
4
+ inference utilities in download_imp/run_inference.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import cv2
13
+ import numpy as np
14
+ import torch
15
+
16
+ from download_imp import run_inference as core
17
+
18
+ ARCH = core.BACKBONE
19
+ IMG_SIZE = core.IMG_SIZE
20
+ SUBTYPES = core.SUBTYPES
21
+
22
+
23
+ def _parse_fold_selection(value: str | None) -> str | int:
24
+ """Parse fold selection from env-style values.
25
+
26
+ Accepted values: "ensemble", "best", or an integer fold id.
27
+ """
28
+ raw = (value or "ensemble").strip().lower()
29
+ if raw in ("", "ensemble", "all"):
30
+ return "ensemble"
31
+ if raw == "best":
32
+ # From B4 performance report per-fold any-AUC table.
33
+ return 4
34
+ if raw.isdigit():
35
+ return int(raw)
36
+ return "ensemble"
37
+
38
+
39
+ class _Compose:
40
+ def __init__(self, transforms: list[Any]):
41
+ self.transforms = transforms
42
+
43
+ def __call__(self, x: np.ndarray) -> torch.Tensor:
44
+ out = x
45
+ for t in self.transforms:
46
+ out = t(out)
47
+ return out
48
+
49
+
50
+ class _ToPILImage:
51
+ def __call__(self, x: np.ndarray) -> np.ndarray:
52
+ # The web app pipeline does not require PIL specifically.
53
+ return x
54
+
55
+
56
+ class _ToTensor:
57
+ def __call__(self, x: np.ndarray) -> torch.Tensor:
58
+ arr = np.asarray(x, dtype=np.float32)
59
+ if arr.ndim != 3:
60
+ raise ValueError("Expected HWC image array")
61
+ # Convert HWC -> CHW
62
+ return torch.from_numpy(np.transpose(arr, (2, 0, 1)))
63
+
64
+
65
+ class _Normalize:
66
+ def __init__(self, mean: list[float], std: list[float]):
67
+ self.mean = torch.tensor(mean, dtype=torch.float32).view(-1, 1, 1)
68
+ self.std = torch.tensor(std, dtype=torch.float32).view(-1, 1, 1)
69
+
70
+ def __call__(self, x: torch.Tensor) -> torch.Tensor:
71
+ return (x - self.mean) / (self.std + 1e-7)
72
+
73
+
74
+ class T:
75
+ Compose = _Compose
76
+ ToPILImage = _ToPILImage
77
+ ToTensor = _ToTensor
78
+ Normalize = _Normalize
79
+
80
+
81
+ def build_model(_arch: str | None = None):
82
+ return core.build_model()
83
+
84
+
85
+ def load_runtime_models(device: str, fold_selection: str | None = None):
86
+ """Load one or many fold models for web inference."""
87
+ parsed = _parse_fold_selection(fold_selection)
88
+ models, loaded_folds = core.load_models(device, fold_selection=parsed)
89
+ grad_cams = [GradCAM(m) for m in models]
90
+ return models, grad_cams, loaded_folds
91
+
92
+
93
+ class GradCAM(core.GradCAM):
94
+ def __init__(self, model, _arch: str | None = None):
95
+ super().__init__(model)
96
+
97
+
98
+ def dicom_to_rgb(dcm_path: str, size: int = IMG_SIZE) -> np.ndarray:
99
+ return core.load_single_dicom_3ch(Path(dcm_path), size=size)
100
+
101
+
102
+ def infer_single(
103
+ img_rgb: np.ndarray,
104
+ model,
105
+ grad_cam: GradCAM,
106
+ transform,
107
+ device: str,
108
+ temperature: float,
109
+ ) -> dict[str, Any]:
110
+ # Build 3ch tensor from the app's transform pipeline, then tile to 9ch
111
+ # because the trained model expects 2.5D channels.
112
+ t3 = transform(img_rgb).unsqueeze(0).to(device)
113
+ t9 = torch.cat([t3, t3, t3], dim=1)
114
+
115
+ if isinstance(model, list) and isinstance(grad_cam, list):
116
+ fold_logits = []
117
+ fold_cams = []
118
+ for _m, cam_obj in zip(model, grad_cam):
119
+ logits_i, cam_i = cam_obj.generate(t9, class_idx=0)
120
+ fold_logits.append(logits_i)
121
+ fold_cams.append(cam_i)
122
+ logits = np.mean(np.stack(fold_logits, axis=0), axis=0)
123
+ cam = np.mean(np.stack(fold_cams, axis=0), axis=0)
124
+ else:
125
+ logits, cam = grad_cam.generate(t9, class_idx=0)
126
+
127
+ raw_probs = core.sigmoid_np(logits)
128
+ cal_probs = core.sigmoid_np(logits / max(float(temperature), 1e-6))
129
+
130
+ return {
131
+ "raw_logits": logits,
132
+ "raw_probs": raw_probs,
133
+ "cal_probs": cal_probs,
134
+ "raw_prob_any": float(raw_probs[0]),
135
+ "cal_prob_any": float(cal_probs[0]),
136
+ "cam": cam,
137
+ }
138
+
139
+
140
+ def build_report(
141
+ image_id: str,
142
+ inference: dict[str, Any],
143
+ calib_cfg: dict[str, Any],
144
+ reports_dir: Path,
145
+ img_rgb: np.ndarray,
146
+ true_label: int | None = None,
147
+ ) -> dict[str, Any]:
148
+ reports_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ preview_path = reports_dir / f"{image_id}_preview.png"
151
+ heatmap_path = reports_dir / f"{image_id}_gradcam.png"
152
+
153
+ rgb_u8 = (np.clip(img_rgb, 0.0, 1.0) * 255.0).astype(np.uint8)
154
+ cv2.imwrite(str(preview_path), cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR))
155
+
156
+ overlay_rgb = core.make_overlay(rgb_u8, inference["cam"], alpha=0.45)
157
+ cv2.imwrite(str(heatmap_path), cv2.cvtColor(overlay_rgb, cv2.COLOR_RGB2BGR))
158
+
159
+ probs_dict = {
160
+ name: float(inference["cal_probs"][idx])
161
+ for idx, name in enumerate(SUBTYPES)
162
+ }
163
+ threshold = float(calib_cfg.get("threshold_at_spec90", 0.5))
164
+
165
+ report = core.build_slice_report(
166
+ image_id=image_id,
167
+ patient_id="UNKNOWN",
168
+ probs=probs_dict,
169
+ calib_cfg=calib_cfg,
170
+ threshold=threshold,
171
+ loaded_folds=[0],
172
+ report_image_path=str(preview_path),
173
+ heatmap_path=str(heatmap_path),
174
+ true_label=true_label,
175
+ )
176
+
177
+ report.setdefault("prediction", {})
178
+ report["prediction"]["decision_threshold"] = report["prediction"].get("decision_threshold_any", threshold)
179
+ report["prediction"]["raw_probability"] = round(float(inference["raw_prob_any"]), 6)
180
+ report["prediction"]["calibrated_probability"] = round(float(inference["cal_prob_any"]), 6)
181
+
182
+ return report
static/styles.css ADDED
@@ -0,0 +1,1291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════════
2
+ ICH Screening Dashboard β€” Stylesheet
3
+ ═══════════════════════════════════════════════════════════════ */
4
+
5
+ :root {
6
+ --bg: #070d1a;
7
+ --bg2: #0c1427;
8
+ --panel: #111c33;
9
+ --panel2: #162244;
10
+ --surface: #1a2850;
11
+ --text: #e8ecf6;
12
+ --muted: #8ba0c4;
13
+ --line: #243356;
14
+ --accent: #6ea8fe;
15
+ --green: #34d399;
16
+ --red: #fb7185;
17
+ --orange: #fbbf24;
18
+ --blue: #60a5fa;
19
+ --radius: 14px;
20
+ }
21
+
22
+ /* ── Reset ─────────────────────────────────────────────────── */
23
+ *,
24
+ *::before,
25
+ *::after {
26
+ box-sizing: border-box;
27
+ margin: 0;
28
+ padding: 0;
29
+ }
30
+ html {
31
+ scroll-behavior: smooth;
32
+ }
33
+ body {
34
+ font-family:
35
+ "Inter",
36
+ system-ui,
37
+ -apple-system,
38
+ "Segoe UI",
39
+ Roboto,
40
+ sans-serif;
41
+ background:
42
+ radial-gradient(
43
+ ellipse 1400px 500px at 5% -5%,
44
+ #1a2f55 0%,
45
+ transparent 60%
46
+ ),
47
+ radial-gradient(
48
+ ellipse 1200px 500px at 95% -5%,
49
+ #2a1d46 0%,
50
+ transparent 55%
51
+ ),
52
+ var(--bg);
53
+ color: var(--text);
54
+ line-height: 1.6;
55
+ min-height: 100vh;
56
+ }
57
+
58
+ /* ── Layout ────────────────────────────────────────────────── */
59
+ .container {
60
+ width: min(1240px, 94vw);
61
+ margin: 0 auto;
62
+ }
63
+ .page {
64
+ padding: 20px 0 48px;
65
+ }
66
+
67
+ /* ── Topbar ────────────────────────────────────────────────── */
68
+ .topbar {
69
+ position: sticky;
70
+ top: 0;
71
+ z-index: 50;
72
+ background: rgba(7, 13, 26, 0.88);
73
+ backdrop-filter: blur(12px);
74
+ border-bottom: 1px solid var(--line);
75
+ }
76
+ .topbar-inner {
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: space-between;
80
+ width: 100%;
81
+ padding: 14px 24px;
82
+ }
83
+ .brand {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 10px;
87
+ font-weight: 800;
88
+ font-size: 1.05rem;
89
+ color: var(--text);
90
+ text-decoration: none;
91
+ }
92
+ .brand-icon {
93
+ color: var(--accent);
94
+ display: flex;
95
+ }
96
+ .nav-links {
97
+ display: flex;
98
+ gap: 6px;
99
+ }
100
+ .nav-links a {
101
+ padding: 6px 14px;
102
+ border-radius: 8px;
103
+ color: var(--muted);
104
+ text-decoration: none;
105
+ font-weight: 500;
106
+ font-size: 0.9rem;
107
+ transition: all 0.15s;
108
+ }
109
+ .nav-links a:hover {
110
+ color: var(--text);
111
+ background: var(--panel);
112
+ }
113
+ .nav-links a.active {
114
+ color: var(--accent);
115
+ background: rgba(110, 168, 254, 0.1);
116
+ }
117
+
118
+ /* ── Hero ──────────────────────────────────────────────────── */
119
+ .hero {
120
+ padding: 8px 0 6px;
121
+ }
122
+ .hero h1 {
123
+ font-size: 1.8rem;
124
+ font-weight: 800;
125
+ }
126
+ .hero p {
127
+ color: var(--muted);
128
+ margin-top: 6px;
129
+ }
130
+
131
+ /* ── Stats row ─────────────────────────────────────────────── */
132
+ .stats-row {
133
+ display: grid;
134
+ grid-template-columns: repeat(6, 1fr);
135
+ gap: 12px;
136
+ margin: 16px 0;
137
+ }
138
+ .stat-card {
139
+ background: linear-gradient(180deg, var(--panel2), var(--panel));
140
+ border: 1px solid var(--line);
141
+ border-radius: var(--radius);
142
+ padding: 16px;
143
+ }
144
+ .stat-label {
145
+ font-size: 0.82rem;
146
+ color: var(--muted);
147
+ font-weight: 600;
148
+ text-transform: uppercase;
149
+ letter-spacing: 0.04em;
150
+ }
151
+ .stat-value {
152
+ font-size: 1.6rem;
153
+ font-weight: 800;
154
+ margin-top: 4px;
155
+ }
156
+ .stat-card.accent-green .stat-value {
157
+ color: var(--green);
158
+ }
159
+ .stat-card.accent-red .stat-value {
160
+ color: var(--red);
161
+ }
162
+ .stat-card.accent-orange .stat-value {
163
+ color: var(--orange);
164
+ }
165
+ .stat-card.accent-blue .stat-value {
166
+ color: var(--blue);
167
+ }
168
+
169
+ /* ── Info bar ──────────────────────────────────────────────── */
170
+ .info-bar {
171
+ display: flex;
172
+ gap: 24px;
173
+ flex-wrap: wrap;
174
+ padding: 10px 16px;
175
+ border-radius: 10px;
176
+ background: var(--panel);
177
+ border: 1px solid var(--line);
178
+ font-size: 0.88rem;
179
+ color: var(--muted);
180
+ margin-bottom: 12px;
181
+ }
182
+ .info-bar strong {
183
+ color: var(--text);
184
+ }
185
+
186
+ /* ── Panel ─────────────────────────────────────────────────── */
187
+ .panel {
188
+ background: linear-gradient(180deg, var(--panel2), var(--panel));
189
+ border: 1px solid var(--line);
190
+ border-radius: var(--radius);
191
+ padding: 20px;
192
+ margin-top: 16px;
193
+ }
194
+ .panel h3 {
195
+ font-size: 1rem;
196
+ font-weight: 700;
197
+ margin-bottom: 12px;
198
+ }
199
+
200
+ /* ── Filters ───────────────────────────────────────────────── */
201
+ .filters {
202
+ display: flex;
203
+ gap: 10px;
204
+ flex-wrap: wrap;
205
+ margin-bottom: 14px;
206
+ }
207
+ .filters input,
208
+ .filters select {
209
+ flex: 1;
210
+ min-width: 140px;
211
+ }
212
+ input,
213
+ select {
214
+ background: var(--bg2);
215
+ color: var(--text);
216
+ border: 1px solid var(--line);
217
+ border-radius: 10px;
218
+ padding: 10px 12px;
219
+ font-size: 0.9rem;
220
+ font-family: inherit;
221
+ transition: border-color 0.15s;
222
+ }
223
+ input:focus,
224
+ select:focus {
225
+ outline: none;
226
+ border-color: var(--accent);
227
+ }
228
+
229
+ /* ── Buttons ───────────────────────────────────────────────── */
230
+ .btn,
231
+ button {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ gap: 6px;
235
+ padding: 8px 16px;
236
+ border-radius: 10px;
237
+ border: 1px solid var(--line);
238
+ background: var(--panel);
239
+ color: var(--text);
240
+ font-size: 0.88rem;
241
+ font-weight: 500;
242
+ font-family: inherit;
243
+ cursor: pointer;
244
+ text-decoration: none;
245
+ transition: all 0.15s;
246
+ }
247
+ .btn:hover,
248
+ button:hover {
249
+ border-color: var(--accent);
250
+ background: var(--surface);
251
+ }
252
+ .btn-sm {
253
+ padding: 5px 12px;
254
+ font-size: 0.82rem;
255
+ }
256
+ .btn-ghost {
257
+ background: transparent;
258
+ }
259
+ .btn-outline {
260
+ background: transparent;
261
+ border-color: var(--line);
262
+ color: var(--muted);
263
+ }
264
+ .btn-outline:hover {
265
+ border-color: var(--accent);
266
+ color: var(--accent);
267
+ }
268
+
269
+ /* ── Table ─────────────────────────────────────────────────── */
270
+ .table-wrap {
271
+ overflow-x: auto;
272
+ }
273
+ table {
274
+ width: 100%;
275
+ border-collapse: collapse;
276
+ min-width: 940px;
277
+ }
278
+ th,
279
+ td {
280
+ padding: 10px 12px;
281
+ border-bottom: 1px solid var(--line);
282
+ text-align: left;
283
+ }
284
+ th {
285
+ color: var(--muted);
286
+ font-weight: 600;
287
+ font-size: 0.82rem;
288
+ text-transform: uppercase;
289
+ letter-spacing: 0.03em;
290
+ }
291
+ tr.row-positive {
292
+ background: rgba(251, 113, 133, 0.04);
293
+ }
294
+ a {
295
+ color: var(--accent);
296
+ transition: color 0.15s;
297
+ }
298
+ a:hover {
299
+ color: #9ec5ff;
300
+ }
301
+
302
+ .link-icon {
303
+ display: inline-flex;
304
+ }
305
+
306
+ /* ── Badges ────────────────────────────────────────────────── */
307
+ .badge {
308
+ display: inline-block;
309
+ padding: 3px 10px;
310
+ border-radius: 999px;
311
+ font-size: 0.78rem;
312
+ font-weight: 600;
313
+ letter-spacing: 0.03em;
314
+ border: 1px solid var(--line);
315
+ background: rgba(255, 255, 255, 0.04);
316
+ }
317
+ .badge-high {
318
+ border-color: #3b82f6;
319
+ color: #93bbfd;
320
+ }
321
+ .badge-medium {
322
+ border-color: #f59e0b;
323
+ color: #fcd34d;
324
+ }
325
+ .badge-low {
326
+ border-color: #6b7280;
327
+ color: #9ca3af;
328
+ }
329
+ .badge-urgent {
330
+ border-color: #ef4444;
331
+ color: #fca5a5;
332
+ background: rgba(239, 68, 68, 0.08);
333
+ }
334
+ .badge-standard {
335
+ border-color: #22c55e;
336
+ color: #86efac;
337
+ }
338
+
339
+ /* ── Dots ──────────────────────────────────────────────────── */
340
+ .dot {
341
+ display: inline-block;
342
+ width: 8px;
343
+ height: 8px;
344
+ border-radius: 50%;
345
+ margin-right: 6px;
346
+ vertical-align: middle;
347
+ }
348
+ .dot-green {
349
+ background: var(--green);
350
+ box-shadow: 0 0 8px var(--green);
351
+ }
352
+ .dot-red {
353
+ background: var(--red);
354
+ box-shadow: 0 0 8px var(--red);
355
+ }
356
+
357
+ /* ── Utility ───────────────────────────────────────────────── */
358
+ .mono {
359
+ font-family: "Consolas", "SF Mono", "Fira Code", monospace;
360
+ }
361
+ .muted {
362
+ color: var(--muted);
363
+ }
364
+ .small {
365
+ font-size: 0.85rem;
366
+ }
367
+
368
+ /* ── Detail page ───────────────────────────────────────────── */
369
+ .breadcrumb {
370
+ padding: 8px 0;
371
+ font-size: 0.88rem;
372
+ color: var(--muted);
373
+ }
374
+ .breadcrumb a {
375
+ color: var(--accent);
376
+ text-decoration: none;
377
+ }
378
+ .sep {
379
+ margin: 0 8px;
380
+ opacity: 0.4;
381
+ }
382
+
383
+ .detail-header {
384
+ display: flex;
385
+ justify-content: space-between;
386
+ align-items: flex-start;
387
+ gap: 16px;
388
+ flex-wrap: wrap;
389
+ margin: 6px 0 10px;
390
+ }
391
+ .detail-header h1 {
392
+ font-size: 1.5rem;
393
+ }
394
+ .detail-actions {
395
+ display: flex;
396
+ gap: 8px;
397
+ }
398
+
399
+ .detail-grid {
400
+ display: grid;
401
+ grid-template-columns: 1fr 1fr;
402
+ gap: 16px;
403
+ margin-top: 8px;
404
+ }
405
+
406
+ .kv-group {
407
+ }
408
+ .kv {
409
+ display: flex;
410
+ justify-content: space-between;
411
+ align-items: center;
412
+ padding: 9px 0;
413
+ border-bottom: 1px solid rgba(36, 51, 86, 0.6);
414
+ font-size: 0.92rem;
415
+ gap: 12px;
416
+ }
417
+ .kv span {
418
+ color: var(--muted);
419
+ }
420
+
421
+ .heatmap-img {
422
+ width: 100%;
423
+ border-radius: 12px;
424
+ border: 1px solid var(--line);
425
+ }
426
+
427
+ .empty-state {
428
+ display: flex;
429
+ flex-direction: column;
430
+ align-items: center;
431
+ justify-content: center;
432
+ padding: 48px 16px;
433
+ gap: 12px;
434
+ }
435
+
436
+ /* Probability bar */
437
+ .prob-bar-wrap {
438
+ margin-top: 20px;
439
+ }
440
+ .prob-bar-label {
441
+ display: flex;
442
+ justify-content: space-between;
443
+ font-size: 0.78rem;
444
+ color: var(--muted);
445
+ margin-bottom: 4px;
446
+ }
447
+ .prob-bar {
448
+ position: relative;
449
+ height: 24px;
450
+ border-radius: 12px;
451
+ background: var(--bg2);
452
+ border: 1px solid var(--line);
453
+ overflow: visible;
454
+ }
455
+ .prob-fill {
456
+ height: 100%;
457
+ border-radius: 12px;
458
+ transition: width 0.4s;
459
+ }
460
+ .fill-high {
461
+ background: linear-gradient(90deg, #3b82f6, #6366f1);
462
+ }
463
+ .fill-medium {
464
+ background: linear-gradient(90deg, #f59e0b, #f97316);
465
+ }
466
+ .fill-low {
467
+ background: linear-gradient(90deg, #6b7280, #9ca3af);
468
+ }
469
+ .prob-marker {
470
+ position: absolute;
471
+ top: -22px;
472
+ transform: translateX(-50%);
473
+ font-size: 0.76rem;
474
+ font-weight: 700;
475
+ color: var(--text);
476
+ }
477
+
478
+ .json-pre {
479
+ background: #080e1d;
480
+ border: 1px solid var(--line);
481
+ border-radius: 12px;
482
+ padding: 16px;
483
+ overflow: auto;
484
+ max-height: 500px;
485
+ font-size: 0.82rem;
486
+ line-height: 1.5;
487
+ }
488
+
489
+ /* Disclaimer */
490
+ .disclaimer-box {
491
+ margin-top: 16px;
492
+ padding: 16px 20px;
493
+ border-radius: var(--radius);
494
+ background: rgba(251, 191, 36, 0.06);
495
+ border: 1px solid rgba(251, 191, 36, 0.2);
496
+ font-size: 0.9rem;
497
+ line-height: 1.6;
498
+ color: var(--muted);
499
+ }
500
+ .disclaimer-box strong {
501
+ color: var(--orange);
502
+ }
503
+
504
+ /* ── Evaluation page ───────────────────────────────────────── */
505
+ .eval-grid {
506
+ display: grid;
507
+ grid-template-columns: 1fr 1fr;
508
+ gap: 16px;
509
+ margin-top: 16px;
510
+ }
511
+
512
+ .metric-grid {
513
+ display: grid;
514
+ grid-template-columns: 1fr 1fr;
515
+ gap: 10px;
516
+ }
517
+ .metric-card {
518
+ background: var(--bg2);
519
+ border: 1px solid var(--line);
520
+ border-radius: 10px;
521
+ padding: 14px;
522
+ text-align: center;
523
+ }
524
+ .metric-label {
525
+ font-size: 0.78rem;
526
+ color: var(--muted);
527
+ font-weight: 600;
528
+ text-transform: uppercase;
529
+ }
530
+ .metric-value {
531
+ font-size: 1.3rem;
532
+ font-weight: 800;
533
+ margin-top: 2px;
534
+ color: var(--accent);
535
+ }
536
+
537
+ /* Band analysis */
538
+ .band-grid {
539
+ display: grid;
540
+ grid-template-columns: repeat(3, 1fr);
541
+ gap: 12px;
542
+ margin-top: 12px;
543
+ }
544
+ .band-card {
545
+ background: var(--bg2);
546
+ border: 1px solid var(--line);
547
+ border-radius: 12px;
548
+ padding: 14px;
549
+ }
550
+ .band-header {
551
+ display: flex;
552
+ align-items: center;
553
+ gap: 10px;
554
+ margin-bottom: 12px;
555
+ }
556
+ .band-total {
557
+ color: var(--muted);
558
+ font-size: 0.85rem;
559
+ }
560
+ .band-bar-row {
561
+ display: flex;
562
+ align-items: center;
563
+ gap: 8px;
564
+ margin-bottom: 6px;
565
+ }
566
+ .band-bar-label {
567
+ width: 60px;
568
+ font-size: 0.8rem;
569
+ color: var(--muted);
570
+ }
571
+ .band-bar {
572
+ flex: 1;
573
+ height: 14px;
574
+ border-radius: 7px;
575
+ background: var(--panel);
576
+ overflow: hidden;
577
+ }
578
+ .band-bar-fill {
579
+ height: 100%;
580
+ border-radius: 7px;
581
+ transition: width 0.4s;
582
+ }
583
+ .fill-red {
584
+ background: var(--red);
585
+ }
586
+ .fill-green {
587
+ background: var(--green);
588
+ }
589
+ .band-bar-val {
590
+ width: 36px;
591
+ font-size: 0.82rem;
592
+ text-align: right;
593
+ }
594
+
595
+ /* Histogram */
596
+ .histogram {
597
+ display: flex;
598
+ align-items: flex-end;
599
+ gap: 6px;
600
+ margin-top: 12px;
601
+ padding: 8px 0;
602
+ min-height: 220px;
603
+ }
604
+ .hist-col {
605
+ flex: 1;
606
+ display: flex;
607
+ flex-direction: column;
608
+ align-items: center;
609
+ }
610
+ .hist-bar {
611
+ width: 100%;
612
+ border-radius: 6px 6px 0 0;
613
+ background: linear-gradient(180deg, var(--accent), #3b82f6);
614
+ min-height: 2px;
615
+ position: relative;
616
+ }
617
+ .hist-count {
618
+ position: absolute;
619
+ top: -20px;
620
+ left: 50%;
621
+ transform: translateX(-50%);
622
+ font-size: 0.72rem;
623
+ font-weight: 600;
624
+ color: var(--muted);
625
+ }
626
+ .hist-label {
627
+ font-size: 0.72rem;
628
+ color: var(--muted);
629
+ margin-top: 4px;
630
+ }
631
+
632
+ /* ── About page ────────────────────────────────────────────── */
633
+ .about-grid {
634
+ display: grid;
635
+ grid-template-columns: 1fr 1fr;
636
+ gap: 16px;
637
+ margin-top: 16px;
638
+ }
639
+
640
+ /* Architecture flow */
641
+ .arch-flow {
642
+ display: flex;
643
+ align-items: center;
644
+ gap: 6px;
645
+ flex-wrap: wrap;
646
+ margin-top: 16px;
647
+ padding: 12px 0;
648
+ }
649
+ .arch-step {
650
+ display: flex;
651
+ align-items: center;
652
+ gap: 8px;
653
+ background: var(--bg2);
654
+ border: 1px solid var(--line);
655
+ border-radius: 10px;
656
+ padding: 10px 14px;
657
+ }
658
+ .arch-num {
659
+ width: 26px;
660
+ height: 26px;
661
+ border-radius: 50%;
662
+ background: var(--accent);
663
+ color: var(--bg);
664
+ font-weight: 800;
665
+ font-size: 0.82rem;
666
+ display: flex;
667
+ align-items: center;
668
+ justify-content: center;
669
+ }
670
+ .arch-label {
671
+ font-size: 0.85rem;
672
+ font-weight: 500;
673
+ }
674
+ .arch-arrow {
675
+ color: var(--muted);
676
+ font-size: 1.2rem;
677
+ }
678
+
679
+ /* Triage cards */
680
+ .triage-grid {
681
+ display: grid;
682
+ grid-template-columns: repeat(3, 1fr);
683
+ gap: 12px;
684
+ margin-top: 12px;
685
+ }
686
+ .triage-card {
687
+ background: var(--bg2);
688
+ border: 1px solid var(--line);
689
+ border-radius: 12px;
690
+ padding: 16px;
691
+ }
692
+ .triage-card p {
693
+ font-size: 0.88rem;
694
+ margin-top: 6px;
695
+ color: var(--muted);
696
+ }
697
+ .triage-card p strong {
698
+ color: var(--text);
699
+ }
700
+ .triage-header {
701
+ display: flex;
702
+ align-items: center;
703
+ gap: 10px;
704
+ margin-bottom: 8px;
705
+ font-size: 0.85rem;
706
+ color: var(--muted);
707
+ }
708
+
709
+ /* Ethics */
710
+ .ethics-columns {
711
+ display: grid;
712
+ grid-template-columns: 1fr 1fr;
713
+ gap: 24px;
714
+ margin-top: 12px;
715
+ }
716
+ .ethics-columns h4 {
717
+ font-size: 0.95rem;
718
+ margin-bottom: 8px;
719
+ }
720
+ .check-list,
721
+ .cross-list {
722
+ list-style: none;
723
+ padding: 0;
724
+ }
725
+ .check-list li,
726
+ .cross-list li {
727
+ padding: 5px 0;
728
+ padding-left: 24px;
729
+ position: relative;
730
+ font-size: 0.9rem;
731
+ color: var(--muted);
732
+ }
733
+ .check-list li::before {
734
+ content: "βœ“";
735
+ position: absolute;
736
+ left: 0;
737
+ color: var(--green);
738
+ font-weight: 700;
739
+ }
740
+ .cross-list li::before {
741
+ content: "βœ•";
742
+ position: absolute;
743
+ left: 0;
744
+ color: var(--red);
745
+ font-weight: 700;
746
+ }
747
+
748
+ /* Tech tags */
749
+ .tech-tags {
750
+ display: flex;
751
+ flex-wrap: wrap;
752
+ gap: 8px;
753
+ margin-top: 4px;
754
+ }
755
+ .tech-tag {
756
+ padding: 5px 14px;
757
+ border-radius: 999px;
758
+ font-size: 0.82rem;
759
+ font-weight: 500;
760
+ background: rgba(110, 168, 254, 0.08);
761
+ border: 1px solid rgba(110, 168, 254, 0.2);
762
+ color: var(--accent);
763
+ }
764
+
765
+ /* ── Footer ────────────────────────────────────────────────── */
766
+ .footer {
767
+ margin-top: 48px;
768
+ padding: 20px 0;
769
+ border-top: 1px solid var(--line);
770
+ text-align: center;
771
+ font-size: 0.85rem;
772
+ color: var(--muted);
773
+ }
774
+ .footer p + p {
775
+ margin-top: 4px;
776
+ }
777
+
778
+ /* ── Home Page ─────────────────────────────────────────────── */
779
+ .home-hero {
780
+ text-align: center;
781
+ padding: 48px 0 12px;
782
+ }
783
+ .home-hero h1 {
784
+ font-size: 2.2rem;
785
+ font-weight: 800;
786
+ }
787
+ .home-hero p {
788
+ color: var(--muted);
789
+ margin-top: 8px;
790
+ max-width: 600px;
791
+ margin-left: auto;
792
+ margin-right: auto;
793
+ }
794
+
795
+ .home-cards {
796
+ display: grid;
797
+ grid-template-columns: 1fr 1fr;
798
+ gap: 20px;
799
+ margin-top: 32px;
800
+ }
801
+
802
+ .home-card {
803
+ display: flex;
804
+ flex-direction: column;
805
+ align-items: center;
806
+ text-align: center;
807
+ padding: 40px 32px;
808
+ border-radius: var(--radius);
809
+ border: 1px solid var(--line);
810
+ background: linear-gradient(180deg, var(--panel2), var(--panel));
811
+ text-decoration: none;
812
+ color: var(--text);
813
+ transition: all 0.2s;
814
+ }
815
+ .home-card:hover {
816
+ border-color: var(--accent);
817
+ transform: translateY(-2px);
818
+ box-shadow: 0 8px 32px rgba(110, 168, 254, 0.1);
819
+ }
820
+ .home-card-icon {
821
+ color: var(--accent);
822
+ margin-bottom: 16px;
823
+ }
824
+ .home-card h2 {
825
+ font-size: 1.3rem;
826
+ font-weight: 700;
827
+ margin-bottom: 8px;
828
+ }
829
+ .home-card p {
830
+ color: var(--muted);
831
+ font-size: 0.92rem;
832
+ line-height: 1.5;
833
+ }
834
+ .home-card-action {
835
+ margin-top: 16px;
836
+ color: var(--accent);
837
+ font-weight: 600;
838
+ font-size: 0.9rem;
839
+ }
840
+
841
+ .home-cards-secondary {
842
+ grid-template-columns: repeat(3, 1fr);
843
+ margin-top: 16px;
844
+ }
845
+ .home-card-sm {
846
+ padding: 28px 24px;
847
+ }
848
+ .home-card-sm h3 {
849
+ font-size: 1.05rem;
850
+ font-weight: 700;
851
+ margin-bottom: 4px;
852
+ }
853
+
854
+ /* ── Page Header ───────────────────────────────────────────── */
855
+ .page-header {
856
+ margin-bottom: 24px;
857
+ }
858
+ .page-header h1 {
859
+ font-size: 1.8rem;
860
+ font-weight: 800;
861
+ }
862
+ .page-header p {
863
+ color: var(--muted);
864
+ margin-top: 6px;
865
+ line-height: 1.5;
866
+ }
867
+
868
+ /* ── Logs ───────────────────────────────────────────────────── */
869
+ .log-summary {
870
+ margin-bottom: 12px;
871
+ }
872
+ .logs-table td code {
873
+ font-family: "SF Mono", "Cascadia Code", monospace;
874
+ font-size: 0.85rem;
875
+ color: var(--accent);
876
+ }
877
+ .log-actions {
878
+ display: flex;
879
+ gap: 6px;
880
+ }
881
+
882
+ /* ── Upload Page ───────────────────────────────────────────── */
883
+ .upload-hero {
884
+ padding: 8px 0 6px;
885
+ }
886
+ .upload-hero h1 {
887
+ font-size: 1.8rem;
888
+ font-weight: 800;
889
+ }
890
+ .upload-hero p {
891
+ color: var(--muted);
892
+ margin-top: 6px;
893
+ }
894
+
895
+ .upload-panel {
896
+ position: relative;
897
+ }
898
+
899
+ .dropzone {
900
+ display: flex;
901
+ flex-direction: column;
902
+ align-items: center;
903
+ justify-content: center;
904
+ padding: 48px 24px;
905
+ border: 2px dashed var(--line);
906
+ border-radius: 12px;
907
+ cursor: pointer;
908
+ transition: all 0.2s;
909
+ color: var(--muted);
910
+ }
911
+ .dropzone:hover,
912
+ .dropzone.dragover {
913
+ border-color: var(--accent);
914
+ background: rgba(110, 168, 254, 0.04);
915
+ }
916
+ .dropzone-text {
917
+ font-size: 1.05rem;
918
+ font-weight: 600;
919
+ margin-top: 12px;
920
+ color: var(--text);
921
+ }
922
+
923
+ .file-info {
924
+ display: flex;
925
+ align-items: center;
926
+ gap: 10px;
927
+ padding: 14px 16px;
928
+ border: 1px solid var(--accent);
929
+ border-radius: 10px;
930
+ background: rgba(110, 168, 254, 0.06);
931
+ color: var(--accent);
932
+ font-weight: 500;
933
+ }
934
+ .file-info span {
935
+ flex: 1;
936
+ }
937
+
938
+ .btn-primary {
939
+ margin-top: 16px;
940
+ width: 100%;
941
+ justify-content: center;
942
+ padding: 12px 24px;
943
+ background: linear-gradient(135deg, #3b82f6, #6366f1);
944
+ border-color: #3b82f6;
945
+ font-weight: 600;
946
+ font-size: 0.95rem;
947
+ }
948
+ .btn-primary:hover {
949
+ background: linear-gradient(135deg, #2563eb, #4f46e5);
950
+ }
951
+ .btn-primary:disabled {
952
+ opacity: 0.4;
953
+ cursor: not-allowed;
954
+ }
955
+
956
+ .loading-overlay {
957
+ position: absolute;
958
+ inset: 0;
959
+ display: flex;
960
+ flex-direction: column;
961
+ align-items: center;
962
+ justify-content: center;
963
+ background: rgba(17, 28, 51, 0.95);
964
+ border-radius: var(--radius);
965
+ z-index: 10;
966
+ gap: 16px;
967
+ }
968
+
969
+ .spinner {
970
+ width: 48px;
971
+ height: 48px;
972
+ border: 3px solid var(--line);
973
+ border-top-color: var(--accent);
974
+ border-radius: 50%;
975
+ animation: spin 0.8s linear infinite;
976
+ }
977
+ @keyframes spin {
978
+ to {
979
+ transform: rotate(360deg);
980
+ }
981
+ }
982
+
983
+ .steps-grid {
984
+ display: grid;
985
+ grid-template-columns: repeat(4, 1fr);
986
+ gap: 16px;
987
+ margin-top: 12px;
988
+ }
989
+ .step {
990
+ display: flex;
991
+ align-items: flex-start;
992
+ gap: 12px;
993
+ }
994
+ .step-num {
995
+ width: 28px;
996
+ height: 28px;
997
+ border-radius: 50%;
998
+ background: var(--accent);
999
+ color: var(--bg);
1000
+ font-weight: 800;
1001
+ font-size: 0.82rem;
1002
+ display: flex;
1003
+ align-items: center;
1004
+ justify-content: center;
1005
+ flex-shrink: 0;
1006
+ }
1007
+ .step-text strong {
1008
+ font-size: 0.92rem;
1009
+ }
1010
+
1011
+ /* ── Flash Messages ────────────────────────────────────────── */
1012
+ .flash-messages {
1013
+ margin-bottom: 16px;
1014
+ }
1015
+ .flash {
1016
+ padding: 12px 16px;
1017
+ border-radius: 10px;
1018
+ font-size: 0.9rem;
1019
+ margin-bottom: 8px;
1020
+ }
1021
+ .flash-error {
1022
+ background: rgba(251, 113, 133, 0.1);
1023
+ border: 1px solid rgba(251, 113, 133, 0.3);
1024
+ color: var(--red);
1025
+ }
1026
+ .flash-success {
1027
+ background: rgba(52, 211, 153, 0.1);
1028
+ border: 1px solid rgba(52, 211, 153, 0.3);
1029
+ color: var(--green);
1030
+ }
1031
+
1032
+ /* ── Upload Tabs ───────────────────────────────────────────── */
1033
+ .upload-tabs {
1034
+ display: flex;
1035
+ gap: 4px;
1036
+ margin-bottom: 0;
1037
+ border-bottom: 2px solid var(--line);
1038
+ padding-bottom: 0;
1039
+ }
1040
+ .upload-tab {
1041
+ padding: 10px 20px;
1042
+ background: none;
1043
+ border: none;
1044
+ color: var(--muted);
1045
+ font-size: 0.9rem;
1046
+ font-weight: 600;
1047
+ font-family: inherit;
1048
+ cursor: pointer;
1049
+ border-bottom: 2px solid transparent;
1050
+ margin-bottom: -2px;
1051
+ transition: all 0.15s;
1052
+ }
1053
+ .upload-tab:hover {
1054
+ color: var(--text);
1055
+ }
1056
+ .upload-tab.active {
1057
+ color: var(--accent);
1058
+ border-bottom-color: var(--accent);
1059
+ }
1060
+ .tab-panel {
1061
+ display: none;
1062
+ margin-top: 16px;
1063
+ }
1064
+ .tab-panel.active {
1065
+ display: block;
1066
+ }
1067
+
1068
+ /* ── Directory Input ───────────────────────────────────────── */
1069
+ .dir-label {
1070
+ display: block;
1071
+ font-weight: 600;
1072
+ margin-bottom: 8px;
1073
+ font-size: 0.92rem;
1074
+ }
1075
+ .dir-input-row {
1076
+ display: flex;
1077
+ gap: 10px;
1078
+ }
1079
+ .dir-input-row .input {
1080
+ flex: 1;
1081
+ padding: 10px 14px;
1082
+ font-size: 0.92rem;
1083
+ font-family: "SF Mono", "Cascadia Code", monospace;
1084
+ background: var(--panel);
1085
+ border: 1px solid var(--line);
1086
+ border-radius: var(--radius);
1087
+ color: var(--text);
1088
+ outline: none;
1089
+ transition: border-color 0.15s;
1090
+ }
1091
+ .dir-input-row .input:focus {
1092
+ border-color: var(--accent);
1093
+ }
1094
+ .dir-input-row .btn-primary {
1095
+ margin-top: 0;
1096
+ width: auto;
1097
+ white-space: nowrap;
1098
+ }
1099
+
1100
+ /* ── Batch Progress Page ───────────────────────────────────── */
1101
+ .batch-header {
1102
+ margin-bottom: 20px;
1103
+ }
1104
+ .batch-header h1 {
1105
+ font-size: 1.6rem;
1106
+ font-weight: 800;
1107
+ }
1108
+ .batch-panel {
1109
+ padding: 24px;
1110
+ }
1111
+ .batch-stats-row {
1112
+ display: grid;
1113
+ grid-template-columns: repeat(4, 1fr);
1114
+ gap: 12px;
1115
+ margin-bottom: 20px;
1116
+ }
1117
+ .batch-stat {
1118
+ text-align: center;
1119
+ }
1120
+ .batch-stat-label {
1121
+ display: block;
1122
+ font-size: 0.78rem;
1123
+ color: var(--muted);
1124
+ font-weight: 600;
1125
+ text-transform: uppercase;
1126
+ letter-spacing: 0.04em;
1127
+ }
1128
+ .batch-stat-value {
1129
+ display: block;
1130
+ font-size: 1.6rem;
1131
+ font-weight: 800;
1132
+ margin-top: 2px;
1133
+ }
1134
+ .batch-stat.accent-green .batch-stat-value {
1135
+ color: var(--green);
1136
+ }
1137
+ .batch-stat.accent-red .batch-stat-value {
1138
+ color: var(--red);
1139
+ }
1140
+
1141
+ .progress-track {
1142
+ width: 100%;
1143
+ height: 12px;
1144
+ background: var(--panel);
1145
+ border: 1px solid var(--line);
1146
+ border-radius: 6px;
1147
+ overflow: hidden;
1148
+ }
1149
+ .progress-fill {
1150
+ height: 100%;
1151
+ background: linear-gradient(90deg, #3b82f6, #6366f1);
1152
+ border-radius: 6px;
1153
+ transition: width 0.4s ease;
1154
+ }
1155
+ .progress-text {
1156
+ display: flex;
1157
+ justify-content: space-between;
1158
+ margin-top: 8px;
1159
+ font-size: 0.88rem;
1160
+ font-weight: 600;
1161
+ }
1162
+
1163
+ .batch-feed {
1164
+ list-style: none;
1165
+ padding: 0;
1166
+ margin: 0;
1167
+ }
1168
+ .batch-feed li {
1169
+ padding: 6px 0;
1170
+ border-bottom: 1px solid var(--line);
1171
+ font-size: 0.88rem;
1172
+ }
1173
+ .batch-feed li a {
1174
+ color: var(--accent);
1175
+ text-decoration: none;
1176
+ }
1177
+ .batch-feed li a:hover {
1178
+ text-decoration: underline;
1179
+ }
1180
+
1181
+ .batch-done-panel {
1182
+ text-align: center;
1183
+ padding: 40px 24px;
1184
+ }
1185
+ .batch-done-icon {
1186
+ margin-bottom: 16px;
1187
+ }
1188
+ .batch-done-panel h2 {
1189
+ font-size: 1.5rem;
1190
+ font-weight: 800;
1191
+ margin-bottom: 8px;
1192
+ }
1193
+ .batch-done-actions {
1194
+ display: flex;
1195
+ gap: 12px;
1196
+ justify-content: center;
1197
+ margin-top: 20px;
1198
+ }
1199
+ .batch-done-actions .btn-primary {
1200
+ width: auto;
1201
+ margin-top: 0;
1202
+ }
1203
+
1204
+ .batch-fail-list {
1205
+ padding-left: 20px;
1206
+ }
1207
+ .batch-fail-list li {
1208
+ color: var(--red);
1209
+ font-family: "SF Mono", "Cascadia Code", monospace;
1210
+ font-size: 0.85rem;
1211
+ padding: 3px 0;
1212
+ }
1213
+ .text-red {
1214
+ color: var(--red);
1215
+ }
1216
+
1217
+ /* ── Responsive ────────────────────────────────────────────── */
1218
+ @media (max-width: 1024px) {
1219
+ .stats-row {
1220
+ grid-template-columns: repeat(3, 1fr);
1221
+ }
1222
+ .home-cards-secondary {
1223
+ grid-template-columns: 1fr;
1224
+ }
1225
+ .topbar-inner {
1226
+ padding: 14px 16px;
1227
+ }
1228
+ .detail-grid,
1229
+ .eval-grid,
1230
+ .about-grid,
1231
+ .ethics-columns {
1232
+ grid-template-columns: 1fr;
1233
+ }
1234
+ .triage-grid,
1235
+ .band-grid {
1236
+ grid-template-columns: 1fr;
1237
+ }
1238
+ .arch-flow {
1239
+ justify-content: center;
1240
+ }
1241
+ .home-cards {
1242
+ grid-template-columns: 1fr;
1243
+ }
1244
+ .steps-grid {
1245
+ grid-template-columns: 1fr 1fr;
1246
+ }
1247
+ .batch-stats-row {
1248
+ grid-template-columns: repeat(2, 1fr);
1249
+ }
1250
+ }
1251
+ @media (max-width: 640px) {
1252
+ .stats-row {
1253
+ grid-template-columns: 1fr 1fr;
1254
+ }
1255
+ .topbar-inner {
1256
+ flex-direction: column;
1257
+ gap: 8px;
1258
+ }
1259
+ .nav-links {
1260
+ width: 100%;
1261
+ justify-content: center;
1262
+ flex-wrap: wrap;
1263
+ }
1264
+ .detail-header {
1265
+ flex-direction: column;
1266
+ }
1267
+ .filters {
1268
+ flex-direction: column;
1269
+ }
1270
+ .home-hero h1 {
1271
+ font-size: 1.7rem;
1272
+ }
1273
+ .home-card {
1274
+ padding: 28px 20px;
1275
+ }
1276
+ .steps-grid {
1277
+ grid-template-columns: 1fr;
1278
+ }
1279
+ .upload-tabs {
1280
+ flex-wrap: wrap;
1281
+ }
1282
+ .dir-input-row {
1283
+ flex-direction: column;
1284
+ }
1285
+ .dir-input-row .btn-primary {
1286
+ width: 100%;
1287
+ }
1288
+ .batch-done-actions {
1289
+ flex-direction: column;
1290
+ }
1291
+ }
templates/about.html ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}About β€” ICH Screening{% endblock %}
4
+
5
+ {% block content %}
6
+ <section class="hero">
7
+ <div class="hero-text">
8
+ <h1>About This System</h1>
9
+ <p>
10
+ AI-Assisted CT-Based Intracranial Hemorrhage Detection with Explainability
11
+ and Clinical Reporting
12
+ </p>
13
+ </div>
14
+ </section>
15
+
16
+ <!-- System Overview -->
17
+ <section class="panel">
18
+ <h3>System Overview</h3>
19
+ <p>
20
+ This is an AI-assisted screening tool designed to detect intracranial
21
+ hemorrhage (ICH) from CT brain scans. It combines deep learning with visual
22
+ explainability, confidence calibration, and structured clinical reporting to
23
+ support β€” not replace β€” medical decision-making.
24
+ </p>
25
+ <div class="arch-flow">
26
+ <div class="arch-step">
27
+ <div class="arch-num">1</div>
28
+ <div class="arch-label">CT Brain Image Input</div>
29
+ </div>
30
+ <div class="arch-arrow">β†’</div>
31
+ <div class="arch-step">
32
+ <div class="arch-num">2</div>
33
+ <div class="arch-label">Preprocessing &amp; CT Windowing</div>
34
+ </div>
35
+ <div class="arch-arrow">β†’</div>
36
+ <div class="arch-step">
37
+ <div class="arch-num">3</div>
38
+ <div class="arch-label">2.5D Detection (EfficientNet-B4)</div>
39
+ </div>
40
+ <div class="arch-arrow">β†’</div>
41
+ <div class="arch-step">
42
+ <div class="arch-num">4</div>
43
+ <div class="arch-label">Grad-CAM Explainability</div>
44
+ </div>
45
+ <div class="arch-arrow">β†’</div>
46
+ <div class="arch-step">
47
+ <div class="arch-num">5</div>
48
+ <div class="arch-label">Confidence Calibration</div>
49
+ </div>
50
+ <div class="arch-arrow">β†’</div>
51
+ <div class="arch-step">
52
+ <div class="arch-num">6</div>
53
+ <div class="arch-label">Clinical Report</div>
54
+ </div>
55
+ </div>
56
+ </section>
57
+
58
+ <!-- Technical Details -->
59
+ <section class="about-grid">
60
+ <article class="panel">
61
+ <h3>Model Architecture</h3>
62
+ <div class="kv-group">
63
+ <div class="kv">
64
+ <span>Architecture</span><strong>EfficientNet-B4 (timm)</strong>
65
+ </div>
66
+ <div class="kv">
67
+ <span>Input Representation</span><strong>2.5D (prev/center/next)</strong>
68
+ </div>
69
+ <div class="kv">
70
+ <span>Channels</span><strong>9 (3 CT windows Γ— 3 slices)</strong>
71
+ </div>
72
+ <div class="kv"><span>Outputs</span><strong>6 heads (any + 5 subtypes)</strong></div>
73
+ <div class="kv">
74
+ <span>Inference Strategy</span><strong>5-fold ensemble (logit averaging)</strong>
75
+ </div>
76
+ </div>
77
+ </article>
78
+
79
+ <article class="panel">
80
+ <h3>CT Preprocessing</h3>
81
+ <div class="kv-group">
82
+ <div class="kv">
83
+ <span>Brain Window</span><strong>WC=40, WW=80</strong>
84
+ </div>
85
+ <div class="kv">
86
+ <span>Subdural Window</span><strong>WC=75, WW=215</strong>
87
+ </div>
88
+ <div class="kv">
89
+ <span>Soft Tissue Window</span><strong>WC=40, WW=380</strong>
90
+ </div>
91
+ <div class="kv">
92
+ <span>Channels</span><strong>3 (one per window)</strong>
93
+ </div>
94
+ <div class="kv">
95
+ <span>Format</span><strong>DICOM β†’ HU β†’ windowed RGB</strong>
96
+ </div>
97
+ </div>
98
+ </article>
99
+
100
+ <article class="panel">
101
+ <h3>Calibration</h3>
102
+ <div class="kv-group">
103
+ <div class="kv">
104
+ <span>Method</span
105
+ ><strong>{{ calib.get('method', calib.get('best_method', 'N/A')) }}</strong>
106
+ </div>
107
+ {% if calib %}
108
+ <div class="kv">
109
+ <span>Temperature</span
110
+ ><strong>{{ '%.4f'|format(calib.temperature) }}</strong>
111
+ </div>
112
+ <div class="kv">
113
+ <span>Threshold</span
114
+ ><strong>{{ '%.4f'|format(calib.calibrated_threshold) }}</strong>
115
+ </div>
116
+ {% endif %}
117
+ <div class="kv">
118
+ <span>ECE (Raw β†’ Calibrated)</span
119
+ ><strong>{{ '%.4f'|format(calib.get('raw_ece', 0.0)) }} β†’ {{ '%.4f'|format(calib.get('cal_ece', 0.0)) }}</strong>
120
+ </div>
121
+ <div class="kv">
122
+ <span>Bands</span
123
+ ><strong>
124
+ HIGH (β‰₯{{ '%.2f'|format(calib.get('high_threshold', 0.7)) }}) Β·
125
+ MEDIUM ({{ '%.2f'|format(calib.get('low_threshold', 0.3)) }}–{{ '%.2f'|format(calib.get('high_threshold', 0.7)) }}) Β·
126
+ LOW (&lt;{{ '%.2f'|format(calib.get('low_threshold', 0.3)) }})
127
+ </strong>
128
+ </div>
129
+ </div>
130
+ </article>
131
+
132
+ <article class="panel">
133
+ <h3>Explainability</h3>
134
+ <div class="kv-group">
135
+ <div class="kv"><span>Method</span><strong>Grad-CAM</strong></div>
136
+ <div class="kv">
137
+ <span>Target Layer</span><strong>Last convolutional block</strong>
138
+ </div>
139
+ <div class="kv">
140
+ <span>Output</span><strong>Heatmap overlay on input</strong>
141
+ </div>
142
+ <div class="kv">
143
+ <span>Purpose</span><strong>Visual evidence for review</strong>
144
+ </div>
145
+ </div>
146
+ </article>
147
+ </section>
148
+
149
+ <!-- Confidence-Aware Triage -->
150
+ <section class="panel" style="margin-top: 16px">
151
+ <h3>Confidence-Aware Triage System</h3>
152
+ <p>
153
+ Instead of a simple binary output, the system incorporates prediction
154
+ confidence into a three-band triage workflow:
155
+ </p>
156
+
157
+ <div class="triage-grid">
158
+ <div class="triage-card triage-high">
159
+ <div class="triage-header">
160
+ <span class="badge badge-high">HIGH</span>
161
+ <span>β‰₯ {{ '%.2f'|format(calib.get('high_threshold', 0.7)) }} calibrated probability</span>
162
+ </div>
163
+ <p><strong>If positive:</strong> Urgent radiologist review recommended</p>
164
+ <p><strong>If negative:</strong> Standard workflow β€” no urgent action</p>
165
+ </div>
166
+
167
+ <div class="triage-card triage-medium">
168
+ <div class="triage-header">
169
+ <span class="badge badge-medium">MEDIUM</span>
170
+ <span>{{ '%.2f'|format(calib.get('low_threshold', 0.3)) }} – {{ '%.2f'|format(calib.get('high_threshold', 0.7)) }}</span>
171
+ </div>
172
+ <p>
173
+ <strong>If positive:</strong> Prioritised radiologist review recommended
174
+ </p>
175
+ <p>
176
+ <strong>If negative:</strong> Standard workflow β€” manual review if
177
+ clinically indicated
178
+ </p>
179
+ </div>
180
+
181
+ <div class="triage-card triage-low">
182
+ <div class="triage-header">
183
+ <span class="badge badge-low">LOW</span>
184
+ <span>&lt; {{ '%.2f'|format(calib.get('low_threshold', 0.3)) }}</span>
185
+ </div>
186
+ <p>
187
+ <strong>If positive:</strong> Radiologist review recommended β€” low
188
+ confidence
189
+ </p>
190
+ <p>
191
+ <strong>If negative:</strong> Manual review recommended β€” model
192
+ uncertainty high
193
+ </p>
194
+ </div>
195
+ </div>
196
+ </section>
197
+
198
+ <!-- Dataset -->
199
+ <section class="panel" style="margin-top: 16px">
200
+ <h3>Dataset</h3>
201
+ <div class="kv-group" style="max-width: 600px">
202
+ <div class="kv">
203
+ <span>Source</span><strong>RSNA Intracranial Hemorrhage Detection</strong>
204
+ </div>
205
+ <div class="kv">
206
+ <span>Modality</span><strong>CT brain (axial slices)</strong>
207
+ </div>
208
+ <div class="kv"><span>Format</span><strong>DICOM</strong></div>
209
+ <div class="kv">
210
+ <span>Task</span><strong>Any-hemorrhage screening + subtype-aware outputs</strong>
211
+ </div>
212
+ </div>
213
+ </section>
214
+
215
+ <!-- Ethical Considerations -->
216
+ <section class="panel" style="margin-top: 16px">
217
+ <h3>Ethical Considerations &amp; Limitations</h3>
218
+
219
+ <div class="ethics-columns">
220
+ <div>
221
+ <h4>This System Is:</h4>
222
+ <ul class="check-list">
223
+ <li>A screening and decision-support tool</li>
224
+ <li>Designed to assist, not replace, medical professionals</li>
225
+ <li>Transparent via Grad-CAM visual evidence</li>
226
+ <li>Calibrated for reliable confidence scores</li>
227
+ <li>Built on publicly available, ethically sourced data</li>
228
+ </ul>
229
+ </div>
230
+ <div>
231
+ <h4>This System Is NOT:</h4>
232
+ <ul class="cross-list">
233
+ <li>A diagnostic device or medical diagnosis tool</li>
234
+ <li>A replacement for qualified radiologist review</li>
235
+ <li>Cleared for standalone clinical deployment</li>
236
+ <li>A substitute for clinical subtype confirmation</li>
237
+ <li>Validated for real-time hospital use</li>
238
+ </ul>
239
+ </div>
240
+ </div>
241
+ </section>
242
+
243
+ <!-- Disclaimer -->
244
+ <section class="disclaimer-box" style="margin-top: 16px">
245
+ <strong>Important Disclaimer:</strong>
246
+ This system is produced by an AI-assisted screening tool and does NOT
247
+ constitute a medical diagnosis. All screening findings must be reviewed and
248
+ confirmed by a qualified, licensed medical professional before any clinical
249
+ decision is made. The system is intended solely as a decision-support aid in a
250
+ screening workflow and is not cleared for standalone diagnostic use.
251
+ </section>
252
+
253
+ <!-- Technology Stack -->
254
+ <section class="panel" style="margin-top: 16px">
255
+ <h3>Technology Stack</h3>
256
+ <div class="tech-tags">
257
+ <span class="tech-tag">Python</span>
258
+ <span class="tech-tag">PyTorch</span>
259
+ <span class="tech-tag">EfficientNet-B4</span>
260
+ <span class="tech-tag">timm</span>
261
+ <span class="tech-tag">2.5D Context</span>
262
+ <span class="tech-tag">5-Fold Ensemble</span>
263
+ <span class="tech-tag">Isotonic Calibration</span>
264
+ <span class="tech-tag">OpenCV</span>
265
+ <span class="tech-tag">NumPy</span>
266
+ <span class="tech-tag">Pandas</span>
267
+ <span class="tech-tag">Matplotlib</span>
268
+ <span class="tech-tag">Grad-CAM</span>
269
+ <span class="tech-tag">Flask</span>
270
+ <span class="tech-tag">pydicom</span>
271
+ <span class="tech-tag">scikit-learn</span>
272
+ </div>
273
+ </section>
274
+ {% endblock %}
templates/base.html ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{% block title %}ICH Screening Dashboard{% endblock %}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <link
14
+ rel="stylesheet"
15
+ href="{{ url_for('static', filename='styles.css') }}"
16
+ />
17
+ {% block head %}{% endblock %}
18
+ </head>
19
+ <body>
20
+ <!-- ── Top navigation ─────────────────────────────────────────────── -->
21
+ <header class="topbar">
22
+ <div class="topbar-inner">
23
+ <a class="brand" href="{{ url_for('home') }}">
24
+ <span class="brand-icon">
25
+ <svg
26
+ width="22"
27
+ height="22"
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ stroke-width="2"
32
+ stroke-linecap="round"
33
+ stroke-linejoin="round"
34
+ >
35
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
36
+ </svg>
37
+ </span>
38
+ <span>ICH Screening</span>
39
+ </a>
40
+
41
+ <nav class="nav-links">
42
+ <a href="{{ url_for('home') }}"
43
+ class="{% if request.endpoint == 'home' %}active{% endif %}">Home</a>
44
+ <a href="{{ url_for('upload') }}"
45
+ class="{% if request.endpoint == 'upload' %}active{% endif %}">New Scan</a>
46
+ <a href="{{ url_for('reports') }}"
47
+ class="{% if request.endpoint == 'reports' %}active{% endif %}">Past Reports</a>
48
+ <a href="{{ url_for('logs_page') }}"
49
+ class="{% if request.endpoint == 'logs_page' %}active{% endif %}">Logs</a>
50
+ <a href="{{ url_for('evaluation') }}"
51
+ class="{% if request.endpoint == 'evaluation' %}active{% endif %}">Evaluation</a>
52
+ <a href="{{ url_for('about') }}"
53
+ class="{% if request.endpoint == 'about' %}active{% endif %}">About</a>
54
+ </nav>
55
+ </div>
56
+ </header>
57
+
58
+ <!-- ── Main content ────────────────────────────────────────────────── -->
59
+ <main class="container page">{% block content %}{% endblock %}</main>
60
+
61
+ <!-- ── Footer ──────────────────────────────────────────────────────── -->
62
+ <footer class="footer">
63
+ <div class="container footer-inner">
64
+ <p>
65
+ AI-Assisted CT-Based Intracranial Hemorrhage Detection &mdash;
66
+ Screening Tool, Not a Diagnostic Device
67
+ </p>
68
+ <p class="muted small">
69
+ All findings must be reviewed by a qualified medical professional.
70
+ </p>
71
+ </div>
72
+ </footer>
73
+
74
+ {% block scripts %}{% endblock %}
75
+ </body>
76
+ </html>
templates/batch_progress.html ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Batch Processing β€” ICH Screening{% endblock %}
4
+
5
+ {% block content %}
6
+ <section class="breadcrumb">
7
+ <a href="{{ url_for('home') }}">Home</a>
8
+ <span class="sep">/</span>
9
+ <a href="{{ url_for('upload') }}">Upload</a>
10
+ <span class="sep">/</span>
11
+ <span>Batch {{ batch_id }}</span>
12
+ </section>
13
+
14
+ <section class="batch-header">
15
+ <h1 id="batchTitle">Processing Batch&hellip;</h1>
16
+ <p class="muted" id="batchSubtitle">
17
+ Analyzing {{ batch.total }} DICOM file{{ 's' if batch.total != 1 }} β€” please keep this page open.
18
+ </p>
19
+ </section>
20
+
21
+ <!-- ── Progress bar ────────────────────────────────────────────────── -->
22
+ <section class="panel batch-panel">
23
+ <div class="batch-stats-row">
24
+ <div class="batch-stat">
25
+ <span class="batch-stat-label">Total</span>
26
+ <span class="batch-stat-value" id="statTotal">{{ batch.total }}</span>
27
+ </div>
28
+ <div class="batch-stat">
29
+ <span class="batch-stat-label">Processed</span>
30
+ <span class="batch-stat-value" id="statProcessed">0</span>
31
+ </div>
32
+ <div class="batch-stat accent-green">
33
+ <span class="batch-stat-label">Succeeded</span>
34
+ <span class="batch-stat-value" id="statSucceeded">0</span>
35
+ </div>
36
+ <div class="batch-stat accent-red">
37
+ <span class="batch-stat-label">Failed</span>
38
+ <span class="batch-stat-value" id="statFailed">0</span>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="progress-track">
43
+ <div class="progress-fill" id="progressFill" style="width: 0%"></div>
44
+ </div>
45
+ <div class="progress-text">
46
+ <span id="progressPct">0%</span>
47
+ <span id="currentFile" class="muted"></span>
48
+ </div>
49
+ </section>
50
+
51
+ <!-- ── Live feed of recent results ─────────────────────────────────── -->
52
+ <section class="panel" id="feedPanel" style="display: none">
53
+ <h3>Recent Results</h3>
54
+ <ul class="batch-feed" id="batchFeed"></ul>
55
+ </section>
56
+
57
+ <!-- ── Completion summary (shown when done) ────────────────────────── -->
58
+ <section class="panel batch-done-panel" id="donePanel" style="display: none">
59
+ <div class="batch-done-icon">
60
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none"
61
+ stroke="var(--green)" stroke-width="2">
62
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
63
+ <polyline points="22 4 12 14.01 9 11.01" />
64
+ </svg>
65
+ </div>
66
+ <h2>Batch Complete</h2>
67
+ <p class="muted" id="doneSummary"></p>
68
+ <div class="batch-done-actions">
69
+ <a href="{{ url_for('reports') }}" class="btn btn-primary">View Reports</a>
70
+ <a href="{{ url_for('upload') }}" class="btn">Upload More</a>
71
+ </div>
72
+ </section>
73
+
74
+ <!-- ── Failed files (shown only if failures) ───────────────────────── -->
75
+ <section class="panel" id="failPanel" style="display: none">
76
+ <h3 class="text-red">Failed Files</h3>
77
+ <ul class="batch-fail-list" id="failList"></ul>
78
+ </section>
79
+ {% endblock %}
80
+
81
+ {% block scripts %}
82
+ <script>
83
+ (function () {
84
+ var BATCH_ID = "{{ batch_id }}";
85
+ var POLL_MS = 1000;
86
+ var statusUrl = "/batch/status/" + BATCH_ID;
87
+ var reportsUrl = "{{ url_for('reports') }}";
88
+
89
+ var title = document.getElementById("batchTitle");
90
+ var subtitle = document.getElementById("batchSubtitle");
91
+ var fill = document.getElementById("progressFill");
92
+ var pctLabel = document.getElementById("progressPct");
93
+ var currentFile = document.getElementById("currentFile");
94
+ var statTotal = document.getElementById("statTotal");
95
+ var statProc = document.getElementById("statProcessed");
96
+ var statOK = document.getElementById("statSucceeded");
97
+ var statFail = document.getElementById("statFailed");
98
+ var feedPanel = document.getElementById("feedPanel");
99
+ var feedList = document.getElementById("batchFeed");
100
+ var donePanel = document.getElementById("donePanel");
101
+ var doneSummary = document.getElementById("doneSummary");
102
+ var failPanel = document.getElementById("failPanel");
103
+ var failList = document.getElementById("failList");
104
+
105
+ var prevIds = []; // track already-shown image_ids
106
+
107
+ function poll() {
108
+ fetch(statusUrl)
109
+ .then(function (r) { return r.json(); })
110
+ .then(function (d) {
111
+ var pct = d.total > 0 ? Math.round(d.processed / d.total * 100) : 0;
112
+
113
+ /* Update numbers */
114
+ statTotal.textContent = d.total;
115
+ statProc.textContent = d.processed;
116
+ statOK.textContent = d.succeeded;
117
+ statFail.textContent = d.failed_count;
118
+
119
+ /* Progress bar */
120
+ fill.style.width = pct + "%";
121
+ pctLabel.textContent = pct + "%";
122
+
123
+ /* Current file label */
124
+ if (d.current_file) {
125
+ currentFile.textContent = "Processing: " + d.current_file;
126
+ } else {
127
+ currentFile.textContent = "";
128
+ }
129
+
130
+ /* Live feed of recently processed IDs */
131
+ if (d.image_ids && d.image_ids.length) {
132
+ feedPanel.style.display = "block";
133
+ d.image_ids.forEach(function (iid) {
134
+ if (prevIds.indexOf(iid) === -1) {
135
+ prevIds.push(iid);
136
+ var li = document.createElement("li");
137
+ var a = document.createElement("a");
138
+ a.href = "/case/" + iid;
139
+ a.textContent = iid;
140
+ li.appendChild(a);
141
+ feedList.insertBefore(li, feedList.firstChild);
142
+ /* Keep max 20 items visible */
143
+ while (feedList.children.length > 20) {
144
+ feedList.removeChild(feedList.lastChild);
145
+ }
146
+ }
147
+ });
148
+ }
149
+
150
+ /* Done? */
151
+ if (d.status === "completed" || d.status === "failed") {
152
+ title.textContent = "Batch Complete";
153
+ subtitle.textContent = "";
154
+ donePanel.style.display = "block";
155
+ doneSummary.textContent =
156
+ d.succeeded + " of " + d.total + " files processed successfully" +
157
+ (d.failed_count > 0 ? ", " + d.failed_count + " failed" : "") + ".";
158
+
159
+ /* Show failed files */
160
+ if (d.failed_ids && d.failed_ids.length) {
161
+ failPanel.style.display = "block";
162
+ d.failed_ids.forEach(function (fid) {
163
+ var li = document.createElement("li");
164
+ li.textContent = fid;
165
+ failList.appendChild(li);
166
+ });
167
+ }
168
+ return; /* stop polling */
169
+ }
170
+
171
+ /* Keep polling */
172
+ setTimeout(poll, POLL_MS);
173
+ })
174
+ .catch(function () {
175
+ /* Network error β€” retry after a longer delay */
176
+ setTimeout(poll, POLL_MS * 3);
177
+ });
178
+ }
179
+
180
+ /* Start polling immediately */
181
+ poll();
182
+ })();
183
+ </script>
184
+ {% endblock %}
templates/detail.html ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}{{ row.image_id }} β€” Report{% endblock %}
4
+
5
+ {% block content %}
6
+ <!-- Breadcrumb -->
7
+ <section class="breadcrumb">
8
+ <a href="{{ url_for('home') }}">Home</a>
9
+ <span class="sep">/</span>
10
+ <a href="{{ url_for('reports') }}">Reports</a>
11
+ <span class="sep">/</span>
12
+ <span class="mono">{{ row.image_id }}</span>
13
+ </section>
14
+
15
+ <!-- Header -->
16
+ <section class="detail-header">
17
+ <div>
18
+ <h1 class="mono">{{ row.image_id }}</h1>
19
+ <p>
20
+ {% if row.is_positive %}
21
+ <span class="dot dot-red"></span> {{ row.outcome }}
22
+ {% else %}
23
+ <span class="dot dot-green"></span> {{ row.outcome }}
24
+ {% endif %}
25
+ </p>
26
+ <p class="muted small" style="margin-top: 4px">
27
+ {% if row.date_display != 'β€”' %}
28
+ Generated: {{ row.date_display }} UTC
29
+ {% endif %}
30
+ {% if payload and payload.report_id %}
31
+ &middot; Report ID: {{ payload.report_id }}
32
+ {% endif %}
33
+ </p>
34
+ </div>
35
+ <div class="detail-actions">
36
+ {% if row.report_file %}
37
+ <a class="btn"
38
+ href="{{ url_for('serve_report_json', filename=row.report_file) }}"
39
+ target="_blank">
40
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
41
+ stroke="currentColor" stroke-width="2">
42
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
43
+ <polyline points="14 2 14 8 20 8" />
44
+ </svg>
45
+ Raw JSON
46
+ </a>
47
+ {% endif %}
48
+ <a class="btn" href="{{ url_for('reports') }}">← Back</a>
49
+ </div>
50
+ </section>
51
+
52
+ <!-- Two-column grid -->
53
+ <section class="detail-grid">
54
+ <!-- Left: Prediction Details -->
55
+ <article class="panel">
56
+ <h3>Prediction Summary</h3>
57
+ <div class="kv-group">
58
+ <div class="kv">
59
+ <span>Screening Outcome</span>
60
+ <strong>{{ row.outcome }}</strong>
61
+ </div>
62
+ <div class="kv">
63
+ <span>Calibrated Probability</span>
64
+ <strong>{{ '%.4f'|format(row.cal_prob) if row.cal_prob is not none else 'N/A' }}</strong>
65
+ </div>
66
+ <div class="kv">
67
+ <span>Raw Probability</span>
68
+ <strong>{{ '%.4f'|format(row.raw_prob) if row.raw_prob is not none else 'N/A' }}</strong>
69
+ </div>
70
+ <div class="kv">
71
+ <span>Confidence Band</span>
72
+ <strong><span class="badge badge-{{ row.band|lower }}">{{ row.band }}</span></strong>
73
+ </div>
74
+ <div class="kv">
75
+ <span>Triage Action</span>
76
+ <strong>{{ row.triage }}</strong>
77
+ </div>
78
+ <div class="kv">
79
+ <span>Urgency</span>
80
+ <strong><span class="badge badge-{{ row.urgency|lower }}">{{ row.urgency }}</span></strong>
81
+ </div>
82
+ <div class="kv">
83
+ <span>Ground Truth</span>
84
+ <strong>{{ row.true_label if row.true_label and row.true_label != 'N/A' else 'Not available' }}</strong>
85
+ </div>
86
+ <div class="kv">
87
+ <span>Report Date</span>
88
+ <strong>{{ row.date_display }}</strong>
89
+ </div>
90
+ </div>
91
+
92
+ <!-- Confidence bar -->
93
+ {% if row.cal_prob is not none %}
94
+ <div class="prob-bar-wrap">
95
+ <div class="prob-bar-label">
96
+ <span>0</span>
97
+ <span>Calibrated probability</span>
98
+ <span>1</span>
99
+ </div>
100
+ <div class="prob-bar">
101
+ <div class="prob-fill {% if row.cal_prob >= 0.75 %}fill-high{% elif row.cal_prob >= 0.35 %}fill-medium{% else %}fill-low{% endif %}"
102
+ style="width: {{ (row.cal_prob * 100)|round(1) }}%"></div>
103
+ <div class="prob-marker"
104
+ style="left: {{ (row.cal_prob * 100)|round(1) }}%">
105
+ {{ '%.2f'|format(row.cal_prob) }}
106
+ </div>
107
+ </div>
108
+ </div>
109
+ {% endif %}
110
+ </article>
111
+
112
+ <!-- Right: Grad-CAM -->
113
+ <article class="panel">
114
+ <h3>Grad-CAM Visualization</h3>
115
+ {% if row.gradcam_file %}
116
+ <img class="heatmap-img"
117
+ src="{{ url_for('serve_gradcam', filename=row.gradcam_file) }}"
118
+ alt="Grad-CAM for {{ row.image_id }}" />
119
+ <p class="muted small" style="margin-top: 10px">
120
+ Highlighted regions indicate areas with greatest influence on the
121
+ screening decision. These are <strong>not</strong> confirmed anatomical
122
+ findings.
123
+ </p>
124
+ {% else %}
125
+ <div class="empty-state">
126
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none"
127
+ stroke="currentColor" stroke-width="1.5" opacity="0.3">
128
+ <rect x="3" y="3" width="18" height="18" rx="2" />
129
+ <circle cx="8.5" cy="8.5" r="1.5" />
130
+ <path d="m21 15-5-5L5 21" />
131
+ </svg>
132
+ <p class="muted">No Grad-CAM heatmap available for this case.</p>
133
+ </div>
134
+ {% endif %}
135
+ </article>
136
+ </section>
137
+
138
+ <!-- Model info (from payload) -->
139
+ {% if payload and payload.screening_module %}
140
+ <section class="panel" style="margin-top: 16px">
141
+ <h3>Model Information</h3>
142
+ <div class="kv-group" style="max-width: 500px">
143
+ <div class="kv">
144
+ <span>Architecture</span>
145
+ <strong>{{ payload.screening_module.architecture }}</strong>
146
+ </div>
147
+ <div class="kv">
148
+ <span>Version</span>
149
+ <strong>{{ payload.screening_module.version }}</strong>
150
+ </div>
151
+ <div class="kv">
152
+ <span>Calibration</span>
153
+ <strong>{{ payload.screening_module.calibration_method }}</strong>
154
+ </div>
155
+ <div class="kv">
156
+ <span>Decision Threshold</span>
157
+ <strong>{{ '%.4f'|format(payload.prediction.decision_threshold) }}</strong>
158
+ </div>
159
+ </div>
160
+ </section>
161
+ {% endif %}
162
+
163
+ <!-- Disclaimer -->
164
+ <section class="disclaimer-box">
165
+ <strong>Disclaimer:</strong>
166
+ This report is produced by an AI-assisted screening tool and does NOT
167
+ constitute a medical diagnosis. All screening findings must be reviewed and
168
+ confirmed by a qualified, licensed medical professional before any clinical
169
+ decision is made.
170
+ </section>
171
+ {% endblock %}
templates/evaluation.html ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Evaluation β€” ICH Screening{% endblock %}
4
+
5
+ {% block content %}
6
+ <section class="hero">
7
+ <div class="hero-text">
8
+ <h1>Model Evaluation</h1>
9
+ <p>
10
+ Calibration metrics, confidence band analysis, and probability
11
+ distribution from the inference pipeline.
12
+ </p>
13
+ </div>
14
+ </section>
15
+
16
+ <!-- Calibration metrics -->
17
+ {% if calib %}
18
+ <section class="eval-grid">
19
+ <article class="panel">
20
+ <h3>Calibration Parameters</h3>
21
+ <div class="kv-group">
22
+ <div class="kv">
23
+ <span>Method</span><strong>{{ calib.get('method', 'N/A') }}</strong>
24
+ </div>
25
+ <div class="kv">
26
+ <span>Temperature</span
27
+ ><strong>{{ '%.4f'|format(calib.temperature) }}</strong>
28
+ </div>
29
+ <div class="kv">
30
+ <span>Decision Threshold</span
31
+ ><strong>{{ '%.4f'|format(calib.calibrated_threshold) }}</strong>
32
+ </div>
33
+ <div class="kv">
34
+ <span>Base Threshold</span
35
+ ><strong>{{ '%.4f'|format(calib.base_threshold) }}</strong>
36
+ </div>
37
+ <div class="kv">
38
+ <span>High Band β‰₯</span><strong>{{ calib.high_threshold }}</strong>
39
+ </div>
40
+ <div class="kv">
41
+ <span>Low Band &lt;</span><strong>{{ calib.low_threshold }}</strong>
42
+ </div>
43
+ </div>
44
+ </article>
45
+
46
+ <article class="panel">
47
+ <h3>Calibration Quality</h3>
48
+ <div class="metric-grid">
49
+ <div class="metric-card">
50
+ <div class="metric-label">ECE (Raw)</div>
51
+ <div class="metric-value">{{ '%.4f'|format(calib.raw_ece) }}</div>
52
+ </div>
53
+ <div class="metric-card">
54
+ <div class="metric-label">ECE (Calibrated)</div>
55
+ <div class="metric-value">{{ '%.4f'|format(calib.cal_ece) }}</div>
56
+ </div>
57
+ <div class="metric-card">
58
+ <div class="metric-label">Brier (Raw)</div>
59
+ <div class="metric-value">{{ '%.4f'|format(calib.raw_brier) }}</div>
60
+ </div>
61
+ <div class="metric-card">
62
+ <div class="metric-label">Brier (Cal)</div>
63
+ <div class="metric-value">{{ '%.4f'|format(calib.cal_brier) }}</div>
64
+ </div>
65
+ </div>
66
+ <p class="muted small" style="margin-top: 12px">
67
+ Temperature scaling adjusts logits by T={{
68
+ '%.4f'|format(calib.temperature) }} to produce better-calibrated
69
+ probabilities. Lower ECE = better calibration.
70
+ </p>
71
+ </article>
72
+ </section>
73
+ {% endif %}
74
+
75
+ <!-- Normalization -->
76
+ {% if norm %}
77
+ <section class="panel" style="margin-top: 16px">
78
+ <h3>Normalization Statistics</h3>
79
+ <div class="kv-group" style="max-width: 500px">
80
+ <div class="kv">
81
+ <span>Mean (per channel)</span><strong>{{ norm.mean }}</strong>
82
+ </div>
83
+ <div class="kv">
84
+ <span>Std (per channel)</span><strong>{{ norm.std }}</strong>
85
+ </div>
86
+ <div class="kv">
87
+ <span>Computed from</span
88
+ ><strong>{{ norm.get('n_images', 'N/A') }} images</strong>
89
+ </div>
90
+ </div>
91
+ </section>
92
+ {% endif %}
93
+
94
+ <!-- Confidence Band Breakdown -->
95
+ <section class="panel" style="margin-top: 16px">
96
+ <h3>Confidence Band Analysis</h3>
97
+ <p class="muted small">
98
+ Distribution of {{ total }} processed cases across the three confidence
99
+ bands.
100
+ </p>
101
+
102
+ <div class="band-grid">
103
+ {% for bnd in ['HIGH', 'MEDIUM', 'LOW'] %} {% set d = band_data.get(bnd,
104
+ {'total': 0, 'positive': 0, 'negative': 0}) %}
105
+ <div class="band-card band-{{ bnd|lower }}">
106
+ <div class="band-header">
107
+ <span class="badge badge-{{ bnd|lower }}">{{ bnd }}</span>
108
+ <span class="band-total">{{ d.total }} cases</span>
109
+ </div>
110
+ <div class="band-bars">
111
+ <div class="band-bar-row">
112
+ <span class="band-bar-label">Positive</span>
113
+ <div class="band-bar">
114
+ <div
115
+ class="band-bar-fill fill-red"
116
+ style="width: {{ (d.positive / d.total * 100) if d.total else 0 }}%"
117
+ ></div>
118
+ </div>
119
+ <span class="band-bar-val">{{ d.positive }}</span>
120
+ </div>
121
+ <div class="band-bar-row">
122
+ <span class="band-bar-label">Negative</span>
123
+ <div class="band-bar">
124
+ <div
125
+ class="band-bar-fill fill-green"
126
+ style="width: {{ (d.negative / d.total * 100) if d.total else 0 }}%"
127
+ ></div>
128
+ </div>
129
+ <span class="band-bar-val">{{ d.negative }}</span>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ {% endfor %}
134
+ </div>
135
+ </section>
136
+
137
+ <!-- Probability Distribution -->
138
+ <section class="panel" style="margin-top: 16px">
139
+ <h3>Calibrated Probability Distribution</h3>
140
+ <p class="muted small">
141
+ Histogram of calibrated probabilities across all cases (10 bins).
142
+ </p>
143
+
144
+ <div class="histogram">
145
+ {% set max_bin = bins|max if bins|max > 0 else 1 %} {% for count in bins %}
146
+ <div class="hist-col">
147
+ <div
148
+ class="hist-bar"
149
+ style="height: {{ (count / max_bin * 180)|round }}px"
150
+ title="{{ '%.1f'|format(loop.index0 * 0.1) }}–{{ '%.1f'|format(loop.index0 * 0.1 + 0.1) }}: {{ count }}"
151
+ >
152
+ <span class="hist-count">{{ count }}</span>
153
+ </div>
154
+ <div class="hist-label">{{ '%.1f'|format(loop.index0 * 0.1) }}</div>
155
+ </div>
156
+ {% endfor %}
157
+ </div>
158
+ </section>
159
+
160
+ <!-- Summary stats -->
161
+ <section class="panel" style="margin-top: 16px">
162
+ <h3>Summary Statistics</h3>
163
+ <div class="kv-group" style="max-width: 500px">
164
+ <div class="kv">
165
+ <span>Total processed</span><strong>{{ stats.total }}</strong>
166
+ </div>
167
+ <div class="kv">
168
+ <span>Positive (flagged)</span><strong>{{ stats.positive }}</strong>
169
+ </div>
170
+ <div class="kv">
171
+ <span>Negative</span><strong>{{ stats.negative }}</strong>
172
+ </div>
173
+ <div class="kv">
174
+ <span>Urgent escalations</span><strong>{{ stats.urgent }}</strong>
175
+ </div>
176
+ <div class="kv">
177
+ <span>Average calibrated prob</span
178
+ ><strong>{{ '%.4f'|format(stats.avg_cal_prob) }}</strong>
179
+ </div>
180
+ <div class="kv">
181
+ <span>Heatmaps generated</span><strong>{{ stats.heatmaps }}</strong>
182
+ </div>
183
+ </div>
184
+ </section>
185
+ {% endblock %}
templates/home.html ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}ICH Screening β€” Home{% endblock %}
4
+
5
+ {% block content %}
6
+ <section class="home-hero">
7
+ <h1>ICH Screening System</h1>
8
+ <p>
9
+ AI-Assisted CT-Based Intracranial Hemorrhage Detection with
10
+ Explainability and Clinical Reporting
11
+ </p>
12
+ </section>
13
+
14
+ <!-- Quick stats row -->
15
+ {% if stats.total > 0 %}
16
+ <section class="stats-row home-stats">
17
+ <div class="stat-card">
18
+ <div class="stat-label">Total Scans</div>
19
+ <div class="stat-value">{{ stats.total }}</div>
20
+ </div>
21
+ <div class="stat-card accent-red">
22
+ <div class="stat-label">Positive</div>
23
+ <div class="stat-value">{{ stats.positive }}</div>
24
+ </div>
25
+ <div class="stat-card accent-green">
26
+ <div class="stat-label">Negative</div>
27
+ <div class="stat-value">{{ stats.negative }}</div>
28
+ </div>
29
+ <div class="stat-card accent-orange">
30
+ <div class="stat-label">Urgent</div>
31
+ <div class="stat-value">{{ stats.urgent }}</div>
32
+ </div>
33
+ <div class="stat-card accent-blue">
34
+ <div class="stat-label">Positivity Rate</div>
35
+ <div class="stat-value">{{ '%.1f'|format(stats.pos_rate) }}%</div>
36
+ </div>
37
+ <div class="stat-card">
38
+ <div class="stat-label">Avg Cal. Prob</div>
39
+ <div class="stat-value">{{ '%.3f'|format(stats.avg_cal_prob) }}</div>
40
+ </div>
41
+ </section>
42
+ {% endif %}
43
+
44
+ <!-- Main action cards -->
45
+ <section class="home-cards">
46
+ <a href="{{ url_for('upload') }}" class="home-card">
47
+ <div class="home-card-icon">
48
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none"
49
+ stroke="currentColor" stroke-width="1.5"
50
+ stroke-linecap="round" stroke-linejoin="round">
51
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
52
+ <polyline points="17 8 12 3 7 8" />
53
+ <line x1="12" y1="3" x2="12" y2="15" />
54
+ </svg>
55
+ </div>
56
+ <h2>Upload Scans</h2>
57
+ <p>
58
+ Upload single or batch DICOM scans (.dcm / .zip) for AI-powered
59
+ hemorrhage screening with Grad-CAM visualization.
60
+ </p>
61
+ <span class="home-card-action">Upload files &rarr;</span>
62
+ </a>
63
+
64
+ <a href="{{ url_for('reports') }}" class="home-card">
65
+ <div class="home-card-icon">
66
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none"
67
+ stroke="currentColor" stroke-width="1.5"
68
+ stroke-linecap="round" stroke-linejoin="round">
69
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
70
+ <polyline points="14 2 14 8 20 8" />
71
+ <line x1="16" y1="13" x2="8" y2="13" />
72
+ <line x1="16" y1="17" x2="8" y2="17" />
73
+ </svg>
74
+ </div>
75
+ <h2>Past Reports</h2>
76
+ <p>
77
+ Browse {{ stats.total }} screening reports with confidence bands,
78
+ triage actions, and Grad-CAM heatmaps.
79
+ </p>
80
+ <span class="home-card-action">View reports &rarr;</span>
81
+ </a>
82
+ </section>
83
+
84
+ <!-- Secondary cards -->
85
+ <section class="home-cards home-cards-secondary">
86
+ <a href="{{ url_for('logs_page') }}" class="home-card home-card-sm">
87
+ <div class="home-card-icon">
88
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none"
89
+ stroke="currentColor" stroke-width="1.5">
90
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
91
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
92
+ </svg>
93
+ </div>
94
+ <h3>Execution Logs</h3>
95
+ <p class="muted small">{{ log_count }} inference trace{{ 's' if log_count != 1 }} recorded</p>
96
+ </a>
97
+
98
+ <a href="{{ url_for('evaluation') }}" class="home-card home-card-sm">
99
+ <div class="home-card-icon">
100
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none"
101
+ stroke="currentColor" stroke-width="1.5">
102
+ <line x1="18" y1="20" x2="18" y2="10" />
103
+ <line x1="12" y1="20" x2="12" y2="4" />
104
+ <line x1="6" y1="20" x2="6" y2="14" />
105
+ </svg>
106
+ </div>
107
+ <h3>Model Evaluation</h3>
108
+ <p class="muted small">Calibration metrics and band analysis</p>
109
+ </a>
110
+
111
+ <a href="{{ url_for('about') }}" class="home-card home-card-sm">
112
+ <div class="home-card-icon">
113
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none"
114
+ stroke="currentColor" stroke-width="1.5">
115
+ <circle cx="12" cy="12" r="10" />
116
+ <line x1="12" y1="16" x2="12" y2="12" />
117
+ <line x1="12" y1="8" x2="12.01" y2="8" />
118
+ </svg>
119
+ </div>
120
+ <h3>About</h3>
121
+ <p class="muted small">System architecture and methodology</p>
122
+ </a>
123
+ </section>
124
+
125
+ <section class="disclaimer-box" style="margin-top: 32px">
126
+ <strong>Disclaimer:</strong>
127
+ This is an AI-assisted screening tool and does NOT constitute a medical
128
+ diagnosis. All findings must be reviewed by a qualified medical professional.
129
+ </section>
130
+ {% endblock %}
templates/logs.html ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}ICH Screening β€” Execution Logs{% endblock %}
4
+
5
+ {% block content %}
6
+ <section class="page-header">
7
+ <h1>Execution Logs</h1>
8
+ <p class="muted">
9
+ Inference execution traces recorded by <code>blackbox-recorder</code>.
10
+ Each upload generates a human-readable <strong>.txt</strong> report and
11
+ a machine-parseable <strong>.json</strong> trace.
12
+ </p>
13
+ </section>
14
+
15
+ {% if logs %}
16
+ <div class="log-summary">
17
+ <span class="badge">{{ logs | length }} trace{{ 's' if logs | length != 1 }}</span>
18
+ </div>
19
+
20
+ <table class="data-table logs-table">
21
+ <thead>
22
+ <tr>
23
+ <th>#</th>
24
+ <th>Timestamp</th>
25
+ <th>Image ID</th>
26
+ <th>Size (KB)</th>
27
+ <th>Actions</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ {% for entry in logs %}
32
+ <tr>
33
+ <td>{{ loop.index }}</td>
34
+ <td>{{ entry.timestamp }}</td>
35
+ <td><code>{{ entry.image_id }}</code></td>
36
+ <td>{{ entry.size_kb }}</td>
37
+ <td class="log-actions">
38
+ {% if entry.txt_file %}
39
+ <a href="{{ url_for('serve_log', filename=entry.txt_file) }}"
40
+ target="_blank" class="btn btn-sm" title="View text report">
41
+ TXT
42
+ </a>
43
+ {% endif %}
44
+ {% if entry.json_file %}
45
+ <a href="{{ url_for('serve_log', filename=entry.json_file) }}"
46
+ target="_blank" class="btn btn-sm btn-outline" title="View JSON trace">
47
+ JSON
48
+ </a>
49
+ {% endif %}
50
+ </td>
51
+ </tr>
52
+ {% endfor %}
53
+ </tbody>
54
+ </table>
55
+
56
+ {% else %}
57
+ <div class="empty-state">
58
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none"
59
+ stroke="var(--muted)" stroke-width="1.2">
60
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
61
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
62
+ </svg>
63
+ <h3>No execution logs yet</h3>
64
+ <p class="muted">
65
+ Upload and screen a DICOM file to generate the first inference trace.
66
+ </p>
67
+ <a href="{{ url_for('upload') }}" class="btn">Upload a Scan</a>
68
+ </div>
69
+ {% endif %}
70
+ {% endblock %}
templates/reports.html ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Past Reports β€” ICH Screening{% endblock %}
4
+
5
+ {% block content %}
6
+ <section class="breadcrumb">
7
+ <a href="{{ url_for('home') }}">Home</a>
8
+ <span class="sep">/</span>
9
+ <span>Past Reports</span>
10
+ </section>
11
+
12
+ <section class="hero">
13
+ <div class="hero-text">
14
+ <h1>Past Reports</h1>
15
+ <p>Browse screening results, confidence bands, triage actions, and Grad-CAM
16
+ visualizations from previous inference runs.</p>
17
+ </div>
18
+ </section>
19
+
20
+ <!-- Stats summary cards -->
21
+ <section class="stats-row">
22
+ <div class="stat-card">
23
+ <div class="stat-label">Total</div>
24
+ <div class="stat-value">{{ stats.total }}</div>
25
+ </div>
26
+ <div class="stat-card accent-green">
27
+ <div class="stat-label">Negative</div>
28
+ <div class="stat-value">{{ stats.negative }}</div>
29
+ </div>
30
+ <div class="stat-card accent-red">
31
+ <div class="stat-label">Positive</div>
32
+ <div class="stat-value">{{ stats.positive }}</div>
33
+ </div>
34
+ <div class="stat-card accent-orange">
35
+ <div class="stat-label">Urgent</div>
36
+ <div class="stat-value">{{ stats.urgent }}</div>
37
+ </div>
38
+ <div class="stat-card accent-blue">
39
+ <div class="stat-label">Positivity Rate</div>
40
+ <div class="stat-value">{{ '%.1f'|format(stats.pos_rate) }}%</div>
41
+ </div>
42
+ <div class="stat-card">
43
+ <div class="stat-label">Avg Prob</div>
44
+ <div class="stat-value">{{ '%.3f'|format(stats.avg_cal_prob) }}</div>
45
+ </div>
46
+ </section>
47
+
48
+ <!-- Calibration bar -->
49
+ {% if calib %}
50
+ <section class="info-bar">
51
+ <span>Temperature: <strong>{{ '%.4f'|format(calib.temperature) }}</strong></span>
52
+ <span>Threshold: <strong>{{ '%.4f'|format(calib.calibrated_threshold) }}</strong></span>
53
+ <span>ECE (raw): <strong>{{ '%.4f'|format(calib.raw_ece) }}</strong></span>
54
+ <span>ECE (cal): <strong>{{ '%.4f'|format(calib.cal_ece) }}</strong></span>
55
+ </section>
56
+ {% endif %}
57
+
58
+ <!-- Filter bar -->
59
+ <section class="panel">
60
+ <!-- prettier-ignore-start -->
61
+ <form method="get" class="filters">
62
+ <input type="text" name="q" value="{{ q }}"
63
+ placeholder="Search image ID or outcome..." />
64
+
65
+ <select name="outcome">
66
+ <option value="">All Outcomes</option>
67
+ <option value="POSITIVE" {% if outcome == "POSITIVE" %}selected{% endif %}>Positive</option>
68
+ <option value="NEGATIVE" {% if outcome == "NEGATIVE" %}selected{% endif %}>Negative</option>
69
+ </select>
70
+
71
+ <select name="band">
72
+ <option value="">All Bands</option>
73
+ <option value="HIGH" {% if band == "HIGH" %}selected{% endif %}>HIGH</option>
74
+ <option value="MEDIUM" {% if band == "MEDIUM" %}selected{% endif %}>MEDIUM</option>
75
+ <option value="LOW" {% if band == "LOW" %}selected{% endif %}>LOW</option>
76
+ </select>
77
+
78
+ <select name="urgency">
79
+ <option value="">All Urgency</option>
80
+ <option value="URGENT" {% if urgency == "URGENT" %}selected{% endif %}>URGENT</option>
81
+ <option value="STANDARD" {% if urgency == "STANDARD" %}selected{% endif %}>STANDARD</option>
82
+ </select>
83
+
84
+ <select name="sort">
85
+ <option value="">Default Sort</option>
86
+ <option value="date_desc" {% if sort == "date_desc" %}selected{% endif %}>Newest First</option>
87
+ <option value="date_asc" {% if sort == "date_asc" %}selected{% endif %}>Oldest First</option>
88
+ <option value="prob_desc" {% if sort == "prob_desc" %}selected{% endif %}>Highest Prob</option>
89
+ <option value="prob_asc" {% if sort == "prob_asc" %}selected{% endif %}>Lowest Prob</option>
90
+ </select>
91
+
92
+ <select name="page_size">
93
+ <option value="10" {% if page_size == 10 %}selected{% endif %}>10 / page</option>
94
+ <option value="50" {% if page_size == 50 %}selected{% endif %}>50 / page</option>
95
+ <option value="100" {% if page_size == 100 %}selected{% endif %}>100 / page</option>
96
+ </select>
97
+
98
+ <button type="submit">Filter</button>
99
+ {% if q or band or urgency or outcome or sort %}
100
+ <a href="{{ url_for('reports', page_size=page_size) }}" class="btn btn-ghost">Clear</a>
101
+ {% endif %}
102
+ </form>
103
+ <!-- prettier-ignore-end -->
104
+
105
+ <!-- Results meta bar -->
106
+ <div class="info-bar" style="margin-bottom: 14px">
107
+ <span>Filtered: <strong>{{ total_items }}</strong> of {{ total_cases }}</span>
108
+ <span>Page: <strong>{{ page }} / {{ total_pages }}</strong></span>
109
+ <span>Showing: <strong>{{ rows|length }}</strong> rows</span>
110
+ <span>{{ '%.1f'|format(route_compute_ms) }} ms</span>
111
+ <span>Cache: <strong>{{ 'HIT' if data_cache_hit else 'MISS' }}</strong></span>
112
+ </div>
113
+
114
+ <!-- Results table -->
115
+ <div class="table-wrap">
116
+ <table>
117
+ <thead>
118
+ <tr>
119
+ <th>#</th>
120
+ <th>Image ID</th>
121
+ <th>Date</th>
122
+ <th>Outcome</th>
123
+ <th>Cal. Prob</th>
124
+ <th>Band</th>
125
+ <th>Urgency</th>
126
+ <th>Grad-CAM</th>
127
+ <th>Report</th>
128
+ </tr>
129
+ </thead>
130
+ <tbody>
131
+ {% for row in rows %}
132
+ <tr class="{% if row.is_positive %}row-positive{% endif %}">
133
+ <td class="muted">{{ page_start + loop.index }}</td>
134
+ <td class="mono">{{ row.image_id }}</td>
135
+ <td class="muted small">{{ row.date_display }}</td>
136
+ <td>
137
+ {% if row.is_positive %}
138
+ <span class="dot dot-red"></span> Positive
139
+ {% else %}
140
+ <span class="dot dot-green"></span> Negative
141
+ {% endif %}
142
+ </td>
143
+ <td>{{ '%.4f'|format(row.cal_prob) if row.cal_prob is not none else 'β€”' }}</td>
144
+ <td><span class="badge badge-{{ row.band|lower }}">{{ row.band }}</span></td>
145
+ <td><span class="badge badge-{{ row.urgency|lower }}">{{ row.urgency }}</span></td>
146
+ <td>
147
+ {% if row.gradcam_file %}
148
+ <a href="{{ url_for('serve_gradcam', filename=row.gradcam_file) }}"
149
+ target="_blank" class="link-icon" title="View Grad-CAM">
150
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
151
+ stroke="currentColor" stroke-width="2">
152
+ <rect x="3" y="3" width="18" height="18" rx="2" />
153
+ <circle cx="8.5" cy="8.5" r="1.5" />
154
+ <path d="m21 15-5-5L5 21" />
155
+ </svg>
156
+ </a>
157
+ {% else %}
158
+ <span class="muted">β€”</span>
159
+ {% endif %}
160
+ </td>
161
+ <td>
162
+ <a href="{{ url_for('case_detail', image_id=row.image_id) }}" class="btn btn-sm">Open</a>
163
+ </td>
164
+ </tr>
165
+ {% endfor %}
166
+
167
+ {% if not rows %}
168
+ <tr>
169
+ <td colspan="9" class="muted" style="text-align: center; padding: 32px">
170
+ No cases match your filters.
171
+ </td>
172
+ </tr>
173
+ {% endif %}
174
+ </tbody>
175
+ </table>
176
+ </div>
177
+
178
+ <!-- Pagination -->
179
+ {% if total_pages > 1 %}
180
+ <div class="filters" style="justify-content: space-between; margin-top: 14px">
181
+ <div>
182
+ {% if page > 1 %}
183
+ <a class="btn" href="{{ url_for('reports', q=q, band=band, urgency=urgency, outcome=outcome, sort=sort, page=page-1, page_size=page_size) }}">← Previous</a>
184
+ {% else %}
185
+ <span class="btn btn-ghost" style="opacity: 0.5; pointer-events: none">← Previous</span>
186
+ {% endif %}
187
+ </div>
188
+ <div class="muted small" style="align-self: center">Page {{ page }} of {{ total_pages }}</div>
189
+ <div>
190
+ {% if page < total_pages %}
191
+ <a class="btn" href="{{ url_for('reports', q=q, band=band, urgency=urgency, outcome=outcome, sort=sort, page=page+1, page_size=page_size) }}">Next β†’</a>
192
+ {% else %}
193
+ <span class="btn btn-ghost" style="opacity: 0.5; pointer-events: none">Next β†’</span>
194
+ {% endif %}
195
+ </div>
196
+ </div>
197
+ {% endif %}
198
+ </section>
199
+ {% endblock %}
200
+
templates/upload.html ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Upload Scan β€” ICH Screening{% endblock %}
4
+
5
+ {% block content %}
6
+ <section class="breadcrumb">
7
+ <a href="{{ url_for('home') }}">Home</a>
8
+ <span class="sep">/</span>
9
+ <span>Upload Scans</span>
10
+ </section>
11
+
12
+ <section class="upload-hero">
13
+ <h1>Upload DICOM Scans</h1>
14
+ <p>
15
+ Upload one or many CT brain scans for AI-powered hemorrhage screening.
16
+ A single exam may contain hundreds of slices β€” all modes below handle
17
+ that seamlessly.
18
+ </p>
19
+ </section>
20
+
21
+ {% with messages = get_flashed_messages(with_categories=true) %}
22
+ {% if messages %}
23
+ <div class="flash-messages">
24
+ {% for category, message in messages %}
25
+ <div class="flash flash-{{ category }}">{{ message }}</div>
26
+ {% endfor %}
27
+ </div>
28
+ {% endif %}
29
+ {% endwith %}
30
+
31
+ <!-- ── Tab navigation ──────────────────────────────────────────────── -->
32
+ <div class="upload-tabs" role="tablist">
33
+ <button class="upload-tab active" data-tab="single" role="tab">Single File</button>
34
+ <button class="upload-tab" data-tab="multi" role="tab">Multi-File / ZIP</button>
35
+ {% if local_mode %}
36
+ <button class="upload-tab" data-tab="dirscan" role="tab">Scan Directory</button>
37
+ {% endif %}
38
+ </div>
39
+
40
+ <!-- ════════════════════════════════════════════════════════════════════ -->
41
+ <!-- TAB 1 β€” Single .dcm file -->
42
+ <!-- ════════════════════════════════════════════════════════════════════ -->
43
+ <section class="panel upload-panel tab-panel active" id="tab-single">
44
+ <form method="post" action="{{ url_for('analyze') }}"
45
+ enctype="multipart/form-data" id="singleForm">
46
+
47
+ <div class="dropzone" id="dropzoneSingle">
48
+ <svg width="56" height="56" viewBox="0 0 24 24" fill="none"
49
+ stroke="currentColor" stroke-width="1.5"
50
+ stroke-linecap="round" stroke-linejoin="round">
51
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
52
+ <polyline points="17 8 12 3 7 8" />
53
+ <line x1="12" y1="3" x2="12" y2="15" />
54
+ </svg>
55
+ <p class="dropzone-text">Drag &amp; drop a .dcm file here</p>
56
+ <p class="muted small">or click to browse</p>
57
+ <input type="file" name="file" id="singleInput" accept=".dcm" hidden />
58
+ </div>
59
+
60
+ <div class="file-info" id="singleInfo" style="display: none">
61
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none"
62
+ stroke="currentColor" stroke-width="2">
63
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
64
+ <polyline points="14 2 14 8 20 8" />
65
+ </svg>
66
+ <span id="singleFileName"></span>
67
+ <button type="button" class="btn btn-sm btn-ghost js-clear-single">Remove</button>
68
+ </div>
69
+
70
+ <button type="submit" class="btn btn-primary" id="singleSubmit" disabled>
71
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
72
+ stroke="currentColor" stroke-width="2">
73
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
74
+ </svg>
75
+ Analyze Scan
76
+ </button>
77
+ </form>
78
+
79
+ <div class="loading-overlay" id="singleOverlay" style="display: none">
80
+ <div class="spinner"></div>
81
+ <p>Running AI analysis&hellip;</p>
82
+ <p class="muted small">This may take a moment on first run while the model loads.</p>
83
+ </div>
84
+ </section>
85
+
86
+ <!-- ════════════════════════════════════════════════════════════════════ -->
87
+ <!-- TAB 2 β€” Multi-file / ZIP upload -->
88
+ <!-- ════════════════════════════════════════════════════════════════════ -->
89
+ <section class="panel upload-panel tab-panel" id="tab-multi">
90
+ <form method="post" action="{{ url_for('analyze') }}"
91
+ enctype="multipart/form-data" id="multiForm">
92
+
93
+ <div class="dropzone" id="dropzoneMulti">
94
+ <svg width="56" height="56" viewBox="0 0 24 24" fill="none"
95
+ stroke="currentColor" stroke-width="1.5"
96
+ stroke-linecap="round" stroke-linejoin="round">
97
+ <rect x="2" y="7" width="20" height="14" rx="2" />
98
+ <path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
99
+ </svg>
100
+ <p class="dropzone-text">Drag &amp; drop .dcm files or a .zip archive</p>
101
+ <p class="muted small">Select multiple files, or a single .zip containing DICOM slices</p>
102
+ <input type="file" name="file" id="multiInput"
103
+ accept=".dcm,.zip" multiple hidden />
104
+ </div>
105
+
106
+ <div class="file-info" id="multiInfo" style="display: none">
107
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none"
108
+ stroke="currentColor" stroke-width="2">
109
+ <rect x="2" y="7" width="20" height="14" rx="2" />
110
+ <path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
111
+ </svg>
112
+ <span id="multiFileName"></span>
113
+ <button type="button" class="btn btn-sm btn-ghost js-clear-multi">Remove all</button>
114
+ </div>
115
+
116
+ <button type="submit" class="btn btn-primary" id="multiSubmit" disabled>
117
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
118
+ stroke="currentColor" stroke-width="2">
119
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
120
+ </svg>
121
+ Analyze Batch
122
+ </button>
123
+ </form>
124
+
125
+ <div class="loading-overlay" id="multiOverlay" style="display: none">
126
+ <div class="spinner"></div>
127
+ <p>Uploading files&hellip;</p>
128
+ <p class="muted small">Large batches may take a moment to upload.</p>
129
+ </div>
130
+ </section>
131
+
132
+ <!-- ════════════════════════════════════════════════════════════════════ -->
133
+ <!-- TAB 3 β€” Directory scan (local mode only) -->
134
+ <!-- ════════════════════════════════════════════════════════════════════ -->
135
+ {% if local_mode %}
136
+ <section class="panel upload-panel tab-panel" id="tab-dirscan">
137
+ <form method="post" action="{{ url_for('analyze_directory') }}" id="dirForm">
138
+ <label class="dir-label" for="dirPath">
139
+ Server-side directory containing .dcm files
140
+ </label>
141
+ <div class="dir-input-row">
142
+ <input type="text" name="dir_path" id="dirPath" class="input"
143
+ placeholder="D:\scans\patient_001"
144
+ spellcheck="false" autocomplete="off" />
145
+ <button type="submit" class="btn btn-primary" id="dirSubmit">
146
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
147
+ stroke="currentColor" stroke-width="2">
148
+ <circle cx="11" cy="11" r="8" />
149
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
150
+ </svg>
151
+ Scan &amp; Analyze
152
+ </button>
153
+ </div>
154
+ <p class="muted small" style="margin-top: 8px">
155
+ The server will recursively find all <code>.dcm</code> files in this
156
+ directory and its sub-folders, then run inference on each.
157
+ This option is only available when running locally.
158
+ </p>
159
+ </form>
160
+ </section>
161
+ {% endif %}
162
+
163
+ <!-- ── How it works ────────────────────────────────────────────────── -->
164
+ <section class="panel" style="margin-top: 16px">
165
+ <h3>How It Works</h3>
166
+ <div class="steps-grid">
167
+ <div class="step">
168
+ <div class="step-num">1</div>
169
+ <div class="step-text">
170
+ <strong>Upload</strong>
171
+ <p class="muted small">Select DICOM files, a ZIP, or enter a directory path</p>
172
+ </div>
173
+ </div>
174
+ <div class="step">
175
+ <div class="step-num">2</div>
176
+ <div class="step-text">
177
+ <strong>Process</strong>
178
+ <p class="muted small">CT windowing &amp; preprocessing on each slice</p>
179
+ </div>
180
+ </div>
181
+ <div class="step">
182
+ <div class="step-num">3</div>
183
+ <div class="step-text">
184
+ <strong>Analyze</strong>
185
+ <p class="muted small">EfficientNet-B0 model with calibrated scoring</p>
186
+ </div>
187
+ </div>
188
+ <div class="step">
189
+ <div class="step-num">4</div>
190
+ <div class="step-text">
191
+ <strong>Report</strong>
192
+ <p class="muted small">Grad-CAM visualization &amp; clinical report per slice</p>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </section>
197
+ {% endblock %}
198
+
199
+ {% block scripts %}
200
+ <script>
201
+ (function () {
202
+ /* ── Tab switching ────────────────────────────────────────────────── */
203
+ var tabs = document.querySelectorAll(".upload-tab");
204
+ var panels = document.querySelectorAll(".tab-panel");
205
+
206
+ tabs.forEach(function (tab) {
207
+ tab.addEventListener("click", function () {
208
+ tabs.forEach(function (t) { t.classList.remove("active"); });
209
+ panels.forEach(function (p) { p.classList.remove("active"); });
210
+ tab.classList.add("active");
211
+ var target = document.getElementById("tab-" + tab.dataset.tab);
212
+ if (target) target.classList.add("active");
213
+ });
214
+ });
215
+
216
+ /* ── Helper: generic dropzone wiring ─────────────────────────────── */
217
+ function wireDropzone(opts) {
218
+ var zone = document.getElementById(opts.zoneId);
219
+ var input = document.getElementById(opts.inputId);
220
+ var info = document.getElementById(opts.infoId);
221
+ var label = document.getElementById(opts.labelId);
222
+ var clear = document.querySelector(opts.clearSel);
223
+ var submit = document.getElementById(opts.submitId);
224
+ var form = document.getElementById(opts.formId);
225
+ var overlay = document.getElementById(opts.overlayId);
226
+
227
+ if (!zone || !input) return;
228
+
229
+ function showFiles(files) {
230
+ var validFiles = [];
231
+ for (var i = 0; i < files.length; i++) {
232
+ var name = files[i].name.toLowerCase();
233
+ if (name.endsWith(".dcm") || name.endsWith(".zip")) {
234
+ validFiles.push(files[i]);
235
+ }
236
+ }
237
+ if (!validFiles.length) return;
238
+
239
+ if (opts.multi) {
240
+ var totalSizeMB = 0;
241
+ for (var j = 0; j < validFiles.length; j++) {
242
+ totalSizeMB += validFiles[j].size / (1024 * 1024);
243
+ }
244
+ label.textContent = validFiles.length + " file" +
245
+ (validFiles.length > 1 ? "s" : "") +
246
+ " (" + totalSizeMB.toFixed(1) + " MB)";
247
+ } else {
248
+ label.textContent = validFiles[0].name;
249
+ }
250
+
251
+ info.style.display = "flex";
252
+ zone.style.display = "none";
253
+ submit.disabled = false;
254
+ }
255
+
256
+ function reset() {
257
+ input.value = "";
258
+ info.style.display = "none";
259
+ zone.style.display = "flex";
260
+ submit.disabled = true;
261
+ }
262
+
263
+ zone.addEventListener("click", function () { input.click(); });
264
+
265
+ zone.addEventListener("dragover", function (e) {
266
+ e.preventDefault();
267
+ zone.classList.add("dragover");
268
+ });
269
+ zone.addEventListener("dragleave", function () {
270
+ zone.classList.remove("dragover");
271
+ });
272
+ zone.addEventListener("drop", function (e) {
273
+ e.preventDefault();
274
+ zone.classList.remove("dragover");
275
+ if (e.dataTransfer.files.length) {
276
+ input.files = e.dataTransfer.files;
277
+ showFiles(e.dataTransfer.files);
278
+ }
279
+ });
280
+
281
+ input.addEventListener("change", function () {
282
+ if (input.files.length) showFiles(input.files);
283
+ });
284
+
285
+ if (clear) clear.addEventListener("click", reset);
286
+
287
+ if (form && overlay) {
288
+ form.addEventListener("submit", function () {
289
+ overlay.style.display = "flex";
290
+ submit.disabled = true;
291
+ });
292
+ }
293
+ }
294
+
295
+ /* ── Wire single-file dropzone ───────────────────────────────────── */
296
+ wireDropzone({
297
+ zoneId: "dropzoneSingle",
298
+ inputId: "singleInput",
299
+ infoId: "singleInfo",
300
+ labelId: "singleFileName",
301
+ clearSel: ".js-clear-single",
302
+ submitId: "singleSubmit",
303
+ formId: "singleForm",
304
+ overlayId: "singleOverlay",
305
+ multi: false,
306
+ });
307
+
308
+ /* ── Wire multi-file dropzone ────────────────────────────────────── */
309
+ wireDropzone({
310
+ zoneId: "dropzoneMulti",
311
+ inputId: "multiInput",
312
+ infoId: "multiInfo",
313
+ labelId: "multiFileName",
314
+ clearSel: ".js-clear-multi",
315
+ submitId: "multiSubmit",
316
+ formId: "multiForm",
317
+ overlayId: "multiOverlay",
318
+ multi: true,
319
+ });
320
+
321
+ /* ── Directory scan: disable submit when input is empty ──────────── */
322
+ var dirInput = document.getElementById("dirPath");
323
+ var dirSubmit = document.getElementById("dirSubmit");
324
+ if (dirInput && dirSubmit) {
325
+ function checkDir() { dirSubmit.disabled = !dirInput.value.trim(); }
326
+ dirInput.addEventListener("input", checkDir);
327
+ checkDir();
328
+ }
329
+ })();
330
+ </script>
331
+ {% endblock %}