itsLu commited on
Commit
86821de
·
1 Parent(s): 5d46dd6

Replace Space with NeuraScan final version

Browse files
README.md CHANGED
@@ -1,11 +1,37 @@
1
- ---
2
- title: CerebroScan
3
- emoji: 📚
4
- colorFrom: red
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 sys
 
 
 
3
  import numpy as np
4
- from flask import Flask, render_template, request, jsonify, send_from_directory
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
- # 1. Load Model (Safe Mode)
13
- MODEL_PATH = 'model.h5'
14
- model = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- CLASS_NAMES = ['Mild Demented', 'Moderate Demented', 'Non Demented', 'Very Mild Demented']
23
- CONFIDENCE_THRESHOLD = 0.80
 
 
 
24
 
25
- def prepare_image(img_bytes):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  try:
27
- image = tf.io.decode_image(img_bytes, channels=3, expand_animations=False)
28
- image = tf.image.resize(image, [224, 224])
29
- image = tf.cast(image, tf.float32)
30
- image = tf.expand_dims(image, axis=0)
31
- image = preprocess_input(image)
32
-
33
- return image
34
- except Exception as e:
35
- print(f"Error processing image: {e}", file=sys.stderr)
36
- raise e
 
 
 
 
 
 
 
 
 
37
 
38
- # --- ROUTES ---
39
 
40
- @app.route('/')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def home():
42
- return render_template('index.html')
43
-
44
- @app.route('/assets/<path:filename>')
45
- def serve_assets(filename):
46
- return send_from_directory('static/assets', filename)
47
-
48
- @app.route('/<path:filename>')
49
- def serve_root_files(filename):
50
- return send_from_directory('static', filename)
51
-
52
- # Predict Route
53
- @app.route('/api/classify', methods=['POST'])
54
- def predict():
55
- if model is None:
56
- return jsonify({'error': 'Model not loaded correctly'}), 500
57
-
58
- if not request.files:
59
- return jsonify({'error': 'No file uploaded'}), 400
60
-
61
- file = next(iter(request.files.values()))
62
-
 
 
 
 
 
 
 
 
 
 
63
  try:
64
- file_bytes = file.read()
65
- processed_img = prepare_image(file_bytes)
66
-
67
- prediction_tensor = model(processed_img, training=False)
68
- prediction = prediction_tensor.numpy()
69
-
70
- class_index = np.argmax(prediction)
71
- confidence = float(np.max(prediction))
72
-
73
- if confidence < CONFIDENCE_THRESHOLD:
74
- result_class = "Not Sure"
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
- print(f"PREDICTION CRASHED: {str(e)}", file=sys.stderr)
94
- return jsonify({'error': str(e)}), 500
95
 
96
- if __name__ == '__main__':
97
- app.run(host='0.0.0.0', port=7860, debug=False)
 
 
 
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

  • SHA256: e781f3619b0021e39ded4ea45a2252bdc06ca3227cfbefd44d9f99bb5785876e
  • Pointer size: 131 Bytes
  • Size of remote file: 129 kB
static/assets/brain/unsupportedexample.jpg ADDED

Git LFS Details

  • SHA256: 8796fd3b447a8babe7c8693abf00bf0a3237833dd8802e66bd4c9c68c591440c
  • Pointer size: 129 Bytes
  • Size of remote file: 3.42 kB
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
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/brain.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <meta name="description" content="NeuraScan - Advanced AI-powered brain MRI Alzheimer's disease analysis tool" />
8
- <title>NeuraScan | AI Brain MRI Analysis</title>
9
-
10
- <script type="module" crossorigin src="/assets/index-DBG8gcSd.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-CtQJEsfm.css">
12
-
13
- <style>
14
- #uncertainty-modal {
15
- display: none;
16
- position: fixed;
17
- bottom: 20px;
18
- left: 50%;
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
- @keyframes slideUp {
33
- from { transform: translate(-50%, 100%); opacity: 0; }
34
- to { transform: translate(-50%, 0); opacity: 1; }
35
- }
36
 
37
- .modal-header {
38
- display: flex;
39
- align-items: center;
40
- gap: 12px;
41
- color: #991b1b;
42
- margin-bottom: 12px;
43
- }
44
 
45
- .modal-title { font-weight: 700; font-size: 1.125rem; }
46
-
47
- .modal-body {
48
- color: #374151;
49
- font-size: 0.95rem;
50
- line-height: 1.6;
51
- margin-bottom: 20px;
52
- }
53
 
