File size: 13,799 Bytes
a9e276d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
"""
Parkinson's Motor Impairment Score Predictor
=============================================
Reproduces the exact preprocessing and scoring pipeline from the training notebook.
torch / torchvision are NOT required β€” transforms are reimplemented in NumPy/PIL/cv2.

Models:
  - Wave    β†’ Final_wave_VGG19.h5       (VGG19 backbone, single logit output)
  - Spiral  β†’ Final_spiral_ResNet101.h5 (ResNet101 backbone, single logit output)
"""

import os
import numpy as np
import cv2
from PIL import Image

import tensorflow as tf
from tensorflow.keras.models import load_model
from huggingface_hub import hf_hub_download


# ──────────────────────────────────────────────────────────────────────────────
# Constants  (all values taken directly from the training notebook)
# ──────────────────────────────────────────────────────────────────────────────

HF_REPO_ID        = "xplorers/Motor_Impairment_Score_models"
WAVE_MODEL_FILE   = "Final_wave_VGG19.h5"
SPIRAL_MODEL_FILE = "Final_spiral_ResNet101.h5"

# Logit range calibrated on the training set (1st / 99th percentile)
SPIRAL_MIN_LOGIT = -16.384981
SPIRAL_MAX_LOGIT =  26.600843

WAVE_MIN_LOGIT   = -45.584194
WAVE_MAX_LOGIT   =  78.02814

# Decision boundary in 0-100 score space (where logit == 0 lands).
# Scores BELOW this β†’ "Normal Pattern" (healthy).
SPIRAL_NORMAL_BOUNDARY = 38.117172
WAVE_NORMAL_BOUNDARY   = 36.876736


# ──────────────────────────────────────────────────────────────────────────────
# Model loading  (lazy, module-level cache)
# ──────────────────────────────────────────────────────────────────────────────

_wave_model   = None
_spiral_model = None


def _get_wave_model() -> tf.keras.Model:
    global _wave_model
    if _wave_model is None:
        print("[parkinson_predictor] Downloading wave model (VGG19)…")
        path = hf_hub_download(repo_id=HF_REPO_ID, filename=WAVE_MODEL_FILE)
        _wave_model = load_model(path)
        print("[parkinson_predictor] Wave model ready βœ“")
    return _wave_model


def _get_spiral_model() -> tf.keras.Model:
    global _spiral_model
    if _spiral_model is None:
        print("[parkinson_predictor] Downloading spiral model (ResNet101)…")
        path = hf_hub_download(repo_id=HF_REPO_ID, filename=SPIRAL_MODEL_FILE)
        _spiral_model = load_model(path)
        print("[parkinson_predictor] Spiral model ready βœ“")
    return _spiral_model


# ──────────────────────────────────────────────────────────────────────────────
# Preprocessing  β€” replicates the notebook's torchvision transforms exactly,
#                  but using only NumPy, PIL, and cv2.
#
# Original torchvision pipeline:
#   Grayscale(num_output_channels=3)
#   Resize((224, 224))
#   ToTensor()                          # uint8 [0,255] β†’ float32 [0,1]
#   Normalize(mean=[0.5,0.5,0.5],       # [0,1] β†’ [-1,1]
#             std=[0.5,0.5,0.5])
# Then:
#   tensor.permute(1,2,0)               # CHW β†’ HWC
#   (img * 255).astype(uint8)           # [-1,1] float β†’ [0,255] uint8  ← notebook quirk
#
# The net result of ToTensor + Normalize + Γ—255:
#   output = (pixel/255.0 - 0.5) / 0.5 * 255
#           = (pixel - 127.5)
# So the final uint8 array is a simple mean-subtraction by 127.5 (clipped to uint8).
# ──────────────────────────────────────────────────────────────────────────────

def _to_numpy_bgr(source) -> np.ndarray:
    """Convert any image source to a BGR uint8 numpy array."""
    if isinstance(source, (str, os.PathLike)):
        img = cv2.imread(str(source))
        if img is None:
            raise FileNotFoundError(f"cv2.imread could not open: {source}")
        return img
    if isinstance(source, (bytes, bytearray)):
        arr = np.frombuffer(source, dtype=np.uint8)
        return cv2.imdecode(arr, cv2.IMREAD_COLOR)
    if isinstance(source, Image.Image):
        rgb = np.array(source.convert("RGB"), dtype=np.uint8)
        return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
    # File-like object (e.g. Flask request.files['img'])
    raw = source.read()
    arr = np.frombuffer(raw, dtype=np.uint8)
    return cv2.imdecode(arr, cv2.IMREAD_COLOR)


