Spaces:
Sleeping
Sleeping
Replace Space with NeuraScan final version
Browse files- README.md +37 -11
- app.py +180 -80
- models/README.md +4 -0
- models/efficientnetb2.h5 +3 -0
- models/resnet101.h5 +3 -0
- model.h5 → models/resnet50.h5 +0 -0
- static/assets/brain/PLACE_IMAGES_HERE.txt +3 -0
- static/{brain.svg → assets/brain/brain.svg} +0 -0
- static/assets/brain/supportedexample.jpg +3 -0
- static/assets/brain/unsupportedexample.jpg +3 -0
- static/assets/index-CtQJEsfm.css +0 -1
- static/assets/index-DBG8gcSd.js +0 -0
- templates/index.html +354 -186
README.md
CHANGED
|
@@ -1,11 +1,37 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
--
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NeuraScan (Hugging Face Space)
|
| 2 |
+
|
| 3 |
+
Flask app for AI-assisted brain MRI screening (demo).
|
| 4 |
+
|
| 5 |
+
## Repo layout
|
| 6 |
+
|
| 7 |
+
- `app.py` - Flask backend + inference API
|
| 8 |
+
- `templates/index.html` - Simple, maintainable UI (no bundler required)
|
| 9 |
+
- `static/assets/brain/` - Logo + example images
|
| 10 |
+
- `static/assets/images/team/` - Team member photos (local)
|
| 11 |
+
- `models/` - Put your trained `.h5` files here
|
| 12 |
+
|
| 13 |
+
## Add your models
|
| 14 |
+
|
| 15 |
+
Place these files under `models/`:
|
| 16 |
+
|
| 17 |
+
- `resnet50.h5`
|
| 18 |
+
- `resnet101.h5`
|
| 19 |
+
- `efficientnetb2.h5`
|
| 20 |
+
|
| 21 |
+
If you want different names/paths, edit `MODEL_SPECS` in `app.py`.
|
| 22 |
+
|
| 23 |
+
## Run locally
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
pip install -r requirements.txt
|
| 27 |
+
python app.py
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
Then open http://localhost:7860
|
| 31 |
+
|
| 32 |
+
## API
|
| 33 |
+
|
| 34 |
+
- `GET /api/models` -> available model IDs and names
|
| 35 |
+
- `POST /api/classify` (multipart form):
|
| 36 |
+
- `file`: image file
|
| 37 |
+
- `model_id`: one of `atlas`, `orion`, `pulse`
|
app.py
CHANGED
|
@@ -1,97 +1,197 @@
|
|
| 1 |
import os
|
| 2 |
-
import
|
|
|
|
|
|
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
-
from flask import Flask,
|
| 5 |
|
| 6 |
import tensorflow as tf
|
| 7 |
from tensorflow.keras.models import load_model
|
| 8 |
-
from tensorflow.keras.applications.resnet50 import preprocess_input
|
| 9 |
|
| 10 |
-
app = Flask(__name__, template_folder='templates', static_folder='static')
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
try:
|
| 17 |
-
model = load_model(MODEL_PATH, compile=False)
|
| 18 |
-
print("SUCCESS: Model loaded!", file=sys.stderr)
|
| 19 |
-
except Exception as e:
|
| 20 |
-
print(f"CRITICAL ERROR: Failed to load model. {e}", file=sys.stderr)
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
try:
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
# --- ROUTES ---
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
def home():
|
| 42 |
-
return render_template(
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
try:
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
message = "Uncertain prediction. Please interpret with caution or upload a clearer image."
|
| 76 |
-
is_uncertain = True
|
| 77 |
-
else:
|
| 78 |
-
result_class = CLASS_NAMES[class_index]
|
| 79 |
-
message = "Prediction success."
|
| 80 |
-
is_uncertain = False
|
| 81 |
-
|
| 82 |
-
result = {
|
| 83 |
-
'result': result_class,
|
| 84 |
-
'confidence': confidence,
|
| 85 |
-
'message': message,
|
| 86 |
-
'is_uncertain': is_uncertain,
|
| 87 |
-
'top_class_suggestion': CLASS_NAMES[class_index]
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
return jsonify(result)
|
| 91 |
-
|
| 92 |
except Exception as e:
|
| 93 |
-
|
| 94 |
-
|
| 95 |
|
| 96 |
-
if __name__ ==
|
| 97 |
-
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
import re
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Dict, List, Tuple
|
| 5 |
+
|
| 6 |
import numpy as np
|
| 7 |
+
from flask import Flask, jsonify, render_template, request
|
| 8 |
|
| 9 |
import tensorflow as tf
|
| 10 |
from tensorflow.keras.models import load_model
|
|
|
|
| 11 |
|
|
|
|
| 12 |
|
| 13 |
+
# ----------------------------
|
| 14 |
+
# Model definitions
|
| 15 |
+
# ----------------------------
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class ModelSpec:
|
| 19 |
+
id: str
|
| 20 |
+
display_name: str # what the user sees (friendly + technical)
|
| 21 |
+
filename: str # under ./models/
|
| 22 |
+
arch: str # "resnet" | "efficientnet"
|
| 23 |
+
img_size: int # input resolution
|
| 24 |
+
class_names: Tuple[str, ...] # output order used during training
|
| 25 |
+
recommended_threshold: float # per-model uncertainty cutoff (from notebooks)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# NOTE:
|
| 29 |
+
# Your training notebooks use *different* class ordering between the ResNet notebooks
|
| 30 |
+
# (sorted unique categories) and the EfficientNet notebook (explicit list).
|
| 31 |
+
# We keep per-model class order to avoid mislabeling probabilities.
|
| 32 |
+
RESNET_CLASS_ORDER = ("MildDemented", "ModerateDemented", "NonDemented", "VeryMildDemented")
|
| 33 |
+
EFFICIENTNET_CLASS_ORDER = ("NonDemented", "VeryMildDemented", "MildDemented", "ModerateDemented")
|
| 34 |
+
|
| 35 |
+
MODEL_SPECS: List[ModelSpec] = [
|
| 36 |
+
ModelSpec("atlas", "Atlas — ResNet-50", "resnet50.h5", "resnet", 224, RESNET_CLASS_ORDER, 0.95),
|
| 37 |
+
ModelSpec("orion", "Orion — ResNet-101", "resnet101.h5", "resnet", 224, RESNET_CLASS_ORDER, 0.95),
|
| 38 |
+
ModelSpec("pulse", "Pulse — EfficientNet-B2", "efficientnetb2.h5", "efficientnet", 260, EFFICIENTNET_CLASS_ORDER, 0.95),
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ----------------------------
|
| 44 |
+
# Flask app
|
| 45 |
+
# ----------------------------
|
| 46 |
+
|
| 47 |
+
app = Flask(__name__)
|
| 48 |
+
|
| 49 |
+
# Lazy-loaded models (load on first use). Keep only what we need in CPU Spaces.
|
| 50 |
+
_loaded_models: Dict[str, tf.keras.Model] = {}
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
def _get_spec(model_id: str) -> ModelSpec:
|
| 54 |
+
for s in MODEL_SPECS:
|
| 55 |
+
if s.id == model_id:
|
| 56 |
+
return s
|
| 57 |
+
raise KeyError(f"Unknown model_id: {model_id}")
|
| 58 |
|
| 59 |
+
|
| 60 |
+
def _get_preprocess_fn(arch: str):
|
| 61 |
+
if arch == "resnet":
|
| 62 |
+
from tensorflow.keras.applications.resnet50 import preprocess_input as resnet_preprocess
|
| 63 |
+
return resnet_preprocess
|
| 64 |
+
if arch == "efficientnet":
|
| 65 |
+
from tensorflow.keras.applications.efficientnet import preprocess_input as eff_preprocess
|
| 66 |
+
return eff_preprocess
|
| 67 |
+
raise ValueError(f"Unknown arch: {arch}")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _load_model(spec: ModelSpec) -> tf.keras.Model:
|
| 71 |
+
if spec.id in _loaded_models:
|
| 72 |
+
return _loaded_models[spec.id]
|
| 73 |
+
|
| 74 |
+
model_path = os.path.join(os.path.dirname(__file__), "models", spec.filename)
|
| 75 |
+
if not os.path.exists(model_path):
|
| 76 |
+
raise FileNotFoundError(
|
| 77 |
+
f"Model file not found: {model_path}. "
|
| 78 |
+
f"Place it at models/{spec.filename} in your Space."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# CPU-friendly TF settings (small wins on free Spaces)
|
| 82 |
try:
|
| 83 |
+
tf.config.threading.set_intra_op_parallelism_threads(0)
|
| 84 |
+
tf.config.threading.set_inter_op_parallelism_threads(0)
|
| 85 |
+
except Exception:
|
| 86 |
+
pass
|
| 87 |
+
|
| 88 |
+
model = load_model(model_path, compile=False)
|
| 89 |
+
_loaded_models[spec.id] = model
|
| 90 |
+
return model
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _read_image(file_storage, img_size: int, preprocess_fn):
|
| 94 |
+
# Decode image
|
| 95 |
+
raw = file_storage.read()
|
| 96 |
+
image = tf.io.decode_image(raw, channels=3, expand_animations=False)
|
| 97 |
+
image = tf.image.resize(image, [img_size, img_size])
|
| 98 |
+
image = tf.cast(image, tf.float32)
|
| 99 |
+
image = preprocess_fn(image)
|
| 100 |
+
image = tf.expand_dims(image, axis=0) # [1, H, W, 3]
|
| 101 |
+
return image
|
| 102 |
|
|
|
|
| 103 |
|
| 104 |
+
def _predict(model: tf.keras.Model, image_tensor, class_names: Tuple[str, ...], threshold: float):
|
| 105 |
+
probs = model.predict(image_tensor, verbose=0)[0].astype(float)
|
| 106 |
+
probs = np.clip(probs, 0.0, 1.0)
|
| 107 |
+
|
| 108 |
+
best_idx = int(np.argmax(probs))
|
| 109 |
+
best_prob = float(np.max(probs))
|
| 110 |
+
|
| 111 |
+
# Add "Uncertain" post-hoc (not a model output class)
|
| 112 |
+
is_uncertain = best_prob < threshold
|
| 113 |
+
|
| 114 |
+
# Build response payload
|
| 115 |
+
by_class = [
|
| 116 |
+
{"id": name, "label": _pretty_label(name), "prob": float(probs[i])}
|
| 117 |
+
for i, name in enumerate(class_names)
|
| 118 |
+
]
|
| 119 |
+
by_class.sort(key=lambda x: x["prob"], reverse=True)
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"prediction": {
|
| 123 |
+
"id": "Uncertain" if is_uncertain else class_names[best_idx],
|
| 124 |
+
"label": "Uncertain" if is_uncertain else _pretty_label(class_names[best_idx]),
|
| 125 |
+
"confidence": best_prob,
|
| 126 |
+
"threshold": threshold,
|
| 127 |
+
},
|
| 128 |
+
"probabilities": by_class,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _pretty_label(name: str) -> str:
|
| 133 |
+
# Internal training labels -> user-facing labels (final wording)
|
| 134 |
+
mapping = {
|
| 135 |
+
"NonDemented": "Healthy",
|
| 136 |
+
"VeryMildDemented": "Very Mildly Demented",
|
| 137 |
+
"MildDemented": "Mildly Demented",
|
| 138 |
+
"ModerateDemented": "Moderately Demented",
|
| 139 |
+
# Post-hoc
|
| 140 |
+
"Uncertain": "Uncertain",
|
| 141 |
+
}
|
| 142 |
+
return mapping.get(name, name)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.get("/")
|
| 146 |
def home():
|
| 147 |
+
return render_template("index.html")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@app.get("/api/models")
|
| 151 |
+
def api_models():
|
| 152 |
+
return jsonify({
|
| 153 |
+
"models": [
|
| 154 |
+
{
|
| 155 |
+
"id": s.id,
|
| 156 |
+
"name": s.display_name,
|
| 157 |
+
"img_size": s.img_size,
|
| 158 |
+
"classes": [{"id": c, "label": _pretty_label(c)} for c in s.class_names],
|
| 159 |
+
"recommended_threshold": s.recommended_threshold,
|
| 160 |
+
}
|
| 161 |
+
for s in MODEL_SPECS
|
| 162 |
+
],
|
| 163 |
+
"default_model_id": MODEL_SPECS[0].id,
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@app.post("/api/classify")
|
| 169 |
+
def api_classify():
|
| 170 |
+
if "file" not in request.files:
|
| 171 |
+
return jsonify({"error": "No file uploaded (field name must be 'file')."}), 400
|
| 172 |
+
|
| 173 |
+
model_id = request.form.get("model_id", MODEL_SPECS[0].id)
|
| 174 |
+
spec = _get_spec(model_id)
|
| 175 |
+
# Threshold is model-specific and not user-adjustable
|
| 176 |
+
threshold = spec.recommended_threshold
|
| 177 |
+
|
| 178 |
try:
|
| 179 |
+
model = _load_model(spec)
|
| 180 |
+
preprocess_fn = _get_preprocess_fn(spec.arch)
|
| 181 |
+
|
| 182 |
+
image_tensor = _read_image(request.files["file"], spec.img_size, preprocess_fn)
|
| 183 |
+
payload = _predict(model, image_tensor, spec.class_names, threshold)
|
| 184 |
+
|
| 185 |
+
payload["model"] = {"id": spec.id, "name": spec.display_name}
|
| 186 |
+
return jsonify(payload)
|
| 187 |
+
|
| 188 |
+
except FileNotFoundError as e:
|
| 189 |
+
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
except Exception as e:
|
| 191 |
+
return jsonify({"error": f"Failed to classify image: {e}"}), 500
|
| 192 |
+
|
| 193 |
|
| 194 |
+
if __name__ == "__main__":
|
| 195 |
+
# Local dev: python app.py
|
| 196 |
+
# In Spaces (Dockerfile), gunicorn is used.
|
| 197 |
+
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "7860")), debug=False)
|
models/README.md
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Place your model files here:
|
| 2 |
+
- resnet50.h5
|
| 3 |
+
- resnet101.h5
|
| 4 |
+
- efficientnetb2.h5
|
models/efficientnetb2.h5
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:92ca0621bacfb11477e4242a4d38409b4f44a33483b5167dc5808e3413f7e243
|
| 3 |
+
size 102821032
|
models/resnet101.h5
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:33946fb58dc03e58886e2b40fabff8ce545c6d75808b3ac87ab7d6b4cd75d39a
|
| 3 |
+
size 524986352
|
model.h5 → models/resnet50.h5
RENAMED
|
File without changes
|
static/assets/brain/PLACE_IMAGES_HERE.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Put these files here:
|
| 2 |
+
- supportedexample.jpg
|
| 3 |
+
- unsupportedexample.jpg
|
static/{brain.svg → assets/brain/brain.svg}
RENAMED
|
File without changes
|
static/assets/brain/supportedexample.jpg
ADDED
|
Git LFS Details
|
static/assets/brain/unsupportedexample.jpg
ADDED
|
Git LFS Details
|
static/assets/index-CtQJEsfm.css
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.flex{display:flex}.grid{display:grid}.h-12{height:3rem}.h-16{height:4rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-8{height:2rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-32{width:8rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-6{--tw-rotate: 6deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(1rem * var(--tw-space-x-reverse));margin-left:calc(1rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border-2{border-width:2px}.border-dashed{border-style:dashed}.border-blue-500{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity))}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity))}.border-green-200{--tw-border-opacity: 1;border-color:rgb(187 247 208 / var(--tw-border-opacity))}.border-red-200{--tw-border-opacity: 1;border-color:rgb(254 202 202 / var(--tw-border-opacity))}.bg-blue-100{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity))}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity: 1;color:rgb(29 78 216 / var(--tw-text-opacity))}.text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity))}.text-yellow-600{--tw-text-opacity: 1;color:rgb(202 138 4 / var(--tw-text-opacity))}.opacity-0{opacity:0}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.delay-150{transition-delay:.15s}.delay-300{transition-delay:.3s}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.hover\:bg-gray-300:hover{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity))}.hover\:underline:hover{text-decoration-line:underline}.group:hover .group-hover\:-translate-y-1{--tw-translate-y: -.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:rotate-12{--tw-rotate: 12deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.dark\:border-gray-600:is(.dark *){--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity))}.dark\:border-gray-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity))}.dark\:border-green-900:is(.dark *){--tw-border-opacity: 1;border-color:rgb(20 83 45 / var(--tw-border-opacity))}.dark\:border-red-900:is(.dark *){--tw-border-opacity: 1;border-color:rgb(127 29 29 / var(--tw-border-opacity))}.dark\:bg-blue-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 58 138 / var(--tw-bg-opacity))}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1e3a8a33}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity))}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity))}.dark\:bg-gray-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity))}.dark\:bg-red-900\/20:is(.dark *){background-color:#7f1d1d33}.dark\:text-blue-200:is(.dark *){--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity))}.dark\:text-blue-400:is(.dark *){--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity))}.dark\:text-gray-200:is(.dark *){--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity))}.dark\:text-gray-300:is(.dark *){--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.dark\:text-green-400:is(.dark *){--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity))}.dark\:text-red-200:is(.dark *){--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity))}.dark\:text-red-400:is(.dark *){--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity))}.dark\:text-white:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.dark\:text-yellow-400:is(.dark *){--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity))}.dark\:hover\:bg-gray-600:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity))}.dark\:hover\:bg-gray-700:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity))}@media (min-width: 640px){.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(0px * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px * var(--tw-space-y-reverse))}}@media (min-width: 1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}}
|
|
|
|
|
|
static/assets/index-DBG8gcSd.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
templates/index.html
CHANGED
|
@@ -1,205 +1,373 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en">
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
transform: translateX(-50%);
|
| 20 |
-
width: 90%;
|
| 21 |
-
max-width: 480px;
|
| 22 |
-
background: #ffffff;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
| 25 |
-
z-index: 10000;
|
| 26 |
-
padding: 24px;
|
| 27 |
-
border: 1px solid #fee2e2;
|
| 28 |
-
font-family: system-ui, -apple-system, sans-serif;
|
| 29 |
-
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
| 30 |
-
}
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
to { transform: translate(-50%, 0); opacity: 1; }
|
| 35 |
-
}
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
gap: 12px;
|
| 41 |
-
color: #991b1b;
|
| 42 |
-
margin-bottom: 12px;
|
| 43 |
-
}
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
line-height: 1.6;
|
| 51 |
-
margin-bottom: 20px;
|
| 52 |
-
}
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
display: none;
|
| 60 |
-
border-left: 4px solid #3b82f6;
|
| 61 |
-
color: #1e3a8a;
|
| 62 |
-
}
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
flex: 1;
|
| 68 |
-
padding: 10px 16px;
|
| 69 |
-
border: none;
|
| 70 |
-
border-radius: 8px;
|
| 71 |
-
font-weight: 600;
|
| 72 |
-
cursor: pointer;
|
| 73 |
-
transition: all 0.2s;
|
| 74 |
-
font-size: 0.9rem;
|
| 75 |
-
}
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
.btn-reveal:hover { background-color: #1d4ed8; transform: translateY(-1px); }
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
</button>
|
| 118 |
-
|
| 119 |
-
<div id="uncertainty-modal">
|
| 120 |
-
<div class="modal-header">
|
| 121 |
-
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
| 122 |
-
<div class="modal-title">Low Confidence Warning</div>
|
| 123 |
</div>
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
<div
|
| 131 |
-
<
|
| 132 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
</div>
|
| 134 |
-
|
| 135 |
-
<div class="
|
| 136 |
-
<
|
| 137 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
</div>
|
| 139 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
}
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
</
|
| 204 |
-
|
| 205 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>NeuraScan | AI Brain MRI Analysis</title>
|
| 7 |
+
<meta name="description" content="NeuraScan - AI-powered brain MRI Alzheimer's disease analysis tool" />
|
| 8 |
+
<link rel="icon" type="image/svg+xml" href="/assets/brain/brain.svg" />
|
| 9 |
+
<style>
|
| 10 |
+
:root { color-scheme: dark; }
|
| 11 |
+
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background:#0b1020; color:#e6e8ee; }
|
| 12 |
+
a { color: inherit; }
|
| 13 |
+
.container { max-width: 1040px; margin: 0 auto; padding: 28px 18px 64px; }
|
| 14 |
+
.header { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom: 22px; }
|
| 15 |
+
.brand { display:flex; align-items:center; gap:12px; }
|
| 16 |
+
.brand img { width:42px; height:42px; }
|
| 17 |
+
h1 { font-size: 22px; margin:0; letter-spacing: .3px; }
|
| 18 |
+
.sub { margin: 4px 0 0; color:#aab0c0; font-size: 14px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
.grid { display:grid; gap:16px; grid-template-columns: 1.05fr .95fr; }
|
| 21 |
+
@media (max-width: 920px) { .grid { grid-template-columns: 1fr; } }
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
.card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.08); border-radius: 18px; padding: 18px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
|
| 24 |
+
.card h2 { margin: 0 0 10px; font-size: 16px; }
|
| 25 |
+
.muted { color:#aab0c0; font-size: 13px; line-height: 1.45; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
.controls { display:flex; gap:10px; flex-wrap: wrap; align-items: center; margin: 10px 0 14px; }
|
| 28 |
+
select, button { border-radius: 12px; border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.06); color: #e6e8ee; padding: 10px 12px; font-size: 14px; }
|
| 29 |
+
button { cursor:pointer; }
|
| 30 |
+
button.primary { background: rgba(59,130,246,.9); border-color: rgba(59,130,246,.9); }
|
| 31 |
+
button.primary:disabled { opacity:.55; cursor:not-allowed; }
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
.drop { border: 1px dashed rgba(255,255,255,.22); border-radius: 18px; padding: 18px; background: rgba(255,255,255,.03); }
|
| 34 |
+
.drop.dragover { border-color: rgba(59,130,246,.9); box-shadow: 0 0 0 4px rgba(59,130,246,.18); }
|
| 35 |
+
.drop .row { display:flex; align-items:center; justify-content:space-between; gap: 12px; flex-wrap: wrap; }
|
| 36 |
+
.hintlist { margin: 12px 0 0; padding-left: 18px; }
|
| 37 |
+
.hintlist li { margin: 6px 0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
.preview { margin-top: 14px; display:none; gap: 12px; align-items: center; }
|
| 40 |
+
.preview img { width: 92px; height: 92px; object-fit: cover; border-radius: 14px; border: 1px solid rgba(255,255,255,.12); }
|
| 41 |
+
.pill { display:inline-flex; align-items:center; gap: 8px; padding: 8px 10px; border-radius: 999px; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.10); font-size: 13px; color:#d6daea; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
.result { margin-top: 12px; display:none; }
|
| 44 |
+
.result .big { font-size: 18px; margin: 4px 0 0; }
|
| 45 |
+
.bar { height: 10px; border-radius: 999px; background: rgba(255,255,255,.08); overflow:hidden; border: 1px solid rgba(255,255,255,.08); }
|
| 46 |
+
.bar > div { height: 100%; width: 0%; background: rgba(59,130,246,.9); }
|
| 47 |
+
.warn { margin-top: 12px; padding: 12px; border-radius: 14px; background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.24); color:#ffe9c2; display:none; }
|
|
|
|
| 48 |
|
| 49 |
+
.team { display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; margin-top: 10px; }
|
| 50 |
+
.member { display:flex; gap: 10px; align-items:center; padding: 10px; border-radius: 14px; background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07); }
|
| 51 |
+
.member img { width: 42px; height: 42px; border-radius: 999px; object-fit: cover; border: 1px solid rgba(255,255,255,.10); }
|
| 52 |
+
.member .name { font-weight: 600; font-size: 14px; }
|
| 53 |
+
.member .role { color:#aab0c0; font-size: 12px; margin-top: 2px; }
|
| 54 |
+
|
| 55 |
+
.examples { display:grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
|
| 56 |
+
.ex { border-radius: 16px; overflow:hidden; border: 1px solid rgba(255,255,255,.10); background: rgba(255,255,255,.03); }
|
| 57 |
+
.ex img { width: 100%; height: 140px; object-fit: cover; display:block; }
|
| 58 |
+
.ex .cap { padding: 10px; font-size: 13px; color:#aab0c0; }
|
| 59 |
+
footer { margin-top: 20px; color:#8f95a8; font-size: 12px; }
|
| 60 |
+
code { background: rgba(255,255,255,.08); padding: 2px 6px; border-radius: 8px; }
|
| 61 |
+
|
| 62 |
+
/* Modal */
|
| 63 |
+
.modal { position: fixed; inset: 0; display:none; }
|
| 64 |
+
.modal[aria-hidden="false"] { display:block; }
|
| 65 |
+
.modal-backdrop { position:absolute; inset:0; background: rgba(0,0,0,.55); }
|
| 66 |
+
.modal-card { position:relative; max-width: 520px; margin: 8vh auto; background: rgba(10,12,18,.95);
|
| 67 |
+
border: 1px solid rgba(255,255,255,.12); border-radius: 18px; padding: 18px; box-shadow: 0 30px 60px rgba(0,0,0,.35); }
|
| 68 |
+
.prob-row { display:flex; justify-content:space-between; gap:12px; padding:10px 12px; border-radius: 14px;
|
| 69 |
+
border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); margin-top: 10px; }
|
| 70 |
+
</style>
|
| 71 |
+
</head>
|
| 72 |
+
|
| 73 |
+
<body>
|
| 74 |
+
<div class="container">
|
| 75 |
+
<div class="header">
|
| 76 |
+
<div class="brand">
|
| 77 |
+
<img src="/assets/brain/brain.svg" alt="NeuraScan logo" />
|
| 78 |
+
<div>
|
| 79 |
+
<h1>NeuraScan</h1>
|
| 80 |
+
<div class="sub">AI-assisted MRI screening for Alzheimer’s disease (demo)</div>
|
| 81 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
+
<div class="pill" id="statusPill">Ready</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div class="grid">
|
| 87 |
+
<div class="card">
|
| 88 |
+
<h2>New scan</h2>
|
| 89 |
+
<div class="muted">
|
| 90 |
+
Choose a model, then upload an MRI image. You can <b>drag & drop</b>, <b>paste</b> (Ctrl+V), or use the upload button.
|
| 91 |
</div>
|
| 92 |
+
|
| 93 |
+
<div class="controls">
|
| 94 |
+
<label class="muted" for="modelSelect">Model</label>
|
| 95 |
+
<select id="modelSelect" aria-label="Model selection"></select><input id="fileInput" type="file" accept="image/*" hidden />
|
| 96 |
+
<button id="uploadBtn">Upload image</button>
|
| 97 |
+
<button class="primary" id="scanBtn" disabled>Run scan</button>
|
| 98 |
+
<button id="newBtn" style="display:none;">New scan</button>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div id="drop" class="drop" tabindex="0">
|
| 102 |
+
<div class="row">
|
| 103 |
+
<div>
|
| 104 |
+
<div style="font-weight:600;">Drop an image here</div>
|
| 105 |
+
<div class="muted">Or paste from clipboard • PNG/JPG recommended</div>
|
| 106 |
+
</div>
|
| 107 |
+
<div class="pill"><span>Tip</span><span>Click box then Ctrl+V</span></div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<ul class="hintlist muted">
|
| 111 |
+
<li>Best results: clear axial brain MRI, centered, minimal blur.</li>
|
| 112 |
+
<li>We don’t store uploads — they’re processed for inference only.</li>
|
| 113 |
+
</ul>
|
| 114 |
+
|
| 115 |
+
<div class="preview" id="preview">
|
| 116 |
+
<img id="previewImg" alt="Preview" />
|
| 117 |
+
<div>
|
| 118 |
+
<div class="muted" id="fileMeta"></div>
|
| 119 |
+
<div class="muted">Selected model: <span id="modelName"></span></div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div class="result" id="result">
|
| 125 |
+
<div class="muted">Result</div>
|
| 126 |
+
<div class="big" id="resultText">—</div>
|
| 127 |
+
<div class="muted" id="msgText" style="margin-top:6px;"></div>
|
| 128 |
+
<div style="margin-top:12px;">
|
| 129 |
+
<div class="muted" style="margin-bottom:8px;">Confidence</div>
|
| 130 |
+
<div class="bar"><div id="confBar"></div></div>
|
| 131 |
+
<div class="muted" style="margin-top:6px;" id="confText">—</div>
|
| 132 |
+
</div>
|
| 133 |
+
<div class="warn" id="warnBox">
|
| 134 |
+
Low confidence: consider re-uploading a clearer MRI or trying another model.
|
| 135 |
+
</div>
|
| 136 |
+
<div style="display:flex; gap:10px; margin-top:12px; flex-wrap:wrap;">
|
| 137 |
+
<button id="detailsBtn" style="display:none;">Show most likely result</button>
|
| 138 |
+
<button id="newBtn">Start a new scan</button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<footer>
|
| 143 |
+
This is an educational demo and not medical advice.
|
| 144 |
+
</footer>
|
| 145 |
</div>
|
| 146 |
+
|
| 147 |
+
<div class="card">
|
| 148 |
+
<h2>Examples</h2>
|
| 149 |
+
<div class="muted">Replace the placeholder example images in <code>static/assets/brain/</code>.</div>
|
| 150 |
+
<div class="examples">
|
| 151 |
+
<div class="ex">
|
| 152 |
+
<img src="/assets/brain/supportedexample.jpg" onerror="this.style.display='none';" alt="Supported example"/>
|
| 153 |
+
<div class="cap">Supported example</div>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="ex">
|
| 156 |
+
<img src="/assets/brain/unsupportedexample.jpg" onerror="this.style.display='none';" alt="Unsupported example"/>
|
| 157 |
+
<div class="cap">Unsupported example</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<h2 style="margin-top:18px;">Team</h2>
|
| 162 |
+
<div class="team">
|
| 163 |
+
<div class="member"><img src="/assets/images/team/asem.jpg" alt="Asem"/><div><div class="name">Asem</div><div class="role">ML / Deployment</div></div></div>
|
| 164 |
+
<div class="member"><img src="/assets/images/team/sameh.jpg" alt="Sameh"/><div><div class="name">Sameh</div><div class="role">ML / Research</div></div></div>
|
| 165 |
+
<div class="member"><img src="/assets/images/team/fatma.jpg" alt="Fatma"/><div><div class="name">Fatma</div><div class="role">Data / QA</div></div></div>
|
| 166 |
+
<div class="member"><img src="/assets/images/team/gehad.jpg" alt="Gehad"/><div><div class="name">Gehad</div><div class="role">Frontend</div></div></div>
|
| 167 |
+
</div>
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<script>
|
| 173 |
+
const statusPill = document.getElementById('statusPill');
|
| 174 |
+
const modelSelect = document.getElementById('modelSelect');
|
| 175 |
+
const detailsBtn = document.getElementById('detailsBtn'); const modelName = document.getElementById('modelName');
|
| 176 |
+
const fileInput = document.getElementById('fileInput');
|
| 177 |
+
const uploadBtn = document.getElementById('uploadBtn');
|
| 178 |
+
const scanBtn = document.getElementById('scanBtn');
|
| 179 |
+
const newBtn = document.getElementById('newBtn');
|
| 180 |
+
const modal = document.getElementById('modal');
|
| 181 |
+
const modalBackdrop = document.getElementById('modalBackdrop');
|
| 182 |
+
const modalClose = document.getElementById('modalClose');
|
| 183 |
+
const modalTitle = document.getElementById('modalTitle');
|
| 184 |
+
const modalSubtitle = document.getElementById('modalSubtitle');
|
| 185 |
+
const modalList = document.getElementById('modalList');
|
| 186 |
+
const drop = document.getElementById('drop');
|
| 187 |
+
const preview = document.getElementById('preview');
|
| 188 |
+
const previewImg = document.getElementById('previewImg');
|
| 189 |
+
const fileMeta = document.getElementById('fileMeta');
|
| 190 |
+
|
| 191 |
+
const resultBox = document.getElementById('result');
|
| 192 |
+
const resultText = document.getElementById('resultText');
|
| 193 |
+
const msgText = document.getElementById('msgText');
|
| 194 |
+
const confBar = document.getElementById('confBar');
|
| 195 |
+
const confText = document.getElementById('confText');
|
| 196 |
+
const warnBox = document.getElementById('warnBox');
|
| 197 |
|
| 198 |
+
let selectedFile = null;
|
| 199 |
+
let modelMeta = {};
|
| 200 |
+
let lastResult = null;
|
| 201 |
+
let models = [];
|
| 202 |
+
|
| 203 |
+
function setStatus(text) { statusPill.textContent = text; }
|
| 204 |
+
|
| 205 |
+
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
function openModal(result) {
|
| 210 |
+
if (!result) return;
|
| 211 |
+
const top = result.top || (result.probabilities || [])[0] || {};
|
| 212 |
+
const label = top.label || '—';
|
| 213 |
+
const prob = (top.prob ?? top.probability ?? 0);
|
| 214 |
+
modalTitle.textContent = label;
|
| 215 |
+
modalSubtitle.textContent = `Confidence: ${(prob*100).toFixed(1)}%`;
|
| 216 |
+
modalList.innerHTML = '';
|
| 217 |
+
modal.setAttribute('aria-hidden','false');
|
| 218 |
+
}
|
| 219 |
+
function closeModal() {
|
| 220 |
+
modal.setAttribute('aria-hidden','true');
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
async function loadModels() {
|
| 225 |
+
const res = await fetch('/api/models');
|
| 226 |
+
const payload = await res.json();
|
| 227 |
+
const list = payload.models || [];
|
| 228 |
+
modelMeta = Object.fromEntries(list.map(m => [m.id, m]));
|
| 229 |
+
modelSelect.innerHTML = list.map(m => `<option value="${m.id}">${m.name}</option>`).join('');
|
| 230 |
+
if (payload.default_model_id && modelMeta[payload.default_model_id]) {
|
| 231 |
+
modelSelect.value = payload.default_model_id;
|
| 232 |
+
}
|
| 233 |
+
modelName.textContent = modelSelect.options[modelSelect.selectedIndex]?.text || 'Model';
|
| 234 |
+
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function resetUI() {
|
| 238 |
+
selectedFile = null;
|
| 239 |
+
fileInput.value = '';
|
| 240 |
+
preview.style.display = 'none';
|
| 241 |
+
resultBox.style.display = 'none';
|
| 242 |
+
warnBox.style.display = 'none';
|
| 243 |
+
scanBtn.disabled = true;
|
| 244 |
+
scanBtn.style.display = '';
|
| 245 |
+
detailsBtn.style.display = 'none';
|
| 246 |
+
newBtn.style.display = '';
|
| 247 |
+
|
| 248 |
+
setStatus('Ready');
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function setFile(file) {
|
| 252 |
+
selectedFile = file;
|
| 253 |
+
if (!file) return;
|
| 254 |
+
previewImg.src = URL.createObjectURL(file);
|
| 255 |
+
preview.style.display = 'flex';
|
| 256 |
+
fileMeta.textContent = `${file.name} • ${(file.size/1024/1024).toFixed(2)} MB`;
|
| 257 |
+
modelName.textContent = modelSelect.options[modelSelect.selectedIndex].text;
|
| 258 |
+
scanBtn.disabled = false;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
uploadBtn.addEventListener('click', () => fileInput.click());
|
| 262 |
+
fileInput.addEventListener('change', (e) => setFile(e.target.files?.[0]));
|
| 263 |
+
|
| 264 |
+
modelSelect.addEventListener('change', () => {
|
| 265 |
+
modelName.textContent = modelSelect.options[modelSelect.selectedIndex].text;
|
| 266 |
+
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
// Threshold controls
|
| 270 |
+
|
| 271 |
+
// Drag & drop
|
| 272 |
+
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, (e) => {
|
| 273 |
+
e.preventDefault(); e.stopPropagation();
|
| 274 |
+
drop.classList.add('dragover');
|
| 275 |
+
}));
|
| 276 |
+
['dragleave','drop'].forEach(ev => drop.addEventListener(ev, (e) => {
|
| 277 |
+
e.preventDefault(); e.stopPropagation();
|
| 278 |
+
drop.classList.remove('dragover');
|
| 279 |
+
}));
|
| 280 |
+
drop.addEventListener('drop', (e) => {
|
| 281 |
+
const f = e.dataTransfer.files?.[0];
|
| 282 |
+
if (f) setFile(f);
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
// Paste
|
| 286 |
+
window.addEventListener('paste', (e) => {
|
| 287 |
+
const item = [...(e.clipboardData?.items || [])].find(i => i.type.startsWith('image/'));
|
| 288 |
+
if (!item) return;
|
| 289 |
+
const f = item.getAsFile();
|
| 290 |
+
if (f) setFile(f);
|
| 291 |
+
});
|
| 292 |
+
|
| 293 |
+
async function runScan() {
|
| 294 |
+
if (!selectedFile) return;
|
| 295 |
+
setStatus('Scanning…');
|
| 296 |
+
scanBtn.disabled = true;
|
| 297 |
+
warnBox.style.display = 'none';
|
| 298 |
+
|
| 299 |
+
const fd = new FormData();
|
| 300 |
+
fd.append('file', selectedFile);
|
| 301 |
+
fd.append('model_id', modelSelect.value);
|
| 302 |
+
|
| 303 |
+
try {
|
| 304 |
+
const res = await fetch('/api/classify', { method: 'POST', body: fd });
|
| 305 |
+
const data = await res.json();
|
| 306 |
+
if (!res.ok) throw new Error(data.error || 'Request failed');
|
| 307 |
+
|
| 308 |
+
resultBox.style.display = 'block';
|
| 309 |
+
lastResult = data;
|
| 310 |
+
const pred = data.prediction || {};
|
| 311 |
+
const model = data.model || {};
|
| 312 |
+
const top = (data.probabilities || [])[0] || {};
|
| 313 |
+
const isUncertain = (pred.id === 'Uncertain') || (pred.label === 'Uncertain');
|
| 314 |
+
|
| 315 |
+
resultText.textContent = pred.label || '—';
|
| 316 |
+
const pct = Math.round((pred.confidence || 0) * 100);
|
| 317 |
+
|
| 318 |
+
if (isUncertain) {
|
| 319 |
+
confBar.style.width = '0%';
|
| 320 |
+
confText.textContent = '';
|
| 321 |
+
} else {
|
| 322 |
+
confBar.style.width = pct + '%';
|
| 323 |
+
confText.textContent = `${pct}%`;
|
| 324 |
}
|
| 325 |
|
| 326 |
+
if (isUncertain) {
|
| 327 |
+
warnBox.style.display = 'block';
|
| 328 |
+
msgText.textContent = `${model.name || 'Model'} • Uncertain — consult a medical professional.`;
|
| 329 |
+
detailsBtn.style.display = '';
|
| 330 |
+
} else {
|
| 331 |
+
warnBox.style.display = 'none';
|
| 332 |
+
msgText.textContent = `${model.name || 'Model'} • Prediction generated successfully.`;
|
| 333 |
+
detailsBtn.style.display = 'none';
|
| 334 |
}
|
| 335 |
|
| 336 |
+
setStatus('Done');
|
| 337 |
+
scanBtn.style.display = 'none';
|
| 338 |
+
newBtn.style.display = '';
|
| 339 |
+
} catch (err) {
|
| 340 |
+
setStatus('Error');
|
| 341 |
+
alert(err.message);
|
| 342 |
+
scanBtn.disabled = false;
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
scanBtn.addEventListener('click', runScan);
|
| 347 |
+
newBtn.addEventListener('click', resetUI);
|
| 348 |
+
detailsBtn.addEventListener('click', () => openModal(lastResult));
|
| 349 |
+
modalBackdrop.addEventListener('click', closeModal);
|
| 350 |
+
modalClose.addEventListener('click', closeModal);
|
| 351 |
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
| 352 |
+
|
| 353 |
+
loadModels().catch(() => setStatus('Ready (offline)'));
|
| 354 |
+
resetUI();
|
| 355 |
+
</script>
|
| 356 |
+
|
| 357 |
+
<div id="modal" class="modal" aria-hidden="true">
|
| 358 |
+
<div class="modal-backdrop" id="modalBackdrop"></div>
|
| 359 |
+
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
| 360 |
+
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
|
| 361 |
+
<div>
|
| 362 |
+
<div class="muted">Most likely result</div>
|
| 363 |
+
<div class="big" id="modalTitle">—</div>
|
| 364 |
+
</div>
|
| 365 |
+
<button id="modalClose" aria-label="Close details">Close</button>
|
| 366 |
+
</div>
|
| 367 |
+
<div class="muted" style="margin-top:8px;" id="modalSubtitle">—</div>
|
| 368 |
+
<div style="margin-top:14px;" id="modalList"></div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
</body>
|
| 373 |
+
</html>
|