UdasriHasindu commited on
Commit
a9e276d
Β·
1 Parent(s): f703f0d

core: add prediction function for drawings

Browse files
Files changed (3) hide show
  1. .gitignore +36 -0
  2. predictor.py +322 -0
  3. requirements.txt +48 -0
.gitignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .env
7
+ env/
8
+ venv/
9
+ env3.*/
10
+
11
+ # Distribution / packaging
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+ *.egg
16
+
17
+ # Testing / coverage
18
+ test/
19
+ htmlcov/
20
+ .coverage
21
+ .coverage.*
22
+ coverage/
23
+ .pytest_cache/
24
+
25
+ # Type checking
26
+ .mypy_cache/
27
+
28
+ # IDE specific files
29
+ .idea/
30
+ .vscode/
31
+ *.swp
32
+ *~
33
+
34
+ # OS generated files
35
+ .DS_Store
36
+ Thumbs.db
predictor.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Parkinson's Motor Impairment Score Predictor
3
+ =============================================
4
+ Reproduces the exact preprocessing and scoring pipeline from the training notebook.
5
+ torch / torchvision are NOT required β€” transforms are reimplemented in NumPy/PIL/cv2.
6
+
7
+ Models:
8
+ - Wave β†’ Final_wave_VGG19.h5 (VGG19 backbone, single logit output)
9
+ - Spiral β†’ Final_spiral_ResNet101.h5 (ResNet101 backbone, single logit output)
10
+ """
11
+
12
+ import os
13
+ import numpy as np
14
+ import cv2
15
+ from PIL import Image
16
+
17
+ import tensorflow as tf
18
+ from tensorflow.keras.models import load_model
19
+ from huggingface_hub import hf_hub_download
20
+
21
+
22
+ # ──────────────────────────────────────────────────────────────────────────────
23
+ # Constants (all values taken directly from the training notebook)
24
+ # ──────────────────────────────────────────────────────────────────────────────
25
+
26
+ HF_REPO_ID = "xplorers/Motor_Impairment_Score_models"
27
+ WAVE_MODEL_FILE = "Final_wave_VGG19.h5"
28
+ SPIRAL_MODEL_FILE = "Final_spiral_ResNet101.h5"
29
+
30
+ # Logit range calibrated on the training set (1st / 99th percentile)
31
+ SPIRAL_MIN_LOGIT = -16.384981
32
+ SPIRAL_MAX_LOGIT = 26.600843
33
+
34
+ WAVE_MIN_LOGIT = -45.584194
35
+ WAVE_MAX_LOGIT = 78.02814
36
+
37
+ # Decision boundary in 0-100 score space (where logit == 0 lands).
38
+ # Scores BELOW this β†’ "Normal Pattern" (healthy).
39
+ SPIRAL_NORMAL_BOUNDARY = 38.117172
40
+ WAVE_NORMAL_BOUNDARY = 36.876736
41
+
42
+
43
+ # ──────────────────────────────────────────────────────────────────────────────
44
+ # Model loading (lazy, module-level cache)
45
+ # ──────────────────────────────────────────────────────────────────────────────
46
+
47
+ _wave_model = None
48
+ _spiral_model = None
49
+
50
+
51
+ def _get_wave_model() -> tf.keras.Model:
52
+ global _wave_model
53
+ if _wave_model is None:
54
+ print("[parkinson_predictor] Downloading wave model (VGG19)…")
55
+ path = hf_hub_download(repo_id=HF_REPO_ID, filename=WAVE_MODEL_FILE)
56
+ _wave_model = load_model(path)
57
+ print("[parkinson_predictor] Wave model ready βœ“")
58
+ return _wave_model
59
+
60
+
61
+ def _get_spiral_model() -> tf.keras.Model:
62
+ global _spiral_model
63
+ if _spiral_model is None:
64
+ print("[parkinson_predictor] Downloading spiral model (ResNet101)…")
65
+ path = hf_hub_download(repo_id=HF_REPO_ID, filename=SPIRAL_MODEL_FILE)
66
+ _spiral_model = load_model(path)
67
+ print("[parkinson_predictor] Spiral model ready βœ“")
68
+ return _spiral_model
69
+
70
+
71
+ # ──────────────────────────────────────────────────────────────────────────────
72
+ # Preprocessing β€” replicates the notebook's torchvision transforms exactly,
73
+ # but using only NumPy, PIL, and cv2.
74
+ #
75
+ # Original torchvision pipeline:
76
+ # Grayscale(num_output_channels=3)
77
+ # Resize((224, 224))
78
+ # ToTensor() # uint8 [0,255] β†’ float32 [0,1]
79
+ # Normalize(mean=[0.5,0.5,0.5], # [0,1] β†’ [-1,1]
80
+ # std=[0.5,0.5,0.5])
81
+ # Then:
82
+ # tensor.permute(1,2,0) # CHW β†’ HWC
83
+ # (img * 255).astype(uint8) # [-1,1] float β†’ [0,255] uint8 ← notebook quirk
84
+ #
85
+ # The net result of ToTensor + Normalize + Γ—255:
86
+ # output = (pixel/255.0 - 0.5) / 0.5 * 255
87
+ # = (pixel - 127.5)
88
+ # So the final uint8 array is a simple mean-subtraction by 127.5 (clipped to uint8).
89
+ # ──────────────────────────────────────────────────────────────────────────────
90
+
91
+ def _to_numpy_bgr(source) -> np.ndarray:
92
+ """Convert any image source to a BGR uint8 numpy array."""
93
+ if isinstance(source, (str, os.PathLike)):
94
+ img = cv2.imread(str(source))
95
+ if img is None:
96
+ raise FileNotFoundError(f"cv2.imread could not open: {source}")
97
+ return img
98
+ if isinstance(source, (bytes, bytearray)):
99
+ arr = np.frombuffer(source, dtype=np.uint8)
100
+ return cv2.imdecode(arr, cv2.IMREAD_COLOR)
101
+ if isinstance(source, Image.Image):
102
+ rgb = np.array(source.convert("RGB"), dtype=np.uint8)
103
+ return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
104
+ # File-like object (e.g. Flask request.files['img'])
105
+ raw = source.read()
106
+ arr = np.frombuffer(raw, dtype=np.uint8)
107
+ return cv2.imdecode(arr, cv2.IMREAD_COLOR)
108
+
109
+
110
+ def preprocess_image(source) -> np.ndarray:
111
+ """
112
+ Full preprocessing pipeline β€” identical to the notebook's preprocess_image(),
113
+ but without torch/torchvision:
114
+
115
+ 1. Load image as BGR
116
+ 2. Convert to grayscale
117
+ 3. Otsu binarisation with inversion (THRESH_BINARY_INV | THRESH_OTSU)
118
+ 4. Resize to 224Γ—224
119
+ 5. Stack to 3-channel (grayscale β†’ RGB)
120
+ 6. Normalize: pixel β†’ (pixel βˆ’ 127.5) [equivalent to ToTensor+NormalizeΓ—255]
121
+ 7. Clip and cast to uint8
122
+ 8. Add batch dimension β†’ shape (1, 224, 224, 3)
123
+
124
+ Parameters
125
+ ----------
126
+ source : str | bytes | file-like | PIL.Image.Image
127
+
128
+ Returns
129
+ -------
130
+ np.ndarray shape (1, 224, 224, 3) dtype uint8
131
+ """
132
+ # Step 1-3: load, grayscale, binarise
133
+ bgr = _to_numpy_bgr(source)
134
+ gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
135
+ _, binarised = cv2.threshold(
136
+ gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
137
+ )
138
+
139
+ # Step 4: resize
140
+ resized = cv2.resize(binarised, (224, 224), interpolation=cv2.INTER_LINEAR)
141
+
142
+ # Step 5: grayscale β†’ 3-channel (same as Grayscale(num_output_channels=3))
143
+ rgb = np.stack([resized, resized, resized], axis=-1) # (224, 224, 3) uint8
144
+
145
+ # Step 6-7: replicate ToTensor() β†’ Normalize(0.5,0.5) β†’ Γ—255
146
+ # ToTensor: x = pixel / 255.0 β†’ [0, 1]
147
+ # Normalize: x = (x - 0.5) / 0.5 β†’ [-1, 1]
148
+ # Γ—255: x = x * 255 β†’ [-255, 255]
149
+ # Combined: x = pixel - 127.5
150
+ img_np = rgb.astype(np.float32) - 127.5 # (224, 224, 3) float32
151
+ img_np = np.clip(img_np, 0, 255).astype(np.uint8) # (224, 224, 3) uint8
152
+
153
+ # Step 8: batch dimension
154
+ return np.expand_dims(img_np, axis=0) # (1, 224, 224, 3)
155
+
156
+
157
+ # ──────────────────────────────────────────────────────────────────────────────
158
+ # Severity interpretation (from the notebook's interpret_severity functions)
159
+ # ──────────────────────────────────────────────────────────────────────────────
160
+
161
+ def _interpret_spiral_severity(score: float) -> tuple:
162
+ if score < SPIRAL_NORMAL_BOUNDARY: # < 38.117172
163
+ return "Normal Pattern", "No motor impairment detected."
164
+ elif score < 55:
165
+ return "Mild", "Slight motor irregularities observed."
166
+ elif score < 70:
167
+ return "Moderate", "Noticeable motor impairment detected."
168
+ elif score < 85:
169
+ return "High", "Significant motor impairment observed."
170
+ else:
171
+ return "Severe", "Strong Parkinsonian motor patterns detected."
172
+
173
+
174
+ def _interpret_wave_severity(score: float) -> tuple:
175
+ if score < WAVE_NORMAL_BOUNDARY: # < 36.876736
176
+ return "Normal Pattern", "No motor impairment detected."
177
+ elif score < 55:
178
+ return "Mild", "Slight motor irregularities observed."
179
+ elif score < 70:
180
+ return "Moderate", "Noticeable motor impairment detected."
181
+ elif score < 85:
182
+ return "High", "Significant motor impairment observed."
183
+ else:
184
+ return "Severe", "Strong Parkinsonian motor patterns detected."
185
+
186
+
187
+ # ──────────────────────────────────────────────────────────────────────────────
188
+ # Public prediction functions
189
+ # ──────────────────────────────────────────────────────────────────────────────
190
+
191
+ def predict_wave(image_source) -> dict:
192
+ """
193
+ Classify a **wave drawing** and return the motor impairment score.
194
+
195
+ The VGG19 model outputs a single raw logit (no sigmoid activation).
196
+ The logit is normalised into a 0-100 motor impairment score:
197
+
198
+ score = clip( (logit - MIN) / (MAX - MIN), 0, 1 ) Γ— 100
199
+
200
+ Scores below 36.88 β†’ "Normal Pattern" (no Parkinson's detected).
201
+
202
+ Parameters
203
+ ----------
204
+ image_source : str | bytes | file-like | PIL.Image.Image
205
+ predict_wave("path/to/wave.png")
206
+ predict_wave(open("wave.png", "rb").read())
207
+ predict_wave(pil_image)
208
+ predict_wave(flask_request_files_obj)
209
+
210
+ Returns
211
+ -------
212
+ dict
213
+ {
214
+ "drawing_type" : "wave",
215
+ "raw_logit" : float,
216
+ "sigmoid_probability" : float, # P(Parkinson's) in [0, 1]
217
+ "motor_impairment_score" : float, # normalised score in [0, 100]
218
+ "severity_level" : str, # "Normal Pattern" | "Mild" |
219
+ # "Moderate" | "High" | "Severe"
220
+ "description" : str,
221
+ "is_parkinson" : bool
222
+ }
223
+
224
+ Example
225
+ -------
226
+ >>> result = predict_wave("patient_wave.png")
227
+ >>> print(result["motor_impairment_score"]) # e.g. 72.4
228
+ >>> print(result["severity_level"]) # "High"
229
+ """
230
+ model = _get_wave_model()
231
+ tensor = preprocess_image(image_source)
232
+
233
+ logit = float(model.predict(tensor, verbose=0)[0][0])
234
+ sigmoid_prob = float(1.0 / (1.0 + np.exp(-logit)))
235
+
236
+ normalized = (logit - WAVE_MIN_LOGIT) / (WAVE_MAX_LOGIT - WAVE_MIN_LOGIT)
237
+ score = round(float(np.clip(normalized, 0.0, 1.0)) * 100, 2)
238
+
239
+ level, description = _interpret_wave_severity(score)
240
+
241
+ return {
242
+ "drawing_type" : "wave",
243
+ "raw_logit" : round(logit, 4),
244
+ "sigmoid_probability" : round(sigmoid_prob, 4),
245
+ "motor_impairment_score" : score,
246
+ "severity_level" : level,
247
+ "description" : description,
248
+ "is_parkinson" : level != "Normal Pattern",
249
+ }
250
+
251
+
252
+ def predict_spiral(image_source) -> dict:
253
+ """
254
+ Classify a **spiral drawing** and return the motor impairment score.
255
+
256
+ The ResNet101 model outputs a single raw logit (no sigmoid activation).
257
+ Same normalisation as predict_wave():
258
+
259
+ score = clip( (logit - MIN) / (MAX - MIN), 0, 1 ) Γ— 100
260
+
261
+ Scores below 38.12 β†’ "Normal Pattern" (no Parkinson's detected).
262
+
263
+ Parameters
264
+ ----------
265
+ image_source : str | bytes | file-like | PIL.Image.Image
266
+ Same flexible input types as predict_wave().
267
+
268
+ Returns
269
+ -------
270
+ dict (identical structure to predict_wave, with "drawing_type": "spiral")
271
+
272
+ Example
273
+ -------
274
+ >>> result = predict_spiral("patient_spiral.png")
275
+ >>> print(result["motor_impairment_score"]) # e.g. 61.8
276
+ >>> print(result["severity_level"]) # "Moderate"
277
+ """
278
+ model = _get_spiral_model()
279
+ tensor = preprocess_image(image_source)
280
+
281
+ logit = float(model.predict(tensor, verbose=0)[0][0])
282
+ sigmoid_prob = float(1.0 / (1.0 + np.exp(-logit)))
283
+
284
+ normalized = (logit - SPIRAL_MIN_LOGIT) / (SPIRAL_MAX_LOGIT - SPIRAL_MIN_LOGIT)
285
+ score = round(float(np.clip(normalized, 0.0, 1.0)) * 100, 2)
286
+
287
+ level, description = _interpret_spiral_severity(score)
288
+
289
+ return {
290
+ "drawing_type" : "spiral",
291
+ "raw_logit" : round(logit, 4),
292
+ "sigmoid_probability" : round(sigmoid_prob, 4),
293
+ "motor_impairment_score" : score,
294
+ "severity_level" : level,
295
+ "description" : description,
296
+ "is_parkinson" : level != "Normal Pattern",
297
+ }
298
+
299
+
300
+ # ──────────────────────────────────────────────────────────────────────────────
301
+ # CLI demo: python parkinson_predictor.py <wave|spiral> <image_path>
302
+ # ──────────────────────────────────────────────────────────────────────────────
303
+
304
+ if __name__ == "__main__":
305
+ import sys, json
306
+
307
+ if len(sys.argv) < 3:
308
+ print("Usage: python parkinson_predictor.py <wave|spiral> <image_path>")
309
+ sys.exit(1)
310
+
311
+ draw_type = sys.argv[1].lower()
312
+ image_path = sys.argv[2]
313
+
314
+ if draw_type == "wave":
315
+ result = predict_wave(image_path)
316
+ elif draw_type == "spiral":
317
+ result = predict_spiral(image_path)
318
+ else:
319
+ print("First argument must be 'wave' or 'spiral'.")
320
+ sys.exit(1)
321
+
322
+ print(json.dumps(result, indent=2))
requirements.txt ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ absl-py==2.4.0
2
+ annotated-doc==0.0.4
3
+ anyio==4.13.0
4
+ astunparse==1.6.3
5
+ certifi==2026.2.25
6
+ charset-normalizer==3.4.6
7
+ click==8.3.1
8
+ filelock==3.25.2
9
+ flatbuffers==25.12.19
10
+ fsspec==2026.3.0
11
+ gast==0.7.0
12
+ google-pasta==0.2.0
13
+ grpcio==1.80.0
14
+ h11==0.16.0
15
+ h5py==3.14.0
16
+ hf-xet==1.4.2
17
+ httpcore==1.0.9
18
+ httpx==0.28.1
19
+ huggingface_hub==1.8.0
20
+ idna==3.11
21
+ keras==3.13.2
22
+ libclang==18.1.1
23
+ markdown-it-py==4.0.0
24
+ mdurl==0.1.2
25
+ ml_dtypes==0.5.4
26
+ namex==0.1.0
27
+ numpy==2.4.4
28
+ opencv-python==4.13.0.92
29
+ opt_einsum==3.4.0
30
+ optree==0.19.0
31
+ packaging==26.0
32
+ pillow==12.1.1
33
+ protobuf==7.34.1
34
+ Pygments==2.20.0
35
+ PyYAML==6.0.3
36
+ requests==2.33.0
37
+ rich==14.3.3
38
+ setuptools==82.0.1
39
+ shellingham==1.5.4
40
+ six==1.17.0
41
+ tensorflow==2.21.0
42
+ termcolor==3.3.0
43
+ tqdm==4.67.3
44
+ typer==0.24.1
45
+ typing_extensions==4.15.0
46
+ urllib3==2.6.3
47
+ wheel==0.46.3
48
+ wrapt==2.1.2