def preprocess_image(source) -> np.ndarray:
    """
    Full preprocessing pipeline β€” identical to the notebook's preprocess_image(),
    but without torch/torchvision:

      1. Load image as BGR
      2. Convert to grayscale
      3. Otsu binarisation with inversion  (THRESH_BINARY_INV | THRESH_OTSU)
      4. Resize to 224Γ—224
      5. Stack to 3-channel (grayscale β†’ RGB)
      6. Normalize: pixel β†’ (pixel βˆ’ 127.5)  [equivalent to ToTensor+NormalizeΓ—255]
      7. Clip and cast to uint8
      8. Add batch dimension β†’ shape (1, 224, 224, 3)

    Parameters
    ----------
    source : str | bytes | file-like | PIL.Image.Image

    Returns
    -------
    np.ndarray  shape (1, 224, 224, 3)  dtype uint8
    """
    # Step 1-3: load, grayscale, binarise
    bgr  = _to_numpy_bgr(source)
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    _, binarised = cv2.threshold(
        gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
    )

    # Step 4: resize
    resized = cv2.resize(binarised, (224, 224), interpolation=cv2.INTER_LINEAR)

    # Step 5: grayscale β†’ 3-channel (same as Grayscale(num_output_channels=3))
    rgb = np.stack([resized, resized, resized], axis=-1)   # (224, 224, 3) uint8

    # Step 6-7: replicate ToTensor() β†’ Normalize(0.5,0.5) β†’ Γ—255
    #   ToTensor:   x = pixel / 255.0          β†’ [0, 1]
    #   Normalize:  x = (x - 0.5) / 0.5        β†’ [-1, 1]
    #   Γ—255:       x = x * 255                β†’ [-255, 255]
    #   Combined:   x = pixel - 127.5
    img_np = rgb.astype(np.float32) - 127.5                # (224, 224, 3) float32
    img_np = np.clip(img_np, 0, 255).astype(np.uint8)      # (224, 224, 3) uint8

    # Step 8: batch dimension
    return np.expand_dims(img_np, axis=0)                  # (1, 224, 224, 3)


# ──────────────────────────────────────────────────────────────────────────────
# Severity interpretation  (from the notebook's interpret_severity functions)
# ──────────────────────────────────────────────────────────────────────────────

def _interpret_spiral_severity(score: float) -> tuple:
    if score < SPIRAL_NORMAL_BOUNDARY:      # < 38.117172
        return "Normal Pattern", "No motor impairment detected."
    elif score < 55:
        return "Mild",     "Slight motor irregularities observed."
    elif score < 70:
        return "Moderate", "Noticeable motor impairment detected."
    elif score < 85:
        return "High",     "Significant motor impairment observed."
    else:
        return "Severe",   "Strong Parkinsonian motor patterns detected."


def _interpret_wave_severity(score: float) -> tuple:
    if score < WAVE_NORMAL_BOUNDARY:        # < 36.876736
        return "Normal Pattern", "No motor impairment detected."
    elif score < 55:
        return "Mild",     "Slight motor irregularities observed."
    elif score < 70:
        return "Moderate", "Noticeable motor impairment detected."
    elif score < 85:
        return "High",     "Significant motor impairment observed."
    else:
        return "Severe",   "Strong Parkinsonian motor patterns detected."


# ──────────────────────────────────────────────────────────────────────────────
# Public prediction functions
# ──────────────────────────────────────────────────────────────────────────────

