From 8d15735b05e6b87726b7855dbbf90e4877190b89 Mon Sep 17 00:00:00 2001 From: sahil_aka Date: Tue, 31 Mar 2026 20:16:45 +0530 Subject: [PATCH 1/3] Remove venv from repository --- .github/workflows/main.yml | 19 -- app/main.py | 62 +++++++ app/requirements.txt | 3 +- app/routes/session.py | 148 ++++++++++++++- app/services/benchmark/report_generator.py | 42 +++++ app/services/config.py | 67 ++----- app/services/gaze_tracker.py | 11 +- app/services/metrics.py | 115 +++++++++++- app/services/reports/report.md | 200 +++++++++++++++++++++ 9 files changed, 592 insertions(+), 75 deletions(-) create mode 100644 app/services/benchmark/report_generator.py create mode 100644 app/services/reports/report.md diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index dd51045c..29480fcc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,31 +1,12 @@ -# This is a basic workflow to help you get started with Actions - name: CI -# Controls when the action will run. on: - # Triggers the workflow on push or pull request events but only for the main branch - push: - branches: [main] pull_request: branches: [main] - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" build: - # The type of runner that the job will run on runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - - uses: akhileshns/heroku-deploy@v3.12.12 # This is the action - with: - heroku_api_key: ${{secrets.HEROKU_API_KEY}} # Located in GitHub secrets - heroku_app_name: "web-eye-tracker-1204" # Must be unique in Heroku - heroku_email: "karine.pistili@gmail.com" diff --git a/app/main.py b/app/main.py index 0635c724..9ba0744d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,7 @@ from flask import Flask, request, Response, jsonify from flask_cors import CORS +import pandas as pd +from app.services.metrics import EyeTrackingBenchmark # Local imports from app from app.routes import session as session_route @@ -77,3 +79,63 @@ def batch_predict(): if request.method == 'POST': return session_route.batch_predict() return Response('Invalid request method for route', status=405, mimetype='application/json') + +""" +POST /api/session/benchmark + +Runs eye-tracking benchmark evaluation. + +Expected JSON: +{ + "screen_width_px": int, + "screen_width_cm": float, + "viewing_distance_cm": float, + "samples": [ + { + "True X": float, + "True Y": float, + "Predicted X": float, + "Predicted Y": float + } + ] +} +""" +""" +POST /api/session/benchmark + +Evaluates eye-tracking accuracy and precision. +""" +@app.route('/api/session/benchmark', methods=['POST']) +def run_benchmark(): + try: + data = request.get_json() + + required_keys = { + "samples", + "screen_width_px", + "screen_width_cm", + "viewing_distance_cm" + } + + if not required_keys.issubset(data.keys()): + missing = required_keys - set(data.keys()) + return jsonify({"error": f"Missing fields: {missing}"}), 400 + + df = pd.DataFrame(data["samples"]) + + benchmark = EyeTrackingBenchmark( + df=df, + screen_width_px=data["screen_width_px"], + screen_width_cm=data["screen_width_cm"], + viewing_distance_cm=data["viewing_distance_cm"] + ) + + results = { + "overall": benchmark.evaluate(), + "per_target": benchmark.evaluate_per_target() + } + + return jsonify(results), 200 + + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/app/requirements.txt b/app/requirements.txt index ba4582f3..b2c3bb83 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -17,4 +17,5 @@ threadpoolctl==3.6.0 tzdata==2025.2 Werkzeug==3.1.3 gunicorn==23.0.0 -requests==2.31.0 \ No newline at end of file +requests==2.31.0 +reportlab \ No newline at end of file diff --git a/app/routes/session.py b/app/routes/session.py index 1db18593..4d46d5aa 100644 --- a/app/routes/session.py +++ b/app/routes/session.py @@ -22,6 +22,9 @@ # from app.services import database as db from app.services import gaze_tracker +# Import utility for generating benchmark PDF reports +from app.services.benchmark.report_generator import generate_benchmark_report + # Constants ALLOWED_EXTENSIONS = {"txt", "webm"} @@ -56,7 +59,11 @@ def convert_nan_to_none(obj): return float(obj) if isinstance(obj, np.floating) else int(obj) return obj - +# ------------------------------------------------------------------ +# Calibration endpoint +# Generates training CSV files from calibration points and runs the +# gaze prediction model to compute calibration results. +# ------------------------------------------------------------------ def calib_results(): @@ -147,6 +154,12 @@ def calib_results(): data = convert_nan_to_none(data) return Response(json.dumps(data), status=200, mimetype='application/json') + +# ------------------------------------------------------------------ +# Batch prediction endpoint +# Uses stored calibration data to predict gaze positions for new +# iris tracking samples sent from the client. +# ------------------------------------------------------------------ def batch_predict(): try: data = request.get_json() @@ -193,4 +206,135 @@ def batch_predict(): except Exception as e: print("Erro batch_predict:", e) traceback.print_exc() - return Response("Erro interno", status=500) \ No newline at end of file + return Response("Erro interno", status=500) + +# ------------------------------------------------------------------ +# Benchmark evaluation endpoint +# +# Endpoint: +# POST /api/session//benchmark +# +# Computes benchmark metrics for eye-tracking predictions including: +# - Accuracy metrics +# - Precision metrics +# - Per-target analysis +# +# Also warns if the number of samples is small (<30) since metrics +# like p95 error may be statistically unreliable. +# ------------------------------------------------------------------ +@app.route('/api/session//benchmark', methods=['POST']) +def run_benchmark(session_id): + try: + data = request.get_json() + + samples = data.get("samples") + + if not samples: + return jsonify({"error": "Missing samples"}), 400 + + # Convert to DataFrame + df = pd.DataFrame(samples) + + # Minimum sample validation + if len(df) < 30: + warning = "Sample size is small; metrics may be statistically unreliable" + else: + warning = None + + # Get session metadata + session = Session.get(session_id) + + if not session: + return jsonify({"error": "Session not found"}), 404 + + screen_width_px = session.get("screen_width_px") + screen_width_cm = session.get("screen_width_cm") + viewing_distance_cm = session.get("viewing_distance_cm") + + # Import benchmark module + from app.services.calib_validation.metrics import EyeTrackingBenchmark + + benchmark = EyeTrackingBenchmark( + df=df, + screen_width_px=screen_width_px, + screen_width_cm=screen_width_cm, + viewing_distance_cm=viewing_distance_cm + ) + + results = { + "overall": benchmark.evaluate(), + "per_target": benchmark.evaluate_per_target() + } + + if warning: + results["warning"] = warning + + return jsonify(convert_nan_to_none(results)), 200 + + except Exception as e: + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + +# ------------------------------------------------------------------ +# Benchmark report generation endpoint +# +# Endpoint: +# POST /api/session//benchmark/report +# +# Generates a downloadable PDF report summarizing benchmark results +# and visualizing true vs predicted gaze points. +# ------------------------------------------------------------------ +@app.route('/api/session//benchmark/report', methods=['POST']) +def benchmark_report(session_id): + try: + data = request.get_json() + samples = data.get("samples") + + if not samples: + return jsonify({"error": "Missing samples"}), 400 + + # Convert samples to DataFrame + df = pd.DataFrame(samples) + + # Retrieve session metadata (screen + device parameters) + session = Session.get(session_id) + + if not session: + return jsonify({"error": "Session not found"}), 404 + + screen_width_px = session.get("screen_width_px") + screen_width_cm = session.get("screen_width_cm") + viewing_distance_cm = session.get("viewing_distance_cm") + + # Import benchmark evaluator + from app.services.calib_validation.metrics import EyeTrackingBenchmark + + # Run benchmark evaluation + benchmark = EyeTrackingBenchmark( + df=df, + screen_width_px=screen_width_px, + screen_width_cm=screen_width_cm, + viewing_distance_cm=viewing_distance_cm + ) + + results = { + "overall": benchmark.evaluate(), + "per_target": benchmark.evaluate_per_target() + } + + # Path where the report will be saved + report_path = f"reports/{session_id}_benchmark_report.pdf" + + # Generate the PDF report + generate_benchmark_report( + samples, + results["overall"]["accuracy"], + report_path + ) + + # Return generated file + return send_file(report_path, as_attachment=True) + + except Exception as e: + traceback.print_exc() + return jsonify({"error": str(e)}), 500 \ No newline at end of file diff --git a/app/services/benchmark/report_generator.py b/app/services/benchmark/report_generator.py new file mode 100644 index 00000000..3a87eafd --- /dev/null +++ b/app/services/benchmark/report_generator.py @@ -0,0 +1,42 @@ +import pandas as pd +import matplotlib.pyplot as plt +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image +from reportlab.lib.styles import getSampleStyleSheet + + +def generate_benchmark_report(samples, metrics, output_path): + + df = pd.DataFrame(samples) + + # Create visualization + plt.figure() + plt.scatter(df["True X"], df["True Y"], label="True") + plt.scatter(df["Predicted X"], df["Predicted Y"], label="Predicted") + + plt.legend() + plt.title("Gaze Prediction Accuracy") + + plot_path = "benchmark_plot.png" + plt.savefig(plot_path) + + styles = getSampleStyleSheet() + + doc = SimpleDocTemplate(output_path) + elements = [] + + elements.append(Paragraph("Eye Tracking Benchmark Report", styles["Title"])) + elements.append(Spacer(1, 20)) + + elements.append( + Paragraph( + f"Mean Accuracy Error (px): {metrics['mean_accuracy_error_px']}", + styles["BodyText"], + ) + ) + + elements.append(Spacer(1, 20)) + elements.append(Image(plot_path)) + + doc.build(elements) + + return output_path \ No newline at end of file diff --git a/app/services/config.py b/app/services/config.py index 941c23fb..e2db0d12 100644 --- a/app/services/config.py +++ b/app/services/config.py @@ -2,56 +2,18 @@ hyperparameters = { "Lasso Regression": { "param_grid": { - "lasso__alpha": [ - 1e-15, - 1e-10, - 1e-8, - 1e-3, - 1e-2, - 1e-1, - 0.5, - 1, - 5, - 10, - 20, - 30, - 35, - 40, - 45, - 50, - 55, - 100, - ] + "lasso__alpha": [10, 20, 30, 40, 45, 50, 55, 100, 200, 500] } }, "Ridge Regression": { "param_grid": { - "ridge__alpha": [ - 1e-15, - 1e-10, - 1e-8, - 1e-3, - 1e-2, - 1e-1, - 0.5, - 1, - 5, - 10, - 20, - 30, - 35, - 40, - 45, - 50, - 55, - 100, - ] + "ridge__alpha": [ 1e-3, 0.005, 0.01, 0.1, 0.5, 1.0, 10, 20, 50, 100] } }, "Elastic Net": { "param_grid": { - "elasticnet__alpha": [1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 0.0, 1.0, 10.0, 100.0], - "elasticnet__l1_ratio": [0, 0.01, 0.2, 0.5, 0.8, 1], + "elasticnet__alpha": [0.1, 0.5, 1.0, 2.0, 5.0], + "elasticnet__l1_ratio": [0.5, 0.7, 0.8, 0.9, 1.0], } }, "Bayesian Ridge": { @@ -62,17 +24,24 @@ }, "SGD Regressor": { "param_grid": { - "sgdregressor__alpha": [0.0001, 0.001, 0.01, 0.1], - "sgdregressor__l1_ratio": [0, 0.2, 0.5, 0.7, 1], - "sgdregressor__max_iter": [500, 1000], - "sgdregressor__eta0": [0.0001, 0.001, 0.01], + "sgdregressor__alpha": [0.0001, 0.001], + "sgdregressor__l1_ratio": [0.5, 0.7, 0.8, 1], + "sgdregressor__max_iter": [1000], + "sgdregressor__eta0": [0.0001, 0.001], } }, "Support Vector Regressor": { "param_grid": { - "svr__C": [0.1, 1, 10, 100, 1000], - "svr__gamma": [0.0001, 0.001, 0.01, 0.1, 1], - "svr__kernel": ["linear", "rbf", "poly"], + "svr__C": [50, 100, 200, 500, 1000, 2000], + "svr__gamma": [0.1, 0.5, 1, 2, 5], + "svr__kernel": ["rbf"], + } + }, + "Random Forest Regressor": { + "param_grid": { + "randomforestregressor__n_estimators": [100], + "randomforestregressor__max_depth": [10], + "randomforestregressor__min_samples_split": [2, 5, 10], } }, } diff --git a/app/services/gaze_tracker.py b/app/services/gaze_tracker.py index 33545511..31a35f92 100644 --- a/app/services/gaze_tracker.py +++ b/app/services/gaze_tracker.py @@ -14,7 +14,7 @@ from sklearn.pipeline import make_pipeline from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import Ridge - +import time # Model imports from sklearn import linear_model @@ -87,13 +87,15 @@ def trian_and_predict(model_name, X_train, y_train, X_test, y_test, label): """ if ( model_name == "Linear Regression" - or model_name == "Elastic Net" - or model_name == "Support Vector Regressor" + or model_name == "Random Forest Regressor" ): model = models[model_name] + start_time = time.time() model.fit(X_train, y_train) + end_time = time.time() y_pred = model.predict(X_test) print(f"Score {label}: {r2_score(y_test, y_pred)}") + print(f"Time {label}: {end_time - start_time}") return y_pred else: pipeline = models[model_name] @@ -106,9 +108,12 @@ def trian_and_predict(model_name, X_train, y_train, X_test, y_test, label): refit="r2", return_train_score=True, ) + start_time = time.time() grid_search.fit(X_train, y_train) + end_time = time.time() best_model = grid_search.best_estimator_ y_pred = best_model.predict(X_test) + print(f"Time {label}: {end_time - start_time}") return y_pred diff --git a/app/services/metrics.py b/app/services/metrics.py index 0473ecbb..32e46f95 100644 --- a/app/services/metrics.py +++ b/app/services/metrics.py @@ -72,4 +72,117 @@ def func_total_accuracy(group): np.square(group["True X"] - group["Predicted X"]) + np.square(group["True Y"] - group["Predicted Y"]) ) - return np.mean(distances) # Returns average error in pixels \ No newline at end of file + return np.mean(distances) # Returns average error in pixels + + +""" +EyeTrackingBenchmark + +Implements: +- Spatial accuracy (Euclidean pixel error) +- Angular accuracy (visual angle degrees) +- Temporal precision (frame-to-frame RMS) +- Data quality (sample loss percentage) +- Per-target spatial breakdown + +Designed for benchmarking eye-tracking systems across devices and setups. +""" + + +class EyeTrackingBenchmark: + + def __init__(self, df, screen_width_px, screen_width_cm, viewing_distance_cm): + required_cols = {"True X", "True Y", "Predicted X", "Predicted Y"} + + if not required_cols.issubset(df.columns): + missing = required_cols - set(df.columns) + raise ValueError( + f"Missing required columns: {missing}. " + f"Expected columns: {required_cols}" + ) + + self.df = df + self.screen_width_px = screen_width_px + self.screen_width_cm = screen_width_cm + self.viewing_distance_cm = viewing_distance_cm + + def _euclidean_error_px(self): + dx = self.df["True X"] - self.df["Predicted X"] + dy = self.df["True Y"] - self.df["Predicted Y"] + return np.sqrt(dx**2 + dy**2) + + def accuracy_metrics(self): + errors = self._euclidean_error_px() + return { + "mean_accuracy_error_px": float(np.mean(errors)), + "median_accuracy_error_px": float(np.median(errors)), + "p95_accuracy_error_px": float(np.percentile(errors, 95)), + "mean_accuracy_error_deg": float(self._mean_error_deg(errors)) + } + + + def _mean_error_deg(self, errors_px): + pixel_size_cm = self.screen_width_cm / self.screen_width_px + errors_cm = errors_px * pixel_size_cm + errors_deg = 2 * np.degrees( + np.arctan(errors_cm / (2 * self.viewing_distance_cm)) + ) + return np.mean(errors_deg) + + def precision_metrics(self): + grouped = self.df.groupby(["True X", "True Y"]) + + rms_values = [] + + for (_, _), group in grouped: + x = group["Predicted X"].values + y = group["Predicted Y"].values + + dx = x - np.mean(x) + dy = y - np.mean(y) + + rms = np.sqrt(np.mean(dx ** 2 + dy ** 2)) + rms_values.append(rms) + + return { + "mean_rms_px": float(np.mean(rms_values)), + "median_rms_px": float(np.median(rms_values)) + } + + def data_quality_metrics(self): + total = len(self.df) + missing = self.df["Predicted X"].isna().sum() + return { + "data_loss_percent": float((missing / total) * 100), + "num_samples": int(total) + } + + def evaluate(self): + return { + "accuracy": self.accuracy_metrics(), + "precision": self.precision_metrics(), + "data_quality": self.data_quality_metrics() + } + + def evaluate_per_target(self): + grouped = self.df.groupby(["True X", "True Y"]) + + results = [] + + for (tx, ty), group in grouped: + bench = EyeTrackingBenchmark( + group, + self.screen_width_px, + self.screen_width_cm, + self.viewing_distance_cm + ) + + metrics = bench.accuracy_metrics() + + results.append({ + "true_x": float(tx), + "true_y": float(ty), + **metrics + }) + + return results \ No newline at end of file diff --git a/app/services/reports/report.md b/app/services/reports/report.md new file mode 100644 index 00000000..96355843 --- /dev/null +++ b/app/services/reports/report.md @@ -0,0 +1,200 @@ +# 🎯 Eye-Gaze Calibration Regression Study + +This project evaluates multiple regression models for **eye-gaze calibration**, aiming to map iris landmark coordinates to screen positions. + +The goal is to determine which regression model provides the best trade-off between: + +- 🎯 Prediction accuracy (screen point error) +- πŸ“ Precision +- ⚑ Execution speed +- 🧠 Generalization (overfitting behavior) + +--- + +## πŸ“Š Dataset Overview + +- Total samples: **900** +- Features: + - `left_iris_x`, `left_iris_y` + - `right_iris_x`, `right_iris_y` +- Targets: + - `point_x`, `point_y` +- Calibration points detected: **9 unique points (k = 9)** + +--- + +## 🧠 Models Evaluated + +The following regression models were tested: + +- Linear Regression +- Ridge Regression +- Lasso Regression +- Elastic Net +- Bayesian Ridge +- SGD Regressor +- Support Vector Regressor (SVR) +- Random Forest Regressor + +--- + +## βš™οΈ Evaluation Metrics + +The pipeline reports: + +- **Avg Accuracy** β†’ positional error (lower is better) +- **Avg Precision** +- **Execution Time** +- Axis-wise RΒ² scores during calibration + +Hyperparameter tuning was performed using **GridSearchCV** when available. + +--- + +# πŸ§ͺ Experiment 1 β€” Baseline Results + +Initial run before pipeline modifications. + +### πŸ”Ž Key Observations + +- Linear & Ridge produced stable baseline performance. +- Elastic Net was very fast but less precise. +- SVR achieved strong RΒ² values. +- Random Forest failed due to missing configuration. + +### πŸ“‹ Performance Summary + +| Model | Avg Accuracy | Avg Precision | Time (s) | +|------|-------------|---------------|---------| +| Linear Regression | 235.37 | 49.11 | 1.56 | +| Ridge Regression | 236.13 | 48.81 | 0.67 | +| Lasso Regression | 290.82 | 45.88 | 0.77 | +| Elastic Net | 298.97 | 38.66 | 0.11 | +| Bayesian Ridge | 235.39 | 48.12 | 1.69 | +| SGD Regressor | 241.21 | 46.88 | 15.31 | +| Support Vector Regressor | 266.18 | 48.07 | 0.13 | +| Random Forest | ❌ Error | β€” | β€” | + +--- + +# πŸ§ͺ Experiment 2 β€” Pipeline Improvements + +Changes made: + +- Added Random Forest configuration +- Expanded hyperparameter grids +- Improved pipeline stability + +### πŸ“‹ Updated Performance Summary + +| Model | Avg Accuracy | Avg Precision | Time (s) | +|------|-------------|---------------|---------| +| Linear Regression | 235.37 | 49.11 | 1.50 | +| Ridge Regression | 235.87 | 48.71 | 0.39 | +| Lasso Regression | 292.79 | 45.10 | 0.41 | +| Elastic Net | 298.97 | 38.66 | 0.13 | +| Bayesian Ridge | 235.39 | 48.12 | 1.68 | +| SGD Regressor | 241.24 | 46.94 | 3.78 | +| Support Vector Regressor | 266.18 | 48.07 | 0.36 | +| Random Forest | **52.85** | 31.96 | 4.43 | + +### πŸ’‘ Insights + +- Random Forest dramatically reduced positional error. +- Precision dropped, suggesting sensitivity or instability. +- Ridge became faster after optimization. +- SVR remained a strong non-linear alternative. + +--- + +# πŸ§ͺ Experiment 3 β€” Train/Validation Split (Overfitting Check) + +To evaluate generalization: + +- Training: **765 samples** +- Validation: **135 samples** + +Each model went through: + +1. Calibration phase (internal split) +2. Validation phase (hold-out set) + +--- + +## πŸ“‹ Train vs Validation Results + +| Model | Calib Acc | Calib Prec | Valid Acc | Valid Prec | Time (s) | +|------|-----------|------------|-----------|------------|---------| +| Linear Regression | 245.73 | 49.84 | 229.45 | 51.55 | 1.55 | +| Ridge Regression | 247.09 | 49.94 | 229.64 | 51.80 | 0.42 | +| Lasso Regression | 251.61 | 47.05 | 265.08 | 44.32 | 0.44 | +| Elastic Net | 301.30 | 41.36 | 294.61 | 38.25 | 0.09 | +| Bayesian Ridge | 246.86 | 50.26 | 229.11 | 50.94 | 1.69 | +| SGD Regressor | 247.04 | 49.60 | 239.26 | 48.56 | 3.98 | +| Support Vector Regressor | 281.02 | 49.79 | 257.47 | 47.05 | 0.22 | +| Random Forest | **69.50** | 46.49 | **46.65** | 29.15 | 6.57 | + +--- + +## πŸ” Overfitting Analysis + +### βœ… Stable Models +- Linear Regression +- Ridge Regression +- Bayesian Ridge + +These models showed consistent calibration and validation behavior. + +### ⚠️ Potential Overfitting +- Random Forest achieved lowest error but suffered large precision drop. +- Indicates high capacity and sensitivity to dataset structure. + +### ⚑ Best Balance +- SVR provided a strong balance between: + - Accuracy + - Speed + - Generalization + +--- + +# πŸ† Final Findings + +### πŸ₯‡ Best Raw Accuracy +**Random Forest Regressor** + +- Lowest positional error +- Higher computation cost +- Possible overfitting + +### πŸ₯ˆ Most Stable Models +- Linear Regression +- Ridge Regression + +### πŸ₯‰ Best Overall Trade-off +**Support Vector Regressor (SVR)** + +--- + +# πŸš€ Future Improvements + +Possible next steps: + +- Feature scaling & normalization experiments +- Temporal smoothing for gaze stability +- Ensemble methods (Linear + Non-linear) +- Neural network-based gaze regression +- Real-time latency benchmarking + +--- + +# πŸ“Œ Conclusion + +This study shows that: + +- Non-linear models improve gaze estimation accuracy. +- Random Forest can greatly reduce error but may overfit. +- Linear models remain strong baselines for robustness. +- Proper train/validation splits are essential for realistic performance evaluation. + +--- + From b3c36b0381e52da2bad80b1b5c22584d6cdb8b02 Mon Sep 17 00:00:00 2001 From: "karine.pistili" Date: Thu, 11 Jun 2026 17:30:25 -0300 Subject: [PATCH 2/3] fix(merge): resolved conflicts --- .env.example | 1 + .github/workflows/main.yml | 18 +++- app/main.py | 76 ++++++------- app/routes/session.py | 42 ++++++-- app/services/config.py | 6 +- app/services/dash.py | 31 +++--- app/services/gaze_tracker.py | 95 +++++++++++------ app/services/quality_monitor.py | 81 ++++++++++++++ pytest.ini | 5 + requirements.txt | 17 ++- tests/__init__.py | 0 tests/conftest.py | 80 ++++++++++++++ tests/test_config.py | 32 ++++++ tests/test_gaze_tracker.py | 182 ++++++++++++++++++++++++++++++++ tests/test_metrics.py | 65 ++++++++++++ tests/test_nan_conversion.py | 56 ++++++++++ tests/test_routes.py | 113 ++++++++++++++++++++ tests/test_session_model.py | 55 ++++++++++ 18 files changed, 848 insertions(+), 107 deletions(-) create mode 100644 .env.example create mode 100644 app/services/quality_monitor.py create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_gaze_tracker.py create mode 100644 tests/test_metrics.py create mode 100644 tests/test_nan_conversion.py create mode 100644 tests/test_routes.py create mode 100644 tests/test_session_model.py diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..ad9275ac --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +FUNCTIONS_ENDPOINT_URL= #firebase functions url (Firebase emulator or production Cloud Run URL) \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 29480fcc..17ba4968 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,7 +6,21 @@ on: workflow_dispatch: jobs: - build: + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + + - name: Run tests + run: pytest -v diff --git a/app/main.py b/app/main.py index 9ba0744d..33ff790a 100644 --- a/app/main.py +++ b/app/main.py @@ -2,60 +2,21 @@ from flask_cors import CORS import pandas as pd from app.services.metrics import EyeTrackingBenchmark +import numpy as np # Local imports from app from app.routes import session as session_route +from dotenv import load_dotenv +load_dotenv() +from app.services.quality_monitor import quality_monitor_instance + +realtime_request_count = 0 # Initialize Flask app and enable CORS app = Flask(__name__) CORS(app) - -# @app.route('/', methods=['GET']) -# def welcome(): -# return Response(f'Welcome to EyeLab!', status=200, mimetype='application/json') - -# @app.route('/api/user/sessions', methods=['GET']) -# def get_user_sessions(): -# # Get user sessions -# if request.method == 'GET': -# return session_route.get_user_sessions() - -# return Response('Invalid request method for route', status=405, mimetype='application/json') - -# @app.route('/api/session', methods=['GET','POST','PATCH','DELETE']) -# def session(): -# # Get by ID -# if request.method == 'GET': -# return session_route.get_session_by_id() - -# # Create Session -# elif request.method == 'POST': -# return session_route.create_session() - -# # Delete by ID -# elif request.method == 'DELETE': -# return session_route.delete_session_by_id() - -# # Update by ID -# elif request.method == 'PATCH': -# return session_route.update_session_by_id() - -# return Response('Invalid request method for route', status=405, mimetype='application/json') - -# @app.route('/api/session/results/record', methods=['GET']) -# def manage_recording(): -# if request.method == 'GET': -# return session_route.session_results_record() -# return Response('Invalid request method for route', status=405, mimetype='application/json') - -# @app.route('/api/session/results', methods=['GET']) -# def manage_results(): -# if request.method == 'GET': -# return session_route.session_results() -# return Response('Invalid request method for route', status=405, mimetype='application/json') - @app.route('/api/session/health', methods=['GET']) def health_check(): return jsonify({'status': 'ok'}), 200 @@ -139,3 +100,28 @@ def run_benchmark(): except Exception as e: return jsonify({"error": str(e)}), 500 + +@app.route('/api/realtime-validation', methods=['POST', 'OPTIONS']) +def realtime_validation(): + global realtime_request_count + + if request.method == 'OPTIONS': + return '', 200 + try: + data = request.get_json() + prediction = data.get('prediction') + + # Increment count to throttle logs from being spammed on terminal + realtime_request_count += 1 + should_print = (realtime_request_count % 30 == 0) + + # Delegate the actual processing logic and state management to the service + result = quality_monitor_instance.process_prediction(prediction, should_print=should_print) + + return jsonify(result) + + except Exception as e: + import traceback + print(f"Server Error: {e}") + traceback.print_exc() + return jsonify({'error': str(e)}), 500 diff --git a/app/routes/session.py b/app/routes/session.py index 4d46d5aa..02b02583 100644 --- a/app/routes/session.py +++ b/app/routes/session.py @@ -6,7 +6,7 @@ import csv import math import numpy as np - +import uuid from pathlib import Path import os import pandas as pd @@ -27,9 +27,25 @@ # Constants + ALLOWED_EXTENSIONS = {"txt", "webm"} COLLECTION_NAME = "session" + +def sanitize_filename(name): + """Sanitize a user-provided filename to prevent path traversal attacks. + + Strips directory components and allows only alphanumeric characters, + hyphens, underscores, and dots. Raises ValueError if the result is empty. + """ + # Take only the final path component to strip any directory traversal + name = os.path.basename(name) + # Allow only safe characters + name = re.sub(r'[^a-zA-Z0-9_\-.]', '', name) + if not name: + raise ValueError("Invalid filename") + return name + # Initialize Flask app app = Flask(__name__) @@ -68,7 +84,7 @@ def convert_nan_to_none(obj): def calib_results(): from_ruxailab = json.loads(request.form['from_ruxailab']) - file_name = json.loads(request.form['file_name']) + file_name = sanitize_filename(json.loads(request.form['file_name'])) fixed_points = json.loads(request.form['fixed_circle_iris_points']) calib_points = json.loads(request.form['calib_circle_iris_points']) screen_height = json.loads(request.form['screen_height']) @@ -140,12 +156,13 @@ def calib_results(): "screen_width": screen_width, "k": k } - - RUXAILAB_WEBHOOK_URL = "https://receivecalibration-ffptzpxikq-uc.a.run.app" + + FUNCTIONS_ENDPOINT_URL = os.getenv('FUNCTIONS_ENDPOINT_URL') + FUNCTIONS_ENDPOINT_URL+='/receiveCalibration' print("file_name:", file_name) - resp = requests.post(RUXAILAB_WEBHOOK_URL, json=payload) + resp = requests.post(FUNCTIONS_ENDPOINT_URL, json=payload) print("Enviado para RuxaiLab:", resp.status_code, resp.text) except Exception as e: print("Erro ao enviar para RuxaiLab:", e) @@ -166,16 +183,18 @@ def batch_predict(): iris_data = data["iris_tracking_data"] screen_width = data.get("screen_width") screen_height = data.get("screen_height") - model_X = data.get("model_X", "Linear Regression") - model_Y = data.get("model_Y", "Linear Regression") + model_name_X = data.get("model_name_X", "Linear Regression") + model_name_Y = data.get("model_name_Y", "Linear Regression") calib_id = data.get("calib_id") if not calib_id: return Response("Missing calib_id", status=400) + calib_id = sanitize_filename(calib_id) + base_path = Path().absolute() / "app/services/calib_validation/csv/data" calib_csv_path = base_path / f"{calib_id}_fixed_train_data.csv" - predict_csv_path = base_path / "temp_batch_predict.csv" + predict_csv_path = base_path / f"temp_batch_predict_{uuid.uuid4().hex}.csv" # CSV temporΓ‘rio with open(predict_csv_path, "w", newline="") as csvfile: @@ -195,12 +214,13 @@ def batch_predict(): calib_csv_path=calib_csv_path, predict_csv_path=predict_csv_path, iris_data=iris_data, - # model_X="Random Forest Regressor", - # model_Y="Random Forest Regressor", + model_name_X=model_name_X, + model_name_Y=model_name_Y, screen_width=screen_width, screen_height=screen_height, ) - + os.remove(predict_csv_path) + return jsonify(convert_nan_to_none(result)) except Exception as e: diff --git a/app/services/config.py b/app/services/config.py index e2db0d12..7c2c12e3 100644 --- a/app/services/config.py +++ b/app/services/config.py @@ -32,9 +32,9 @@ }, "Support Vector Regressor": { "param_grid": { - "svr__C": [50, 100, 200, 500, 1000, 2000], - "svr__gamma": [0.1, 0.5, 1, 2, 5], - "svr__kernel": ["rbf"], + "svr__C": [1, 10 ], + "svr__gamma": [ 0.01, 0.1], + "svr__kernel": ["linear", "rbf", "poly"], } }, "Random Forest Regressor": { diff --git a/app/services/dash.py b/app/services/dash.py index 662ca8f4..595f1e10 100644 --- a/app/services/dash.py +++ b/app/services/dash.py @@ -27,8 +27,11 @@ ) -# Get all the files in the data directory -data_dir = rf"C:\Users\SITAM MEUR\Desktop\web-eye-tracker-main\web-eye-tracker-main\app\services\calib_validation\csv\data" +# Get the data directory (relative to this file's location) +current_dir = os.path.dirname(os.path.abspath(__file__)) +data_dir = os.path.join(current_dir, "calib_validation", "csv", "data") + +# Get all files in the data directory files = os.listdir(data_dir) # Extract the prefixes from the file names @@ -47,12 +50,14 @@ prefix = st.selectbox("Select the prefix for the calibration data", prefixes) # Load the dataset -dataset_train_path = rf"C:\Users\SITAM MEUR\Desktop\web-eye-tracker-main\web-eye-tracker-main\app\services\calib_validation\csv\data\{prefix}_fixed_train_data.csv" +dataset_train_path = os.path.join(data_dir, f"{prefix}_fixed_train_data.csv") try: raw_dataset = pd.read_csv(dataset_train_path) + st.success(f"Loaded: {prefix}_fixed_train_data.csv") # File not found error handling except FileNotFoundError: st.error("File not found. Please make sure the file path is correct.") + st.stop() def model_for_mouse_x(X1, Y1, models, model_names): @@ -68,7 +73,7 @@ def model_for_mouse_x(X1, Y1, models, model_names): Returns: None """ # Split dataset into train and test sets (80/20 where 20 is for test) - X1_train, X1_test, Y1_train, Y1_test = train_test_split(X1, Y1, test_size=0.2) + X1_train, X1_test, Y1_train, Y1_test = train_test_split(X1, Y1, test_size=0.2, random_state=42) metrics_list = [] @@ -116,7 +121,7 @@ def model_for_mouse_x(X1, Y1, models, model_names): # Display metrics using Streamlit st.subheader("Metrics for the test set - X") - st.dataframe(metrics_df_test, use_container_width=True) + st.dataframe(metrics_df_test, width="stretch") # Bar charts for visualization for metric in metrics_df_test.columns[1:]: @@ -141,7 +146,7 @@ def model_for_mouse_x(X1, Y1, models, model_names): errors_df["Error"] = errors_df["Actual"] - errors_df["Predicted"] # Create the box plot - st.dataframe(errors_df, use_container_width=True) + st.dataframe(errors_df, width="stretch") fig = px.box(errors_df, x="Model", y="Error") st.plotly_chart(fig) @@ -189,7 +194,7 @@ def model_for_mouse_y(X2, Y2, models, model_names): Returns: None """ # Split dataset into train and test sets (80/20 where 20 is for test) - X2_train, X2_test, Y2_train, Y2_test = train_test_split(X2, Y2, test_size=0.2) + X2_train, X2_test, Y2_train, Y2_test = train_test_split(X2, Y2, test_size=0.2, random_state=42) # Initialize empty lists to store the metrics data metrics_list = [] @@ -238,7 +243,7 @@ def model_for_mouse_y(X2, Y2, models, model_names): # Display metrics using Streamlit st.subheader("Metrics for the test set - Y") - st.dataframe(metrics_df_test, use_container_width=True) + st.dataframe(metrics_df_test, width="stretch") # Bar charts for visualization for metric in metrics_df_test.columns[1:]: @@ -263,7 +268,7 @@ def model_for_mouse_y(X2, Y2, models, model_names): errors_df["Error"] = errors_df["Actual"] - errors_df["Predicted"] # Create the box plot - st.dataframe(errors_df, use_container_width=True) + st.dataframe(errors_df, width="stretch") fig = px.box(errors_df, x="Model", y="Error") st.plotly_chart(fig) @@ -307,7 +312,7 @@ def model_for_mouse_y(X2, Y2, models, model_names): with tab1: # Display the raw dataset st.subheader("Data Obtained from Calibration") - st.dataframe(raw_dataset, use_container_width=True) + st.dataframe(raw_dataset, width="stretch") # Two columns for the plots col1, col2 = st.columns(2) @@ -327,7 +332,7 @@ def model_for_mouse_y(X2, Y2, models, model_names): ) # Display the plot - st.plotly_chart(fig_left, theme="streamlit", use_container_width=True) + st.plotly_chart(fig_left, theme="streamlit", width="stretch") with col2: # Subheader @@ -343,7 +348,7 @@ def model_for_mouse_y(X2, Y2, models, model_names): ) # Display the plot - st.plotly_chart(fig_right, theme="streamlit", use_container_width=True) + st.plotly_chart(fig_right, theme="streamlit", width="stretch") # Create the line plot fig3 = px.line( @@ -352,7 +357,7 @@ def model_for_mouse_y(X2, Y2, models, model_names): title="Left and Right Iris Position", ) # Display the plot - st.plotly_chart(fig3, theme="streamlit", use_container_width=True) + st.plotly_chart(fig3, theme="streamlit", width="stretch") # With the second tab diff --git a/app/services/gaze_tracker.py b/app/services/gaze_tracker.py index 31a35f92..da238d53 100644 --- a/app/services/gaze_tracker.py +++ b/app/services/gaze_tracker.py @@ -70,25 +70,61 @@ ) )} +models_gaze_engineered = { + "Linear Regression": make_pipeline( + StandardScaler(), + linear_model.LinearRegression() + ), + "Ridge Regression": make_pipeline( + StandardScaler(), + linear_model.Ridge() + ), + "Lasso Regression": make_pipeline( + StandardScaler(), + linear_model.Lasso() + ), + "Elastic Net": make_pipeline( + StandardScaler(), + linear_model.ElasticNet(alpha=1.0, l1_ratio=0.5) + ), + "Bayesian Ridge": make_pipeline( + StandardScaler(), + linear_model.BayesianRidge() + ), + "SGD Regressor": make_pipeline( + StandardScaler(), + linear_model.SGDRegressor() + ), + "Support Vector Regressor": make_pipeline( + StandardScaler(), + SVR(kernel="linear") + ), + "Random Forest Regressor": make_pipeline( + StandardScaler(), + RandomForestRegressor( + n_estimators=200, + max_depth=10, + min_samples_split=5, + random_state=42 + ) + ) +} + # Set the scoring metrics for GridSearchCV to r2_score and mean_absolute_error scoring = { "r2": make_scorer(r2_score), "mae": make_scorer(mean_absolute_error), } - def squash(v, limit=1.0): """Squash nΓ£o-linear estilo WebGazer""" return np.tanh(v / limit) -def trian_and_predict(model_name, X_train, y_train, X_test, y_test, label): +def train_and_predict(model_name, X_train, y_train, X_test, y_test, label): """ Helper to train a model (with or without GridSearchCV) and return predictions. """ - if ( - model_name == "Linear Regression" - or model_name == "Random Forest Regressor" - ): + if model_name == "Linear Regression": model = models[model_name] start_time = time.time() model.fit(X_train, y_train) @@ -166,7 +202,7 @@ def predict(data, k, model_X, model_Y): X_train_x = scaler_x.fit_transform(X_train_x) X_test_x = scaler_x.transform(X_test_x) - y_pred_x = trian_and_predict(model_X, X_train_x, y_train_x, X_test_x, y_test_x, "X") + y_pred_x = train_and_predict(model_X, X_train_x, y_train_x, X_test_x, y_test_x, "X") # Scaling (fit on train only) scaler_y = StandardScaler() @@ -174,7 +210,7 @@ def predict(data, k, model_X, model_Y): X_test_y = scaler_y.transform(X_test_y) - y_pred_y = trian_and_predict(model_Y, X_train_y, y_train_y, X_test_y, y_test_y, "Y") + y_pred_y = train_and_predict(model_Y, X_train_y, y_train_y, X_test_y, y_test_y, "Y") # Convert the predictions to a numpy array and apply KMeans clustering data = np.array([y_pred_x, y_pred_y]).T @@ -204,49 +240,42 @@ def predict(data, k, model_X, model_Y): # Calculate the average accuracy (eculidian distance) accuracy_xy = df_data.groupby("True XY").apply(func_total_accuracy) - # Create a dictionary to store the data data = {} + grouped = df_data.groupby("True XY") - # Iterate over the dataframe and store the data - for index, row in df_data.iterrows(): + for (true_x, true_y), group in grouped: - # Get the outer and inner keys - outer_key = str(row["True X"]).split(".")[0] - inner_key = str(row["True Y"]).split(".")[0] + # keys + outer_key = str(true_x).split(".")[0] + inner_key = str(true_y).split(".")[0] - # If the outer key is not in the dictionary, add it + # create outer key if missing if outer_key not in data: data[outer_key] = {} - # Add the data to the dictionary + # fill data data[outer_key][inner_key] = { - "predicted_x": df_data[ - (df_data["True X"] == row["True X"]) - & (df_data["True Y"] == row["True Y"]) - ]["Predicted X"].values.tolist(), - "predicted_y": df_data[ - (df_data["True X"] == row["True X"]) - & (df_data["True Y"] == row["True Y"]) - ]["Predicted Y"].values.tolist(), - "PrecisionSD": precision_xy[(row["True X"], row["True Y"])], - "Accuracy": accuracy_xy[(row["True X"], row["True Y"])], + "predicted_x": group["Predicted X"].tolist(), + "predicted_y": group["Predicted Y"].tolist(), + "PrecisionSD": precision_xy[(true_x, true_y)], + "Accuracy": accuracy_xy[(true_x, true_y)], } - # Centroids of the clusters data["centroids"] = model.cluster_centers_.tolist() # Return the data return data - def predict_new_data_simple( calib_csv_path, predict_csv_path, iris_data, + model_name_X="Linear Regression", + model_name_Y="Linear Regression", screen_width=None, screen_height=None, -): +): # ============================ # CONFIG (WebGazer-inspired) # ============================ @@ -317,8 +346,9 @@ def predict_new_data_simple( # ============================ # MODELS # ============================ - model_x = make_pipeline(StandardScaler(), Ridge(alpha=1.0)) - model_y = make_pipeline(StandardScaler(), Ridge(alpha=1.0)) + + model_x=models_gaze_engineered.get(model_name_X,models_gaze_engineered['Linear Regression']) + model_y=models.get(model_name_Y,models['Linear Regression']) model_x.fit(X_train_x, y_train_x) model_y.fit(X_train_y, y_train_y) @@ -381,10 +411,13 @@ def predict_new_data_simple( y_pred_y = y_pred_y * Y_GAIN + # ============================ # PREDICTION LOOP (WebGazer) # ============================ predictions = [] + window_size = 30 + for i in range(len(y_pred_x)): # baseline dinΓ’mico diff --git a/app/services/quality_monitor.py b/app/services/quality_monitor.py new file mode 100644 index 00000000..cb499818 --- /dev/null +++ b/app/services/quality_monitor.py @@ -0,0 +1,81 @@ +import numpy as np +import math + +class QualityMonitor: + def __init__(self, window_size=50, precision_cutoff_degrees=1.0, screen_dpi=110, user_distance_mm=600): + self.window_size = window_size + self.precision_cutoff_degrees = precision_cutoff_degrees + self.screen_dpi = screen_dpi + self.user_distance_mm = user_distance_mm + self.x_center_coords = [] + self.y_center_coords = [] + + def pixels_to_degrees(self, pixels, ppi=96.0, distance_cm=60.0): + # pixels in one centimeter calculation + pixels_per_cm = ppi / 2.54 + + # Convert the pixel distance to physical size in centimeters + size_cm = pixels / pixels_per_cm + + # Visual angle calculation + angle_rad = 2 * np.arctan(size_cm / (2 * distance_cm)) + angle_deg = np.degrees(angle_rad) + + return angle_deg + + def calculate_rms_s2s_precision(self, x_coords, y_coords): + if len(x_coords) < 2: + return 0.0 + + x = np.array(x_coords) + y = np.array(y_coords) + + # Calculate dfferences between consecutive points + dx = np.diff(x) + dy = np.diff(y) + + # Calculate squared distances between consecutive samples + distances = np.sqrt(dx**2 + dy**2) + + # Root Mean Square calculation + rms_s2s_px = np.sqrt(np.mean(distances**2)) + return rms_s2s_px + + def process_prediction(self, prediction, should_print=True): + + if not prediction or len(prediction) == 0: + return {"status": "no_prediction"} + + face = prediction[0] + annotations = face.get("annotations", {}) + right_iris = annotations.get("rightEyeIris", []) + + if not right_iris: + return {"status": "no_prediction"} + + right_iris_center = right_iris[0] + x_center = right_iris_center[0] + y_center = right_iris_center[1] + + self.x_center_coords.append(x_center) + self.y_center_coords.append(y_center) + + # Keep only the last N points + if len(self.x_center_coords) > self.window_size: + self.x_center_coords.pop(0) + self.y_center_coords.pop(0) + + # RMS-S2S metric calculation + precision_px = self.calculate_rms_s2s_precision(self.x_center_coords, self.y_center_coords) + precision_deg = self.pixels_to_degrees(precision_px) + + status = "good" if precision_deg < self.precision_cutoff_degrees else "poor" + points_collected = len(self.x_center_coords) + + if should_print: + print(f"Coordinates: X={x_center:.2f}, Y={y_center:.2f} | Precision: {precision_deg:.2f}Β° (RMS-S2S) | Status: {status}") + + return {"status": status, "precision_degrees": float(precision_deg), "precision_pixels": float(precision_px), "points_collected": points_collected} + +# Initialize with standard 0.5 degrees cutoff +quality_monitor_instance = QualityMonitor(window_size=50, precision_cutoff_degrees=0.5) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..4ecb1ad2 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/requirements.txt b/requirements.txt index 69429c32..4fa362a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,33 @@ blinker==1.9.0 +certifi==2026.5.20 +charset-normalizer==3.4.7 click==8.1.8 +contourpy==1.3.3 +cycler==0.12.1 Flask==3.1.0 flask-cors==5.0.1 +fonttools==4.63.0 +gunicorn==23.0.0 +idna==3.18 itsdangerous==2.2.0 Jinja2==3.1.6 joblib==1.4.2 +kiwisolver==1.5.0 MarkupSafe==3.0.2 +matplotlib==3.10.9 numpy==2.2.4 +packaging==26.2 pandas==2.2.3 +pillow==12.2.0 +pyparsing==3.3.2 python-dateutil==2.9.0.post0 pytz==2025.2 +requests==2.31.0 scikit-learn==1.6.1 scipy==1.15.2 six==1.17.0 threadpoolctl==3.6.0 tzdata==2025.2 +urllib3==2.7.0 Werkzeug==3.1.3 -gunicorn==23.0.0 -requests==2.31.0 +dotenv==0.9.9 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..f0f6077b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +import pytest +import csv +import pandas as pd + +from app.main import app as flask_app + + +@pytest.fixture +def app(): + flask_app.config["TESTING"] = True + return flask_app + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def sample_iris_points(): + points = [] + calibration_targets = [ + (100, 100, 0.30, 0.40, 0.60, 0.40), + (500, 300, 0.50, 0.50, 0.80, 0.50), + (900, 500, 0.70, 0.60, 0.90, 0.60), + (100, 500, 0.20, 0.70, 0.50, 0.70), + (900, 100, 0.60, 0.30, 0.85, 0.30), + ] + for px, py, lx, ly, rx, ry in calibration_targets: + for jitter in [-0.01, 0.0, 0.01]: + points.append({ + "left_iris_x": lx + jitter, "left_iris_y": ly + jitter, + "right_iris_x": rx + jitter, "right_iris_y": ry + jitter, + "point_x": px, "point_y": py, + }) + return points + + +@pytest.fixture +def sample_calib_iris_points(): + return [ + {"left_iris_x": 0.35, "left_iris_y": 0.45, "right_iris_x": 0.65, "right_iris_y": 0.45}, + {"left_iris_x": 0.55, "left_iris_y": 0.55, "right_iris_x": 0.85, "right_iris_y": 0.55}, + {"left_iris_x": 0.45, "left_iris_y": 0.50, "right_iris_x": 0.75, "right_iris_y": 0.50}, + ] + + +@pytest.fixture +def calib_csv_path(tmp_path, sample_iris_points): + csv_path = tmp_path / "test_fixed_train_data.csv" + columns = ["left_iris_x", "left_iris_y", "right_iris_x", "right_iris_y", + "point_x", "point_y", "screen_height", "screen_width"] + with open(csv_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=columns) + writer.writeheader() + for row in sample_iris_points: + writer.writerow({**row, "screen_height": 600, "screen_width": 1000}) + return str(csv_path) + + +@pytest.fixture +def predict_csv_path(tmp_path, sample_calib_iris_points): + csv_path = tmp_path / "test_predict_data.csv" + columns = ["left_iris_x", "left_iris_y", "right_iris_x", "right_iris_y"] + with open(csv_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=columns) + writer.writeheader() + for row in sample_calib_iris_points: + writer.writerow(row) + return str(csv_path) + + +@pytest.fixture +def sample_metrics_df(): + return pd.DataFrame({ + "True X": [100, 100, 100, 500, 500], + "Predicted X": [110, 95, 105, 490, 510], + "True Y": [200, 200, 200, 400, 400], + "Predicted Y": [210, 195, 205, 390, 410], + }) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..bcbfdb26 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,32 @@ +from app.services.config import hyperparameters + + +TUNABLE_MODELS = [ + "Lasso Regression", "Ridge Regression", "Elastic Net", + "Bayesian Ridge", "SGD Regressor", + "Support Vector Regressor", "Random Forest Regressor", +] + + +def test_all_tunable_models_present(): + for model in TUNABLE_MODELS: + assert model in hyperparameters + + +def test_param_grids_are_valid(): + for name, config in hyperparameters.items(): + assert "param_grid" in config + grid = config["param_grid"] + assert len(grid) > 0 + for param, values in grid.items(): + assert isinstance(values, list), f"{name}.{param} should be a list" + + +def test_ridge_alpha_values(): + alphas = hyperparameters["Ridge Regression"]["param_grid"]["ridge__alpha"] + assert min(alphas) > 0 + assert len(alphas) >= 5 + + +def test_linear_regression_not_included(): + assert "Linear Regression" not in hyperparameters diff --git a/tests/test_gaze_tracker.py b/tests/test_gaze_tracker.py new file mode 100644 index 00000000..e3c1aa58 --- /dev/null +++ b/tests/test_gaze_tracker.py @@ -0,0 +1,182 @@ +import numpy as np +from sklearn.pipeline import Pipeline + +from app.services.gaze_tracker import ( + squash, + normalizeData, + models, + train_and_predict, + predict, + predict_new_data_simple, +) + + +def test_squash_zero_in_zero_out(): + assert squash(0) == 0.0 + + +def test_squash_bounded_output(): + assert 0 < squash(1.0) < 1.0 + assert -1.0 < squash(-1.0) < 0 + + +def test_squash_symmetry(): + assert abs(squash(0.5) + squash(-0.5)) < 1e-10 + + +def test_squash_saturates(): + assert abs(squash(100.0) - 1.0) < 0.01 + + +def test_squash_respects_limit(): + result = squash(2.0, limit=2.0) + assert abs(result - np.tanh(1.0)) < 1e-10 + + +def test_squash_handles_arrays(): + result = squash(np.array([-1.0, 0.0, 1.0])) + assert len(result) == 3 + assert result[1] == 0.0 + + +def test_normalize_maps_to_0_1(): + result = normalizeData(np.array([0.0, 5.0, 10.0])) + np.testing.assert_array_almost_equal(result, [0.0, 0.5, 1.0]) + + +def test_normalize_handles_negatives(): + result = normalizeData(np.array([-10.0, 0.0, 10.0])) + np.testing.assert_array_almost_equal(result, [0.0, 0.5, 1.0]) + + +def test_normalize_endpoints(): + result = normalizeData(np.array([1.0, 2.0, 3.0])) + assert result[0] == 0.0 + assert result[-1] == 1.0 + + +def test_all_models_registered(): + expected = [ + "Linear Regression", + "Ridge Regression", + "Lasso Regression", + "Elastic Net", + "Bayesian Ridge", + "SGD Regressor", + "Support Vector Regressor", + "Random Forest Regressor", + ] + + for name in expected: + assert name in models + + for name, model in models.items(): + assert isinstance(model, Pipeline), f"{name} should be a Pipeline" + + +def test_train_predict_linear(): + np.random.seed(42) + + X_train = np.random.rand(20, 2) + y_train = X_train[:, 0] * 100 + X_train[:, 1] * 200 + + X_test = np.random.rand(5, 2) + y_test = X_test[:, 0] * 100 + X_test[:, 1] * 200 + + preds = train_and_predict( + "Linear Regression", + X_train, + y_train, + X_test, + y_test, + "X", + ) + + assert len(preds) == 5 + + +def test_train_predict_ridge_with_gridsearch(): + np.random.seed(42) + + X_train = np.random.rand(30, 2) + y_train = X_train[:, 0] * 100 + X_train[:, 1] * 200 + + X_test = np.random.rand(5, 2) + y_test = X_test[:, 0] * 100 + X_test[:, 1] * 200 + + preds = train_and_predict( + "Ridge Regression", + X_train, + y_train, + X_test, + y_test, + "X", + ) + + assert len(preds) == 5 + + +def test_predict_full_pipeline(calib_csv_path): + result = predict( + calib_csv_path, + k=2, + model_X="Linear Regression", + model_Y="Linear Regression", + ) + + assert isinstance(result, dict) + assert "centroids" in result + assert len(result["centroids"]) == 2 + + for centroid in result["centroids"]: + assert len(centroid) == 2 + + +def test_predict_new_data( + calib_csv_path, + predict_csv_path, + sample_calib_iris_points, +): + iris_data = [ + {**pt, "timestamp": i * 100} + for i, pt in enumerate(sample_calib_iris_points) + ] + + result = predict_new_data_simple( + calib_csv_path=calib_csv_path, + predict_csv_path=predict_csv_path, + iris_data=iris_data, + screen_width=1000, + screen_height=600, + ) + + assert isinstance(result, list) + assert len(result) == len(iris_data) + + for p in result: + assert isinstance(p["predicted_x"], float) + assert isinstance(p["predicted_y"], float) + assert "timestamp" in p + + +def test_predict_new_data_preserves_screen_size( + calib_csv_path, + predict_csv_path, + sample_calib_iris_points, +): + iris_data = [ + {**pt, "timestamp": i * 100} + for i, pt in enumerate(sample_calib_iris_points) + ] + + result = predict_new_data_simple( + calib_csv_path=calib_csv_path, + predict_csv_path=predict_csv_path, + iris_data=iris_data, + screen_width=1920, + screen_height=1080, + ) + + for p in result: + assert p["screen_width"] == 1920 + assert p["screen_height"] == 1080 diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 00000000..e6036bf7 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,65 @@ +import numpy as np +import pandas as pd + +from app.services.metrics import ( + func_precision_x, + func_presicion_y, + func_accuracy_x, + func_accuracy_y, + func_total_accuracy, +) + + +def test_precision_is_zero_when_predictions_are_identical(): + df = pd.DataFrame({"Predicted X": [100.0, 100.0, 100.0]}) + assert func_precision_x(df) == 0.0 + + df_y = pd.DataFrame({"Predicted Y": [200.0, 200.0, 200.0]}) + assert func_presicion_y(df_y) == 0.0 + + +def test_precision_grows_with_spread(): + tight = pd.DataFrame({"Predicted X": [99.0, 100.0, 101.0]}) + wide = pd.DataFrame({"Predicted X": [80.0, 100.0, 120.0]}) + assert func_precision_x(tight) < func_precision_x(wide) + + +def test_precision_known_rms(): + df = pd.DataFrame({"Predicted X": [90.0, 100.0, 110.0]}) + expected = np.sqrt(np.mean([100.0, 0.0, 100.0])) + assert abs(func_precision_x(df) - expected) < 1e-10 + + +def test_accuracy_zero_when_perfect(): + df = pd.DataFrame({"True X": [100.0, 200.0], "Predicted X": [100.0, 200.0]}) + assert func_accuracy_x(df) == 0.0 + + +def test_accuracy_rmse_value(): + df = pd.DataFrame({"True X": [100.0, 200.0], "Predicted X": [110.0, 190.0]}) + assert func_accuracy_x(df) == 10.0 + + +def test_accuracy_y(): + df = pd.DataFrame({"True Y": [100.0, 200.0], "Predicted Y": [105.0, 195.0]}) + assert func_accuracy_y(df) == 5.0 + + +def test_total_accuracy_uses_euclidean_distance(): + df = pd.DataFrame({ + "True X": [100.0], "Predicted X": [103.0], + "True Y": [200.0], "Predicted Y": [204.0], + }) + assert abs(func_total_accuracy(df) - 5.0) < 1e-10 + + +def test_total_accuracy_averages_multiple_points(): + df = pd.DataFrame({ + "True X": [0.0, 0.0], "Predicted X": [3.0, 0.0], + "True Y": [0.0, 0.0], "Predicted Y": [4.0, 5.0], + }) + assert abs(func_total_accuracy(df) - 5.0) < 1e-10 + + +def test_total_accuracy_on_sample_data(sample_metrics_df): + assert func_total_accuracy(sample_metrics_df) > 0 diff --git a/tests/test_nan_conversion.py b/tests/test_nan_conversion.py new file mode 100644 index 00000000..4e74f776 --- /dev/null +++ b/tests/test_nan_conversion.py @@ -0,0 +1,56 @@ +import numpy as np + +from app.routes.session import convert_nan_to_none + + +def test_nan_and_inf_become_none(): + assert convert_nan_to_none(float("nan")) is None + assert convert_nan_to_none(float("inf")) is None + assert convert_nan_to_none(float("-inf")) is None + + +def test_regular_values_pass_through(): + assert convert_nan_to_none(3.14) == 3.14 + assert convert_nan_to_none(0.0) == 0.0 + assert convert_nan_to_none(42) == 42 + assert convert_nan_to_none("hello") == "hello" + assert convert_nan_to_none(None) is None + + +def test_numpy_nan_and_inf(): + assert convert_nan_to_none(np.float64("nan")) is None + assert convert_nan_to_none(np.float64("inf")) is None + + +def test_numpy_types_converted_to_python(): + result_f = convert_nan_to_none(np.float64(2.5)) + assert result_f == 2.5 + assert isinstance(result_f, float) + + result_i = convert_nan_to_none(np.int64(10)) + assert result_i == 10 + assert isinstance(result_i, int) + + +def test_handles_dicts_and_lists(): + assert convert_nan_to_none({"a": 1.0, "b": float("nan")}) == {"a": 1.0, "b": None} + assert convert_nan_to_none([1.0, float("nan"), float("inf")]) == [1.0, None, None] + + +def test_handles_nested_structures(): + obj = { + "outer": { + "values": [1.0, float("nan")], + "score": float("inf"), + }, + "name": "test", + } + result = convert_nan_to_none(obj) + assert result["outer"]["values"] == [1.0, None] + assert result["outer"]["score"] is None + assert result["name"] == "test" + + +def test_empty_containers_unchanged(): + assert convert_nan_to_none({}) == {} + assert convert_nan_to_none([]) == [] diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 00000000..544f70ef --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,113 @@ +import json +import csv +import os +from pathlib import Path +from unittest.mock import patch + + +def test_health_endpoint(client): + resp = client.get("/api/session/health") + assert resp.status_code == 200 + assert resp.get_json() == {"status": "ok"} + assert resp.content_type == "application/json" + + +def test_calib_validation(client, sample_iris_points, sample_calib_iris_points, tmp_path): + with patch("app.routes.session.Path") as mock_path: + mock_path.return_value.absolute.return_value = tmp_path + + form_data = { + "from_ruxailab": json.dumps(False), + "file_name": json.dumps("test_session_123"), + "fixed_circle_iris_points": json.dumps(sample_iris_points), + "calib_circle_iris_points": json.dumps(sample_calib_iris_points), + "screen_height": json.dumps(600), + "screen_width": json.dumps(1000), + "model": json.dumps("Linear Regression"), + "k": json.dumps(2), + } + resp = client.post("/api/session/calib_validation", data=form_data) + assert resp.status_code == 200 + + data = json.loads(resp.data) + assert "centroids" in data + + +def test_calib_validation_fails_without_data(client): + resp = client.post("/api/session/calib_validation", data={}) + assert resp.status_code in (400, 500) + + +def test_calib_validation_rejects_get(client): + assert client.get("/api/session/calib_validation").status_code == 405 + + +def test_batch_predict_needs_calib_id(client): + payload = { + "iris_tracking_data": [ + {"left_iris_x": 0.3, "left_iris_y": 0.4, + "right_iris_x": 0.6, "right_iris_y": 0.4, "timestamp": 100} + ], + "screen_width": 1920, + "screen_height": 1080, + } + resp = client.post("/api/session/batch_predict", + data=json.dumps(payload), + content_type="application/json") + assert resp.status_code == 400 + + +def test_batch_predict_full_flow(client, sample_iris_points): + base_path = Path().absolute() / "app/services/calib_validation/csv/data" + os.makedirs(base_path, exist_ok=True) + + calib_id = "integration_test_batch" + calib_csv = base_path / f"{calib_id}_fixed_train_data.csv" + columns = ["left_iris_x", "left_iris_y", "right_iris_x", "right_iris_y", + "point_x", "point_y", "screen_height", "screen_width"] + with open(calib_csv, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=columns) + writer.writeheader() + for row in sample_iris_points: + writer.writerow({**row, "screen_height": 600, "screen_width": 1000}) + + payload = { + "iris_tracking_data": [ + {"left_iris_x": 0.35, "left_iris_y": 0.45, + "right_iris_x": 0.65, "right_iris_y": 0.45, "timestamp": 100}, + {"left_iris_x": 0.55, "left_iris_y": 0.55, + "right_iris_x": 0.85, "right_iris_y": 0.55, "timestamp": 200}, + ], + "screen_width": 1000, + "screen_height": 600, + "calib_id": calib_id, + } + + resp = client.post("/api/session/batch_predict", + data=json.dumps(payload), + content_type="application/json") + assert resp.status_code == 200 + + data = resp.get_json() + assert len(data) == 2 + for p in data: + assert "predicted_x" in p + assert "predicted_y" in p + assert "timestamp" in p + + if calib_csv.exists(): + os.remove(calib_csv) + temp_file = base_path / "temp_batch_predict.csv" + if temp_file.exists(): + os.remove(temp_file) + + +def test_batch_predict_rejects_get(client): + assert client.get("/api/session/batch_predict").status_code == 405 + + +def test_batch_predict_handles_bad_json(client): + resp = client.post("/api/session/batch_predict", + data="not json", + content_type="application/json") + assert resp.status_code == 500 diff --git a/tests/test_session_model.py b/tests/test_session_model.py new file mode 100644 index 00000000..11a67061 --- /dev/null +++ b/tests/test_session_model.py @@ -0,0 +1,55 @@ +from app.models.session import Session + + +def _make_session(**overrides): + defaults = dict( + id=1, title="Test Session", description="A test session", + user_id=42, created_date="2025-01-01", + website_url="https://example.com", + screen_record_url="https://example.com/screen.webm", + webcam_record_url="https://example.com/webcam.webm", + heatmap_url="https://example.com/heatmap.png", + calib_points=[[100, 200], [300, 400]], + iris_points=[[0.3, 0.4], [0.5, 0.6]], + ) + defaults.update(overrides) + return Session(**defaults) + + +def test_attributes_are_stored(): + s = _make_session() + assert s.id == 1 + assert s.title == "Test Session" + assert s.user_id == 42 + assert s.website_url == "https://example.com" + + +def test_to_dict(): + s = _make_session(id=5, title="X", calib_points=[[1, 2]], iris_points=[[0.1, 0.2]]) + d = s.to_dict() + + assert d["id"] == 5 + assert d["title"] == "X" + assert d["callib_points"] == [[1, 2]] + assert d["iris_points"] == [[0.1, 0.2]] + + +def test_to_dict_has_all_expected_keys(): + d = _make_session().to_dict() + assert set(d.keys()) == { + "id", "title", "description", "user_id", "created_date", + "website_url", "screen_record_url", "webcam_record_url", + "heatmap_url", "callib_points", "iris_points", + } + + +def test_none_values_are_preserved(): + s = Session( + id=None, title=None, description=None, user_id=None, + created_date=None, website_url=None, screen_record_url=None, + webcam_record_url=None, heatmap_url=None, calib_points=None, + iris_points=None, + ) + d = s.to_dict() + assert d["id"] is None + assert d["callib_points"] is None From 95f2f294f088ed0fe487a5431299ba5dc645f01e Mon Sep 17 00:00:00 2001 From: "karine.pistili" Date: Fri, 12 Jun 2026 09:15:12 -0300 Subject: [PATCH 3/3] fix: updated requirements.txt --- requirements.txt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4fa362a1..f7528735 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,11 +4,13 @@ charset-normalizer==3.4.7 click==8.1.8 contourpy==1.3.3 cycler==0.12.1 +dotenv==0.9.9 Flask==3.1.0 flask-cors==5.0.1 fonttools==4.63.0 gunicorn==23.0.0 idna==3.18 +iniconfig==2.3.0 itsdangerous==2.2.0 Jinja2==3.1.6 joblib==1.4.2 @@ -19,9 +21,14 @@ numpy==2.2.4 packaging==26.2 pandas==2.2.3 pillow==12.2.0 +pluggy==1.6.0 +Pygments==2.20.0 pyparsing==3.3.2 +pytest==9.0.3 python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 pytz==2025.2 +reportlab==4.5.1 requests==2.31.0 scikit-learn==1.6.1 scipy==1.15.2 @@ -30,4 +37,3 @@ threadpoolctl==3.6.0 tzdata==2025.2 urllib3==2.7.0 Werkzeug==3.1.3 -dotenv==0.9.9 \ No newline at end of file