Harshit Ghosh commited on
Commit Β·
b0bcfd5
0
Parent(s):
initialize
Browse files- .env.example +18 -0
- .gitignore +56 -0
- README.md +567 -0
- app.py +1119 -0
- download_imp/__init__.py +0 -0
- requirements.txt +16 -0
- run_interface.py +182 -0
- static/styles.css +1291 -0
- templates/about.html +274 -0
- templates/base.html +76 -0
- templates/batch_progress.html +184 -0
- templates/detail.html +171 -0
- templates/evaluation.html +185 -0
- templates/home.html +130 -0
- templates/logs.html +70 -0
- templates/reports.html +200 -0
- templates/upload.html +331 -0
.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 & 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 (<{{ '%.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>< {{ '%.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 & 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 —
|
| 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…</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 |
+
· 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 <</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 →</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 →</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 & 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…</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 & 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…</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 & 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 & 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 & 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 %}
|