Commit
·
e9ee222
0
Parent(s):
first commit
Browse files- .gitattributes +37 -0
- .gitignore +4 -0
- README.md +12 -0
- app/__init__.py +40 -0
- app/auth.py +75 -0
- app/email.py +15 -0
- app/ml_logic.py +148 -0
- app/models.py +39 -0
- app/routes.py +172 -0
- app/static/css/new_test.css +92 -0
- app/static/css/style.css +215 -0
- app/static/js/admin-charts.js +57 -0
- app/static/js/new_test.js +28 -0
- app/static/js/recorder.js +147 -0
- app/templates/404.html +0 -0
- app/templates/500.html +0 -0
- app/templates/_flash_messages.html +19 -0
- app/templates/_formhelpers.html +24 -0
- app/templates/admin/dashboard.html +84 -0
- app/templates/admin/users.html +39 -0
- app/templates/audio_test.html +126 -0
- app/templates/auth/login.html +48 -0
- app/templates/auth/register.html +54 -0
- app/templates/base.html +131 -0
- app/templates/dashboard.html +99 -0
- app/templates/email/result_notification.html +34 -0
- app/templates/email/result_notification.txt +25 -0
- app/templates/index.html +55 -0
- app/templates/new_test.html +239 -0
- config.py +23 -0
- instance/app.db +0 -0
- migrations/README +1 -0
- migrations/alembic.ini +50 -0
- migrations/env.py +113 -0
- migrations/script.py.mako +24 -0
- parkinson_cnn_model_stft_grayscale.h5 +3 -0
- requirements.txt +0 -0
- run.py +14 -0
- start.sh +3 -0
- symptom_model.joblib +3 -0
- train_model.py +174 -0
- train_symptom_model.py +67 -0
- wsgi.py +23 -0
.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)
|