def predict_wave(image_source) -> dict:
    """
    Classify a **wave drawing** and return the motor impairment score.

    The VGG19 model outputs a single raw logit (no sigmoid activation).
    The logit is normalised into a 0-100 motor impairment score:

        score = clip( (logit - MIN) / (MAX - MIN), 0, 1 ) Γ— 100

    Scores below 36.88 β†’ "Normal Pattern" (no Parkinson's detected).

    Parameters
    ----------
    image_source : str | bytes | file-like | PIL.Image.Image
        predict_wave("path/to/wave.png")
        predict_wave(open("wave.png", "rb").read())
        predict_wave(pil_image)
        predict_wave(flask_request_files_obj)

    Returns
    -------
    dict
        {
          "drawing_type"           : "wave",
          "raw_logit"              : float,
          "sigmoid_probability"    : float,   # P(Parkinson's) in [0, 1]
          "motor_impairment_score" : float,   # normalised score in [0, 100]
          "severity_level"         : str,     # "Normal Pattern" | "Mild" |
                                              # "Moderate" | "High" | "Severe"
          "description"            : str,
          "is_parkinson"           : bool
        }

    Example
    -------
    >>> result = predict_wave("patient_wave.png")
    >>> print(result["motor_impairment_score"])   # e.g. 72.4
    >>> print(result["severity_level"])           # "High"
    """
    model  = _get_wave_model()
    tensor = preprocess_image(image_source)

    logit        = float(model.predict(tensor, verbose=0)[0][0])
    sigmoid_prob = float(1.0 / (1.0 + np.exp(-logit)))

    normalized = (logit - WAVE_MIN_LOGIT) / (WAVE_MAX_LOGIT - WAVE_MIN_LOGIT)
    score      = round(float(np.clip(normalized, 0.0, 1.0)) * 100, 2)

    level, description = _interpret_wave_severity(score)

    return {
        "drawing_type"           : "wave",
        "raw_logit"              : round(logit, 4),
        "sigmoid_probability"    : round(sigmoid_prob, 4),
        "motor_impairment_score" : score,
        "severity_level"         : level,
        "description"            : description,
        "is_parkinson"           : level != "Normal Pattern",
    }


def predict_spiral(image_source) -> dict:
    """
    Classify a **spiral drawing** and return the motor impairment score.

    The ResNet101 model outputs a single raw logit (no sigmoid activation).
    Same normalisation as predict_wave():

        score = clip( (logit - MIN) / (MAX - MIN), 0, 1 ) Γ— 100

    Scores below 38.12 β†’ "Normal Pattern" (no Parkinson's detected).

    Parameters
    ----------
    image_source : str | bytes | file-like | PIL.Image.Image
        Same flexible input types as predict_wave().

    Returns
    -------
    dict  (identical structure to predict_wave, with "drawing_type": "spiral")

    Example
    -------
    >>> result = predict_spiral("patient_spiral.png")
    >>> print(result["motor_impairment_score"])   # e.g. 61.8
    >>> print(result["severity_level"])           # "Moderate"
    """
    model  = _get_spiral_model()
    tensor = preprocess_image(image_source)

    logit        = float(model.predict(tensor, verbose=0)[0][0])
    sigmoid_prob = float(1.0 / (1.0 + np.exp(-logit)))

    normalized = (logit - SPIRAL_MIN_LOGIT) / (SPIRAL_MAX_LOGIT - SPIRAL_MIN_LOGIT)
    score      = round(float(np.clip(normalized, 0.0, 1.0)) * 100, 2)

    level, description = _interpret_spiral_severity(score)

    return {
        "drawing_type"           : "spiral",
        "raw_logit"              : round(logit, 4),
        "sigmoid_probability"    : round(sigmoid_prob, 4),
        "motor_impairment_score" : score,
        "severity_level"         : level,
        "description"            : description,
        "is_parkinson"           : level != "Normal Pattern",
    }


# ──────────────────────────────────────────────────────────────────────────────
# CLI demo:  python parkinson_predictor.py <wave|spiral> <image_path>
# ──────────────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import sys, json

    if len(sys.argv) < 3:
        print("Usage:  python parkinson_predictor.py <wave|spiral> <image_path>")
        sys.exit(1)

    draw_type  = sys.argv[1].lower()
    image_path = sys.argv[2]

    if draw_type == "wave":
        result = predict_wave(image_path)
    elif draw_type == "spiral":
        result = predict_spiral(image_path)
    else:
        print("First argument must be 'wave' or 'spiral'.")
        sys.exit(1)

    print(json.dumps(result, indent=2))