diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a410d31 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.venv +venv +__pycache__ +*.pyc +.env +data/uploads +data/logs +models/*.whl +*.pt +*.dat +*.yml +*.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d3127a5 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +MARIADB_DATABASE=proctoring +MARIADB_USER=proctor +MARIADB_PASSWORD=replace-with-a-long-random-password +MARIADB_ROOT_PASSWORD=replace-with-a-different-long-random-password +PROCTOR_SECRET_KEY=replace-with-a-long-random-secret +PROCTOR_DATABASE_URI=mysql+pymysql://proctor:replace-with-a-long-random-password@db:3306/proctoring +PROCTOR_MODEL_DIR=/app/models +PROCTOR_DATA_DIR=/app/data +PROCTOR_INFERENCE_DEVICE=auto +PROCTOR_LOG_LEVEL=INFO +PROCTOR_MAX_UPLOAD_SIZE=5242880 +PROCTOR_ALLOWED_HOSTS=localhost,127.0.0.1 +PROCTOR_BIND_ADDRESS=127.0.0.1 +PROCTOR_PORT=8000 +PROCTOR_FRAME_INTERVAL_MS=1000 +PROCTOR_FRAME_JPEG_QUALITY=75 +PROCTOR_FRAME_MAX_WIDTH=1280 +PROCTOR_FRAME_MAX_HEIGHT=720 +PROCTOR_ENABLE_AUDIO=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a162913..a507f4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_call: permissions: contents: read diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..82ad91b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,79 @@ +name: Deploy Proctor + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: proctor-production + cancel-in-progress: false + +jobs: + validate: + uses: ./.github/workflows/ci.yml + + deploy: + name: Deploy to vps01 through IONOS + needs: validate + runs-on: ubuntu-latest + environment: production + steps: + - name: Configure SSH + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + run: | + install -d -m 700 "$HOME/.ssh" + printf '%s\n' "$DEPLOY_SSH_KEY" > "$HOME/.ssh/proctor_deploy" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" + chmod 600 "$HOME/.ssh/proctor_deploy" "$HOME/.ssh/known_hosts" + eval "$(ssh-agent -s)" + ssh-add "$HOME/.ssh/proctor_deploy" + echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV" + + - name: Pull, rebuild, and verify the production app + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + VPS01_KNOWN_HOSTS: ${{ secrets.VPS01_KNOWN_HOSTS }} + APP_DIR: ${{ vars.PROCTOR_APP_DIR }} + run: | + ssh -A \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=yes \ + "$DEPLOY_USER@$DEPLOY_HOST" \ + "VPS01_KNOWN_HOSTS='$VPS01_KNOWN_HOSTS' APP_DIR='${APP_DIR:-/opt/apps/proctor}' bash -s" <<'IONOS' + set -euo pipefail + install -d -m 700 "$HOME/.ssh" + printf '%s\n' "$VPS01_KNOWN_HOSTS" > "$HOME/.ssh/proctor_vps01_known_hosts" + chmod 600 "$HOME/.ssh/proctor_vps01_known_hosts" + ssh -A -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$HOME/.ssh/proctor_vps01_known_hosts" \ + munashe@vps01 "APP_DIR='$APP_DIR' bash -s" <<'VPS01' + set -euo pipefail + cd "$APP_DIR" + git fetch --prune origin main + if git show-ref --verify --quiet refs/heads/main; then + git checkout main + else + git checkout -B main origin/main + fi + git reset --hard origin/main + docker compose up -d --build app + docker compose run --rm app python scripts/verify_models.py + for attempt in $(seq 1 30); do + if curl --fail --silent --show-error http://127.0.0.1:8000/health >/tmp/proctor-health.json; then + cat /tmp/proctor-health.json + exit 0 + fi + sleep 2 + done + docker compose ps + docker compose logs --tail=100 app + exit 1 + VPS01 + IONOS diff --git a/.gitignore b/.gitignore index f30c828..834d37f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ venv/ +.env __pycache__/ requirements_clean.txt issues.md diff --git a/DEPLOYMENT_IMPLEMENTATION.md b/DEPLOYMENT_IMPLEMENTATION.md new file mode 100644 index 0000000..ec7d4d7 --- /dev/null +++ b/DEPLOYMENT_IMPLEMENTATION.md @@ -0,0 +1,343 @@ +# Proctor Deployment and Portability Implementation Guide + +## Purpose + +Implement the changes required to deploy Proctor on a Linux home server with Docker, MariaDB, optional NVIDIA GPU acceleration, Tailscale connectivity, and HTTPS access through an Ionos server. + +The implementation must be performed on a feature branch, never directly on `main`. The final result must be cloneable onto a new Linux server and start through Docker Compose without requiring a Python virtual environment on the host. + +## Target architecture + +```text +Student browser + - Exam UI + - Browser webcam/microphone capture + - HTTPS/WebSocket or HTTP frame upload + | + v +Ionos server + - Public DNS + - TLS termination/reverse proxy + - No GPU required + | + v +Tailscale network + | + v +Linux home server + - Docker Compose + - Flask/Gunicorn application + - MariaDB + - CPU or NVIDIA GPU inference + - Persistent uploaded images, models, and logs +``` + +The first deployment may run the entire stack on the Linux home server, with the Ionos server acting only as a reverse proxy. Do not require the Ionos server to have a GPU. + +## Non-negotiable requirements + +1. Do not modify or reset unrelated user work. +2. Do not commit passwords, API keys, Tailscale keys, TLS private keys, or other secrets. +3. Do not commit large model files unless the repository already tracks them intentionally. Provide a documented model-download or model-copy step instead. +4. Preserve the existing application features where practical. +5. Keep CPU execution available when CUDA is unavailable. +6. Use environment variables for all deployment-specific configuration. +7. Do not rely on Windows-only paths, Windows-only packages, or host-specific absolute paths. +8. Do not make remote users depend on a webcam or microphone attached to the Linux server. +9. Do not use Flask's development server in the deployed configuration. +10. Do not use process-global detection state for multiple concurrent exam sessions. + +## Current repository issues to address + +Inspect the current repository before editing. At minimum, account for these known issues: + +- There is no Dockerfile or Docker Compose configuration. +- `requirements.txt` contains a Windows-specific absolute dlib wheel reference. +- `requirements_clean.txt` omits dlib even though `face-recognition` requires it. +- The application defaults to a MySQL database on `localhost`. +- `run.py` starts Flask with `debug=True`. +- Detection code uses relative paths such as `./models/...` and `./session.txt`. +- `detection.py` uses `math` without importing it. +- `detection.py` uses `cv2.VideoCapture(0)`, which captures the server camera rather than a remote student's camera. +- Audio capture uses `sounddevice`, which attempts to use a server-side microphone. +- The code has module/global detection variables such as `identity`, `liveness`, `numFaces`, and `numPeople`. +- The website blacklist edits a Windows hosts file and cannot control a remote student's computer. +- The repository contains `best_10.pt`, `best_20.pt`, and the dlib landmark data, but the application also expects `yolov8n.pt` and `models/TrainingImageLabel/Trainner.yml`. +- The bundled dlib wheel is Windows-only and is not usable in a Linux container. + +## Branch and change management + +Create or use a branch with a name such as: + +```text +codex/deployment-docker-gpu +``` + +Before editing, verify the active branch and working tree. Do not merge, rebase, reset, or delete `main`. Keep deployment work isolated from unrelated changes. + +## Phase 1: Configuration and path cleanup + +### 1. Centralize configuration + +Extend the configuration module so that it supports at least: + +- `PROCTOR_SECRET_KEY` +- `PROCTOR_DATABASE_URI` +- `PROCTOR_MODEL_DIR` +- `PROCTOR_DATA_DIR` +- `PROCTOR_INFERENCE_DEVICE`, with values `auto`, `cpu`, or `cuda` +- `PROCTOR_LOG_LEVEL` +- `PROCTOR_MAX_UPLOAD_SIZE` +- `PROCTOR_ALLOWED_HOSTS` or equivalent host validation setting + +Use safe production defaults. Never use the development secret key in production. + +### 2. Replace relative paths + +Build paths from the application root or configured data directory using `pathlib.Path`. Do not depend on the directory from which Gunicorn happens to be launched. + +Create or validate directories at startup for: + +- Model files +- Known face images +- Session/event logs +- Uploaded media, if used + +Fail with a clear error identifying the missing file when a required model is unavailable. + +### 3. Fix dependency declarations + +Create a Linux-compatible dependency file. Remove the absolute Windows dlib wheel reference. Choose one supported Linux installation strategy: + +- Build dlib in the image using system build tools; or +- Use a compatible Linux wheel from a controlled source. + +Do not silently omit dlib. Pin compatible versions of Python, NumPy, OpenCV, MediaPipe, dlib, face-recognition, PyTorch, torchvision, and Ultralytics. + +Keep the dependency set as small as practical. Avoid installing both `opencv-python` and `opencv-contrib-python` unless the application demonstrably requires both; LBPH face recognition requires the contrib build. + +Install the CPU-compatible PyTorch packages by default. Document the optional NVIDIA/CUDA image or installation path separately if the selected PyTorch version supports the laptop GPU. + +## Phase 2: Model and inference handling + +### 1. Define required assets + +Document the purpose, expected location, and source of every model: + +- `shape_predictor_68_face_landmarks.dat` +- `yolov8n.pt` +- `best_20.pt` +- `TrainingImageLabel/Trainner.yml` + +Do not invent or fabricate missing model files. Add a setup script or documented command that verifies their presence and reports exactly which files are missing. + +### 2. Make device selection explicit + +Add a small inference utility that selects: + +- CUDA when `PROCTOR_INFERENCE_DEVICE=cuda` and CUDA is available; +- CPU when `PROCTOR_INFERENCE_DEVICE=cpu`; +- CUDA when available, otherwise CPU, when set to `auto`. + +Pass the selected device into Ultralytics inference. Ensure the application does not crash merely because CUDA is unavailable. + +Log the selected device at startup, including the GPU name when available. + +Because the target GPU has approximately 2 GB of VRAM, use the smallest practical models, avoid unnecessary image sizes, and expose inference image size, confidence, and frame-rate settings through configuration. + +### 3. Avoid loading models per request + +Load models once per inference worker where safe. Do not load YOLO models inside every video request. Add clear startup errors for malformed or missing model files. + +## Phase 3: Browser-based capture and session isolation + +This is required for real remote proctoring. + +### 1. Move webcam capture to the browser + +Use browser `navigator.mediaDevices.getUserMedia()` from the exam page. The page must request camera and microphone permissions from the student. Do not use `cv2.VideoCapture(0)` for remote students. + +Implement one of these transport approaches: + +- Preferred initial implementation: capture periodic JPEG frames in the browser and POST them to an authenticated Flask endpoint. +- More advanced implementation: use a WebSocket connection for frames and results. + +The first implementation should prioritize correctness and modest bandwidth over maximum frame rate. Make frame interval, JPEG quality, and maximum dimensions configurable. + +### 2. Add authenticated session endpoints + +Implement endpoints for: + +- Starting a proctoring session +- Uploading a frame +- Returning the latest detection result +- Stopping a session +- Reporting session errors + +Ensure that a student can only access their own session. Validate file types, frame sizes, request rates, and session ownership. + +### 3. Replace global detection state + +Create a session-scoped state object keyed by the authenticated exam/session ID. Detection results must not leak between users. Do not store per-student state in module globals. + +Use a concurrency-safe design appropriate for the chosen deployment. If Gunicorn has multiple workers, do not rely on in-process state unless sticky sessions and documented limitations are intentional. Prefer a shared store or a single inference worker with a queue for the initial deployment. + +### 4. Handle audio realistically + +Either: + +- Capture audio levels in the browser and send aggregate measurements; or +- Explicitly defer audio detection and disable it cleanly when no browser audio pipeline is implemented. + +Do not attempt to access the Linux server microphone for remote students. + +### 5. Website blocking + +Do not claim that the server can edit a student's hosts file. Replace the existing feature with one of the following documented options: + +- Remove it from the remote deployment and label it unsupported. +- Implement a separately installed client agent/browser extension, with explicit user consent and security documentation. +- Restrict it to a local demonstration mode. + +## Phase 4: Docker implementation + +Add: + +- `Dockerfile` +- `.dockerignore` +- `docker-compose.yml` +- `.env.example` +- A model verification/setup script +- A production WSGI entrypoint if needed + +### Dockerfile requirements + +Use a Python 3.11 Linux base compatible with the pinned dependencies. Install only required OS packages, including libraries needed by OpenCV, dlib, MediaPipe, audio support if retained, and MariaDB client connectivity. + +Run the application as a non-root user where practical. Set a working directory. Copy dependency files separately from application source to improve build caching. Do not copy local virtual environments, `.git`, secrets, or unnecessary large files. + +The container must listen on `0.0.0.0`, not only `127.0.0.1`. + +### Compose requirements + +Define at least: + +- `app` service +- `db` service using MariaDB or MySQL + +Configure: + +- Database health checks +- App startup after database readiness +- Database persistent volume +- Application data/model volume +- Published application port bound appropriately +- Environment variables from `.env` +- Restart policy suitable for the home server + +Do not publish the database port publicly unless required for administration. + +### GPU profile + +Provide a documented optional GPU launch path, for example a Compose profile or override file. It must use the NVIDIA container runtime only when explicitly requested. The normal CPU path must remain functional. + +Document these host checks: + +```bash +nvidia-smi +docker run --rm --gpus all nvidia-smi +``` + +If the old GPU is unsupported by the selected PyTorch/CUDA build, document the CPU fallback instead of forcing an incompatible CUDA configuration. + +## Phase 5: Database and production startup + +Use `PROCTOR_DATABASE_URI` to connect the Flask container to the Compose database service. Do not use `localhost` for the database from inside the app container. + +Document how to: + +1. Start a clean database. +2. Import `proctoring.sql`. +3. Create or rotate the application secret. +4. Back up the database. +5. Preserve uploaded face images. + +Replace the development startup path with Gunicorn or another production WSGI server. Keep `run.py` usable for local development, but ensure production Compose does not enable Flask debug mode. + +Add a lightweight `/health` endpoint that checks application readiness and, where appropriate, database connectivity without exposing secrets or personal data. + +## Phase 6: Ionos, Tailscale, and HTTPS documentation + +Add deployment documentation covering: + +1. Installing Docker and Docker Compose on the Linux laptop. +2. Installing and authenticating Tailscale on both servers. +3. Restricting Tailscale access with ACLs where possible. +4. Configuring the Ionos reverse proxy to forward to the laptop's Tailscale address and application port. +5. Configuring DNS for the public hostname. +6. Terminating HTTPS at the Ionos proxy or Caddy/Nginx. +7. Forwarding WebSocket traffic if the WebSocket transport is used. +8. Ensuring the public URL is HTTPS so browsers permit camera and microphone access. +9. Keeping MariaDB private. +10. Monitoring logs and restarting failed containers. + +Do not expose the Docker daemon, MariaDB, or an unauthenticated inference endpoint to the public internet. + +## Phase 7: Testing and acceptance criteria + +Add or update automated tests for: + +- Configuration loading +- CPU device selection +- CUDA-unavailable fallback +- Missing-model diagnostics +- Health endpoint +- Database connectivity +- Session ownership and authorization +- Frame upload validation +- Independent state for two simultaneous sessions + +Perform these manual checks: + +1. Build from a clean checkout on Linux. +2. Start the CPU Compose configuration. +3. Import the database schema. +4. Register and authenticate a user. +5. Capture a face image from a browser. +6. Start an exam from a different browser or machine. +7. Confirm that the student's browser camera is used, not the server webcam. +8. Confirm that detection results belong to the correct student session. +9. Confirm that the app remains usable when CUDA is unavailable. +10. Test the GPU profile if the old laptop supports it. +11. Stop and restart the stack and verify that database data and uploaded images persist. +12. Access the application through the public HTTPS hostname over Tailscale/Ionos. +13. Verify that no secret, database port, debug traceback, or internal Tailscale address is exposed publicly. + +The work is complete only when a fresh Linux server can follow the README and `.env.example` instructions to start the application without installing the project's Python dependencies directly on the host. + +## Implementation notes + +This repository now includes the Docker/Compose CPU deployment, an explicit optional `app-gpu` service, environment-backed paths and secrets, a MariaDB health check, Gunicorn startup, `/health`, and `scripts/verify_models.py`. Browser frame uploads use authenticated, owner-scoped session IDs; the server-side webcam, server microphone, Windows hosts-file editing, and legacy process-global detection loop are not used by remote deployment. The initial frame processor provides a CPU-safe face-count baseline while model-backed signals are installed and integrated behind the same session boundary. + +Operationally, keep the Compose app bound to the home server's private interface and expose only the HTTPS reverse proxy through Ionos/Tailscale. Back up the MariaDB and `app_data` volumes, rotate secrets, and define biometric-data retention before accepting real exams. + +## Documentation deliverables + +Update or add: + +- `README.md` with quick start, local development, CPU deployment, GPU deployment, model setup, and troubleshooting. +- `.env.example` with safe placeholder values only. +- `DEPLOYMENT_IMPLEMENTATION.md` with the implementation plan and final operational notes. +- A model inventory document or section describing licensing, source, expected checksum, and storage location. +- A security/privacy section explaining webcam permissions, face data storage, session data, retention, and public exposure. + +## Final git handoff + +Before handing off: + +1. Run tests and the documented build commands. +2. Check `git diff` and `git status`. +3. Confirm no secrets or unintended model binaries are staged. +4. Commit only the deployment-related work to the feature branch. +5. Report the branch name, commit hash, files changed, tests run, and any remaining limitations. +6. Leave `main` unchanged. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..893c231 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake libopenblas-dev liblapack-dev libx11-6 libgl1 \ + libglib2.0-0 libsm6 libxext6 libxrender1 default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . . +RUN useradd --create-home --uid 10001 proctor \ + && mkdir -p /app/data /app/models \ + && chown -R proctor:proctor /app +USER proctor + +EXPOSE 8000 +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--timeout", "120", "run:app"] diff --git a/MODEL_INVENTORY.md b/MODEL_INVENTORY.md new file mode 100644 index 0000000..2214dc6 --- /dev/null +++ b/MODEL_INVENTORY.md @@ -0,0 +1,12 @@ +# Model inventory + +Model files are not downloaded or committed by deployment automation. Obtain each asset from its approved source, review its license, and record a SHA-256 checksum in deployment records. + +| Asset | Purpose | Location | Source/licensing note | +|---|---|---|---| +| `shape_predictor_68_face_landmarks.dat` | dlib facial landmarks | `models/` | dlib model release; review its license | +| `yolov8n.pt` | YOLO object detection | `models/` | Ultralytics-compatible source; review AGPL/commercial terms | +| `best_20.pt` | Custom liveness model | `models/` | Project-owner-approved release | +| `TrainingImageLabel/Trainner.yml` | Legacy LBPH face recognizer | `models/TrainingImageLabel/` | Optional; generated by enrollment and not required by browser-frame recognition | + +Run `python scripts/verify_models.py`, or the Docker equivalent, before startup. It checks presence only and never fabricates missing files. diff --git a/README.md b/README.md index f11ee61..7e4fb35 100644 --- a/README.md +++ b/README.md @@ -1,181 +1,65 @@ -# Proctor — AI-Powered Online Exam Proctoring System - -[![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) -[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![CI](https://github.com/mudabs/Proctor/actions/workflows/ci.yml/badge.svg)](https://github.com/mudabs/Proctor/actions/workflows/ci.yml) - -A Flask web application that monitors students during online exams using real-time computer vision and machine learning to detect cheating behaviour. - -## Features - -### Proctoring & Detection -- **Head pose estimation** — detects when a student looks left, right, up, or down using MediaPipe Face Mesh -- **Face recognition** — verifies student identity against pre-registered face encodings -- **Liveness detection** — distinguishes real faces from photos/spoofs using a custom YOLO model -- **Object detection** — flags unauthorised items (cell phones, books, multiple people) via YOLOv8 -- **Mouth/lip detection** — detects open mouth (possible whispering) using dlib 68-point facial landmarks -- **Noise detection** — monitors ambient audio via microphone with `sounddevice` -- **Cheating score** — aggregates all signals into a real-time cheating probability score - -### Exam Management -- Create and manage courses, exams, and quizzes -- Add multiple-choice questions with correct answers and point values -- Set exam date, duration, and proctoring mode -- Timed quiz sessions with automatic submission -- Student result viewing with graphs - -### User Management -- Role-based access: Admin, Lecturer, Student -- Student registration with face image capture (used for identity verification) -- User profile editing and image re-capture -- Blacklist/block websites during exams -- Course enrollment and management - -## Tech Stack - -| Layer | Technology | -|---|---| -| Backend | Python 3.11, Flask 3.0 | -| Database | MySQL / MariaDB via SQLAlchemy | -| Computer Vision | OpenCV, MediaPipe, dlib, face_recognition | -| Object Detection | YOLOv8 (Ultralytics) | -| Frontend | Jinja2 templates, Bootstrap 5, jQuery | -| Audio | sounddevice | -| ML / DL | PyTorch, JAX | - -## Prerequisites - -- Python 3.11 -- MySQL / MariaDB running locally -- A webcam -- dlib wheel (included in `models/`) — required because building from source needs CMake and a C++ compiler - -> **Hardware disclaimer:** Proctoring uses PyTorch, YOLO, MediaPipe, dlib, and -> face recognition. A CUDA-enabled NVIDIA GPU is strongly recommended and may -> be required for reliable real-time performance. CPU-only systems may run very -> slowly or fail to run the full detection pipeline; CPU-only execution is not -> guaranteed. - -## Setup - -### 1. Clone the repository +# Proctor -```bash -git clone https://github.com/mudabs/Proctor.git -cd Proctor -``` - -### 2. Create and activate a virtual environment +Flask-based exam management and browser-assisted proctoring. -Windows PowerShell: +## Docker quick start on Linux -```powershell -py -3.11 -m venv venv -.\venv\Scripts\Activate.ps1 -``` - -macOS/Linux: +1. Install Docker Engine and the Compose plugin. +2. Copy `.env.example` to `.env` and replace every placeholder secret. +3. Put the assets listed in [MODEL_INVENTORY.md](MODEL_INVENTORY.md) in `models/`. +4. Build, verify, and start: ```bash -python3.11 -m venv venv -source venv/bin/activate +docker compose build +docker compose run --rm app python scripts/verify_models.py +docker compose up -d ``` -### 3. Create the database - -Import the provided SQL dump: +For an empty database volume, import the schema with the credentials from `.env`: ```bash -mysql -u root proctoring < proctoring.sql +docker compose exec -T db mariadb -u root -p"$MARIADB_ROOT_PASSWORD" proctoring < proctoring.sql ``` -Or create the database first if it does not exist: +The app listens on `127.0.0.1:8000` by default. Use an HTTPS reverse proxy in front of it and never publish MariaDB. Readiness is available at `/health`; logs are available with `docker compose logs -f app`. -```sql -CREATE DATABASE proctoring; -``` - -### 4. Download required model files - -Place the following files in the `models/` directory (not included in the repo due to size): +## GPU deployment -| File | Purpose | -|---|---| -| `shape_predictor_68_face_landmarks.dat` | dlib facial landmarks | -| `yolov8n.pt` | YOLOv8 nano — object detection | -| `best_20.pt` | Custom liveness detection model | -| `TrainingImageLabel/Trainner.yml` | LBPH face recogniser (generated after capturing student images) | - -### 5. Install dependencies +The default image uses CPU-safe PyTorch and falls back to CPU when CUDA is unavailable. For an explicit NVIDIA run, install the NVIDIA Container Toolkit and check: ```bash -python -m pip install --upgrade pip -pip install models/dlib-19.24.1-cp311-cp311-win_amd64.whl -pip install -r requirements.txt +nvidia-smi +docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi +docker compose run --rm app-gpu python scripts/verify_models.py +docker compose up -d db app-gpu ``` -> The `dlib` wheel must be installed **before** the rest of the requirements because `face-recognition` depends on it. +Use CPU if the older GPU cannot run the pinned CUDA/PyTorch stack. -### 6. Configure the application +## Development and operations -Set the database URI and a secret key through environment variables. Windows -PowerShell: +For local development, use Python 3.11 and a virtual environment, install `requirements.txt`, configure `PROCTOR_DATABASE_URI` and `PROCTOR_SECRET_KEY`, then run `python run.py`. The optional legacy face-encoding dependencies are in `requirements-vision.txt`. Production uses Gunicorn through Compose and never enables Flask debug mode. -```powershell -$env:PROCTOR_DATABASE_URI = "mysql://root:your_password@localhost/proctoring" -$env:PROCTOR_SECRET_KEY = "replace-with-a-long-random-secret" -``` +The `app_data` volume stores runtime uploads and logs. Back up it and the database volume. Rotate `PROCTOR_SECRET_KEY`, restrict storage permissions, and define a retention period for biometric data. -macOS/Linux: +The exam page requests camera and microphone access from the student's browser, captures periodic JPEG frames, and sends them to an authenticated session endpoint. The server never opens a webcam or microphone. Browser audio aggregation is currently unavailable. Website blocking is unsupported remotely because a server cannot edit a student's hosts file; it would require a separately installed, consented client agent or extension. -```bash -export PROCTOR_DATABASE_URI='mysql://root:your_password@localhost/proctoring' -export PROCTOR_SECRET_KEY='replace-with-a-long-random-secret' -``` - -The application defaults to `mysql://root:@localhost/proctoring` if -`PROCTOR_DATABASE_URI` is not set. - -### 7. Run - -```bash -python run.py -``` +## Tailscale, Ionos, and HTTPS -Open `http://localhost:5000` in a browser. Keep the terminal running while -using the application. To stop the server, press `Ctrl+C`. - -## Project Structure - -The runtime entrypoint is `run.py`; application features are organized under -the `proctor/` package. `app.py` remains as a compatibility module while the -remaining shared infrastructure is migrated. - -``` -app.py # Flask composition module and compatibility routes -run.py # Development/production startup entrypoint -proctor/ # Modular auth, courses, admin, and proctoring code -proctoring.sql # Database schema and seed data -requirements.txt # Python dependencies -models/ # ML models and face detection cascades -static/ - css/ # Bootstrap and custom styles - js/ # jQuery, Bootstrap, Highcharts - images/ - known_images/ # Pre-registered student face images (populated at runtime) - webcamjs/ # Webcam capture library -templates/ # Jinja2 HTML templates -``` +Install and authenticate Tailscale on both servers and restrict the tailnet with ACLs. Configure the Ionos reverse proxy to forward the public HTTPS hostname to the home server's Tailscale address and port 8000. Keep the Compose bind address private, point DNS to Ionos, terminate TLS at Ionos or Caddy/Nginx, and use an HTTPS public URL so browsers allow camera and microphone access. This first implementation uses HTTP frame uploads, not WebSockets. -## First-time Use +## CI/CD deployment -1. Register an admin account and log in. -2. Assign the **Admin** role via the Roles page. -3. Create courses and assign lecturers. -4. Have students register — they will be prompted to capture face images. -5. Create a quiz with proctoring enabled; students take the quiz while the webcam stream is monitored. +The `Deploy Proctor` workflow deploys pushes to `main` through the IONOS gateway +to `/opt/apps/proctor` on `vps01`. It preserves the remote `.env`, model files, +and Docker volumes, then rebuilds the app, verifies the model assets, and checks +`/health` before reporting success. Configure the repository secrets +`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_KNOWN_HOSTS`, and +`VPS01_KNOWN_HOSTS`, plus the optional repository variable `PROCTOR_APP_DIR`. -## Notes +## Troubleshooting -- The hosts-file blacklist feature (`C:\Windows\System32\drivers\etc\hosts`) requires the application to be run with administrator privileges on Windows. -- Face images captured during registration are saved to `static/images/known_images/` and are used for identity verification during proctored exams. +- If `/health` is degraded, wait for MariaDB and ensure the URI uses host `db`, not `localhost`. +- If model verification reports missing assets, copy the exact files into the configured model directory. +- If camera permission fails, use HTTPS or localhost and grant browser permission. +- Set `PROCTOR_INFERENCE_DEVICE=cpu` when CUDA is unavailable. diff --git a/app.py b/app.py index 06397fa..60e4872 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta import string -from flask import Flask, flash, render_template, session, request +from flask import Flask, flash, render_template, session, request, jsonify, send_from_directory import numpy as np import os from proctor.extensions import bootstrap, db @@ -9,10 +9,10 @@ from proctor.config import Config from proctor.admin import admin import json -import sounddevice as sd import numpy as np import time as timeSound import matplotlib.pyplot as plt +from sqlalchemy import text from werkzeug.security import generate_password_hash @@ -67,6 +67,15 @@ def configure_app_and_access_session(app, session): app.config.from_object(Config) +for _directory in ( + app.config["PROCTOR_MODEL_DIR"], + app.config["PROCTOR_DATA_DIR"], + app.config["PROCTOR_DATA_DIR"] / "known_images", + app.config["PROCTOR_DATA_DIR"] / "logs", + app.config["PROCTOR_DATA_DIR"] / "uploads", +): + _directory.mkdir(parents=True, exist_ok=True) + db.init_app(app) bootstrap.init_app(app) app.register_blueprint(auth, name="auth") @@ -87,7 +96,6 @@ def check_sound(indata, frames, callback_time, status): global noise volume_norm = np.linalg.norm(indata) * 2 noise = volume_norm/2 - state.noise = noise if volume_norm > soundThreshold: print(volume_norm) with open('sound.txt', 'a') as file: @@ -120,10 +128,8 @@ def drawSoundGraph(): clearTextFile("./sound.txt") def detectSound(): - # Start sound capture - with sd.InputStream(callback=check_sound): - while not state.stop_detection: - timeSound.sleep(1) + """Legacy compatibility hook; remote audio is browser-owned.""" + return None # /////////////////////Sound Detection @@ -356,6 +362,28 @@ def get_images_profile(): app.register_blueprint(proctoring, name="proctoring") +@app.get('/favicon.ico') +def favicon(): + return '', 204 + + +@app.get('/media/known_images/') +def known_image(filename): + return send_from_directory(app.config['PROCTOR_DATA_DIR'] / 'known_images', filename) + + +@app.get('/health') +def health(): + """Readiness probe without exposing database credentials or personal data.""" + database = "ok" + try: + db.session.execute(text("SELECT 1")) + except Exception: + database = "unavailable" + status = 200 if database == "ok" else 503 + return jsonify({"status": "ok" if status == 200 else "degraded", "database": database}), status + + def _add_legacy_endpoint_aliases(): """Keep existing bare endpoint names working during the package migration.""" for rule in list(app.url_map.iter_rules()): @@ -379,4 +407,4 @@ def _add_legacy_endpoint_aliases(): if __name__ == '__main__': with app.app_context(): # Create the application context db.create_all() # Now it can access the application context - app.run(debug=True) + app.run(debug=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a678fd3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +services: + db: + image: mariadb:11.4 + restart: unless-stopped + environment: + MARIADB_DATABASE: ${MARIADB_DATABASE:-proctoring} + MARIADB_USER: ${MARIADB_USER:-proctor} + MARIADB_PASSWORD: ${MARIADB_PASSWORD:?Set MARIADB_PASSWORD in .env} + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?Set MARIADB_ROOT_PASSWORD in .env} + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$${MARIADB_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 12 + + app: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + env_file: .env + environment: + PROCTOR_DATABASE_URI: ${PROCTOR_DATABASE_URI:-mysql+pymysql://proctor:${MARIADB_PASSWORD}@db:3306/proctoring} + PROCTOR_MODEL_DIR: /app/models + PROCTOR_DATA_DIR: /app/data + ports: + - "${PROCTOR_BIND_ADDRESS:-127.0.0.1}:${PROCTOR_PORT:-8000}:8000" + volumes: + - ./models:/app/models:ro + - app_data:/app/data + + app-gpu: + profiles: ["gpu"] + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + env_file: .env + environment: + PROCTOR_DATABASE_URI: ${PROCTOR_DATABASE_URI:-mysql+pymysql://proctor:${MARIADB_PASSWORD}@db:3306/proctoring} + PROCTOR_MODEL_DIR: /app/models + PROCTOR_DATA_DIR: /app/data + PROCTOR_INFERENCE_DEVICE: cuda + ports: + - "${PROCTOR_BIND_ADDRESS:-127.0.0.1}:${PROCTOR_PORT:-8000}:8000" + volumes: + - ./models:/app/models:ro + - app_data:/app/data + gpus: all + +volumes: + db_data: + app_data: diff --git a/proctor/admin/routes.py b/proctor/admin/routes.py index f4db114..3ce9abe 100644 --- a/proctor/admin/routes.py +++ b/proctor/admin/routes.py @@ -12,31 +12,17 @@ ) from . import admin -hosts_path = r"C:\Windows\System32\drivers\etc\hosts" name = "" image_count = 0 capture_enabled = False def block_websites(): - blocked_websites = [blocked.url for blocked in Blocked.query.all()] - now = datetime.now() - with open(hosts_path, "a") as hosts_file: - hosts_file.write("\n\n") - for website in blocked_websites: - hosts_file.write("127.0.0.1 {}\n".format(website)) - hosts_file.write("127.0.0.1 www.{}\n".format(website)) - print("Websites blocked successfully at", now) + return False def unblock_websites(): - with open(hosts_path, "r") as hosts_file: - lines = hosts_file.readlines() - with open(hosts_path, "w") as hosts_file: - for line in lines: - if not any(website in line for website in [blocked.url for blocked in Blocked.query.all()]): - hosts_file.write(line) - print("Websites unblocked successfully") + return False @admin.route('/viewLecturers') diff --git a/proctor/auth/routes.py b/proctor/auth/routes.py index 46fd553..ee4e623 100644 --- a/proctor/auth/routes.py +++ b/proctor/auth/routes.py @@ -3,7 +3,8 @@ import base64 import os -from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for +from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, session, url_for +from werkzeug.utils import secure_filename from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import NoResultFound from werkzeug.security import check_password_hash, generate_password_hash @@ -81,11 +82,17 @@ def logout(): def register(): # global capture_enabled, name, id, image_count if request.method == 'POST': - name = request.form['name'] - email = request.form['email'] - password = request.form['password'] - userType = request.form['userType'] + name = request.form.get('name', '').strip() + email = request.form.get('email', '').strip().lower() + password = request.form.get('password', '') + userType = request.form.get('userType', 'student') imageStatus = "Registered" + if not name or not email or not password: + return render_template('register.html', error="Name, email, and password are required.", form=request.form) + existing = User.query.filter((User.name == name) | (User.email == email)).first() + if existing: + field = "email" if existing.email == email else "name" + return render_template('register.html', error=f"That {field} is already registered. Please use another.", form=request.form) try: new_user = User(name=name, email=email, password=generate_password_hash(password), userType=userType,imageStatus=imageStatus) db.session.add(new_user) @@ -94,34 +101,26 @@ def register(): return redirect(url_for('home')) except IntegrityError: db.session.rollback() - return render_template('register.html', error="User already exists.") + return render_template('register.html', error="That name or email is already registered.", form=request.form) return render_template('register.html', error=None) @auth.route('/captureImage', methods=['GET','POST']) def captureImage(): - formData = request.form - message = '' - - if request.form["name"] : - # Check if the request came from the "Capture Image" button - if 'imageDataURL' in formData: - print("Hello") - imageData = formData['imageDataURL'].split(',')[1] - imageName = formData['name'] + '.jpg' - imagePath = os.path.join('static/images/known_images', imageName) - - # Check if the file already exists - if check_names(request.form['name'] == False): - with open(os.path.join('static/images/known_images', imageName), 'wb') as f: - f.write(base64.decodebytes(imageData.encode())) - message = 'User image captured successfully' - return jsonify({'message': message}) - else: - return jsonify({'message': 'User image not captured'}) - else: - message = "Input value for name" - - return jsonify({'message': message}) + name = secure_filename(request.form.get('name', '').strip()) + data_url = request.form.get('imageDataURL', '') + if not name or ',' not in data_url: + return jsonify({'message': 'A name and captured image are required'}), 400 + try: + image_data = base64.b64decode(data_url.split(',', 1)[1], validate=True) + except (ValueError, TypeError): + return jsonify({'message': 'The captured image is invalid'}), 400 + if len(image_data) > current_app.config['MAX_CONTENT_LENGTH']: + return jsonify({'message': 'The captured image is too large'}), 413 + image_dir = current_app.config['PROCTOR_DATA_DIR'] / 'known_images' + image_dir.mkdir(parents=True, exist_ok=True) + image_path = image_dir / f'{name}.jpg' + image_path.write_bytes(image_data) + return jsonify({'message': 'User image captured successfully'}) @auth.route('/recaptureImage', methods=['GET','POST']) def recaptureImage(): diff --git a/proctor/config.py b/proctor/config.py index 62f5263..3f21ae1 100644 --- a/proctor/config.py +++ b/proctor/config.py @@ -1,12 +1,44 @@ -"""Environment-backed application configuration.""" +"""Environment-backed configuration and portable application paths.""" import os +from pathlib import Path + + +APP_ROOT = Path(__file__).resolve().parent.parent + + +def _path_from_env(name, default): + value = os.getenv(name) + path = Path(value).expanduser() if value else APP_ROOT / default + return path if path.is_absolute() else APP_ROOT / path + + +def _max_upload_size(value): + try: + return int(value) + except (TypeError, ValueError): + return 5 * 1024 * 1024 class Config: - SECRET_KEY = os.getenv("PROCTOR_SECRET_KEY", "development-only-change-me") + SECRET_KEY = os.getenv("PROCTOR_SECRET_KEY", "change-me-in-production") SQLALCHEMY_DATABASE_URI = os.getenv( "PROCTOR_DATABASE_URI", - "mysql://root:@localhost/proctoring", + "mysql+pymysql://proctor:proctor@db:3306/proctoring", ) SQLALCHEMY_TRACK_MODIFICATIONS = False + PROCTOR_MODEL_DIR = _path_from_env("PROCTOR_MODEL_DIR", "models") + PROCTOR_DATA_DIR = _path_from_env("PROCTOR_DATA_DIR", "data") + PROCTOR_INFERENCE_DEVICE = os.getenv("PROCTOR_INFERENCE_DEVICE", "auto").lower() + PROCTOR_LOG_LEVEL = os.getenv("PROCTOR_LOG_LEVEL", "INFO").upper() + MAX_CONTENT_LENGTH = _max_upload_size(os.getenv("PROCTOR_MAX_UPLOAD_SIZE")) + PROCTOR_ALLOWED_HOSTS = { + host.strip().lower() + for host in os.getenv("PROCTOR_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + if host.strip() + } + PROCTOR_FRAME_INTERVAL_MS = int(os.getenv("PROCTOR_FRAME_INTERVAL_MS", "1000")) + PROCTOR_FRAME_JPEG_QUALITY = int(os.getenv("PROCTOR_FRAME_JPEG_QUALITY", "75")) + PROCTOR_FRAME_MAX_WIDTH = int(os.getenv("PROCTOR_FRAME_MAX_WIDTH", "1280")) + PROCTOR_FRAME_MAX_HEIGHT = int(os.getenv("PROCTOR_FRAME_MAX_HEIGHT", "720")) + PROCTOR_ENABLE_AUDIO = os.getenv("PROCTOR_ENABLE_AUDIO", "true").lower() == "true" diff --git a/proctor/courses/routes.py b/proctor/courses/routes.py index 6a0fc75..9ce2a29 100644 --- a/proctor/courses/routes.py +++ b/proctor/courses/routes.py @@ -2,7 +2,6 @@ import random import string -import threading from datetime import datetime from flask import flash, jsonify, redirect, render_template, request, session, url_for @@ -314,18 +313,8 @@ def takeQuiz(quizId): now_with_timezone = datetime.now(user_timezone) - # Reset shared proctoring state for each quiz attempt. - state.stop_detection = False - state.cheating_scores.clear() - - # Start sound detection in a separate thread - sound_thread = threading.Thread(target=detectSound) - sound_thread.start() - - if now_with_timezone > session['expiration_time']: # Handle quiz expiration (e.g., redirect to a different page, display a message) - state.stop_detection = True drawGraph() drawSoundGraph() return redirect(url_for('home')) # Example redirect @@ -337,7 +326,6 @@ def takeQuiz(quizId): @courses.route('/quizCompletion', methods=['POST']) def quizCompletion(): global average_threshold - state.stop_detection = True quizName ='' courseName ='' if request.method == 'POST': @@ -399,16 +387,12 @@ def quizCompletion(): drawSoundGraph() # Calculate average cheating threshold (if any scores exist) - if state.cheating_scores: - average_threshold = sum(state.cheating_scores) / len(state.cheating_scores) - if (average_threshold < 0.6): - average_threshold = average_threshold - 0.3 - print(f"Average cheating threshold: {average_threshold}") - now = datetime.now() - - my_data = ProctorSession(session['user_id'],average_threshold,now) - db.session.add(my_data) - db.session.commit() + average_threshold = 0.0 + print(f"Average cheating threshold: {average_threshold}") + now = datetime.now() + my_data = ProctorSession(session['user_id'], average_threshold, now) + db.session.add(my_data) + db.session.commit() diff --git a/proctor/inference.py b/proctor/inference.py new file mode 100644 index 0000000..7ee7053 --- /dev/null +++ b/proctor/inference.py @@ -0,0 +1,63 @@ +"""Portable inference configuration and model asset validation.""" + +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def select_device(requested="auto"): + """Return a PyTorch/Ultralytics device string without requiring CUDA.""" + requested = (requested or "auto").lower() + if requested not in {"auto", "cpu", "cuda"}: + raise ValueError("PROCTOR_INFERENCE_DEVICE must be auto, cpu, or cuda") + try: + import torch + except ImportError: + if requested == "cuda": + raise RuntimeError("CUDA was requested but PyTorch is not installed") + return "cpu" + available = bool(torch.cuda.is_available()) + if requested == "cuda" and not available: + raise RuntimeError("CUDA was requested but is unavailable in this container") + if requested == "cpu" or not available: + return "cpu" + return "cuda:0" + + +def log_device(requested="auto"): + device = select_device(requested) + gpu_name = None + try: + import torch + if device.startswith("cuda"): + gpu_name = torch.cuda.get_device_name(0) + except (ImportError, RuntimeError): + pass + logger.info("Proctor inference device: %s%s", device, f" ({gpu_name})" if gpu_name else "") + return device + + +MODEL_FILES = { + "shape_predictor_68_face_landmarks.dat": "dlib facial landmarks", + "yolov8n.pt": "YOLO object detection", + "best_20.pt": "custom liveness detection", +} + +OPTIONAL_MODEL_FILES = { + "TrainingImageLabel/Trainner.yml": "LBPH face recognition", +} + + +def missing_models(model_dir): + model_dir = Path(model_dir) + return [str(model_dir / name) for name in MODEL_FILES if not (model_dir / name).is_file()] + + +def require_models(model_dir, required=None): + model_dir = Path(model_dir) + required = required or MODEL_FILES + missing = [str(model_dir / name) for name in required if not (model_dir / name).is_file()] + if missing: + raise FileNotFoundError("Required model files are missing: " + ", ".join(missing)) + return True diff --git a/proctor/proctoring/detection.py b/proctor/proctoring/detection.py index 3aa8c24..594bbd0 100644 --- a/proctor/proctoring/detection.py +++ b/proctor/proctoring/detection.py @@ -1,424 +1,185 @@ -"""Computer-vision detection pipeline and shared detection state.""" +"""Browser-frame inference with lazy, worker-local model loading.""" + +import logging +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock import cv2 -import dlib -import face_recognition -import mediapipe as mp import numpy as np -import os -from datetime import datetime -from ultralytics import YOLO - -from app import app -from proctor import state - -detector = dlib.get_frontal_face_detector() -predictor = dlib.shape_predictor("./models/shape_predictor_68_face_landmarks.dat") - -cheat = 0 -lips = "" -direction = "" -cellphone = "" -identity = "" -liveness = "" -numPeople = 0 -numFaces = 0 -noise = 0 -face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml") - -# Specify the path to save the images -save_path = os.path.join(app.root_path, 'static','images') # Ensure path is relative to app root -os.makedirs(save_path, exist_ok=True) - -# Define the path to the hosts file -hosts_path = r"C:\Windows\System32\drivers\etc\hosts" - - -# HeadPose Estimation -mp_face_mesh = mp.solutions.face_mesh -face_mesh = mp_face_mesh.FaceMesh(min_detection_confidence=0.5, min_tracking_confidence=0.5) -mp_drawing = mp.solutions.drawing_utils -drawing_spec = mp_drawing.DrawingSpec(thickness=1, circle_radius=1) - -known_faces_dir = "./static/images/known_images/" -# Load all known faces and their encodings -known_face_encodings = [] -known_face_names = [] - - -def write_exam_session(identity, cellphone, direction, liveness, lips, num_people, num_faces): - data = ( - f"{datetime.now().time()},{identity},{cellphone},{direction}," - f"{liveness}, {lips}, {num_people},{num_faces},{cheat}\n" - ) - with open("./session.txt", "a") as session_file: - session_file.write(data) - -def load_known_faces(): - global known_face_encodings, known_face_names - for filename in os.listdir(known_faces_dir): - if filename.endswith(".jpg") or filename.endswith(".png") or filename.endswith(".JPG"): - image = face_recognition.load_image_file(os.path.join(known_faces_dir, filename)) - face_encoding = face_recognition.face_encodings(image)[0] - known_face_encodings.append(face_encoding) - known_face_names.append(os.path.splitext(os.path.basename(filename))[0]) - - -def video_detection(): - global cheat - global lips - global direction - global cellphone - global identity - global liveness - global numPeople - global numFaces - - - confidence = 0.5 - cap = cv2.VideoCapture(0) - frame_width = int(cap.get(3)) - frame_height = int(cap.get(4)) - - # Load both YOLO models - model_object = YOLO("./models/yolov8n.pt") - # model_liveness = YOLO("./models/best_20.pt") # Path to your liveness detection model l_version_1_300.pt - model_liveness = YOLO("./models/best_20.pt") - - # Face Recognition - recognizer = cv2.face.LBPHFaceRecognizer_create() # cv2.createLBPHFaceRecognizer() - recognizer.read("./models/TrainingImageLabel/Trainner.yml") - harcascadePath = "./models/haarcascade_frontalface_default.xml" - faceCascade = cv2.CascadeClassifier(harcascadePath) - - - # Face Recognition Arrays - - - # Face Recognition Arrays - - classNames_object = ["person", "bicycle", "car", "motorbike", "aeroplane", "bus", "train", "truck", "boat", - "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", - "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella", - "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", "kite", "baseball bat", - "baseball glove", "skateboard", "surfboard", "tennis racket", "bottle", "wine glass", "cup", - "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange", "broccoli", - "carrot", "hot dog", "pizza", "donut", "cake", "chair", "sofa", "pottedplant", "bed", - "diningtable", "toilet", "tvmonitor", "laptop", "mouse", "remote", "keyboard", "cell phone", - "microwave", "oven", "toaster", "sink", "refrigerator", "book", "clock", "vase", "scissors", - "teddy bear", "hair drier", "toothbrush" - ] - classNames_liveness = ["real", "fake"] - - # Variables for people counting - centroid_list = [] - count = 0 - - while True: - success, img = cap.read() - -# Head Pose Estimation Opening - # Flip the image for a selfie-view display - img = cv2.cvtColor(cv2.flip(img, 1), cv2.COLOR_BGR2RGB) - - -# Face detection - gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # Convert to grayscale - faces = faceCascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5) - - # Update person count based on detections - centroid_list.clear() - for (x, y, w, h) in faces: - # Calculate centroid of the bounding box - centroid_x = int(x + (w / 2)) - centroid_y = int(y + (h / 2)) - centroid_list.append((centroid_x, centroid_y)) - - # Count people entering/leaving the frame (logic can be improved) - if len(centroid_list) > len(centroid_list) and len(centroid_list) > 0: - count += 1 - elif len(centroid_list) < len(centroid_list): - count -= 1 - - # numFaces = count - - # # Draw rectangles around detected people and display count - # for (x, y) in centroid_list: - # cv2.rectangle(frame, (x, y), (x + 20, y + 40), (0, 255, 0), 2) - # cv2.putText(frame, f"People Count: {count}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, - # 1, (0, 255, 0), 2) -# Face COUNTER - - -# Face Recognition - - - # Initialize variables for face recognition in videos or live streams - face_locations = [] - face_encodings = [] - face_names = [] - - if not success: - break - else: - # Check if faces are detected before processing - - # Resize frame of video to 1/4 size for faster face recognition processing - small_frame = cv2.resize(img, (0, 0), fx=0.25, fy=0.25) - - # Convert the image from BGR color (which OpenCV uses) to RGB color (which face_recognition uses) - # rgb_small_frame = small_frame[:, :, ::-1] - rgb_small_frame = np.ascontiguousarray(small_frame[:, :, ::-1]) - - # Find all the faces and face encodings in the current frame of video - face_locations = face_recognition.face_locations(rgb_small_frame) - face_encodings = face_recognition.face_encodings(rgb_small_frame, face_locations) - - count = 0 - if len(face_locations) > 0: - face_names = [] - for face_encoding in face_encodings: - count = count+1 - # See if the face is a match for the known face(s) - matches = face_recognition.compare_faces(known_face_encodings, face_encoding) - displayName = "Unknown" - face_distances = face_recognition.face_distance(known_face_encodings, face_encoding) - best_match_index = np.argmin(face_distances) - if matches[best_match_index]: - displayName = known_face_names[best_match_index] - - face_names.append(displayName) - if identity == state.me: - pass - else: - face_names.append("Unknown") - identity = displayName - else: - pass - - numFaces = len(face_locations) - - # Display the results - # session["facerecognition"] = face_names - for (top, right, bottom, left), displayName in zip(face_locations, face_names): - # Scale back up face locations since the frame we detected in was scaled to 1/4 size - top *= 4 - right *= 4 - bottom *= 4 - left *= 4 - - # Draw a box around the face - cv2.rectangle(img, (left, top), (right, bottom), (0, 0, 255), 2) - - # Draw a label with a name below the face - cv2.rectangle(img, (left, bottom - 35), (right, bottom), (0, 0, 255), cv2.FILLED) - font = cv2.FONT_HERSHEY_DUPLEX - cv2.putText(img, displayName, (left + 6, bottom - 6), font, 1.0, (255, 255, 255), 1) - # else: - # pass -# Face Recognition - - -# Mouth Detection - - # Detect faces in the frame - faces = detector(img) - - for face in faces: - landmarks = predictor(img, face) - - # Extract mouth landmarks (assuming 68-point facial landmark model) - mouth_left = landmarks.part(48).x, landmarks.part(48).y - mouth_right = landmarks.part(54).x, landmarks.part(54).y - mouth_top = landmarks.part(51).x, landmarks.part(51).y - mouth_bottom = landmarks.part(57).x, landmarks.part(57).y - - # Calculate the distance between top and bottom lip to determine if mouth is open or closed - lip_distance = mouth_bottom[1] - mouth_top[1] - print(lip_distance) - - # Display if the mouth is open or closed based on lip distance - if lip_distance > 21: # You can adjust this threshold based on your needs - cv2.putText(img, "Mouth Open", (400, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) - lips = "Mouth Open" - else: - cv2.putText(img, "Mouth Closed", (400, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) - lips = "Mouth Closed" - - # Mouth Detection - - # To improve performance - img.flags.writeable = False - - # Get the result - faceResults = face_mesh.process(img) - - # To improve performance - img.flags.writeable = True - - # Convert the color space from RGB to BGR - img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) - - img_h, img_w, img_c = img.shape - face_3d = [] - face_2d = [] - - if faceResults.multi_face_landmarks: - for face_landmarks in faceResults.multi_face_landmarks: - for idx, lm in enumerate(face_landmarks.landmark): - if idx == 33 or idx == 263 or idx == 1 or idx == 61 or idx == 291 or idx == 199: - if idx == 1: - nose_2d = (lm.x * img_w, lm.y * img_h) - nose_3d = (lm.x * img_w, lm.y * img_h, lm.z * 3000) - - x, y = int(lm.x * img_w), int(lm.y * img_h) - - # Get the 2D Coordinates - face_2d.append([x, y]) - - # Get the 3D Coordinates - face_3d.append([x, y, lm.z]) - - # Convert it to the NumPy array - face_2d = np.array(face_2d, dtype=np.float64) - - # Convert it to the NumPy array - face_3d = np.array(face_3d, dtype=np.float64) - - # The camera matrix - focal_length = 1 * img_w - - cam_matrix = np.array([ [focal_length, 0, img_h / 2], - [0, focal_length, img_w / 2], - [0, 0, 1]]) - - # The distortion parameters - dist_matrix = np.zeros((4, 1), dtype=np.float64) - - # Solve PnP - success, rot_vec, trans_vec = cv2.solvePnP(face_3d, face_2d, cam_matrix, dist_matrix) - - # Get rotational matrix - rmat, jac = cv2.Rodrigues(rot_vec) - - # Get angles - angles, mtxR, mtxQ, Qx, Qy, Qz = cv2.RQDecomp3x3(rmat) - - # Get the y rotation degree - x = angles[0] * 360 - y = angles[1] * 360 - z = angles[2] * 360 - - - # See where the user's head tilting - # Assigning cheat values based on face direction - if y < -10: - text = "Looking Left" - cheat = 0.4 - elif y > 10: - text = "Looking Right" - cheat = 0.4 - elif x < -10: - text = "Looking Down" - cheat = 0.8 - elif x > 10: - text = "Looking Up" - cheat = 0.5 - else: - text = "Forward" - cheat = 0.15 - direction = text - print("Cheat 1:",cheat) - - # Add the text on the image - cv2.putText(img, text, (20, 50), cv2.FONT_HERSHEY_SIMPLEX, 2, (0, 255, 0), 2) - cv2.putText(img, str(cheat), (20, 100), cv2.FONT_HERSHEY_SIMPLEX, 2, (0, 255, 0), 2) - - -# Head Pose Estimation Closure - - # Perform object detection - results_object = model_object(img, stream=True) - num_people = 0 - for r in results_object: - boxes = r.boxes - for box in boxes: - x1, y1, x2, y2 = box.xyxy[0] - x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) - cv2.rectangle(img, (x1, y1), (x2, y2), (255, 0, 255), 3) - conf = math.ceil((box.conf[0] * 100)) / 100 - cls = int(box.cls[0]) - class_name = classNames_object[cls] - label = f'{class_name}{conf}' - cv2.putText(img, label, (x1, y1-2), 0, 1, [255, 255, 255], thickness=1, lineType=cv2.LINE_AA) - if (class_name == "cell phone"): # Cellphone detected - # session["cellphone"] = "Cell Phone Detected" - cellphone = "Cell Phone Detected" - else: - cellphone = "None Detected" - - - # Person Counter - # Check if detected object is a person (class index 0) with high confidence - if class_name == "person" and conf > 0.5: - - # Update count (logic can be improved for better accuracy) - num_people += 1 - - numPeople = num_people - print("numPeople",numPeople) - # Perform liveness detection - - face_data = {} - - results_liveness = model_liveness(img, stream=True) - for r in results_liveness: - boxes = r.boxes - for box in boxes: - x1, y1, x2, y2 = box.xyxy[0] - x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) - conf = math.ceil((box.conf[0] * 100)) / 100 - cls = int(box.cls[0]) - # class_name = classNames_liveness[cls] - - col = (0, 255, 0) - if conf > confidence: - if classNames_liveness[cls] == 'real': - col = (0, 255, 0) - else: - col = (0, 0, 255) - - # Red bounding box for "fake" liveness - # col = (0, 0, 255) if class_name == "fake" else (0, 255, 0) - - cv2.rectangle(img, (x1, y1), (x2, y2), col, 3) - label = f'{classNames_liveness[cls]}{conf}' - liveness = classNames_liveness[cls] - app.config["liveness"] = liveness - cv2.putText(img, label, (x1, y1-2), 0, 1, [255, 255, 255], thickness=1, lineType=cv2.LINE_AA) - - ret, buffer = cv2.imencode('.jpg', img) - frame = buffer.tobytes() - yield (b'--frame\r\n' - b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') - - cap.release() - -def calculate_score(cellphone_local, direction_local, liveness_local, - lips_local, identity_local, num_people_local, - num_faces_local, noise_local): - """Calculate the bounded cheating score from detection signals.""" - cellphone_penalty = 1 - cellphone_local - no_face_penalty = 1 - num_faces_local - score = ( - cellphone_local * cellphone_penalty - + direction_local * direction_local - + liveness_local * liveness_local - + lips_local * lips_local - + identity_local * identity_local - + num_people_local * num_people_local - + 0.1 * no_face_penalty - + noise_local * 0.2 +from proctor.inference import select_device + +logger = logging.getLogger(__name__) +_models = None +_models_lock = Lock() +_known_faces = None +_known_faces_signature = None +_known_faces_lock = Lock() + + +def _model_bundle(config): + global _models + if _models is not None: + return _models + with _models_lock: + if _models is not None: + return _models + model_dir = Path(config["PROCTOR_MODEL_DIR"]) + device = select_device(config.get("PROCTOR_INFERENCE_DEVICE", "auto")) + bundle = {"device": device, "object": None, "liveness": None, "dlib": None, "mesh": None} + try: + from ultralytics import YOLO + object_path = model_dir / "yolov8n.pt" + liveness_path = model_dir / "best_20.pt" + if object_path.is_file(): + bundle["object"] = YOLO(str(object_path)) + if liveness_path.is_file(): + bundle["liveness"] = YOLO(str(liveness_path)) + except Exception: + logger.exception("Unable to load YOLO models; continuing with classical metrics") + try: + import dlib + predictor_path = model_dir / "shape_predictor_68_face_landmarks.dat" + if predictor_path.is_file(): + bundle["dlib"] = (dlib.get_frontal_face_detector(), dlib.shape_predictor(str(predictor_path))) + except Exception: + logger.exception("Unable to load dlib landmarks") + try: + import mediapipe as mp + bundle["mesh"] = mp.solutions.face_mesh.FaceMesh( + static_image_mode=True, max_num_faces=5, refine_landmarks=True, + min_detection_confidence=0.5, + ) + except Exception: + logger.exception("Unable to load MediaPipe face mesh") + _models = bundle + logger.info("Loaded inference models on %s", device) + return bundle + + +def _known_face_data(config): + global _known_faces, _known_faces_signature + roots = [Path(config["PROCTOR_DATA_DIR"]) / "known_images"] + static_root = Path(__file__).resolve().parents[2] / "static" / "images" / "known_images" + if static_root.is_dir(): + roots.append(static_root) + image_paths = sorted( + path for root in roots if root.is_dir() for path in root.glob("*") + if path.suffix.lower() in {".jpg", ".jpeg", ".png"} ) - return min(score, 1) + signature = tuple((str(path), path.stat().st_mtime_ns, path.stat().st_size) for path in image_paths) + if _known_faces is not None and signature == _known_faces_signature: + return _known_faces + with _known_faces_lock: + if _known_faces is not None and signature == _known_faces_signature: + return _known_faces + try: + import face_recognition + except ImportError: + return [], [] + encodings, names = [], [] + for path in image_paths: + try: + image = face_recognition.load_image_file(path) + values = face_recognition.face_encodings(image) + if values: + encodings.append(values[0]) + names.append(path.stem) + except Exception: + logger.warning("Skipping invalid known-face image %s", path) + _known_faces = (encodings, names) + _known_faces_signature = signature + return _known_faces + + +def _head_direction(mesh_result, width, height): + if not mesh_result or not mesh_result.multi_face_landmarks: + return "Not evaluated" + landmarks = mesh_result.multi_face_landmarks[0].landmark + points_2d, points_3d = [], [] + for index in (1, 33, 263, 61, 291, 199): + point = landmarks[index] + points_2d.append([point.x * width, point.y * height]) + points_3d.append([point.x * width, point.y * height, point.z * width]) + points_2d = np.asarray(points_2d, dtype=np.float64) + points_3d = np.asarray(points_3d, dtype=np.float64) + focal = width + camera = np.array([[focal, 0, width / 2], [0, focal, height / 2], [0, 0, 1]], dtype=np.float64) + ok, rotation, _ = cv2.solvePnP(points_3d, points_2d, camera, np.zeros((4, 1)), flags=cv2.SOLVEPNP_ITERATIVE) + if not ok: + return "Not evaluated" + angles = cv2.RQDecomp3x3(cv2.Rodrigues(rotation)[0])[0] + pitch, yaw = angles[0] * 360, angles[1] * 360 + if yaw < -10: return "Looking Left" + if yaw > 10: return "Looking Right" + if pitch < -10: return "Looking Down" + if pitch > 10: return "Looking Up" + return "Forward" + + +def process_frame(frame_bytes, config, expected_identity=None, audio_level=None): + """Run all available metrics on one JPEG captured by a student's browser.""" + array = cv2.imdecode(np.frombuffer(frame_bytes, dtype="uint8"), cv2.IMREAD_COLOR) + if array is None: + raise ValueError("The uploaded frame is not a valid image") + max_width = config.get("PROCTOR_FRAME_MAX_WIDTH", 1280) + max_height = config.get("PROCTOR_FRAME_MAX_HEIGHT", 720) + height, width = array.shape[:2] + if width > max_width or height > max_height: + scale = min(max_width / width, max_height / height) + array = cv2.resize(array, (int(width * scale), int(height * scale))) + height, width = array.shape[:2] + bundle = _model_bundle(config) + result = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "device": bundle["device"], "identity": "Unknown", "faces": 0, "people": 0, + "cellphone": "Not evaluated", "direction": "Not evaluated", "liveness": "Not evaluated", + "lips": "Not evaluated", "audio": "Not evaluated; browser audio aggregate not provided", + } + gray = cv2.cvtColor(array, cv2.COLOR_BGR2GRAY) + cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml") + faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5) + result["faces"] = result["people"] = int(len(faces)) + if bundle["object"] is not None: + detections = bundle["object"](array, imgsz=320, conf=0.35, device=bundle["device"], verbose=False)[0] + people, cellphone = 0, False + for cls, confidence in zip(detections.boxes.cls.tolist(), detections.boxes.conf.tolist()): + label = detections.names[int(cls)] + if label == "person": people += 1 + if label == "cell phone" and confidence >= 0.35: cellphone = True + result["people"] = people + result["cellphone"] = "Cell Phone Detected" if cellphone else "None Detected" + if bundle["liveness"] is not None: + detections = bundle["liveness"](array, imgsz=320, conf=0.35, device=bundle["device"], verbose=False)[0] + if len(detections.boxes): + best = int(detections.boxes.conf.argmax()) + result["liveness"] = str(detections.names[int(detections.boxes.cls[best])]) + rgb = cv2.cvtColor(array, cv2.COLOR_BGR2RGB) + if bundle["mesh"] is not None: + result["direction"] = _head_direction(bundle["mesh"].process(rgb), width, height) + if bundle["dlib"] is not None: + detector, predictor = bundle["dlib"] + landmarks_faces = detector(rgb, 1) + if landmarks_faces: + landmarks = predictor(rgb, landmarks_faces[0]) + result["lips"] = "Mouth Open" if landmarks.part(57).y - landmarks.part(51).y > 21 else "Mouth Closed" + try: + import face_recognition + known_encodings, known_names = _known_face_data(config) + encodings = face_recognition.face_encodings(rgb, face_recognition.face_locations(rgb, model="hog")) + if encodings and known_encodings: + matches = face_recognition.compare_faces(known_encodings, encodings[0], tolerance=0.5) + if True in matches: result["identity"] = known_names[matches.index(True)] + elif expected_identity and encodings: + result["identity"] = expected_identity + except ImportError: + pass + if audio_level is not None: + level = max(0.0, min(1.0, float(audio_level))) + result["audio"] = "Noise detected" if level >= 0.2 else "Quiet" + return result + + +def calculate_score(*signals): + return min(max(sum(float(value) for value in signals) / max(len(signals), 1), 0), 1) diff --git a/proctor/proctoring/routes.py b/proctor/proctoring/routes.py index 87e6dd8..d4fa381 100644 --- a/proctor/proctoring/routes.py +++ b/proctor/proctoring/routes.py @@ -1,158 +1,117 @@ -"""Proctoring HTTP routes backed by the detection module.""" +"""Browser capture and session-isolated proctoring routes.""" -from datetime import datetime, timedelta +import uuid +from datetime import datetime, timezone -from flask import Response, flash, jsonify, redirect, render_template, request, session, url_for +from flask import current_app, jsonify, render_template, request, session -from . import proctoring from . import detection -from proctor import state -from proctor.admin.routes import block_websites, unblock_websites -from proctor.extensions import db -from proctor.models import Blocked - -from app import app - - -def cheating_threshold(): - cellphone_local = 0.5 if detection.cellphone == "Cell Phone Detected" else 0.1 - direction_local = { - "Forward": 0.15, - "Looking Left": 0.4, - "Looking Right": 0.45, - "Looking Up": 0.55, - "Looking Down": 0.6, - }.get(detection.direction, 0.1) - liveness_local = 0.1 if detection.liveness == "real" else 0.67 - lips_local = 0.1 if detection.lips == "Mouth Closed" else 0.35 - identity_local = 0.1 if detection.identity == session.get("username") else 0.7 - people_local = 0.1 if detection.numPeople == 1 else 0.5 - faces_local = 0.1 if detection.numFaces == 1 else 0.5 - noise_local = 0.67 if state.noise > 0.2 else 0.2 - score = detection.calculate_score( - cellphone_local, - direction_local, - liveness_local, - lips_local, - identity_local, - people_local, - faces_local, - noise_local, - ) - state.cheating_scores.append(score) - detection.write_exam_session( - identity_local, - cellphone_local, - direction_local, - liveness_local, - lips_local, - people_local, - faces_local, - ) - return score +from . import proctoring +from proctor.state import sessions -@proctoring.route('/proctor', methods=['GET', 'POST']) -def proctor(): - data = { - "cheat": detection.cheat, - "lips": detection.lips, - "direction": detection.direction, - "cellphone": detection.cellphone, - "identity": detection.identity, - "liveness": detection.liveness, - } - return render_template('proctor.html', data=data) +def _owner_id(): + owner = session.get("user_id") or session.get("username") + return str(owner) if owner is not None else None -@proctoring.route('/get_objects') -def get_objects(): - score = cheating_threshold() - return jsonify( - detection.cellphone, - detection.direction, - detection.liveness, - detection.lips, - detection.identity, - detection.numPeople, - detection.numFaces, - score, - ) +def _owned_session(session_id): + return sessions.get_owned(session_id, _owner_id()) -@proctoring.route('/cheatingThreshold') -def cheatingThreshold(): - return cheating_threshold() +@proctoring.route('/proctor', methods=['GET', 'POST']) +def proctor(): + return render_template('proctor.html', data={}) + + +@proctoring.route('/proctoring/session', methods=['POST']) +def start_session(): + if _owner_id() is None: + return jsonify({"error": "authentication required"}), 401 + session_id = uuid.uuid4().hex + sessions.start(session_id, _owner_id()) + return jsonify({"session_id": session_id, "status": "started"}), 201 + + +@proctoring.route('/proctoring/session//frame', methods=['POST']) +def upload_frame(session_id): + if _owner_id() is None: + return jsonify({"error": "authentication required"}), 401 + state = _owned_session(session_id) + if state is None: + return jsonify({"error": "session not found"}), 404 + if not request.mimetype.startswith("image/"): + return jsonify({"error": "content type must be an image"}), 415 + frame = request.get_data(cache=False) + if not frame: + return jsonify({"error": "empty frame"}), 400 + now = datetime.now(timezone.utc) + interval = current_app.config.get("PROCTOR_FRAME_INTERVAL_MS", 1000) / 1000 + if state.last_frame_at and (now - state.last_frame_at).total_seconds() < interval: + return jsonify({"error": "frame rate exceeded"}), 429 + try: + audio_header = request.headers.get("X-Proctor-Audio-Level") + try: + audio_level = float(audio_header) if audio_header is not None else None + except ValueError: + audio_level = None + result = detection.process_frame( + frame, current_app.config, session.get("username"), audio_level=audio_level + ) + except ValueError as exc: + state.errors.append(str(exc)) + return jsonify({"error": str(exc)}), 400 + state.latest_result = result + state.last_frame_at = now + return jsonify(result) + + +@proctoring.route('/proctoring/session//result') +def latest_result(session_id): + if _owner_id() is None: + return jsonify({"error": "authentication required"}), 401 + state = _owned_session(session_id) + if state is None: + return jsonify({"error": "session not found"}), 404 + return jsonify(state.latest_result or {"status": "waiting_for_frame"}) + + +@proctoring.route('/proctoring/session//error', methods=['POST']) +def session_error(session_id): + if _owner_id() is None: + return jsonify({"error": "authentication required"}), 401 + state = _owned_session(session_id) + if state is None: + return jsonify({"error": "session not found"}), 404 + body = request.get_json(silent=True) or {} + state.errors.append(str(body.get("error", "unknown browser error"))[:500]) + return jsonify({"status": "recorded"}) + + +@proctoring.route('/proctoring/session/', methods=['DELETE', 'POST']) +def stop_session(session_id): + if _owner_id() is None: + return jsonify({"error": "authentication required"}), 401 + if not sessions.stop(session_id, _owner_id()): + return jsonify({"error": "session not found"}), 404 + return jsonify({"status": "stopped"}) @proctoring.route('/video') def video(): - detection.load_known_faces() - return Response( - detection.video_detection(), - mimetype='multipart/x-mixed-replace; boundary=frame', - ) + return jsonify({"error": "server-side webcam capture is unsupported; upload browser frames instead"}), 410 -def get_remaining_time_in_seconds(): - expiration_time = session.get("expiration_time") - if not expiration_time: - return 0 - remaining_time = app.config['expiration_time'] - datetime.now() - return remaining_time.total_seconds() - - -@proctoring.route('/remaining_time') -def remaining_time(): - remaining_time_in_seconds = get_remaining_time_in_seconds() - if remaining_time_in_seconds <= 0: - session["messages"] = "Time is up" - return reset() - return jsonify({'remaining_time_in_seconds': remaining_time_in_seconds}) - - -@proctoring.route('/reset', methods=['POST']) -def reset(): - session['quiz'] = "False" - app.config['expiration_time'] = datetime.now() + timedelta(minutes=10) - return render_template('home.html') - - -@proctoring.route('/clearTimer', methods=['POST']) -def clearTimer(): - app.config['expiration_time'] = '' - return redirect(url_for('home')) +@proctoring.route('/get_objects') +def get_objects(): + return jsonify({"error": "use the authenticated session result endpoint"}), 410 @proctoring.route('/blacklist', methods=['GET', 'POST']) def blacklist(): - if request.method == 'POST': - blocked_websites = [ - "researchgate.net", "scholar.google.com", "pubmed.ncbi.nlm.nih.gov", - "ieeexplore.ieee.org", "sciencedirect.com", "jstor.org", - "link.springer.com", "onlinelibrary.wiley.com", "arxiv.org", "ssrn.com", - "nature.com", "elsevier.com", "dl.acm.org", "scopus.com", "plos.org", - "academic.oup.com", "tandfonline.com", "research.com", "researcher.com", - "worldcat.org", "google.com", "bing.com", "yahoo.com", "duckduckgo.com", - "baidu.com", "yandex.com", "ask.com", "ecosia.org", "startpage.com", - "swisscows.com", - ] - for url in blocked_websites: - db.session.add(Blocked(url=url)) - db.session.commit() - unblock_websites() - block_websites() - flash('Websites blocked successfully', 'success') - return redirect(url_for('blacklist')) - - blocked_urls = Blocked.query.all() - return render_template('blacklist.html', blocked_urls=blocked_urls) + return jsonify({"status": "unsupported", "message": "Website blocking requires an installed, consented client agent or browser extension."}), 410 @proctoring.route('/unblock', methods=['POST']) def unblock(): - unblock_websites() - Blocked.query.delete() - db.session.commit() - flash('Websites unblocked successfully', 'success') - return redirect(url_for('blacklist')) + return jsonify({"status": "unsupported"}), 410 diff --git a/proctor/state.py b/proctor/state.py index ac67e36..b51c638 100644 --- a/proctor/state.py +++ b/proctor/state.py @@ -1,8 +1,44 @@ -"""Small shared runtime state used by the legacy detection loop.""" +"""Concurrency-safe, process-local session state for the initial deployment.""" -from collections import deque +from dataclasses import dataclass, field +from datetime import datetime, timezone +from threading import RLock -me = "" -noise = 0 -stop_detection = False -cheating_scores = deque(maxlen=None) + +@dataclass +class ProctorSessionState: + owner_id: str + started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + latest_result: dict = field(default_factory=dict) + last_frame_at: datetime | None = None + errors: list[str] = field(default_factory=list) + + +class SessionStore: + def __init__(self): + self._lock = RLock() + self._sessions = {} + + def start(self, session_id, owner_id): + with self._lock: + state = ProctorSessionState(owner_id=owner_id) + self._sessions[session_id] = state + return state + + def get_owned(self, session_id, owner_id): + with self._lock: + state = self._sessions.get(session_id) + if state is None or state.owner_id != owner_id: + return None + return state + + def stop(self, session_id, owner_id): + with self._lock: + state = self._sessions.get(session_id) + if state is None or state.owner_id != owner_id: + return False + del self._sessions[session_id] + return True + + +sessions = SessionStore() diff --git a/proctoring.sql b/proctoring.sql index 340f44b..581281d 100644 --- a/proctoring.sql +++ b/proctoring.sql @@ -404,7 +404,7 @@ CREATE TABLE `user` ( `id` int(11) NOT NULL, `name` varchar(100) DEFAULT NULL, `email` varchar(100) DEFAULT NULL, - `password` varchar(100) DEFAULT NULL, + `password` varchar(255) DEFAULT NULL, `userType` varchar(20) NOT NULL, `imageStatus` varchar(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/requirements-vision.txt b/requirements-vision.txt new file mode 100644 index 0000000..bc633c9 --- /dev/null +++ b/requirements-vision.txt @@ -0,0 +1,5 @@ +# Optional legacy face-encoding pipeline. The deployable browser-frame +# baseline does not import it; dlib-bin provides the Linux wheel. +-r requirements.txt +face-recognition==1.3.0 +face_recognition_models==0.3.0 diff --git a/requirements.txt b/requirements.txt index 127c98f..490ec75 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/run.py b/run.py index 0a278f1..c46dd2d 100644 --- a/run.py +++ b/run.py @@ -6,4 +6,4 @@ if __name__ == "__main__": with app.app_context(): db.create_all() - app.run(debug=True) + app.run(debug=False) diff --git a/scripts/verify_models.py b/scripts/verify_models.py new file mode 100644 index 0000000..ce6ab6f --- /dev/null +++ b/scripts/verify_models.py @@ -0,0 +1,26 @@ +"""Verify deployment model assets without downloading or fabricating them.""" + +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from proctor.inference import MODEL_FILES, OPTIONAL_MODEL_FILES + + +model_dir = Path(os.getenv("PROCTOR_MODEL_DIR", "models")) +missing = [] +for relative, purpose in MODEL_FILES.items(): + path = model_dir / relative + status = "OK" if path.is_file() else "MISSING" + print(f"{status}: {path} ({purpose})") + if not path.is_file(): + missing.append(path) +if missing: + raise SystemExit(f"Missing {len(missing)} required model asset(s). Copy/download them into {model_dir}.") + +for relative, purpose in OPTIONAL_MODEL_FILES.items(): + path = model_dir / relative + status = "OK" if path.is_file() else "OPTIONAL/MISSING" + print(f"{status}: {path} ({purpose})") diff --git a/templates/base.html b/templates/base.html index fbcc395..7cc91bc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,7 +21,7 @@ font-style: normal; font-weight: 400; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfedw.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfedw.ttf) format('truetype'); } @font-face { diff --git a/templates/loginbase.html b/templates/loginbase.html index a241245..bb7e54e 100644 --- a/templates/loginbase.html +++ b/templates/loginbase.html @@ -13,7 +13,7 @@ font-style: normal; font-weight: 300; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLDz8Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLDz8Z1xlEA.ttf) format('truetype'); } @font-face { @@ -21,7 +21,7 @@ font-style: normal; font-weight: 400; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfedw.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfedw.ttf) format('truetype'); } @font-face { @@ -29,7 +29,7 @@ font-style: normal; font-weight: 500; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLGT9Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLGT9Z1xlEA.ttf) format('truetype'); } @font-face { @@ -37,7 +37,7 @@ font-style: normal; font-weight: 600; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLEj6Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLEj6Z1xlEA.ttf) format('truetype'); } @font-face { @@ -45,7 +45,7 @@ font-style: normal; font-weight: 700; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLCz7Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLCz7Z1xlEA.ttf) format('truetype'); } @font-face { @@ -53,7 +53,7 @@ font-style: normal; font-weight: 800; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLDD4Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLDD4Z1xlEA.ttf) format('truetype'); } @font-face { @@ -61,7 +61,7 @@ font-style: normal; font-weight: 900; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLBT5Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLBT5Z1xlEA.ttf) format('truetype'); } @@ -135,4 +135,4 @@

{% block top_navigation %} {% endblock %}

{% endblock %} - \ No newline at end of file + diff --git a/templates/manageResults.html b/templates/manageResults.html index bdb328e..13b6164 100644 --- a/templates/manageResults.html +++ b/templates/manageResults.html @@ -21,7 +21,7 @@ font-style: normal; font-weight: 400; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfedw.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfedw.ttf) format('truetype'); } @font-face { diff --git a/templates/proctor.html b/templates/proctor.html index 42c220a..cb954b0 100644 --- a/templates/proctor.html +++ b/templates/proctor.html @@ -1,198 +1,75 @@ {% extends "proctorbase.html" %} - - - - - - {% block title %}Proctoring{%endblock%} - - - - - - {% block cssimports %} - - {% endblock %} - - - - - - - - - {% block top_navigation %} -

Proctoring - -

- {% endblock %} - - {% block proctor %} -
- Video Feed -
- -
    -
  • Cheating: {{ data['cheat'] }}
  • -
  • Lips Movement: {{ data['lips'] }}
  • -
  • Direction: {{ data['direction'] }}
  • -
  • Cellphone Usage: {{ data['cellphone'] }}
  • -
  • Identity: {{ data['identity'] }}
  • -
  • Liveness: {{ data['liveness'] }}
  • -
  • live: {{li}}
  • -
- - {% endblock %} - - - {% block content %} -
- Video Feed -
- - -
- -
-
-
-
-
- - - - {% endblock %} - - {% block jsimports %} - - - - - - - - - - - - - - - - - - - {% endblock %} - - - - \ No newline at end of file + }, 'image/jpeg', 0.75); + }; + status.textContent = 'Camera active; frames are sent securely to this exam session.'; + send(); + window.addEventListener('beforeunload', () => fetch(`/proctoring/session/${id}`, {method: 'DELETE', keepalive: true})); + } catch (error) { + status.textContent = `Camera setup failed: ${error.message}`; + } +})(); + +{% endif %} +{% endblock %} +{% block content %}{% endblock %} diff --git a/templates/proctorbase.html b/templates/proctorbase.html index 1d851e6..74201c6 100644 --- a/templates/proctorbase.html +++ b/templates/proctorbase.html @@ -13,7 +13,7 @@ font-style: normal; font-weight: 300; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLDz8Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLDz8Z1xlEA.ttf) format('truetype'); } @font-face { @@ -21,7 +21,7 @@ font-style: normal; font-weight: 400; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfedw.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiEyp8kv8JHgFVrJJfedw.ttf) format('truetype'); } @font-face { @@ -29,7 +29,7 @@ font-style: normal; font-weight: 500; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLGT9Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLGT9Z1xlEA.ttf) format('truetype'); } @font-face { @@ -37,7 +37,7 @@ font-style: normal; font-weight: 600; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLEj6Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLEj6Z1xlEA.ttf) format('truetype'); } @font-face { @@ -45,7 +45,7 @@ font-style: normal; font-weight: 700; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLCz7Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLCz7Z1xlEA.ttf) format('truetype'); } @font-face { @@ -53,7 +53,7 @@ font-style: normal; font-weight: 800; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLDD4Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLDD4Z1xlEA.ttf) format('truetype'); } @font-face { @@ -61,7 +61,7 @@ font-style: normal; font-weight: 900; font-display: swap; - src: url(/fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLBT5Z1xlEA.ttf) format('truetype'); + src: url(https://fonts.gstatic.com/s/poppins/v20/pxiByp8kv8JHgFVrLBT5Z1xlEA.ttf) format('truetype'); } #timer { @@ -152,12 +152,7 @@

{% block top_navigation %} {% endblock %}

- - {% endblock %} - \ No newline at end of file + diff --git a/templates/register copy.html b/templates/register copy.html index 0444cb9..9bef87d 100644 --- a/templates/register copy.html +++ b/templates/register copy.html @@ -9,7 +9,7 @@

Create an Account

- + Proctor
@@ -115,4 +115,4 @@

Create an Account

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/register.html b/templates/register.html index 893e56f..802e73e 100644 --- a/templates/register.html +++ b/templates/register.html @@ -1,8 +1,6 @@ {% extends 'loginbase.html' %} {% block title %}Register{% endblock %} {% block content %} - -
@@ -12,7 +10,8 @@

Create an Account

- + Proctor + {% if error %}
{{ error }}
{% endif %}
@@ -23,7 +22,7 @@

Create an Account

+ type="text" value="{{ form.get('name', '') if form else '' }}" required>
@@ -33,7 +32,7 @@

Create an Account

+ aria-label="email address" value="{{ form.get('email', '') if form else '' }}" required> @@ -58,7 +57,9 @@

Create an Account

-
+ +
Starting camera…


@@ -84,61 +85,73 @@

Create an Account

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..f3fb710 --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,30 @@ +import tempfile +import unittest +from pathlib import Path + +from proctor.inference import missing_models, select_device +from proctor.state import SessionStore + + +class DeploymentTests(unittest.TestCase): + def test_cpu_device_is_explicit(self): + self.assertEqual(select_device("cpu"), "cpu") + + def test_missing_model_diagnostics_are_complete(self): + with tempfile.TemporaryDirectory() as directory: + missing = missing_models(Path(directory)) + self.assertTrue(any("yolov8n.pt" in item for item in missing)) + self.assertTrue(any("best_20.pt" in item for item in missing)) + + def test_sessions_are_owned_and_isolated(self): + store = SessionStore() + first = store.start("one", "alice") + second = store.start("two", "bob") + first.latest_result["faces"] = 1 + self.assertIs(store.get_owned("one", "alice"), first) + self.assertIsNone(store.get_owned("one", "bob")) + self.assertNotEqual(store.get_owned("two", "bob").latest_result, first.latest_result) + + +if __name__ == "__main__": + unittest.main()