Genos77 commited on
Commit
e9ee222
·
0 Parent(s):

first commit

Browse files
.gitattributes ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
37
+ *.webm filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ParkinsonDetection
3
+ emoji: 🚀
4
+ colorFrom: yellow
5
+ colorTo: gray
6
+ sdk: gradio
7
+ sdk_version: 5.43.1
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app/__init__.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from flask import Flask
3
+ from flask_sqlalchemy import SQLAlchemy
4
+ from flask_login import LoginManager
5
+ from flask_migrate import Migrate
6
+ from flask_mail import Mail
7
+ from config import Config
8
+
9
+ # Initialize extensions
10
+ db = SQLAlchemy()
11
+ migrate = Migrate()
12
+ login = LoginManager()
13
+ login.login_view = 'auth.login' # Redirect to login page if user is not authenticated
14
+ login.login_message = 'Please log in to access this page.'
15
+ mail = Mail()
16
+
17
+ def create_app(config_class=Config):
18
+ app = Flask(__name__)
19
+ app.config.from_object(config_class)
20
+
21
+ # Make sure these folders exist
22
+ os.makedirs('temp_uploads', exist_ok=True)
23
+ os.makedirs('spectrograms/temp', exist_ok=True)
24
+
25
+ db.init_app(app)
26
+ migrate.init_app(app, db)
27
+ login.init_app(app)
28
+ mail.init_app(app)
29
+
30
+ # Register Blueprints
31
+ from app.auth import bp as auth_bp
32
+ app.register_blueprint(auth_bp, url_prefix='/auth')
33
+
34
+ from app.routes import bp as main_bp
35
+ app.register_blueprint(main_bp)
36
+
37
+ with app.app_context():
38
+ db.create_all() # Create tables for our models
39
+
40
+ return app
app/auth.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, redirect, url_for, flash
2
+ from flask_login import login_user, logout_user, current_user
3
+ from app import db
4
+ from app.models import User
5
+ from flask_wtf import FlaskForm
6
+ from wtforms import StringField, PasswordField, BooleanField, SubmitField
7
+ from wtforms.validators import DataRequired, Email, EqualTo, ValidationError
8
+
9
+ bp = Blueprint('auth', __name__)
10
+
11
+ # --- Forms ---
12
+ class LoginForm(FlaskForm):
13
+ username = StringField('Username', validators=[DataRequired()])
14
+ password = PasswordField('Password', validators=[DataRequired()])
15
+ remember_me = BooleanField('Remember Me')
16
+ submit = SubmitField('Sign In')
17
+
18
+ class RegistrationForm(FlaskForm):
19
+ username = StringField('Username', validators=[DataRequired()])
20
+ email = StringField('Email', validators=[DataRequired(), Email()])
21
+ password = PasswordField('Password', validators=[DataRequired()])
22
+ password2 = PasswordField(
23
+ 'Repeat Password', validators=[DataRequired(), EqualTo('password')])
24
+ submit = SubmitField('Register')
25
+
26
+ def validate_username(self, username):
27
+ user = User.query.filter_by(username=username.data).first()
28
+ if user is not None:
29
+ raise ValidationError('Please use a different username.')
30
+
31
+ def validate_email(self, email):
32
+ user = User.query.filter_by(email=email.data).first()
33
+ if user is not None:
34
+ raise ValidationError('Please use a different email address.')
35
+
36
+
37
+ # --- Routes ---
38
+ @bp.route('/login', methods=['GET', 'POST'])
39
+ def login():
40
+ if current_user.is_authenticated:
41
+ return redirect(url_for('main.dashboard'))
42
+ form = LoginForm()
43
+ if form.validate_on_submit():
44
+ user = User.query.filter_by(username=form.username.data).first()
45
+ if user is None or not user.check_password(form.password.data):
46
+ flash('Invalid username or password')
47
+ return redirect(url_for('auth.login'))
48
+ login_user(user, remember=form.remember_me.data)
49
+ if user.is_admin:
50
+ return redirect(url_for('main.admin_dashboard'))
51
+ return redirect(url_for('main.dashboard'))
52
+ return render_template('auth/login.html', title='Sign In', form=form)
53
+
54
+ @bp.route('/logout')
55
+ def logout():
56
+ logout_user()
57
+ return redirect(url_for('main.index'))
58
+
59
+ @bp.route('/register', methods=['GET', 'POST'])
60
+ def register():
61
+ if current_user.is_authenticated:
62
+ return redirect(url_for('main.dashboard'))
63
+ form = RegistrationForm()
64
+ if form.validate_on_submit():
65
+ user = User(username=form.username.data, email=form.email.data)
66
+ user.set_password(form.password.data)
67
+ # Make the first registered user an admin
68
+ if User.query.count() == 0:
69
+ user.is_admin = True
70
+ flash('Congratulations, you are now the admin!')
71
+ db.session.add(user)
72
+ db.session.commit()
73
+ flash('Congratulations, you are now a registered user!')
74
+ return redirect(url_for('auth.login'))
75
+ return render_template('auth/register.html', title='Register', form=form)
app/email.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from threading import Thread
2
+ from flask import current_app
3
+ from flask_mail import Message
4
+ from app import mail
5
+
6
+ def send_async_email(app, msg):
7
+ with app.app_context():
8
+ mail.send(msg)
9
+
10
+ def send_email(subject, sender, recipients, text_body, html_body):
11
+ app = current_app._get_current_object()
12
+ msg = Message(subject, sender=sender, recipients=recipients)
13
+ msg.body = text_body
14
+ msg.html = html_body
15
+ Thread(target=send_async_email, args=(app, msg)).start()
app/ml_logic.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import shutil
4
+ import joblib
5
+ import pandas as pd
6
+ from imageio_ffmpeg import get_ffmpeg_exe
7
+ import tensorflow as tf
8
+ from tensorflow.keras.preprocessing import image
9
+ import numpy as np
10
+ import librosa
11
+ import librosa.display
12
+ import matplotlib
13
+ matplotlib.use('Agg')
14
+ import matplotlib.pyplot as plt
15
+ import noisereduce as nr
16
+
17
+ # --- Configuration ---
18
+ AUDIO_MODEL_PATH = 'parkinson_cnn_model_stft_grayscale.h5'
19
+ SYMPTOM_MODEL_PATH = 'symptom_model.joblib'
20
+ SPECTROGRAM_PATH = 'spectrograms_stft_5s_grayscale'
21
+ TARGET_DURATION_S = 5
22
+
23
+ # --- Load Both Models at Startup ---
24
+ try:
25
+ audio_model = tf.keras.models.load_model(AUDIO_MODEL_PATH)
26
+ print(f"Successfully loaded Audio CNN model from: {AUDIO_MODEL_PATH}")
27
+ except Exception as e:
28
+ print(f"FATAL: Could not load AUDIO model. Error: {e}")
29
+ audio_model = None
30
+
31
+ try:
32
+ symptom_model = joblib.load(SYMPTOM_MODEL_PATH)
33
+ print(f"Successfully loaded Symptom model from: {SYMPTOM_MODEL_PATH}")
34
+ except Exception as e:
35
+ print(f"FATAL: Could not load SYMPTOM model. Error: {e}")
36
+ symptom_model = None
37
+
38
+ # --- Spectrogram Creation Function (Unchanged) ---
39
+ def create_stft_spectrogram_from_audio(audio_path, save_path):
40
+ """
41
+ Loads, converts, and saves a high-quality, clean grayscale STFT spectrogram.
42
+ """
43
+ audio_path = os.path.abspath(audio_path)
44
+ save_path = os.path.abspath(save_path)
45
+ temp_dir = os.path.join(os.path.dirname(audio_path), f"temp_{os.path.basename(audio_path)}")
46
+ os.makedirs(temp_dir, exist_ok=True)
47
+
48
+ try:
49
+ if audio_path.lower().endswith('.webm'):
50
+ temp_wav_path = os.path.join(temp_dir, 'converted_audio.wav')
51
+ try:
52
+ ffmpeg_executable = get_ffmpeg_exe()
53
+ command = [ffmpeg_executable, '-i', audio_path, '-acodec', 'pcm_s16le', '-ac', '1', '-ar', '44100', '-y', temp_wav_path]
54
+ subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
55
+ except Exception as e:
56
+ shutil.rmtree(temp_dir)
57
+ return False
58
+ processing_path = temp_wav_path
59
+ else:
60
+ processing_path = audio_path
61
+
62
+ y, sr = librosa.load(processing_path, sr=None)
63
+ target_samples = TARGET_DURATION_S * sr
64
+ if len(y) > target_samples:
65
+ start_index = int((len(y) - target_samples) / 2)
66
+ y_segment = y[start_index : start_index + target_samples]
67
+ else:
68
+ y_segment = librosa.util.pad_center(y, size=target_samples)
69
+
70
+ y_reduced = nr.reduce_noise(y=y_segment, sr=sr)
71
+ N_FFT = 1024
72
+ HOP_LENGTH = 256
73
+ S_audio = librosa.stft(y_reduced, n_fft=N_FFT, hop_length=HOP_LENGTH)
74
+ Y_db = librosa.amplitude_to_db(np.abs(S_audio), ref=np.max)
75
+ plt.figure(figsize=(12, 4))
76
+ librosa.display.specshow(Y_db, sr=sr, hop_length=HOP_LENGTH, x_axis='time', y_axis='log', cmap='gray_r')
77
+ plt.axis('off')
78
+ plt.savefig(save_path, bbox_inches='tight', pad_inches=0)
79
+ plt.close()
80
+ shutil.rmtree(temp_dir)
81
+ return True
82
+ except Exception as e:
83
+ if 'temp_dir' in locals() and os.path.exists(temp_dir):
84
+ shutil.rmtree(temp_dir)
85
+ return False
86
+
87
+ # --- Master Prediction Function (Corrected Version) ---
88
+ def get_combined_prediction(symptom_data, audio_path, user_age):
89
+ """
90
+ Gets predictions from both models, combines them, and applies business logic.
91
+ """
92
+ if not audio_model or not symptom_model:
93
+ print("ERROR: One or both models are not loaded.")
94
+ return "Error: Model not loaded.", "Error", 0.5
95
+
96
+ # --- 1. Get Prediction from Symptom Model (M1) ---
97
+ try:
98
+ # Create a pandas DataFrame from the input dictionary.
99
+ symptom_df = pd.DataFrame([symptom_data])
100
+
101
+ # --- THIS IS THE CORRECTION ---
102
+ # Define the exact feature order the model was trained on.
103
+ feature_order = ['tremor', 'stiffness', 'walking_issue']
104
+ # Reorder the DataFrame columns to match the training order.
105
+ symptom_df_ordered = symptom_df[feature_order]
106
+
107
+ # Predict the probability using the correctly ordered data.
108
+ symptom_proba = symptom_model.predict_proba(symptom_df_ordered)[0][1]
109
+ print(f"Symptom Model (M1) Prediction: {symptom_proba:.4f}")
110
+ except Exception as e:
111
+ print(f"Error getting symptom prediction: {e}")
112
+ symptom_proba = 0.5
113
+
114
+ # --- 2. Get Prediction from Audio CNN Model (M2) ---
115
+ audio_proba = 0.5
116
+ try:
117
+ temp_predict_dir = os.path.join(SPECTROGRAM_PATH, 'temp')
118
+ os.makedirs(temp_predict_dir, exist_ok=True)
119
+ temp_spectrogram_path = os.path.join(temp_predict_dir, 'temp_spec.png')
120
+
121
+ if create_stft_spectrogram_from_audio(audio_path, temp_spectrogram_path):
122
+ img = image.load_img(temp_spectrogram_path, target_size=(224, 224), color_mode='grayscale')
123
+ img_array = image.img_to_array(img)
124
+ img_array = np.expand_dims(img_array, axis=0)
125
+ img_array /= 255.0
126
+ audio_proba = audio_model.predict(img_array)[0][0]
127
+ print(f"Audio Model (M2) Prediction: {audio_proba:.4f}")
128
+ if os.path.exists(temp_spectrogram_path):
129
+ os.remove(temp_spectrogram_path)
130
+ else:
131
+ raise ValueError("Spectrogram creation failed.")
132
+ except Exception as e:
133
+ print(f"Error getting audio prediction: {e}")
134
+
135
+ # --- 3. Calculate the Final Weighted Score ---
136
+ weight_symptoms = 0.7
137
+ weight_audio = 0.3
138
+ final_score = (weight_symptoms * symptom_proba) + (weight_audio * audio_proba)
139
+ print(f"Final Combined Score: {final_score:.4f}")
140
+
141
+ # --- 4. Make Final Decision Based on the Combined Score ---
142
+ cnn_result_label = "Positive" if final_score > 0.5 else "Negative"
143
+
144
+ final_result_label = cnn_result_label
145
+ if cnn_result_label == "Positive" and user_age < 40:
146
+ final_result_label = "Negative (Age Override)"
147
+
148
+ return final_result_label, cnn_result_label, float(final_score)
app/models.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from app import db, login
3
+ from werkzeug.security import generate_password_hash, check_password_hash
4
+ from flask_login import UserMixin
5
+
6
+ class User(UserMixin, db.Model):
7
+ id = db.Column(db.Integer, primary_key=True)
8
+ username = db.Column(db.String(64), index=True, unique=True)
9
+ email = db.Column(db.String(120), index=True, unique=True)
10
+ password_hash = db.Column(db.String(128))
11
+ is_admin = db.Column(db.Boolean, default=False)
12
+ reports = db.relationship('Report', backref='author', lazy='dynamic')
13
+
14
+ def set_password(self, password):
15
+ self.password_hash = generate_password_hash(password)
16
+
17
+ def check_password(self, password):
18
+ return check_password_hash(self.password_hash, password)
19
+
20
+ def __repr__(self):
21
+ return f'<User {self.username}>'
22
+
23
+ class Report(db.Model):
24
+ id = db.Column(db.Integer, primary_key=True)
25
+ age = db.Column(db.Integer, nullable=False)
26
+ gender = db.Column(db.String(10), nullable=False)
27
+ symptoms = db.Column(db.Text)
28
+ cnn_prediction = db.Column(db.Float)
29
+ cnn_result = db.Column(db.String(20)) # 'Positive' or 'Negative'
30
+ final_result = db.Column(db.String(20)) # Result after considering age
31
+ timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
32
+ user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
33
+
34
+ def __repr__(self):
35
+ return f'<Report {self.id} - {self.final_result}>'
36
+
37
+ @login.user_loader
38
+ def load_user(id):
39
+ return User.query.get(int(id))
app/routes.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime
3
+ from functools import wraps
4
+ from flask import Blueprint, render_template, flash, redirect, url_for, request, current_app
5
+ from flask_login import current_user, login_required
6
+ from werkzeug.utils import secure_filename
7
+ from sqlalchemy import func
8
+ from app import db
9
+ from app.models import User, Report
10
+ # IMPORTANT: We import the master prediction function
11
+ from app.ml_logic import get_combined_prediction
12
+ from app.email import send_email
13
+
14
+ bp = Blueprint('main', __name__)
15
+
16
+ # --- Decorator for Admin-only routes ---
17
+ def admin_required(f):
18
+ @wraps(f)
19
+ def decorated_function(*args, **kwargs):
20
+ if not current_user.is_admin:
21
+ flash("You do not have permission to access this page.", "danger")
22
+ return redirect(url_for('main.dashboard'))
23
+ return f(*args, **kwargs)
24
+ return decorated_function
25
+
26
+ # =============================================================================
27
+ # === USER-FACING ROUTES
28
+ # =============================================================================
29
+
30
+ @bp.route('/')
31
+ @bp.route('/index')
32
+ def index():
33
+ """Renders the public landing page."""
34
+ return render_template('index.html', title='Home')
35
+
36
+ @bp.route('/dashboard')
37
+ @login_required
38
+ def dashboard():
39
+ """Renders the main user dashboard, which displays past reports."""
40
+ reports = Report.query.filter_by(user_id=current_user.id).order_by(Report.timestamp.desc()).all()
41
+ return render_template('dashboard.html', title='Dashboard', reports=reports)
42
+
43
+ # STAGE 1: Serves the symptom form and handles its submission.
44
+ @bp.route('/new_test', methods=['GET', 'POST'])
45
+ @login_required
46
+ def new_test():
47
+ """On GET, displays the symptom form. On POST, validates and redirects to the audio test page."""
48
+ if request.method == 'POST':
49
+ # Now we can collect the data directly with the correct names
50
+ symptom_args = {
51
+ 'tremor': request.form.get('tremor'),
52
+ 'stiffness': request.form.get('stiffness'),
53
+ 'walking_issue': request.form.get('walking_issue'), # <-- THIS IS THE FIX
54
+ 'age': request.form.get('age'),
55
+ 'gender': request.form.get('gender'),
56
+ 'other_symptoms': request.form.get('other_symptoms', '')
57
+ }
58
+
59
+ # The validation logic now works perfectly because the keys match the form names
60
+ if not all(symptom_args.get(key) for key in ['age', 'gender', 'tremor', 'stiffness', 'walking_issue']):
61
+ flash('Please complete all fields in the step before proceeding.', 'warning')
62
+ return render_template('new_test.html', title='New Test - Step 1')
63
+
64
+ # Redirect to the audio test page with all data
65
+ return redirect(url_for('main.audio_test', **symptom_args))
66
+
67
+ # On a GET request, just display the symptom form.
68
+ return render_template('new_test.html', title='New Test - Step 1')
69
+
70
+ # STAGE 2: Serves the audio form and handles the final combined prediction.
71
+ @bp.route('/audio_test', methods=['GET', 'POST'])
72
+ @login_required
73
+ def audio_test():
74
+ """On GET, displays audio form. On POST, runs both models and saves the report."""
75
+ if request.method == 'POST':
76
+ # Determine which audio file source was used.
77
+ if 'uploaded_audio_data' in request.files and request.files['uploaded_audio_data'].filename != '':
78
+ file = request.files['uploaded_audio_data']
79
+ elif 'recorded_audio_data' in request.files and request.files['recorded_audio_data'].filename != '':
80
+ file = request.files['recorded_audio_data']
81
+ else:
82
+ flash('No audio file was provided. Please record or upload a file.', 'danger')
83
+ return redirect(url_for('main.audio_test', **request.form))
84
+
85
+ # --- Prepare data for BOTH models ---
86
+
87
+ # 1. Prepare data for Symptom Model (M1)
88
+ symptom_data_for_model = {
89
+ 'tremor': 1 if request.form.get('tremor') != 'no' else 0,
90
+ 'stiffness': 1 if request.form.get('stiffness') == 'yes' else 0,
91
+ 'walking_issue': 1 if request.form.get('balance') == 'yes' else 0 # Correctly use 'balance' from form for 'walking_issue' feature
92
+ }
93
+
94
+ # 2. Get user age and gender
95
+ age = request.form.get('age', type=int)
96
+ gender = request.form.get('gender')
97
+
98
+ if not age or not gender:
99
+ flash('Required user data was lost. Please start the test over.', 'danger')
100
+ return redirect(url_for('main.new_test'))
101
+
102
+ # Save the audio file temporarily
103
+ base_filename = secure_filename(file.filename if file.filename else "recording.webm")
104
+ unique_filename = f"user_{current_user.id}_{datetime.utcnow().timestamp()}_{base_filename}"
105
+ audio_path = os.path.join('temp_uploads', unique_filename)
106
+ file.save(audio_path)
107
+
108
+ # Call the master prediction function from ml_logic
109
+ final_result, cnn_result, cnn_pred_value = get_combined_prediction(symptom_data_for_model, audio_path, age)
110
+
111
+ # Create a detailed string for the database report
112
+ symptoms_for_report = (
113
+ f"Tremor: {request.form.get('tremor', 'N/A').replace('_', ' ').title()}, "
114
+ f"Stiffness: {request.form.get('stiffness', 'N/A').title()}, "
115
+ f"Balance: {request.form.get('balance', 'N/A').title()}. "
116
+ f"Other Notes: {request.form.get('other_symptoms', 'None')}"
117
+ )
118
+
119
+ # Save the final report to the database
120
+ report = Report(age=age, gender=gender, symptoms=symptoms_for_report, cnn_prediction=cnn_pred_value, cnn_result=cnn_result, final_result=final_result, author=current_user)
121
+ db.session.add(report)
122
+ db.session.commit()
123
+
124
+ # Prepare a dictionary for the email template for nicer formatting
125
+ symptoms_for_email = {
126
+ "Tremor": request.form.get('tremor', 'N/A').replace('_', ' ').title(),
127
+ "Stiffness or Slowness": request.form.get('stiffness', 'N/A').title(),
128
+ "Balance Issues": request.form.get('balance', 'N/A').title(),
129
+ "Other Notes": request.form.get('other_symptoms', 'None')
130
+ }
131
+ send_email(
132
+ '[Parkinson Detection System] Your Test Result',
133
+ sender=current_app.config['ADMINS'][0], recipients=[current_user.email],
134
+ text_body=render_template('email/result_notification.txt', user=current_user, report=report, symptoms=symptoms_for_email),
135
+ html_body=render_template('email/result_notification.html', user=current_user, report=report, symptoms=symptoms_for_email)
136
+ )
137
+
138
+ flash('Your test is complete! The result has been sent to your email and is available on your dashboard.', 'success')
139
+ if os.path.exists(audio_path): os.remove(audio_path)
140
+ return redirect(url_for('main.dashboard'))
141
+
142
+ # For a GET request, pass URL parameters to the template as hidden fields
143
+ symptom_data = request.args.to_dict()
144
+ return render_template('audio_test.html', title='New Test - Step 2', symptom_data=symptom_data)
145
+
146
+ # =============================================================================
147
+ # === ADMIN ROUTES
148
+ # =============================================================================
149
+ @bp.route('/admin/dashboard')
150
+ @login_required
151
+ @admin_required
152
+ def admin_dashboard():
153
+ user_count = User.query.count()
154
+ report_count = Report.query.count()
155
+ result_stats = db.session.query(Report.final_result, func.count(Report.final_result)).group_by(Report.final_result).all()
156
+ chart_labels = [result for result, count in result_stats]
157
+ chart_data = [count for result, count in result_stats]
158
+ return render_template(
159
+ 'admin/dashboard.html',
160
+ title='Admin Dashboard',
161
+ user_count=user_count,
162
+ report_count=report_count,
163
+ chart_labels=chart_labels,
164
+ chart_data=chart_data
165
+ )
166
+
167
+ @bp.route('/admin/users')
168
+ @login_required
169
+ @admin_required
170
+ def admin_users():
171
+ users = User.query.order_by(User.id).all()
172
+ return render_template('admin/users.html', title='Manage Users', users=users)
app/static/css/new_test.css ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================== */
2
+ /* == Styles for the Multi-Step Test Page == */
3
+ /* =========================================== */
4
+
5
+ /* --- Main Container --- */
6
+ .new-test-container {
7
+ max-width: 700px;
8
+ margin: 2rem auto;
9
+ }
10
+
11
+ /* --- The Slider Mechanism --- */
12
+ .symptom-slider-container {
13
+ width: 100%;
14
+ overflow: hidden; /* This is essential for hiding the other slides */
15
+ }
16
+
17
+ /* This is the wrapper that holds all slides in a long horizontal row */
18
+ .symptom-slider-wrapper {
19
+ display: flex;
20
+ width: 400%; /* We have 4 slides, so 4 * 100% = 400% */
21
+ transition: transform 0.5s ease-in-out; /* This creates the smooth sliding animation */
22
+ }
23
+
24
+ /* --- Individual Slide Styling --- */
25
+ .symptom-slide {
26
+ width: 25%; /* Each slide is 1/4 of the wrapper's width */
27
+ flex-shrink: 0; /* Prevents slides from shrinking to fit */
28
+ padding: 2.5rem;
29
+ box-sizing: border-box;
30
+ }
31
+
32
+ .symptom-slide h3 {
33
+ margin-bottom: 2rem;
34
+ text-align: center;
35
+ font-weight: 600;
36
+ color: #343a40;
37
+ }
38
+
39
+ /* --- Custom Radio Button Box Styling --- */
40
+
41
+ /* First, we reset the default Bootstrap container for the radio buttons */
42
+ .symptom-slide .form-check {
43
+ padding: 0;
44
+ margin-bottom: 1rem;
45
+ }
46
+
47
+ /* Then, we style the LABEL to look like a big, clickable button/card */
48
+ .symptom-slide .form-check-label {
49
+ display: block; /* Makes the label take up the full width for easier clicking */
50
+ background-color: #f8f9fa;
51
+ padding: 1rem 1.25rem;
52
+ border: 2px solid #dee2e6;
53
+ border-radius: 0.375rem; /* Standard Bootstrap border-radius */
54
+ cursor: pointer;
55
+ transition: all 0.2s ease-in-out; /* Smooth transition for hover/active states */
56
+ font-weight: 500;
57
+ }
58
+
59
+ /* Add a hover effect for better user feedback */
60
+ .symptom-slide .form-check-label:hover {
61
+ border-color: #8f94fb; /* A nice highlight color */
62
+ transform: translateY(-2px); /* A slight "lift" effect */
63
+ }
64
+
65
+ /* This is the key part: we completely hide the original radio button circle */
66
+ .symptom-slide .form-check input[type="radio"] {
67
+ display: none;
68
+ opacity: 0;
69
+ position: fixed;
70
+ width: 0;
71
+ }
72
+
73
+ /* This is the magic: When the hidden radio button is checked, we style its ADJACENT label */
74
+ .symptom-slide .form-check input[type="radio"]:checked + label {
75
+ background-color: #e9eafd; /* A light, pleasing primary color */
76
+ border-color: #4e54c8; /* Your main primary color */
77
+ color: #4e54c8;
78
+ font-weight: 600;
79
+ box-shadow: 0 4px 12px rgba(78, 84, 200, 0.15); /* Adds a subtle depth */
80
+ }
81
+
82
+ /* --- Button Styling --- */
83
+ .next-btn {
84
+ float: right;
85
+ margin-top: 1.5rem;
86
+ }
87
+
88
+ .symptom-submit-btn {
89
+ font-weight: 600;
90
+ padding-top: 0.75rem;
91
+ padding-bottom: 0.75rem;
92
+ }
app/static/css/style.css ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* --- A Clean and Modern Stylesheet --- */
2
+
3
+ /* 1. General & Typography */
4
+ :root {
5
+ --primary-color: #4e73df; /* A nice, professional blue */
6
+ --primary-hover: #2e59d9;
7
+ --light-gray: #f8f9fa;
8
+ --medium-gray: #e9ecef;
9
+ --dark-gray: #5a5c69;
10
+ --text-color: #343a40;
11
+ }
12
+
13
+ body {
14
+ background-color: var(--light-gray);
15
+ color: var(--text-color);
16
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
17
+ "Helvetica Neue", Arial, sans-serif;
18
+ }
19
+
20
+ h1,
21
+ h2,
22
+ h3,
23
+ h4,
24
+ h5,
25
+ h6 {
26
+ font-weight: 600;
27
+ color: var(--dark-gray);
28
+ }
29
+
30
+ .btn-primary {
31
+ background-color: var(--primary-color);
32
+ border-color: var(--primary-color);
33
+ }
34
+
35
+ .btn-primary:hover {
36
+ background-color: var(--primary-hover);
37
+ border-color: var(--primary-hover);
38
+ }
39
+
40
+ .text-primary {
41
+ color: var(--primary-color) !important;
42
+ }
43
+
44
+ /* 2. Layout & Main Navigation Bar */
45
+
46
+ /* This is the CRITICAL rule to prevent content from hiding behind the fixed navbar */
47
+ body {
48
+ padding-top: 70px;
49
+ }
50
+
51
+ #mainNavbar .navbar-brand {
52
+ font-weight: 700;
53
+ }
54
+
55
+ /* 3. Authentication Pages (Login/Register) */
56
+ .auth-wrapper {
57
+ /* This makes the auth card vertically centered on the page */
58
+ min-height: calc(100vh - 70px);
59
+ display: flex;
60
+ align-items: center;
61
+ justify-content: center;
62
+ padding: 1rem;
63
+ }
64
+
65
+ .auth-card {
66
+ max-width: 450px;
67
+ width: 100%;
68
+ padding: 2.5rem;
69
+ border: none;
70
+ border-radius: 0.5rem;
71
+ box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.08);
72
+ background-color: #ffffff;
73
+ }
74
+
75
+ /* 4. User Dashboard */
76
+ .dashboard-header {
77
+ border-bottom: 1px solid #dee2e6;
78
+ padding-bottom: 1rem;
79
+ margin-bottom: 2rem;
80
+ }
81
+
82
+ .new-test-card .card-header {
83
+ background-color: var(--primary-color);
84
+ color: white;
85
+ }
86
+
87
+ .table thead th {
88
+ font-weight: 600;
89
+ color: var(--dark-gray);
90
+ text-transform: uppercase;
91
+ font-size: 0.8rem;
92
+ letter-spacing: 0.5px;
93
+ border-bottom-width: 2px;
94
+ }
95
+
96
+ .table tbody tr:hover {
97
+ background-color: #f1f3f5;
98
+ }
99
+
100
+ .table .badge {
101
+ padding: 0.5em 0.8em;
102
+ font-weight: 600;
103
+ font-size: 0.85rem;
104
+ }
105
+
106
+ /* 5. Admin Dashboard */
107
+ .card.border-left-primary {
108
+ border-left: 0.25rem solid var(--primary-color) !important;
109
+ }
110
+ .card.border-left-success {
111
+ border-left: 0.25rem solid #1cc88a !important;
112
+ }
113
+
114
+ .chart-pie {
115
+ position: relative;
116
+ height: 15rem;
117
+ width: 100%;
118
+ }
119
+
120
+ .text-xs {
121
+ font-size: 0.7rem;
122
+ }
123
+
124
+ /* Chart Legend Color Helpers */
125
+ .color-1 {
126
+ color: #4e73df !important;
127
+ }
128
+ .color-2 {
129
+ color: #1cc88a !important;
130
+ }
131
+ .color-3 {
132
+ color: #f6c23e !important;
133
+ }
134
+ .color-4 {
135
+ color: #e74a3b !important;
136
+ }
137
+ .color-5 {
138
+ color: #5a5c69 !important;
139
+ }
140
+ .color-6 {
141
+ color: #36b9cc !important;
142
+ }
143
+ /* =========================================== */
144
+ /* == 5. Admin Panel Specific Styles == */
145
+ /* =========================================== */
146
+
147
+ /* --- Admin Dashboard Cards --- */
148
+
149
+ /* Base style for the stat cards */
150
+ .stat-card {
151
+ border: none;
152
+ border-radius: 0.5rem;
153
+ box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.07);
154
+ transition: transform 0.2s ease-in-out;
155
+ }
156
+
157
+ .stat-card:hover {
158
+ transform: translateY(-5px); /* Lift effect on hover */
159
+ }
160
+
161
+ .stat-card .card-body {
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: space-between;
165
+ }
166
+
167
+ /* Specific border colors for different cards */
168
+ .border-left-primary {
169
+ border-left: 0.25rem solid var(--primary-color) !important;
170
+ }
171
+
172
+ .border-left-success {
173
+ border-left: 0.25rem solid #1cc88a !important;
174
+ }
175
+
176
+ /* Text styling inside the cards */
177
+ .stat-card .text-xs {
178
+ font-size: 0.8rem;
179
+ font-weight: 700;
180
+ text-transform: uppercase;
181
+ letter-spacing: 0.05em;
182
+ }
183
+
184
+ .stat-card .h5 {
185
+ font-weight: 700;
186
+ color: var(--dark-gray);
187
+ }
188
+
189
+ /* The large icon on the right side of the card */
190
+ .stat-card .card-icon {
191
+ font-size: 2.5rem;
192
+ color: #dddfeb; /* A very light gray */
193
+ }
194
+
195
+ /* --- Admin Chart Card --- */
196
+ .chart-card .card-header {
197
+ background-color: #f8f9fa; /* Lighter header for the chart card */
198
+ border-bottom: 1px solid #e3e6f0;
199
+ }
200
+
201
+ .chart-pie {
202
+ position: relative;
203
+ height: 15rem; /* Or adjust as needed */
204
+ width: 100%;
205
+ }
206
+
207
+ /* --- Manage Users Table --- */
208
+ .users-table thead th {
209
+ background-color: #f8f9fa;
210
+ }
211
+
212
+ .users-table .badge {
213
+ font-size: 0.8rem;
214
+ padding: 0.4em 0.7em;
215
+ }
app/static/js/admin-charts.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener("DOMContentLoaded", function () {
2
+ // Check if the chart canvas and the global data variables (created in dashboard.html) exist
3
+ if (
4
+ document.getElementById("myPieChart") &&
5
+ typeof chartLabels !== "undefined" &&
6
+ typeof chartData !== "undefined"
7
+ ) {
8
+ const ctx = document.getElementById("myPieChart")
9
+
10
+ new Chart(ctx, {
11
+ type: "doughnut",
12
+ data: {
13
+ labels: chartLabels, // Use the global variable
14
+ datasets: [
15
+ {
16
+ data: chartData, // Use the global variable
17
+ backgroundColor: [
18
+ "#4e73df",
19
+ "#1cc88a",
20
+ "#f6c23e",
21
+ "#e74a3b",
22
+ "#5a5c69",
23
+ "#36b9cc",
24
+ ],
25
+ hoverBackgroundColor: [
26
+ "#2e59d9",
27
+ "#17a673",
28
+ "#dda20a",
29
+ " #c73021",
30
+ "#404148",
31
+ "#2c9faf",
32
+ ],
33
+ hoverBorderColor: "rgba(234, 236, 244, 1)",
34
+ },
35
+ ],
36
+ },
37
+ options: {
38
+ maintainAspectRatio: false,
39
+ plugins: {
40
+ legend: {
41
+ display: false, // We use our own custom legend in the HTML
42
+ },
43
+ tooltip: {
44
+ backgroundColor: "rgb(255,255,255)",
45
+ bodyColor: "#858796",
46
+ borderColor: "#dddfeb",
47
+ borderWidth: 1,
48
+ padding: 15,
49
+ displayColors: false,
50
+ caretPadding: 10,
51
+ },
52
+ },
53
+ cutout: "80%", // For Chart.js v3+
54
+ },
55
+ })
56
+ }
57
+ })
app/static/js/new_test.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ const sliderWrapper = document.querySelector(".symptom-slider-wrapper")
3
+ if (!sliderWrapper) return // Only run if the slider exists on the page
4
+
5
+ const slides = document.querySelectorAll(".symptom-slide")
6
+ let currentSlide = 0
7
+
8
+ // Use event delegation for the 'Next' buttons
9
+ sliderWrapper.addEventListener("click", (e) => {
10
+ // Check if a 'next-btn' was clicked
11
+ if (e.target && e.target.classList.contains("next-btn")) {
12
+ const currentSlideElement = slides[currentSlide]
13
+ const radios = currentSlideElement.querySelectorAll('input[type="radio"]')
14
+
15
+ // Validate that an option was selected before proceeding
16
+ if (radios.length > 0 && !Array.from(radios).some((r) => r.checked)) {
17
+ alert("Please select an option to continue.")
18
+ return // Stop if nothing is selected
19
+ }
20
+
21
+ // Move to the next slide
22
+ currentSlide++
23
+ if (currentSlide < slides.length) {
24
+ sliderWrapper.style.transform = `translateX(-${currentSlide * 25}%)`
25
+ }
26
+ }
27
+ })
28
+ })
app/static/js/recorder.js ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ // Find the form on the current page. It could be on dashboard.html or audio_test.html
3
+ const form =
4
+ document.getElementById("audio-form") ||
5
+ document.getElementById("report-form")
6
+ if (!form) return // Exit if no form is found
7
+
8
+ const submitButton = document.getElementById("submitButton")
9
+ const audioUpload = document.getElementById("audioUpload")
10
+ const recordButton = document.getElementById("recordButton")
11
+ const stopButton = document.getElementById("stopButton")
12
+ const audioPlayback = document.getElementById("audioPlayback")
13
+ const recordingStatus = document.getElementById("recording-status")
14
+ const submitStatus = document.getElementById("submit-status")
15
+
16
+ let mediaRecorder
17
+ let audioChunks = []
18
+ let recordedBlob = null
19
+
20
+ function checkCanSubmit() {
21
+ const hasRecordedAudio = recordedBlob !== null
22
+ const hasUploadedAudio = audioUpload && audioUpload.files.length > 0
23
+
24
+ if (submitButton) {
25
+ submitButton.disabled = !(hasRecordedAudio || hasUploadedAudio)
26
+ }
27
+ if (submitStatus) {
28
+ submitStatus.textContent =
29
+ hasRecordedAudio || hasUploadedAudio
30
+ ? ""
31
+ : "Please record or upload an audio file to submit."
32
+ }
33
+ }
34
+
35
+ if (recordButton) {
36
+ recordButton.addEventListener("click", async (event) => {
37
+ event.preventDefault()
38
+ if (audioUpload) {
39
+ audioUpload.value = ""
40
+ audioUpload.disabled = true
41
+ }
42
+ recordedBlob = null
43
+ audioPlayback.classList.add("d-none")
44
+ try {
45
+ const constraints = {
46
+ audio: {
47
+ autoGainControl: false,
48
+ echoCancellation: false,
49
+ noiseSuppression: false,
50
+ },
51
+ }
52
+ const stream = await navigator.mediaDevices.getUserMedia(constraints)
53
+ mediaRecorder = new MediaRecorder(stream)
54
+ audioChunks = []
55
+ mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data)
56
+ mediaRecorder.onstop = () => {
57
+ recordedBlob = new Blob(audioChunks, { type: "audio/webm" })
58
+ audioPlayback.src = URL.createObjectURL(recordedBlob)
59
+ audioPlayback.classList.remove("d-none")
60
+ recordingStatus.textContent =
61
+ "Recording finished. Ready for submission."
62
+ checkCanSubmit()
63
+ }
64
+ mediaRecorder.start()
65
+ recordButton.disabled = true
66
+ stopButton.disabled = false
67
+ recordingStatus.textContent = "Recording..."
68
+ } catch (err) {
69
+ recordingStatus.textContent = "Error: Could not access microphone."
70
+ if (audioUpload) audioUpload.disabled = false
71
+ }
72
+ })
73
+ }
74
+
75
+ if (stopButton) {
76
+ stopButton.addEventListener("click", (event) => {
77
+ event.preventDefault()
78
+ if (mediaRecorder?.state === "recording") mediaRecorder.stop()
79
+ recordButton.disabled = false
80
+ stopButton.disabled = true
81
+ })
82
+ }
83
+
84
+ if (audioUpload) {
85
+ audioUpload.addEventListener("change", () => {
86
+ if (audioUpload.files.length > 0) {
87
+ if (recordButton) recordButton.disabled = true
88
+ if (stopButton) stopButton.disabled = true
89
+ recordedBlob = null
90
+ if (audioPlayback) audioPlayback.classList.add("d-none")
91
+ if (recordingStatus)
92
+ recordingStatus.textContent = "File selected. Ready to submit."
93
+ } else {
94
+ if (recordButton) recordButton.disabled = false
95
+ }
96
+ checkCanSubmit()
97
+ })
98
+ }
99
+
100
+ form.addEventListener("submit", (event) => {
101
+ event.preventDefault() // ALWAYS prevent the default submission
102
+
103
+ submitButton.disabled = true
104
+ submitButton.textContent = "Submitting..."
105
+
106
+ // Manually build FormData to guarantee correctness
107
+ const formData = new FormData(form)
108
+
109
+ // This check is crucial. We must ensure only ONE audio source is in the FormData.
110
+ if (recordedBlob) {
111
+ // If a recording exists, it takes precedence.
112
+ formData.delete("uploaded_audio_data") // Remove any selected file
113
+ formData.append("recorded_audio_data", recordedBlob, "recording.webm")
114
+ } else if (audioUpload && audioUpload.files.length > 0) {
115
+ // The uploaded file is already in formData from the constructor, so we do nothing.
116
+ } else {
117
+ // This case should be prevented by the disabled submit button, but as a fallback:
118
+ flash("No audio file was provided.", "danger") // This is a client-side alert
119
+ submitButton.disabled = false
120
+ submitButton.textContent = "Analyze & Complete Test"
121
+ return
122
+ }
123
+
124
+ fetch(form.action, {
125
+ method: "POST",
126
+ body: formData,
127
+ })
128
+ .then((response) => {
129
+ if (response.redirected) {
130
+ window.location.href = response.url
131
+ } else {
132
+ // If not redirected, the server might have sent an error.
133
+ // Reloading is the simplest way to see the flash message.
134
+ window.location.reload()
135
+ }
136
+ })
137
+ .catch((error) => {
138
+ console.error("Submission Error:", error)
139
+ submitButton.disabled = false
140
+ submitButton.textContent = "Analyze & Complete Test"
141
+ if (submitStatus) submitStatus.textContent = "A network error occurred."
142
+ })
143
+ })
144
+
145
+ // Initial check when the page loads
146
+ checkCanSubmit()
147
+ })
app/templates/404.html ADDED
File without changes
app/templates/500.html ADDED
File without changes
app/templates/_flash_messages.html ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% with messages = get_flashed_messages(with_categories=true) %} {% if messages
2
+ %}
3
+ <div class="container-fluid px-0 mb-4">
4
+ {% for category, message in messages %}
5
+ <div
6
+ class="alert alert-{{ category }} alert-dismissible fade show"
7
+ role="alert"
8
+ >
9
+ {{ message }}
10
+ <button
11
+ type="button"
12
+ class="btn-close"
13
+ data-bs-dismiss="alert"
14
+ aria-label="Close"
15
+ ></button>
16
+ </div>
17
+ {% endfor %}
18
+ </div>
19
+ {% endif %} {% endwith %}
app/templates/_formhelpers.html ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% macro render_input_field(field) %}
2
+ <div class="mb-3">
3
+ {{ field.label(class="form-label") }} {# This renders the basic field with
4
+ validation styling #} {{ field(class="form-control" + (" is-invalid" if
5
+ field.errors else "")) }} {% if field.errors %}
6
+ <div class="invalid-feedback">
7
+ {% for error in field.errors %}
8
+ <span>{{ error }}</span>
9
+ {% endfor %}
10
+ </div>
11
+ {% endif %}
12
+ </div>
13
+ {% endmacro %} {% macro render_textarea_field(field, rows=3) %}
14
+ <div class="mb-3">
15
+ {{ field.label(class="form-label") }} {{ field(class="form-control" + ("
16
+ is-invalid" if field.errors else ""), rows=rows) }} {% if field.errors %}
17
+ <div class="invalid-feedback">
18
+ {% for error in field.errors %}
19
+ <span>{{ error }}</span>
20
+ {% endfor %}
21
+ </div>
22
+ {% endif %}
23
+ </div>
24
+ {% endmacro %}
app/templates/admin/dashboard.html ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %} {% block content %}
2
+ <div class="container py-4">
3
+ <h1 class="h2 mb-4">Admin Dashboard</h1>
4
+
5
+ <!-- Stat Cards Row -->
6
+ <div class="row">
7
+ <div class="col-xl-4 col-md-6 mb-4">
8
+ <div class="card stat-card border-left-primary">
9
+ <div class="card-body">
10
+ <div>
11
+ <div class="text-xs text-primary text-uppercase">
12
+ Total Registered Users
13
+ </div>
14
+ <div class="h5 mb-0">{{ user_count }}</div>
15
+ </div>
16
+ <div class="card-icon"><i class="fas fa-users"></i></div>
17
+ </div>
18
+ </div>
19
+ </div>
20
+ <div class="col-xl-4 col-md-6 mb-4">
21
+ <div class="card stat-card border-left-success">
22
+ <div class="card-body">
23
+ <div>
24
+ <div class="text-xs text-success text-uppercase">
25
+ Total Tests Submitted
26
+ </div>
27
+ <div class="h5 mb-0">{{ report_count }}</div>
28
+ </div>
29
+ <div class="card-icon"><i class="fas fa-clipboard-list"></i></div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+
35
+ <!-- Chart Row -->
36
+ <div class="row">
37
+ <div class="col-lg-8">
38
+ <div class="card shadow-sm chart-card mb-4">
39
+ <div class="card-header py-3">
40
+ <h6 class="m-0 fw-bold text-primary">Test Results Breakdown</h6>
41
+ </div>
42
+ <div class="card-body">
43
+ {% if chart_data %}
44
+ <div class="chart-pie pt-4"><canvas id="myPieChart"></canvas></div>
45
+ <div class="mt-4 text-center small">
46
+ {% for label in chart_labels %}
47
+ <span class="me-3">
48
+ <i class="fas fa-circle color-{{ loop.index }}"></i> {{ label }}
49
+ </span>
50
+ {% endfor %}
51
+ </div>
52
+ {% else %}
53
+ <p class="text-center text-muted p-5">
54
+ No test data available to generate a chart.
55
+ </p>
56
+ {% endif %}
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <a
63
+ href="{{ url_for('main.admin_users') }}"
64
+ class="btn btn-info"
65
+ >Manage Users</a
66
+ >
67
+ </div>
68
+ {% endblock %} {% block scripts %} {# Only include scripts if there is data to
69
+ draw #} {% if chart_data %}
70
+ <!-- 1. Load the Chart.js library FIRST -->
71
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
72
+
73
+ <!-- 2. Create global JS variables with our data from Python. This avoids parsing errors. -->
74
+ <script>
75
+ var chartLabels = {{ chart_labels|tojson|safe }};
76
+ var chartData = {{ chart_data|tojson|safe }};
77
+ </script>
78
+
79
+ <!-- 3. Load OUR script LAST, with 'defer' to ensure it runs after the page is ready -->
80
+ <script
81
+ src="{{ url_for('static', filename='js/admin-charts.js') }}"
82
+ defer
83
+ ></script>
84
+ {% endif %} {% endblock %}
app/templates/admin/users.html ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %} {% block content %}
2
+ <div class="container py-4">
3
+ <h1 class="h2 mb-4">Manage Users</h1>
4
+ <p class="text-muted">
5
+ Admin users cannot view individual user reports to maintain privacy.
6
+ </p>
7
+
8
+ <div class="card shadow-sm">
9
+ <div class="card-body p-0">
10
+ <table class="table table-hover mb-0 users-table">
11
+ <thead>
12
+ <tr>
13
+ <th>ID</th>
14
+ <th>Username</th>
15
+ <th>Email</th>
16
+ <th>Admin Status</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ {% for user in users %}
21
+ <tr>
22
+ <td>{{ user.id }}</td>
23
+ <td>{{ user.username }}</td>
24
+ <td>{{ user.email }}</td>
25
+ <td>
26
+ {% if user.is_admin %}
27
+ <span class="badge bg-danger">Admin</span>
28
+ {% else %}
29
+ <span class="badge bg-secondary">User</span>
30
+ {% endif %}
31
+ </td>
32
+ </tr>
33
+ {% endfor %}
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ {% endblock %}
app/templates/audio_test.html ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %} {% block title %}New Test - Step 2{% endblock %} {%
2
+ block content %}
3
+ <div class="container py-4">
4
+ {# This include is optional but good for displaying any potential errors from
5
+ the server #} {% include '_flash_messages.html' %}
6
+
7
+ <div class="d-flex justify-content-between align-items-center mb-4">
8
+ <div>
9
+ <h1 class="h2">New Test - Step 2: Voice Sample</h1>
10
+ <p class="text-muted mb-0">
11
+ For the final step, please provide a voice sample for analysis.
12
+ </p>
13
+ </div>
14
+ <a
15
+ href="{{ url_for('main.dashboard') }}"
16
+ class="btn btn-sm btn-outline-secondary"
17
+ >Cancel Test</a
18
+ >
19
+ </div>
20
+
21
+ <div class="card shadow-sm">
22
+ <div class="card-body p-4">
23
+ <form
24
+ id="audio-form"
25
+ action="{{ url_for('main.audio_test') }}"
26
+ method="post"
27
+ enctype="multipart/form-data"
28
+ >
29
+ <!--
30
+ These hidden fields are crucial. They take the data passed in the URL
31
+ (from the previous step) and include it in this form's submission,
32
+ so the final backend route receives ALL the data at once.
33
+ -->
34
+ {% for key, value in symptom_data.items() %}
35
+ <input
36
+ type="hidden"
37
+ name="{{ key }}"
38
+ value="{{ value }}"
39
+ />
40
+ {% endfor %}
41
+
42
+ <div class="row">
43
+ <div class="col-md-6 mb-4 mb-md-0">
44
+ <h5 class="fw-bold">
45
+ <i class="fas fa-microphone me-2"></i>Option 1: Record Voice
46
+ </h5>
47
+ <p class="small text-muted">
48
+ Click "Start Recording" and sustain a vowel sound (like 'ahhh')
49
+ for 5-10 seconds.
50
+ </p>
51
+ <div
52
+ id="recorder-ui"
53
+ class="mb-3"
54
+ >
55
+ <button
56
+ id="recordButton"
57
+ class="btn btn-danger w-100"
58
+ >
59
+ Start Recording
60
+ </button>
61
+ <button
62
+ id="stopButton"
63
+ class="btn btn-secondary w-100 mt-2"
64
+ disabled
65
+ >
66
+ Stop Recording
67
+ </button>
68
+ </div>
69
+ <div
70
+ id="recording-status"
71
+ class="form-text mt-2 text-center small"
72
+ ></div>
73
+ <audio
74
+ id="audioPlayback"
75
+ controls
76
+ class="mt-2 d-none w-100"
77
+ ></audio>
78
+ </div>
79
+ <div class="col-md-6">
80
+ <h5 class="fw-bold">
81
+ <i class="fas fa-upload me-2"></i>Option 2: Upload File
82
+ </h5>
83
+ <p class="small text-muted">
84
+ Select a .wav or .mp3 file from your device.
85
+ </p>
86
+ <input
87
+ class="form-control"
88
+ type="file"
89
+ id="audioUpload"
90
+ name="uploaded_audio_data"
91
+ accept="audio/*"
92
+ />
93
+ </div>
94
+ </div>
95
+
96
+ <hr class="my-4" />
97
+
98
+ <div class="d-grid">
99
+ <button
100
+ id="submitButton"
101
+ type="submit"
102
+ class="btn btn-primary btn-lg"
103
+ disabled
104
+ >
105
+ Analyze & Complete Test
106
+ </button>
107
+ <div
108
+ id="submit-status"
109
+ class="form-text text-danger text-center mt-2"
110
+ ></div>
111
+ </div>
112
+ </form>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ {% endblock %} {% block scripts %}
117
+ <!--
118
+ We link to the STABLE recorder.js from our checkpoint.
119
+ It is already designed to find the form and elements by their IDs ('audio-form', 'recordButton', etc.)
120
+ so it will work perfectly on this new page without any changes.
121
+ -->
122
+ <script
123
+ src="{{ url_for('static', filename='js/recorder.js') }}"
124
+ defer
125
+ ></script>
126
+ {% endblock %}
app/templates/auth/login.html ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %} {# We no longer import the macro, we will just use the
2
+ fields directly #} {% block title %}Sign In{% endblock %} {% block content %}
3
+ <div class="auth-wrapper">
4
+ <div class="auth-card">
5
+ {% include '_flash_messages.html' %}
6
+ <div class="text-center mb-4">
7
+ <i class="fas fa-sign-in-alt fa-3x text-primary"></i>
8
+ <h1 class="h3 fw-bold mt-3">Sign In</h1>
9
+ <p class="text-muted">Welcome back! Please enter your credentials.</p>
10
+ </div>
11
+
12
+ <form
13
+ action=""
14
+ method="post"
15
+ novalidate
16
+ class="w-100"
17
+ >
18
+ {{ form.hidden_tag() }}
19
+
20
+ <div class="mb-3">
21
+ {{ form.username.label(class="form-label") }} {{
22
+ form.username(class="form-control", placeholder="Your Username") }}
23
+ </div>
24
+
25
+ <div class="mb-3">
26
+ {{ form.password.label(class="form-label") }} {{
27
+ form.password(class="form-control", placeholder="Your Password") }}
28
+ </div>
29
+
30
+ <div class="d-flex justify-content-between align-items-center mb-4">
31
+ <div class="form-check">
32
+ {{ form.remember_me(class="form-check-input") }} {{
33
+ form.remember_me.label(class="form-check-label small") }}
34
+ </div>
35
+ </div>
36
+
37
+ <div class="d-grid">
38
+ {{ form.submit(class="btn btn-primary btn-lg") }}
39
+ </div>
40
+ </form>
41
+
42
+ <p class="text-center small mt-4 mb-0">
43
+ Don't have an account?
44
+ <a href="{{ url_for('auth.register') }}">Register here</a>
45
+ </p>
46
+ </div>
47
+ </div>
48
+ {% endblock %}
app/templates/auth/register.html ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %} {% block title %}Create Account{% endblock %} {% block
2
+ content %}
3
+ <div class="auth-wrapper">
4
+ <div class="auth-card">
5
+ <div class="text-center mb-4">
6
+ <i class="fas fa-user-plus fa-3x text-primary"></i>
7
+ <h1 class="h3 fw-bold mt-3">Create an Account</h1>
8
+ <p class="text-muted">It's free and only takes a minute.</p>
9
+ </div>
10
+
11
+ <form
12
+ action=""
13
+ method="post"
14
+ novalidate
15
+ class="w-100"
16
+ >
17
+ {{ form.hidden_tag() }}
18
+
19
+ <div class="mb-3">
20
+ {{ form.username.label(class="form-label") }} {{
21
+ form.username(class="form-control", placeholder="Choose a unique
22
+ username") }}
23
+ </div>
24
+
25
+ <div class="mb-3">
26
+ {{ form.email.label(class="form-label") }} {{
27
+ form.email(class="form-control", placeholder="your.email@example.com")
28
+ }}
29
+ </div>
30
+
31
+ <div class="mb-3">
32
+ {{ form.password.label(class="form-label") }} {{
33
+ form.password(class="form-control", placeholder="Create a secure
34
+ password") }}
35
+ </div>
36
+
37
+ <div class="mb-4">
38
+ {{ form.password2.label(class="form-label") }} {{
39
+ form.password2(class="form-control", placeholder="Confirm your
40
+ password") }}
41
+ </div>
42
+
43
+ <div class="d-grid">
44
+ {{ form.submit(class="btn btn-primary btn-lg", value="Create Account")
45
+ }}
46
+ </div>
47
+ </form>
48
+
49
+ <p class="text-center small mt-4 mb-0">
50
+ Already have an account? <a href="{{ url_for('auth.login') }}">Sign In</a>
51
+ </p>
52
+ </div>
53
+ </div>
54
+ {% endblock %}
app/templates/base.html ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta
6
+ name="viewport"
7
+ content="width=device-width, initial-scale=1.0"
8
+ />
9
+ <title>{{ title }} - Parkinson's Detector</title>
10
+
11
+ <!-- Third-Party CSS Libraries -->
12
+ <link
13
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
14
+ rel="stylesheet"
15
+ integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
16
+ crossorigin="anonymous"
17
+ />
18
+ <link
19
+ rel="stylesheet"
20
+ href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css"
21
+ integrity="sha512-1ycn6IcaQQ40/MKBW2W4Rhis/DbILU74C1vSrLJxCq57o941Ym01SwNsOMqvEBFlcgUa6xLiPY/NS5R+E6ztJQ=="
22
+ crossorigin="anonymous"
23
+ referrerpolicy="no-referrer"
24
+ />
25
+
26
+ <!-- Our Custom Stylesheet (must be loaded last to override libraries) -->
27
+ <link
28
+ rel="stylesheet"
29
+ href="{{ url_for('static', filename='css/style.css') }}"
30
+ />
31
+ </head>
32
+ <body>
33
+ <!-- ============================================= -->
34
+ <!-- == Main Navigation Bar == -->
35
+ <!-- ============================================= -->
36
+ <nav
37
+ class="navbar navbar-expand-lg navbar-light bg-white fixed-top shadow-sm"
38
+ >
39
+ <div class="container">
40
+ <a
41
+ class="navbar-brand fw-bold text-primary"
42
+ href="{{ url_for('main.index') }}"
43
+ >
44
+ <i class="fas fa-brain me-2"></i>Parkinson's Detector
45
+ </a>
46
+ <button
47
+ class="navbar-toggler"
48
+ type="button"
49
+ data-bs-toggle="collapse"
50
+ data-bs-target="#mainNavbar"
51
+ aria-controls="mainNavbar"
52
+ aria-expanded="false"
53
+ aria-label="Toggle navigation"
54
+ >
55
+ <span class="navbar-toggler-icon"></span>
56
+ </button>
57
+ <div
58
+ class="collapse navbar-collapse"
59
+ id="mainNavbar"
60
+ >
61
+ <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
62
+ <!-- Links for Anonymous (Not Logged-in) Users -->
63
+ {% if current_user.is_anonymous %}
64
+ <li class="nav-item">
65
+ <a
66
+ class="nav-link"
67
+ href="{{ url_for('auth.login') }}"
68
+ >Login</a
69
+ >
70
+ </li>
71
+ <li class="nav-item">
72
+ <a
73
+ class="nav-link btn btn-primary text-white btn-sm ms-2 px-3"
74
+ href="{{ url_for('auth.register') }}"
75
+ >Register</a
76
+ >
77
+ </li>
78
+
79
+ <!-- Links for Logged-in Users -->
80
+ {% else %}
81
+ <li class="nav-item">
82
+ <a
83
+ class="nav-link"
84
+ href="{{ url_for('main.dashboard') }}"
85
+ >Dashboard</a
86
+ >
87
+ </li>
88
+
89
+ <!-- Admin-only Link -->
90
+ {% if current_user.is_admin %}
91
+ <li class="nav-item">
92
+ <a
93
+ class="nav-link"
94
+ href="{{ url_for('main.admin_dashboard') }}"
95
+ >Admin</a
96
+ >
97
+ </li>
98
+ {% endif %}
99
+
100
+ <li class="nav-item">
101
+ <a
102
+ class="nav-link"
103
+ href="{{ url_for('auth.logout') }}"
104
+ >Logout</a
105
+ >
106
+ </li>
107
+ {% endif %}
108
+ </ul>
109
+ </div>
110
+ </div>
111
+ </nav>
112
+
113
+ <!-- ============================================= -->
114
+ <!-- == Main Content Area == -->
115
+ <!-- ============================================= -->
116
+ <main>
117
+ {# This is the main block that all other templates will extend and fill.
118
+ #} {% block content %}{% endblock %}
119
+ </main>
120
+
121
+ <!-- JavaScript Libraries -->
122
+ <script
123
+ src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
124
+ integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
125
+ crossorigin="anonymous"
126
+ ></script>
127
+
128
+ {# A block for page-specific JavaScript files #} {% block scripts %}{%
129
+ endblock %}
130
+ </body>
131
+ </html>
app/templates/dashboard.html ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %} {% block title %}Your Dashboard{% endblock %} {% block
2
+ content %}
3
+ <div class="container py-4">
4
+ {# This includes the reusable snippet to show flash messages like "Login
5
+ successful" #} {% include '_flash_messages.html' %}
6
+
7
+ <!-- Page Header -->
8
+ <div class="dashboard-header">
9
+ <h1 class="h2">Welcome, {{ current_user.username }}!</h1>
10
+ <p class="text-muted">
11
+ Here you can start a new voice analysis or review your test history.
12
+ </p>
13
+ </div>
14
+
15
+ <!-- Primary Call-to-Action (CTA) Card -->
16
+ <div class="card text-center border-0 shadow-sm mb-5 bg-light">
17
+ <div class="card-body p-5">
18
+ <h3 class="card-title h4">Ready for a New Analysis?</h3>
19
+ <p class="card-text text-secondary">
20
+ Begin the two-step process to get your preliminary voice analysis.
21
+ </p>
22
+
23
+ {# This button now points to our new '/new_test' route #}
24
+ <a
25
+ href="{{ url_for('main.new_test') }}"
26
+ class="btn btn-primary btn-lg mt-3"
27
+ >
28
+ <i class="fas fa-vial me-2"></i>Start New Test
29
+ </a>
30
+ </div>
31
+ </div>
32
+
33
+ <!-- Past Reports Section -->
34
+ <h3 class="h4 mb-3">Your Test History</h3>
35
+ <div class="card shadow-sm">
36
+ <div class="card-body p-0">
37
+ {# p-0 to make the table flush with the card edges #} {% if reports %}
38
+ <div class="table-responsive">
39
+ <table class="table table-hover align-middle mb-0">
40
+ <thead>
41
+ <tr>
42
+ <th
43
+ scope="col"
44
+ class="ps-4"
45
+ >
46
+ Date
47
+ </th>
48
+ <th scope="col">Age</th>
49
+ <th scope="col">Gender</th>
50
+ <th scope="col">Result</th>
51
+ <th
52
+ scope="col"
53
+ class="pe-4"
54
+ >
55
+ Note
56
+ </th>
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ {% for report in reports %}
61
+ <tr>
62
+ <td class="ps-4">
63
+ <div class="fw-bold">
64
+ {{ report.timestamp.strftime('%B %d, %Y') }}
65
+ </div>
66
+ <div class="small text-muted">
67
+ {{ report.timestamp.strftime('%I:%M %p') }} UTC
68
+ </div>
69
+ </td>
70
+ <td>{{ report.age }}</td>
71
+ <td>{{ report.gender }}</td>
72
+ <td>
73
+ <span
74
+ class="badge fs-6 rounded-pill {% if 'Positive' in report.final_result %} bg-warning text-dark{% elif 'Negative' in report.final_result %}bg-success{% else %}bg-secondary{% endif %}"
75
+ >
76
+ {{ report.final_result }}
77
+ </span>
78
+ </td>
79
+ <td class="text-muted small pe-4">
80
+ {% if 'Override' in report.final_result %} Initial Prediction
81
+ was 'Positive' Prediction overridden by age criteria. {% else %}
82
+ - {% endif %}
83
+ </td>
84
+ </tr>
85
+ {% endfor %}
86
+ </tbody>
87
+ </table>
88
+ </div>
89
+ {% else %}
90
+ <div class="text-center p-5">
91
+ <p class="text-muted">
92
+ You have no past reports. Click "Start New Test" to begin.
93
+ </p>
94
+ </div>
95
+ {% endif %}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ {% endblock %}
app/templates/email/result_notification.html ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p>Dear {{ user.username }},</p>
2
+ <p>
3
+ Thank you for using the Parkinson's Detection System. Here is a summary of
4
+ your test taken on {{ report.timestamp.strftime('%Y-%m-%d %H:%M') }} UTC.
5
+ </p>
6
+ <hr />
7
+
8
+ <h3 style="color: #333">Vocal Analysis Result</h3>
9
+ <ul>
10
+ <li><b>Final Assessment:</b> <strong>{{ report.final_result }}</strong></li>
11
+ {% if 'Override' in report.final_result %}
12
+ <li>
13
+ <b>Note:</b> The model's prediction was positive but was overridden by the
14
+ age criteria provided (Age: {{report.age}}).
15
+ </li>
16
+ {% endif %}
17
+ </ul>
18
+
19
+ {# --- NEW SECTION TO DISPLAY SYMPTOMS --- #} {% if symptoms %}
20
+ <h3 style="color: #333">Symptoms You Provided</h3>
21
+ <ul>
22
+ {% for key, value in symptoms.items() %}
23
+ <li><b>{{ key }}:</b> {{ value }}</li>
24
+ {% endfor %}
25
+ </ul>
26
+ {% endif %}
27
+
28
+ <hr />
29
+ <p style="font-size: small; color: #777">
30
+ <b>Disclaimer:</b> This result is based on a machine learning model and is for
31
+ informational purposes only. It is not a medical diagnosis. Please consult a
32
+ qualified healthcare professional for accurate medical advice.
33
+ </p>
34
+ <p>Sincerely,<br />The Parkinson's Detector Team</p>
app/templates/email/result_notification.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Dear {{ user.username }},
2
+
3
+ Thank you for using the Parkinson's Disease Voice Detection System. Here is a summary of your test taken on {{ report.timestamp.strftime('%Y-%m-%d %H:%M') }} UTC.
4
+
5
+ ---------------------------------
6
+ VOCAL ANALYSIS RESULT
7
+ ---------------------------------
8
+ - Final Assessment: {{ report.final_result }}
9
+ {% if 'Override' in report.final_result %}- Note: The model's prediction was positive but was overridden by the age criteria provided (Age: {{report.age}}).
10
+ {% endif %}
11
+
12
+ {# --- NEW SECTION TO DISPLAY SYMPTOMS --- #}
13
+ {% if symptoms %}
14
+ ---------------------------------
15
+ SYMPTOMS YOU PROVIDED
16
+ ---------------------------------
17
+ {% for key, value in symptoms.items() %}- {{ key }}: {{ value }}
18
+ {% endfor %}
19
+ {% endif %}
20
+ ---------------------------------
21
+
22
+ Disclaimer: This result is based on a machine learning model and is for informational purposes only. It is not a medical diagnosis. Please consult a qualified healthcare professional for accurate medical advice.
23
+
24
+ Sincerely,
25
+ The Parkinson's Detector Team
app/templates/index.html ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %} {% block title %}Welcome{% endblock %} {% block
2
+ content %}
3
+ <div
4
+ class="container text-center"
5
+ style="padding-top: 5rem; padding-bottom: 5rem"
6
+ >
7
+ <!-- Main Heading and Subtitle -->
8
+ <h1 class="display-4 fw-bold">Vocal Analysis for Parkinson's</h1>
9
+ <p class="lead text-muted col-lg-8 mx-auto mt-3">
10
+ Our platform utilizes advanced machine learning to provide a non-invasive,
11
+ preliminary analysis based on voice characteristics. Get an informational
12
+ result in just a few minutes.
13
+ </p>
14
+
15
+ <!-- Call to Action Button -->
16
+ <div class="my-5">
17
+ {% if current_user.is_anonymous %} {# If user is not logged in, button goes
18
+ to the login page #}
19
+ <a
20
+ href="{{ url_for('auth.login') }}"
21
+ class="btn btn-primary btn-lg px-5 py-3 fs-5 shadow"
22
+ >Get Started</a
23
+ >
24
+ {% else %} {# If user is logged in, button goes directly to their dashboard
25
+ #}
26
+ <a
27
+ href="{{ url_for('main.dashboard') }}"
28
+ class="btn btn-primary btn-lg px-5 py-3 fs-5 shadow"
29
+ >Go to Your Dashboard</a
30
+ >
31
+ {% endif %}
32
+ </div>
33
+
34
+ <!-- Medical Disclaimer Box -->
35
+ <div class="row justify-content-center mt-5">
36
+ <div class="col-lg-10">
37
+ <div
38
+ class="alert alert-warning d-flex align-items-center shadow-sm"
39
+ role="alert"
40
+ >
41
+ <div class="flex-shrink-0 me-3">
42
+ <i class="fas fa-exclamation-triangle fa-2x"></i>
43
+ </div>
44
+ <div class="text-start">
45
+ <h4 class="alert-heading h6">Medical Disclaimer</h4>
46
+ This tool is for informational and research purposes only and is not a
47
+ substitute for professional medical diagnosis or advice. The outcomes
48
+ are not fully reliable. Always consult with a qualified healthcare
49
+ provider for any health concerns.
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ {% endblock %}
app/templates/new_test.html ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %} {% block content %}
2
+ <link
3
+ rel="stylesheet"
4
+ href="{{ url_for('static', filename='css/new_test.css') }}"
5
+ />
6
+
7
+ <div class="new-test-container">
8
+ <div
9
+ class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-4 border-bottom"
10
+ >
11
+ <h1 class="h2">New Test - Step 1: Symptoms</h1>
12
+ <a
13
+ href="{{ url_for('main.dashboard') }}"
14
+ class="btn btn-sm btn-outline-secondary"
15
+ >Cancel</a
16
+ >
17
+ </div>
18
+ {% include '_flash_messages.html' %}
19
+
20
+ <form
21
+ action="{{ url_for('main.new_test') }}"
22
+ method="post"
23
+ >
24
+ <div class="card shadow-sm">
25
+ <div class="card-body p-0">
26
+ <div class="symptom-slider-container">
27
+ <div class="symptom-slider-wrapper">
28
+ <!-- Slide 1: Tremors -->
29
+ <div class="symptom-slide">
30
+ <h3>Do you experience tremors?</h3>
31
+ <div class="form-check">
32
+ <input
33
+ type="radio"
34
+ name="tremor"
35
+ value="at_rest"
36
+ id="tremor_rest"
37
+ class="form-check-input"
38
+ required
39
+ />
40
+ <label
41
+ for="tremor_rest"
42
+ class="form-check-label"
43
+ >Yes, when my hands are resting.</label
44
+ >
45
+ </div>
46
+ <div class="form-check">
47
+ <input
48
+ type="radio"
49
+ name="tremor"
50
+ value="action"
51
+ id="tremor_action"
52
+ class="form-check-input"
53
+ />
54
+ <label
55
+ for="tremor_action"
56
+ class="form-check-label"
57
+ >Yes, when I am performing an action.</label
58
+ >
59
+ </div>
60
+ <div class="form-check">
61
+ <input
62
+ type="radio"
63
+ name="tremor"
64
+ value="no"
65
+ id="tremor_no"
66
+ class="form-check-input"
67
+ />
68
+ <label
69
+ for="tremor_no"
70
+ class="form-check-label"
71
+ >No, I do not experience tremors.</label
72
+ >
73
+ </div>
74
+ <button
75
+ type="button"
76
+ class="btn btn-primary next-btn"
77
+ >
78
+ Next →
79
+ </button>
80
+ </div>
81
+
82
+ <!-- Slide 2: Stiffness -->
83
+ <div class="symptom-slide">
84
+ <h3>Do you experience stiffness or slowness?</h3>
85
+ <div class="form-check">
86
+ <input
87
+ type="radio"
88
+ name="stiffness"
89
+ value="yes"
90
+ id="stiffness_yes"
91
+ class="form-check-input"
92
+ required
93
+ />
94
+ <label
95
+ for="stiffness_yes"
96
+ class="form-check-label"
97
+ >Yes, my limbs feel rigid or I move slower.</label
98
+ >
99
+ </div>
100
+ <div class="form-check">
101
+ <input
102
+ type="radio"
103
+ name="stiffness"
104
+ value="no"
105
+ id="stiffness_no"
106
+ class="form-check-input"
107
+ />
108
+ <label
109
+ for="stiffness_no"
110
+ class="form-check-label"
111
+ >No, I have not noticed this.</label
112
+ >
113
+ </div>
114
+ <button
115
+ type="button"
116
+ class="btn btn-primary next-btn"
117
+ >
118
+ Next →
119
+ </button>
120
+ </div>
121
+
122
+ <!-- Slide 3: Balance / Walking Issue (CORRECTED) -->
123
+ <div class="symptom-slide">
124
+ <h3>Do you have balance or walking issues?</h3>
125
+ <div class="form-check">
126
+ {# The name attribute is now correctly 'walking_issue' #}
127
+ <input
128
+ type="radio"
129
+ name="walking_issue"
130
+ value="yes"
131
+ id="balance_yes"
132
+ class="form-check-input"
133
+ required
134
+ />
135
+ <label
136
+ for="balance_yes"
137
+ class="form-check-label"
138
+ >Yes, I feel unsteady or have shuffling steps.</label
139
+ >
140
+ </div>
141
+ <div class="form-check">
142
+ {# The name attribute is now correctly 'walking_issue' #}
143
+ <input
144
+ type="radio"
145
+ name="walking_issue"
146
+ value="no"
147
+ id="balance_no"
148
+ class="form-check-input"
149
+ />
150
+ <label
151
+ for="balance_no"
152
+ class="form-check-label"
153
+ >No, my balance and walking feel normal.</label
154
+ >
155
+ </div>
156
+ <button
157
+ type="button"
158
+ class="btn btn-primary next-btn"
159
+ >
160
+ Next →
161
+ </button>
162
+ </div>
163
+
164
+ <!-- Slide 4: Final Details & Submit -->
165
+ <div class="symptom-slide">
166
+ <h3>Final Details</h3>
167
+ <div class="mb-3">
168
+ <label
169
+ for="age"
170
+ class="form-label fw-bold"
171
+ >Your Age:</label
172
+ >
173
+ <input
174
+ type="number"
175
+ name="age"
176
+ id="age"
177
+ class="form-control"
178
+ required
179
+ />
180
+ </div>
181
+ <div class="mb-3">
182
+ <label
183
+ for="gender"
184
+ class="form-label fw-bold"
185
+ >Your Gender:</label
186
+ >
187
+ <select
188
+ name="gender"
189
+ id="gender"
190
+ class="form-select"
191
+ required
192
+ >
193
+ <option
194
+ value=""
195
+ disabled
196
+ selected
197
+ >
198
+ Select...
199
+ </option>
200
+ <option value="Male">Male</option>
201
+ <option value="Female">Female</option>
202
+ <option value="Other">Other</option>
203
+ </select>
204
+ </div>
205
+ <div class="mb-3">
206
+ <label
207
+ for="other_symptoms"
208
+ class="form-label fw-bold"
209
+ >Other symptoms or notes:</label
210
+ >
211
+ <textarea
212
+ name="other_symptoms"
213
+ id="other_symptoms"
214
+ rows="2"
215
+ class="form-control"
216
+ placeholder="(e.g., changes in handwriting)"
217
+ ></textarea>
218
+ </div>
219
+ <div class="d-grid mt-4">
220
+ <button
221
+ type="submit"
222
+ class="btn btn-success btn-lg"
223
+ >
224
+ Continue to Audio Test →
225
+ </button>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ </form>
233
+ </div>
234
+ {% endblock %} {% block scripts %}
235
+ <script
236
+ src="{{ url_for('static', filename='js/new_test.js') }}"
237
+ defer
238
+ ></script>
239
+ {% endblock %}
config.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ # Find the .env file in the root directory
5
+ basedir = os.path.abspath(os.path.dirname(__file__))
6
+ load_dotenv(os.path.join(basedir, '.env'))
7
+
8
+ # Now we can simply read the variables from the environment
9
+ class Config:
10
+ # Secret key for session management
11
+ SECRET_KEY = os.environ.get('SECRET_KEY')
12
+
13
+ # Database configuration
14
+ SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
15
+ SQLALCHEMY_TRACK_MODIFICATIONS = False
16
+
17
+ # Email configuration
18
+ MAIL_SERVER = os.environ.get('MAIL_SERVER')
19
+ MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
20
+ MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') == '1'
21
+ MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
22
+ MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
23
+ ADMINS = [os.environ.get('MAIL_USERNAME')]
instance/app.db ADDED
Binary file (57.3 kB). View file
 
migrations/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Single-database configuration for Flask.
migrations/alembic.ini ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # template used to generate migration files
5
+ # file_template = %%(rev)s_%%(slug)s
6
+
7
+ # set to 'true' to run the environment during
8
+ # the 'revision' command, regardless of autogenerate
9
+ # revision_environment = false
10
+
11
+
12
+ # Logging configuration
13
+ [loggers]
14
+ keys = root,sqlalchemy,alembic,flask_migrate
15
+
16
+ [handlers]
17
+ keys = console
18
+
19
+ [formatters]
20
+ keys = generic
21
+
22
+ [logger_root]
23
+ level = WARN
24
+ handlers = console
25
+ qualname =
26
+
27
+ [logger_sqlalchemy]
28
+ level = WARN
29
+ handlers =
30
+ qualname = sqlalchemy.engine
31
+
32
+ [logger_alembic]
33
+ level = INFO
34
+ handlers =
35
+ qualname = alembic
36
+
37
+ [logger_flask_migrate]
38
+ level = INFO
39
+ handlers =
40
+ qualname = flask_migrate
41
+
42
+ [handler_console]
43
+ class = StreamHandler
44
+ args = (sys.stderr,)
45
+ level = NOTSET
46
+ formatter = generic
47
+
48
+ [formatter_generic]
49
+ format = %(levelname)-5.5s [%(name)s] %(message)s
50
+ datefmt = %H:%M:%S
migrations/env.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from logging.config import fileConfig
3
+
4
+ from flask import current_app
5
+
6
+ from alembic import context
7
+
8
+ # this is the Alembic Config object, which provides
9
+ # access to the values within the .ini file in use.
10
+ config = context.config
11
+
12
+ # Interpret the config file for Python logging.
13
+ # This line sets up loggers basically.
14
+ fileConfig(config.config_file_name)
15
+ logger = logging.getLogger('alembic.env')
16
+
17
+
18
+ def get_engine():
19
+ try:
20
+ # this works with Flask-SQLAlchemy<3 and Alchemical
21
+ return current_app.extensions['migrate'].db.get_engine()
22
+ except (TypeError, AttributeError):
23
+ # this works with Flask-SQLAlchemy>=3
24
+ return current_app.extensions['migrate'].db.engine
25
+
26
+
27
+ def get_engine_url():
28
+ try:
29
+ return get_engine().url.render_as_string(hide_password=False).replace(
30
+ '%', '%%')
31
+ except AttributeError:
32
+ return str(get_engine().url).replace('%', '%%')
33
+
34
+
35
+ # add your model's MetaData object here
36
+ # for 'autogenerate' support
37
+ # from myapp import mymodel
38
+ # target_metadata = mymodel.Base.metadata
39
+ config.set_main_option('sqlalchemy.url', get_engine_url())
40
+ target_db = current_app.extensions['migrate'].db
41
+
42
+ # other values from the config, defined by the needs of env.py,
43
+ # can be acquired:
44
+ # my_important_option = config.get_main_option("my_important_option")
45
+ # ... etc.
46
+
47
+
48
+ def get_metadata():
49
+ if hasattr(target_db, 'metadatas'):
50
+ return target_db.metadatas[None]
51
+ return target_db.metadata
52
+
53
+
54
+ def run_migrations_offline():
55
+ """Run migrations in 'offline' mode.
56
+
57
+ This configures the context with just a URL
58
+ and not an Engine, though an Engine is acceptable
59
+ here as well. By skipping the Engine creation
60
+ we don't even need a DBAPI to be available.
61
+
62
+ Calls to context.execute() here emit the given string to the
63
+ script output.
64
+
65
+ """
66
+ url = config.get_main_option("sqlalchemy.url")
67
+ context.configure(
68
+ url=url, target_metadata=get_metadata(), literal_binds=True
69
+ )
70
+
71
+ with context.begin_transaction():
72
+ context.run_migrations()
73
+
74
+
75
+ def run_migrations_online():
76
+ """Run migrations in 'online' mode.
77
+
78
+ In this scenario we need to create an Engine
79
+ and associate a connection with the context.
80
+
81
+ """
82
+
83
+ # this callback is used to prevent an auto-migration from being generated
84
+ # when there are no changes to the schema
85
+ # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
86
+ def process_revision_directives(context, revision, directives):
87
+ if getattr(config.cmd_opts, 'autogenerate', False):
88
+ script = directives[0]
89
+ if script.upgrade_ops.is_empty():
90
+ directives[:] = []
91
+ logger.info('No changes in schema detected.')
92
+
93
+ conf_args = current_app.extensions['migrate'].configure_args
94
+ if conf_args.get("process_revision_directives") is None:
95
+ conf_args["process_revision_directives"] = process_revision_directives
96
+
97
+ connectable = get_engine()
98
+
99
+ with connectable.connect() as connection:
100
+ context.configure(
101
+ connection=connection,
102
+ target_metadata=get_metadata(),
103
+ **conf_args
104
+ )
105
+
106
+ with context.begin_transaction():
107
+ context.run_migrations()
108
+
109
+
110
+ if context.is_offline_mode():
111
+ run_migrations_offline()
112
+ else:
113
+ run_migrations_online()
migrations/script.py.mako ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ ${imports if imports else ""}
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = ${repr(up_revision)}
14
+ down_revision = ${repr(down_revision)}
15
+ branch_labels = ${repr(branch_labels)}
16
+ depends_on = ${repr(depends_on)}
17
+
18
+
19
+ def upgrade():
20
+ ${upgrades if upgrades else "pass"}
21
+
22
+
23
+ def downgrade():
24
+ ${downgrades if downgrades else "pass"}
parkinson_cnn_model_stft_grayscale.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:453d9ece423afe6ff1bb7d4bcf257b546eea734748da7a01b1f07d0f6f82ed7f
3
+ size 309903528
requirements.txt ADDED
Binary file (988 Bytes). View file
 
run.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app import create_app, db
2
+ from app.models import User, Report
3
+
4
+ # The create_app function now handles loading the .env file via config.py
5
+ app = create_app()
6
+
7
+ @app.shell_context_processor
8
+ def make_shell_context():
9
+ return {'db': db, 'User': User, 'Report': Report}
10
+
11
+ if __name__ == '__main__':
12
+ import os
13
+ port = int(os.environ.get("PORT", 5000)) # Use Render's assigned port
14
+ app.run(host="0.0.0.0", port=port)
start.sh ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #!/bin/bash
2
+ # Start Flask app with Gunicorn on port 7860
3
+ gunicorn --bind 0.0.0.0:7860 wsgi:app
symptom_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c592bb212bc14c00375d32e8ae61035d319de3bff193ddc46e7bffcaaf14d11f
3
+ size 1247
train_model.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import numpy as np
3
+ import librosa
4
+ import librosa.display
5
+ import matplotlib.pyplot as plt
6
+ import noisereduce as nr
7
+ import random
8
+ import shutil
9
+
10
+ from tensorflow.keras.preprocessing.image import ImageDataGenerator
11
+ from tensorflow.keras.models import Sequential
12
+ from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout, BatchNormalization
13
+ from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint
14
+ from tensorflow.keras.optimizers import Adam
15
+
16
+
17
+ DATA_SOURCE_PATH = 'data'
18
+ SPECTROGRAM_PATH = 'spectrograms_stft_5s_grayscale'
19
+ MODEL_SAVE_PATH = 'parkinson_cnn_model_stft_grayscale.h5'
20
+ IMG_HEIGHT, IMG_WIDTH = 224, 224
21
+ BATCH_SIZE = 32
22
+ TARGET_DURATION_S = 5
23
+
24
+ # Data Augmentation
25
+ def augment_audio(y, sr):
26
+ y_aug = y.copy()
27
+ pitch_steps = random.uniform(-2, 2)
28
+ y_aug = librosa.effects.pitch_shift(y_aug, sr=sr, n_steps=pitch_steps)
29
+ stretch_rate = random.uniform(0.9, 1.1)
30
+ y_aug = librosa.effects.time_stretch(y_aug, rate=stretch_rate)
31
+ noise_amp = 0.005 * np.random.uniform() * np.amax(y)
32
+ y_aug = y_aug + noise_amp * np.random.normal(size=len(y_aug))
33
+ return y_aug
34
+
35
+ # Spectrogram Creation
36
+ def create_stft_spectrogram(audio_file, save_path, augment=False):
37
+ """
38
+ Creates a high-quality GRAYSCALE spectrogram from a standardized 5s audio segment.
39
+ """
40
+ try:
41
+ y, sr = librosa.load(audio_file, sr=None)
42
+
43
+ target_samples = TARGET_DURATION_S * sr
44
+
45
+ if len(y) > target_samples:
46
+ start_index = int((len(y) - target_samples) / 2)
47
+ y_segment = y[start_index : start_index + target_samples]
48
+ else:
49
+ y_segment = librosa.util.pad_center(y, size=target_samples)
50
+
51
+ if augment:
52
+ y_segment = augment_audio(y_segment, sr)
53
+
54
+ y_reduced = nr.reduce_noise(y=y_segment, sr=sr)
55
+
56
+ N_FFT = 1024
57
+ HOP_LENGTH = 256
58
+ S_audio = librosa.stft(y_reduced, n_fft=N_FFT, hop_length=HOP_LENGTH)
59
+ Y_db = librosa.amplitude_to_db(np.abs(S_audio), ref=np.max)
60
+
61
+ plt.figure(figsize=(12, 4))
62
+
63
+ librosa.display.specshow(Y_db, sr=sr, hop_length=HOP_LENGTH, x_axis='time', y_axis='log', cmap='gray_r')
64
+ plt.axis('off')
65
+ plt.savefig(save_path, bbox_inches='tight', pad_inches=0)
66
+ plt.close()
67
+ return True
68
+
69
+ except Exception as e:
70
+ print(f" - Error processing {audio_file}: {e}")
71
+ return False
72
+
73
+ #Data Preparation
74
+ def process_all_audio_files():
75
+ if os.path.exists(SPECTROGRAM_PATH):
76
+ shutil.rmtree(SPECTROGRAM_PATH)
77
+ print(f"Starting audio to Grayscale STFT Spectrogram conversion ({TARGET_DURATION_S}s)...")
78
+ for split in ['train', 'validation']:
79
+ for category in ['parkinson', 'healthy']:
80
+ os.makedirs(os.path.join(SPECTROGRAM_PATH, split, category), exist_ok=True)
81
+ for category in ['parkinson', 'healthy']:
82
+ source_dir = os.path.join(DATA_SOURCE_PATH, category)
83
+ all_files = [f for f in os.listdir(source_dir) if f.lower().endswith(('.wav', '.mp3'))]
84
+ if not all_files: continue
85
+ random.shuffle(all_files)
86
+ split_index = int(len(all_files) * 0.8)
87
+ train_files, validation_files = all_files[:split_index], all_files[split_index:]
88
+ print(f"--- Processing Category: {category} ---")
89
+ for filename in train_files:
90
+ file_path = os.path.join(source_dir, filename)
91
+ base_name = os.path.splitext(filename)[0]
92
+ for i in range(3):
93
+ save_path = os.path.join(SPECTROGRAM_PATH, 'train', category, f"{base_name}_aug_{i}.png")
94
+ create_stft_spectrogram(file_path, save_path, augment=(i > 0))
95
+ for filename in validation_files:
96
+ file_path = os.path.join(source_dir, filename)
97
+ base_name = os.path.splitext(filename)[0]
98
+ save_path = os.path.join(SPECTROGRAM_PATH, 'validation', category, f"{base_name}.png")
99
+ create_stft_spectrogram(file_path, save_path, augment=False)
100
+ print("Spectrogram generation complete.")
101
+
102
+
103
+ def train_cnn_model():
104
+ """
105
+ Trains a CNN model optimized for grayscale spectrograms.
106
+ """
107
+ if not os.path.exists(SPECTROGRAM_PATH):
108
+ print("Spectrograms not found.")
109
+ return
110
+
111
+ train_datagen = ImageDataGenerator(rescale=1./255)
112
+ validation_datagen = ImageDataGenerator(rescale=1./255)
113
+
114
+
115
+ train_generator = train_datagen.flow_from_directory(
116
+ os.path.join(SPECTROGRAM_PATH, 'train'),
117
+ target_size=(IMG_HEIGHT, IMG_WIDTH),
118
+ batch_size=BATCH_SIZE,
119
+ class_mode='binary',
120
+ color_mode='grayscale' # Tells Keras to load images with 1 channel
121
+ )
122
+ validation_generator = validation_datagen.flow_from_directory(
123
+ os.path.join(SPECTROGRAM_PATH, 'validation'),
124
+ target_size=(IMG_HEIGHT, IMG_WIDTH),
125
+ batch_size=BATCH_SIZE,
126
+ class_mode='binary',
127
+ color_mode='grayscale'
128
+ )
129
+
130
+ if not train_generator.samples > 0:
131
+ print("Error: No training images were generated.")
132
+ return
133
+
134
+
135
+ model = Sequential([
136
+ Conv2D(32, (3, 3), activation='relu', input_shape=(IMG_HEIGHT, IMG_WIDTH, 1), padding='same'),
137
+ BatchNormalization(),
138
+ MaxPooling2D((2, 2)),
139
+ Conv2D(64, (3, 3), activation='relu', padding='same'),
140
+ BatchNormalization(),
141
+ MaxPooling2D((2, 2)),
142
+ Conv2D(128, (3, 3), activation='relu', padding='same'),
143
+ BatchNormalization(),
144
+ MaxPooling2D((2, 2)),
145
+ Flatten(),
146
+ Dense(256, activation='relu'),
147
+ BatchNormalization(),
148
+ Dropout(0.5),
149
+ Dense(128, activation='relu'),
150
+ Dropout(0.4),
151
+ Dense(1, activation='sigmoid')
152
+ ])
153
+
154
+ model.compile(optimizer=Adam(learning_rate=0.001), loss='binary_crossentropy', metrics=['accuracy'])
155
+ model.summary()
156
+
157
+ callbacks_list = [
158
+ EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True),
159
+ ReduceLROnPlateau(monitor='val_loss', factor=0.2, patience=7),
160
+ ModelCheckpoint(MODEL_SAVE_PATH, monitor='val_accuracy', save_best_only=True, mode='max')
161
+ ]
162
+
163
+ model.fit(
164
+ train_generator,
165
+ epochs=100,
166
+ validation_data=validation_generator,
167
+ callbacks=callbacks_list
168
+ )
169
+ print(f"Grayscale model training complete. Best model saved to {MODEL_SAVE_PATH}")
170
+
171
+
172
+ if __name__ == '__main__':
173
+ process_all_audio_files()
174
+ train_cnn_model()
train_symptom_model.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ from sklearn.model_selection import train_test_split
3
+ from sklearn.linear_model import LogisticRegression
4
+ from sklearn.metrics import accuracy_score, classification_report
5
+ import joblib
6
+ import os
7
+
8
+ # --- Configuration ---
9
+ # Ensure your Excel file is in the project root and named this, or change the path.
10
+ DATASET_PATH = 'symptoms_dataset.xlsx'
11
+ MODEL_SAVE_PATH = 'symptom_model.joblib'
12
+
13
+ def train_symptom_model():
14
+ """
15
+ Loads symptom data from an Excel file, trains a Logistic Regression model,
16
+ and saves it to disk.
17
+ """
18
+ # 1. Load the dataset
19
+ try:
20
+ df = pd.read_excel(DATASET_PATH)
21
+ print(f"Dataset '{DATASET_PATH}' loaded successfully. Shape: {df.shape}")
22
+ except FileNotFoundError:
23
+ print(f"Error: The file '{DATASET_PATH}' was not found. Please create it and add your data.")
24
+ return
25
+
26
+ # 2. Define Features (X) and Target (y)
27
+ # These are the columns the model will use to learn.
28
+ features = ['tremor', 'stiffness', 'walking_issue']
29
+ # This is the column the model will try to predict.
30
+ target = 'label'
31
+
32
+ # Validate that all required columns exist in the Excel file
33
+ required_columns = features + [target]
34
+ if not all(col in df.columns for col in required_columns):
35
+ print(f"Error: Your Excel file is missing one or more required columns.")
36
+ print(f"Please ensure it contains: {required_columns}")
37
+ return
38
+
39
+ X = df[features]
40
+ y = df[target]
41
+
42
+ # 3. Split data into training and testing sets
43
+ # We use 'stratify=y' to ensure both train and test sets have a similar proportion of 0s and 1s.
44
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
45
+ print(f"Data split into {len(X_train)} training samples and {len(X_test)} testing samples.")
46
+
47
+ # 4. Initialize and Train the Model
48
+ print("\nTraining Logistic Regression model...")
49
+ # We use class_weight='balanced' to handle cases where there might be more 0s than 1s or vice-versa.
50
+ model = LogisticRegression(random_state=42, class_weight='balanced')
51
+ model.fit(X_train, y_train)
52
+ print("Model training complete.")
53
+
54
+ # 5. Evaluate the Model (optional but good practice)
55
+ print("\nEvaluating model performance...")
56
+ y_pred = model.predict(X_test)
57
+ accuracy = accuracy_score(y_test, y_pred)
58
+ print(f"Model Accuracy on Test Set: {accuracy:.4f}")
59
+ print("\nClassification Report:")
60
+ print(classification_report(y_test, y_pred))
61
+
62
+ # 6. Save the Trained Model
63
+ joblib.dump(model, MODEL_SAVE_PATH)
64
+ print(f"\nSymptom model successfully saved to: {MODEL_SAVE_PATH}")
65
+
66
+ if __name__ == '__main__':
67
+ train_symptom_model()
wsgi.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # wsgi.py
2
+ import os
3
+
4
+ # Use non-GUI backend for Matplotlib
5
+ import matplotlib
6
+ matplotlib.use("Agg")
7
+
8
+ # Import Flask app factory
9
+ from app import create_app
10
+
11
+ # Create Flask app
12
+ app = create_app()
13
+
14
+ # Optional: Pre-import heavy ML libraries to catch issues early
15
+ try:
16
+ import tensorflow as tf
17
+ except Exception as e:
18
+ print(f"TensorFlow import warning: {e}")
19
+
20
+ # Gunicorn will look for 'app' by default
21
+ if __name__ == "__main__":
22
+ port = int(os.environ.get("PORT", 5000))
23
+ app.run(host="0.0.0.0", port=port)