54
- .suggestion-box {
55
- background: #eff6ff;
56
- padding: 16px;
57
- border-radius: 8px;
58
- margin-top: 15px;
59
- display: none;
60
- border-left: 4px solid #3b82f6;
61
- color: #1e3a8a;
62
- }
63
 
64
- .btn-group { display: flex; gap: 12px; margin-top: 5px; }
65
-
66
- .btn {
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
- .btn-reveal {
78
- background-color: #2563eb;
79
- color: white;
80
- box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.2);
81
- }
82
- .btn-reveal:hover { background-color: #1d4ed8; transform: translateY(-1px); }
83
 
84
- .btn-reset {
85
- background-color: #f3f4f6;
86
- color: #4b5563;
87
- border: 1px solid #e5e7eb;
88
- }
89
- .btn-reset:hover { background-color: #e5e7eb; }
90
-
91
- #global-reset-btn {
92
- position: fixed;
93
- bottom: 20px;
94
- right: 20px;
95
- background: #10b981;
96
- color: white;
97
- border: none;
98
- padding: 12px 24px;
99
- border-radius: 50px;
100
- cursor: pointer;
101
- font-weight: 600;
102
- z-index: 999;
103
- display: none;
104
- box-shadow: 0 4px 10px rgba(16, 185, 129, 0.3);
105
- transition: transform 0.2s;
106
- }
107
- #global-reset-btn:hover { transform: scale(1.05); }
108
-
109
- </style>
110
- </head>
111
- <body>
112
-
113
- <div id="root"></div>
114
-
115
- <button id="global-reset-btn" onclick="window.location.reload()">
116
- <span style="font-size: 1.2em; vertical-align: middle;">↻</span> New Scan
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
- <div class="modal-body">
126
- The model is less than <strong>80% confident</strong>.
127
- <br>This result is marked as "Not Sure" for safety.
128
- <br>Would you like to see the AI's best guess?
129
-
130
- <div id="suggestion-content" class="suggestion-box">
131
- <strong>Top Suggestion:</strong> <span id="suggestion-text" style="font-size: 1.1em; font-weight: bold;">---</span>
132
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  </div>
134
-
135
- <div class="btn-group">
136
- <button class="btn btn-reset" onclick="window.location.reload()">Try Another Image</button>
137
- <button class="btn btn-reveal" id="reveal-btn" onclick="revealSuggestion()">Show Suggestion</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  </div>
139
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
- <script>
142
-
143
- const originalFetch = window.fetch;
144
-
145
- window.fetch = async function(...args) {
146
- const response = await originalFetch(...args);
147
-
148
- const url = args[0].toString();
149
-
150
- if (url.includes('/api/classify') && response.ok) {
151
-
152
- setTimeout(() => {
153
- document.getElementById('global-reset-btn').style.display = 'block';
154
- }, 1000);
155
-
156
- const clone = response.clone();
157
-
158
- clone.json().then(data => {
159
- if (data.is_uncertain === true) {
160
- showUncertaintyModal(data.top_class_suggestion);
161
- }
162
- }).catch(err => console.log("Silent interceptor error:", err));
163
- }
164
-
165
- return response;
166
- };
167
-
168
- function showUncertaintyModal(suggestion) {
169
- const modal = document.getElementById('uncertainty-modal');
170
- const textSpan = document.getElementById('suggestion-text');
171
-
172
- textSpan.innerText = suggestion || "Unknown Class";
173
- modal.style.display = 'block';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  }
175
 
176
- function revealSuggestion() {
177
- document.getElementById('suggestion-content').style.display = 'block';
178
- document.getElementById('reveal-btn').style.display = 'none';
 
 
 
 
 
179
  }
180
 
181
- document.addEventListener('paste', function(event) {
182
- const fileInput = document.querySelector('input[type="file"]');
183
- if (!fileInput) return;
184
-
185
- const items = (event.clipboardData || event.originalEvent.clipboardData).items;
186
-
187
- for (let i = 0; i < items.length; i++) {
188
- if (items[i].type.indexOf('image') !== -1) {
189
- const blob = items[i].getAsFile();
190
-
191
- const dataTransfer = new DataTransfer();
192
- dataTransfer.items.add(blob);
193
- fileInput.files = dataTransfer.files;
194
-
195
- const changeEvent = new Event('change', { bubbles: true });
196
- fileInput.dispatchEvent(changeEvent);
197
-
198
- console.log("Image pasted directly from clipboard!");
199
- break;
200
- }
201
- }
202
- });
203
- </script>
204
- </body>
205
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>