Add complete SmartChair AI codebase: ML models, firmware, cloud, data collection
Browse files- smart_chair/HARDWARE_BOM.md +103 -0
- smart_chair/README.md +208 -0
- smart_chair/__init__.py +0 -0
- smart_chair/cloud/__init__.py +0 -0
- smart_chair/cloud/aws_iot_integration.py +358 -0
- smart_chair/config/__init__.py +0 -0
- smart_chair/config/settings.py +122 -0
- smart_chair/data_collection/__init__.py +0 -0
- smart_chair/data_collection/protocol.py +258 -0
- smart_chair/data_collection/synthetic_data_generator.py +327 -0
- smart_chair/firmware/__init__.py +0 -0
- smart_chair/firmware/esp32_firmware.ino +563 -0
- smart_chair/main.py +416 -0
- smart_chair/ml_models/__init__.py +0 -0
- smart_chair/ml_models/break_recommendation.py +489 -0
- smart_chair/ml_models/gamification.py +315 -0
- smart_chair/ml_models/posture_classifier.py +350 -0
- smart_chair/ml_models/spine_risk_predictor.py +555 -0
- smart_chair/ml_models/user_recognition.py +409 -0
- smart_chair/utils/__init__.py +0 -0
- smart_chair/utils/feature_engineering.py +226 -0
smart_chair/HARDWARE_BOM.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SmartChair Hardware Bill of Materials (BOM)
|
| 2 |
+
|
| 3 |
+
## Core Components
|
| 4 |
+
|
| 5 |
+
| Component | Model | Qty | Purpose | Est. Cost |
|
| 6 |
+
|-----------|-------|-----|---------|-----------|
|
| 7 |
+
| **Microcontroller** | ESP32-S3-DevKitC-1 (8MB PSRAM) | 1 | Main MCU, edge ML inference | $8-12 |
|
| 8 |
+
| **IMU Sensor** | MPU6050 (or ICM-42688-P for better accuracy) | 1 | 6-axis accelerometer + gyroscope | $2-5 |
|
| 9 |
+
| **Load Cells** | 50kg Half-Bridge Strain Gauge | 4 | Weight distribution at chair corners | $4-8 (Γ4) |
|
| 10 |
+
| **Load Cell ADC** | HX711 24-bit ADC | 4 | Amplify load cell signals | $1-2 (Γ4) |
|
| 11 |
+
| **Thermal Sensor** | AMG8833 8Γ8 IR Grid-EYE | 1 | Human presence detection | $15-25 |
|
| 12 |
+
| **Edge Computer** | Raspberry Pi 4B (4GB) | 1 | Complex ML + cloud gateway | $45-55 |
|
| 13 |
+
|
| 14 |
+
## Supporting Components
|
| 15 |
+
|
| 16 |
+
| Component | Model | Qty | Purpose | Est. Cost |
|
| 17 |
+
|-----------|-------|-----|---------|-----------|
|
| 18 |
+
| **Status LED** | WS2812B RGB LED Strip (8 LEDs) | 1 | Visual posture feedback | $2 |
|
| 19 |
+
| **Vibration Motor** | 3V coin vibration motor | 1 | Haptic alerts | $1 |
|
| 20 |
+
| **Power Supply** | 5V 3A USB-C adapter | 1 | Power ESP32 + sensors | $5 |
|
| 21 |
+
| **RPi Power** | 5V 3A USB-C (official RPi) | 1 | Power Raspberry Pi | $8 |
|
| 22 |
+
| **Level Shifter** | 3.3V β 5V bi-directional | 1 | I2C level conversion | $1 |
|
| 23 |
+
| **Breadboard/PCB** | Custom PCB or prototype board | 1 | Circuit assembly | $5-15 |
|
| 24 |
+
| **Wiring** | Jumper wires + JST connectors | 1 set | Sensor connections | $3 |
|
| 25 |
+
| **MicroSD Card** | 32GB Class 10 | 1 | RPi OS + local data storage | $5 |
|
| 26 |
+
|
| 27 |
+
## Optional Enhancements
|
| 28 |
+
|
| 29 |
+
| Component | Model | Qty | Purpose | Est. Cost |
|
| 30 |
+
|-----------|-------|-----|---------|-----------|
|
| 31 |
+
| **Better IMU** | ICM-42688-P | 1 | Higher accuracy, lower noise | $8-12 |
|
| 32 |
+
| **BLE Module** | Built into ESP32-S3 | β | Mobile app connectivity | Included |
|
| 33 |
+
| **Speaker** | Small 8Ξ© 0.5W speaker | 1 | Audio break reminders | $1 |
|
| 34 |
+
| **OLED Display** | SSD1306 0.96" I2C | 1 | Local status display | $3 |
|
| 35 |
+
| **Enclosure** | 3D printed or laser-cut | 1 | Professional housing | $5-15 |
|
| 36 |
+
|
| 37 |
+
## Total Estimated Cost: **$110-165**
|
| 38 |
+
|
| 39 |
+
## Wiring Diagram
|
| 40 |
+
|
| 41 |
+
```
|
| 42 |
+
ββββββββββββββββ
|
| 43 |
+
β ESP32-S3 β
|
| 44 |
+
β β
|
| 45 |
+
MPU6050 ββI2CββββΊβ GPIO21 (SDA) β
|
| 46 |
+
AMG8833 ββI2CββββΊβ GPIO22 (SCL) β
|
| 47 |
+
β β
|
| 48 |
+
HX711_FL βββββββββΊβ GPIO32/33 β
|
| 49 |
+
HX711_FR βββββββββΊβ GPIO25/26 β
|
| 50 |
+
HX711_RL βββββββββΊβ GPIO27/14 β
|
| 51 |
+
HX711_RR βββββββββΊβ GPIO12/13 β
|
| 52 |
+
β β
|
| 53 |
+
WS2812B ββββββββββΊβ GPIO48 β
|
| 54 |
+
Vibration Motor βββΊβ GPIO4 (PWM) β
|
| 55 |
+
β β
|
| 56 |
+
RPi ββββ UART ββββΊβ TX/RX β
|
| 57 |
+
ββββββββββββββββ
|
| 58 |
+
β
|
| 59 |
+
WiFi/BLE
|
| 60 |
+
β
|
| 61 |
+
βββββββΌβββββββ
|
| 62 |
+
β Raspberry β
|
| 63 |
+
β Pi 4B β
|
| 64 |
+
β βββββ MQTT/TLS ββββΊ AWS IoT Core
|
| 65 |
+
β Python ML β
|
| 66 |
+
β Dashboard βββββ HTTP ββββΊ Local Web UI
|
| 67 |
+
βββββββββββββββ
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
## Load Cell Placement
|
| 71 |
+
|
| 72 |
+
```
|
| 73 |
+
βββββββββββββββββββββββββββ
|
| 74 |
+
β FRONT β
|
| 75 |
+
β βββββββ βββββββ β
|
| 76 |
+
β β FL β β FR β β FL = Front-Left Load Cell
|
| 77 |
+
β βββββββ βββββββ β FR = Front-Right Load Cell
|
| 78 |
+
β β
|
| 79 |
+
β βββββββββββββ β
|
| 80 |
+
β β SEAT β β IMU mounted under seat center
|
| 81 |
+
β β (IMU) β β AMG8833 mounted on backrest
|
| 82 |
+
β βββββββββββββ β
|
| 83 |
+
β β
|
| 84 |
+
β βββββββ βββββββ β
|
| 85 |
+
β β RL β β RR β β RL = Rear-Left Load Cell
|
| 86 |
+
β βββββββ βββββββ β RR = Rear-Right Load Cell
|
| 87 |
+
β BACK β
|
| 88 |
+
βββββββββββββββββββββββββββ
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Assembly Notes
|
| 92 |
+
|
| 93 |
+
1. **Load cells**: Mount at the 4 corners of the seat, between seat plate and chair frame. Use 3D-printed mounting brackets. Each cell connects to its own HX711 board.
|
| 94 |
+
|
| 95 |
+
2. **IMU (MPU6050)**: Mount rigidly under the center of the seat using double-sided tape or screws. Ensure Z-axis points upward when seat is level. Calibrate by recording 10 seconds of data while seat is empty and level.
|
| 96 |
+
|
| 97 |
+
3. **Thermal sensor (AMG8833)**: Mount on the backrest, facing the seated person. 60Β° FOV covers the upper body. Used only for presence detection (occupied/empty).
|
| 98 |
+
|
| 99 |
+
4. **ESP32-S3**: Place in a small enclosure under the seat. Connect via I2C to IMU and thermal sensor, GPIO to HX711 boards.
|
| 100 |
+
|
| 101 |
+
5. **Raspberry Pi**: Can be mounted under the seat or placed nearby. Connects to ESP32 via UART (USB-serial) or local WiFi.
|
| 102 |
+
|
| 103 |
+
6. **Power**: USB-C power bank for portability, or wall adapter for fixed installation.
|
smart_chair/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# πͺ SmartChair AI β Intelligent Posture Monitoring System
|
| 2 |
+
|
| 3 |
+
**Real-time posture classification, spine risk prediction, and adaptive health coaching using IMU + load cell + thermal sensor fusion.**
|
| 4 |
+
|
| 5 |
+
[](https://python.org)
|
| 6 |
+
[](https://tensorflow.org)
|
| 7 |
+
[](https://espressif.com)
|
| 8 |
+
[](https://aws.amazon.com/iot-core/)
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## π― Features
|
| 13 |
+
|
| 14 |
+
| # | Feature | Status | Accuracy/Performance |
|
| 15 |
+
|---|---------|--------|---------------------|
|
| 16 |
+
| 1 | **Real-time posture classification** | β
| 95.48%+ ML accuracy (ensemble voting) |
|
| 17 |
+
| 2 | **Long-term spine risk prediction** | β
| RULA-based + exponential decay accumulator |
|
| 18 |
+
| 3 | **Personalised sitting behaviour model** | β
| Learns fatigue onset, break patterns per user |
|
| 19 |
+
| 4 | **Smart micro-break recommendations** | β
| Adaptive, score-based urgency (not fixed timer) |
|
| 20 |
+
| 5 | **Exercise suggestion system** | β
| 20+ exercises mapped to specific posture issues |
|
| 21 |
+
| 6 | **Hybrid sensor fusion** | β
| IMU + load cell + thermal (14-channel fusion) |
|
| 22 |
+
| 7 | **Fatigue & drowsiness detection** | β
| CoP variance + micro-movement analysis |
|
| 23 |
+
| 8 | **Daily/weekly posture score dashboard** | β
| 0-100 score with hourly breakdown + trends |
|
| 24 |
+
| 9 | **Injury risk alert system** | β
| Acute + chronic + trend-based alerts |
|
| 25 |
+
| 10 | **Cloud integration (AWS IoT Core)** | β
| MQTT/TLS, DynamoDB, Lambda scoring |
|
| 26 |
+
| 11 | **Multi-user recognition** | β
| Weight signature + k-NN (3-shot enrollment) |
|
| 27 |
+
| 12 | **Gamification system** | β
| Points, streaks, badges, leaderboard |
|
| 28 |
+
|
| 29 |
+
## ποΈ Architecture
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
β SENSOR LAYER β
|
| 34 |
+
β MPU6050 (50Hz) β 4Γ HX711 Load Cells β AMG8833 β
|
| 35 |
+
β 6-axis IMU β Weight Distribution β Thermal β
|
| 36 |
+
ββββββββββ¬ββββββββββ΄βββββββββββ¬ββββββββββββββ΄βββββββ¬βββββββ
|
| 37 |
+
β β β
|
| 38 |
+
βΌ βΌ βΌ
|
| 39 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
β EDGE INFERENCE (ESP32-S3) β
|
| 41 |
+
β Feature Extraction β TFLite INT8 / Random Forest β
|
| 42 |
+
β <30ms inference β <50KB model β 14 input channels β
|
| 43 |
+
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ
|
| 44 |
+
β UART / Local WiFi
|
| 45 |
+
βΌ
|
| 46 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
β INTELLIGENCE LAYER (Raspberry Pi) β
|
| 48 |
+
β β
|
| 49 |
+
β ββββββββββββββββ βββββββββββββββββ ββββββββββββββββ β
|
| 50 |
+
β β Ensemble β β RULA Risk β β Fatigue β β
|
| 51 |
+
β β Classifier β β Scorer β β Detector β β
|
| 52 |
+
β β (98%+ F1) β β (1-7 scale) β β (CoP+IMU) β β
|
| 53 |
+
β ββββββββββββββββ βββββββββββββββββ ββββββββββββββββ β
|
| 54 |
+
β ββββββββββββββββ βββββββββββββββββ ββββββββββββββββ β
|
| 55 |
+
β β User β β Break β β Exercise β β
|
| 56 |
+
β β Recognition β β Engine β β Suggester β β
|
| 57 |
+
β β (k-NN) β β (Adaptive) β β (Targeted) β β
|
| 58 |
+
β ββββββββββββββββ βββββββββββββββββ ββββββββββββββββ β
|
| 59 |
+
β ββββββββββββββββ βββββββββββββββββ ββββββββββββββββ β
|
| 60 |
+
β β Spine Risk β β Gamification β β Injury β β
|
| 61 |
+
β β Predictor β β Engine β β Alerts β β
|
| 62 |
+
β β (Long-term) β β (Points/Bge) β β (3-tier) β β
|
| 63 |
+
β ββββββββββββββββ βββββββββββοΏ½οΏ½βββββ ββββββββββββββββ β
|
| 64 |
+
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ
|
| 65 |
+
β MQTT / TLS
|
| 66 |
+
βΌ
|
| 67 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
+
β CLOUD LAYER (AWS) β
|
| 69 |
+
β IoT Core β DynamoDB (telemetry) β Lambda (daily scores) β
|
| 70 |
+
β β SNS (critical alerts) β S3 (raw data backup) β
|
| 71 |
+
β β API Gateway β Dashboard Web App β
|
| 72 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## π ML Models
|
| 76 |
+
|
| 77 |
+
### Posture Classifier (Primary)
|
| 78 |
+
- **Architecture**: Soft-voting ensemble (SVM + DecisionTree + MLP + XGBoost + RandomForest)
|
| 79 |
+
- **Basis**: [SitPose](https://arxiv.org/abs/2412.12216) (F1=98.2% on 7-class posture)
|
| 80 |
+
- **Input**: 119 hand-crafted features from 2.56s sliding windows (128 samples @ 50Hz)
|
| 81 |
+
- **Features**: IMU statistics, FFT spectral features, cross-axis correlations, CoP metrics, load distribution ratios
|
| 82 |
+
- **Edge deployment**: `micromlgen` β C header for ESP32 (<5KB, <5ms inference)
|
| 83 |
+
|
| 84 |
+
### MLSTM-FCN (Alternative/Deeper)
|
| 85 |
+
- **Architecture**: Parallel Conv1D-FCN + LSTM with Squeeze-Excite attention
|
| 86 |
+
- **Basis**: [MLSTM-FCN](https://arxiv.org/abs/1801.04503) + [FusionActNet](https://arxiv.org/abs/2310.02011)
|
| 87 |
+
- **Input**: (128, 14) sequential tensor (14 sensor channels Γ 128 timesteps)
|
| 88 |
+
- **Edge deployment**: TFLite INT8 quantization (<50KB, <30ms on ESP32-S3)
|
| 89 |
+
|
| 90 |
+
### Supporting Models
|
| 91 |
+
| Model | Method | Purpose |
|
| 92 |
+
|-------|--------|---------|
|
| 93 |
+
| RULA Scorer | Rule-based (ISO 11226) | Real-time ergonomic risk 1-7 |
|
| 94 |
+
| Spine Risk Predictor | Exponential decay accumulator | Long-term risk trajectory |
|
| 95 |
+
| Fatigue Detector | CoP variance + movement analysis | Fatigue/drowsiness classification |
|
| 96 |
+
| User Recognizer | k-NN on weight signatures | Multi-user identification |
|
| 97 |
+
| Break Engine | Weighted multi-factor scoring | Adaptive break timing |
|
| 98 |
+
|
| 99 |
+
## π§ Hardware
|
| 100 |
+
|
| 101 |
+
**Total cost: ~$110-165**
|
| 102 |
+
|
| 103 |
+
See [HARDWARE_BOM.md](HARDWARE_BOM.md) for complete bill of materials and wiring diagram.
|
| 104 |
+
|
| 105 |
+
Key sensors:
|
| 106 |
+
- **IMU**: MPU6050 (6-axis, I2C, 50Hz) β mounted under seat center
|
| 107 |
+
- **Load Cells**: 4Γ 50kg half-bridge with HX711 ADC β at chair corners
|
| 108 |
+
- **Thermal**: AMG8833 8Γ8 IR grid β on backrest for presence detection
|
| 109 |
+
- **MCU**: ESP32-S3 (dual-core 240MHz, 8MB PSRAM, WiFi/BLE)
|
| 110 |
+
- **Gateway**: Raspberry Pi 4B (complex ML + cloud)
|
| 111 |
+
|
| 112 |
+
## π Quick Start
|
| 113 |
+
|
| 114 |
+
### 1. Install Dependencies
|
| 115 |
+
```bash
|
| 116 |
+
pip install numpy scipy scikit-learn xgboost tensorflow matplotlib pandas joblib
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
### 2. Run Demo (Synthetic Data)
|
| 120 |
+
```bash
|
| 121 |
+
python -m smart_chair.main
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### 3. Collect Real Data
|
| 125 |
+
```bash
|
| 126 |
+
python -c "
|
| 127 |
+
from smart_chair.data_collection.protocol import DataCollectionSession
|
| 128 |
+
session = DataCollectionSession('participant_01', weight_kg=70)
|
| 129 |
+
session.run_collection(serial_port='/dev/ttyUSB0') # or None for synthetic
|
| 130 |
+
"
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### 4. Train Production Model
|
| 134 |
+
```python
|
| 135 |
+
from smart_chair.ml_models.posture_classifier import EnsemblePostureClassifier
|
| 136 |
+
from smart_chair.data_collection.protocol import prepare_training_dataset
|
| 137 |
+
from smart_chair.data_collection.synthetic_data_generator import subject_based_split
|
| 138 |
+
|
| 139 |
+
dataset = prepare_training_dataset("./collected_data")
|
| 140 |
+
train, test = subject_based_split(dataset)
|
| 141 |
+
|
| 142 |
+
clf = EnsemblePostureClassifier()
|
| 143 |
+
clf.train(train["imu_data"], train["load_data"], train["labels"],
|
| 144 |
+
test["imu_data"], test["load_data"], test["labels"])
|
| 145 |
+
clf.save("posture_model.joblib")
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### 5. Deploy to ESP32
|
| 149 |
+
```python
|
| 150 |
+
# Option A: Random Forest β C header
|
| 151 |
+
from micromlgen import port
|
| 152 |
+
c_code = port(clf.model.estimators_[4]) # Export RF component
|
| 153 |
+
with open("posture_classifier.h", "w") as f:
|
| 154 |
+
f.write(c_code)
|
| 155 |
+
|
| 156 |
+
# Option B: MLSTM-FCN β TFLite
|
| 157 |
+
from smart_chair.ml_models.posture_classifier import build_mlstm_fcn, convert_to_tflite
|
| 158 |
+
model = build_mlstm_fcn()
|
| 159 |
+
# ... train model ...
|
| 160 |
+
convert_to_tflite(model, X_train, "posture_model.tflite")
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
### 6. Flash ESP32 Firmware
|
| 164 |
+
Upload `firmware/esp32_firmware.ino` via Arduino IDE or PlatformIO.
|
| 165 |
+
|
| 166 |
+
## π Project Structure
|
| 167 |
+
|
| 168 |
+
```
|
| 169 |
+
smart_chair/
|
| 170 |
+
βββ config/
|
| 171 |
+
β βββ settings.py # All system constants and parameters
|
| 172 |
+
βββ utils/
|
| 173 |
+
β βββ feature_engineering.py # Feature extraction pipeline (14 channels)
|
| 174 |
+
βββ data_collection/
|
| 175 |
+
β βββ synthetic_data_generator.py # Physics-based synthetic data
|
| 176 |
+
β βββ protocol.py # Real data collection procedure
|
| 177 |
+
βββ ml_models/
|
| 178 |
+
β βββ posture_classifier.py # Ensemble + MLSTM-FCN classifiers
|
| 179 |
+
β βββ spine_risk_predictor.py # RULA scorer + fatigue detector + alerts
|
| 180 |
+
β βββ user_recognition.py # Multi-user recognition + personalized model
|
| 181 |
+
β βββ break_recommendation.py # Adaptive break engine + exercise suggester
|
| 182 |
+
β βββ gamification.py # Points, badges, streaks, leaderboard
|
| 183 |
+
βββ cloud/
|
| 184 |
+
β βββ aws_iot_integration.py # MQTT client + AWS infrastructure definitions
|
| 185 |
+
βββ firmware/
|
| 186 |
+
β βββ esp32_firmware.ino # ESP32-S3 Arduino firmware
|
| 187 |
+
βββ HARDWARE_BOM.md # Parts list + wiring diagram
|
| 188 |
+
βββ main.py # System orchestrator (Raspberry Pi)
|
| 189 |
+
βββ README.md # This file
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
## π References
|
| 193 |
+
|
| 194 |
+
| Paper | Contribution | ArXiv |
|
| 195 |
+
|-------|-------------|-------|
|
| 196 |
+
| SitPose | Ensemble posture classification (F1=98.2%) | [2412.12216](https://arxiv.org/abs/2412.12216) |
|
| 197 |
+
| MLSTM-FCN | Multivariate time series architecture | [1801.04503](https://arxiv.org/abs/1801.04503) |
|
| 198 |
+
| FusionActNet | Static/Dynamic dual-expert IMU | [2310.02011](https://arxiv.org/abs/2310.02011) |
|
| 199 |
+
| SSL-Wearables | Self-supervised pre-training for HAR | [2206.02909](https://arxiv.org/abs/2206.02909) |
|
| 200 |
+
| UniMTS | Foundation model for IMU (zero-shot) | [2410.19818](https://arxiv.org/abs/2410.19818) |
|
| 201 |
+
| DULA/DEBA | Differentiable ergonomic assessment | [2205.03491](https://arxiv.org/abs/2205.03491) |
|
| 202 |
+
| TinyNav | TFLite Micro on ESP32 (<30ms) | [2603.11071](https://arxiv.org/abs/2603.11071) |
|
| 203 |
+
| AuthentiSense | Few-shot biometric authentication | [2302.02740](https://arxiv.org/abs/2302.02740) |
|
| 204 |
+
| Edge Impulse | TinyML MLOps platform | [2212.03332](https://arxiv.org/abs/2212.03332) |
|
| 205 |
+
|
| 206 |
+
## π License
|
| 207 |
+
|
| 208 |
+
MIT License β Free for personal and commercial use.
|
smart_chair/__init__.py
ADDED
|
File without changes
|
smart_chair/cloud/__init__.py
ADDED
|
File without changes
|
smart_chair/cloud/aws_iot_integration.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair AWS IoT Core Integration.
|
| 3 |
+
|
| 4 |
+
Architecture:
|
| 5 |
+
ESP32-S3 β UART/I2C β Raspberry Pi β MQTT/TLS β AWS IoT Core β DynamoDB/S3/Lambda
|
| 6 |
+
|
| 7 |
+
The ESP32 handles real-time inference locally (TFLite INT8).
|
| 8 |
+
The Raspberry Pi acts as IoT gateway:
|
| 9 |
+
- Runs complex models (MLSTM-FCN, fatigue detector)
|
| 10 |
+
- Manages MQTT connection to AWS IoT Core
|
| 11 |
+
- Publishes telemetry every 5 seconds
|
| 12 |
+
- Stores raw data locally for model retraining
|
| 13 |
+
- Handles OTA firmware updates
|
| 14 |
+
|
| 15 |
+
AWS IoT Core Topics:
|
| 16 |
+
smartchair/{device_id}/telemetry β posture + sensor data (every 5s)
|
| 17 |
+
smartchair/{device_id}/alerts β risk/fatigue/break alerts
|
| 18 |
+
smartchair/{device_id}/commands β remote commands (calibrate, update model)
|
| 19 |
+
smartchair/{device_id}/shadow β device shadow (current state)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import time
|
| 24 |
+
import ssl
|
| 25 |
+
import os
|
| 26 |
+
from datetime import datetime
|
| 27 |
+
from typing import Dict, Optional, Callable
|
| 28 |
+
from dataclasses import dataclass, field, asdict
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class TelemetryPayload:
|
| 33 |
+
"""Standard telemetry packet sent to AWS IoT Core."""
|
| 34 |
+
device_id: str
|
| 35 |
+
timestamp: int # Unix epoch ms
|
| 36 |
+
user_id: str = "unknown"
|
| 37 |
+
|
| 38 |
+
# Posture classification
|
| 39 |
+
posture_class: int = 0
|
| 40 |
+
posture_name: str = "upright"
|
| 41 |
+
posture_confidence: float = 0.0
|
| 42 |
+
|
| 43 |
+
# Sensor readings
|
| 44 |
+
imu_accel: list = field(default_factory=lambda: [0, 0, 9.81])
|
| 45 |
+
imu_gyro: list = field(default_factory=lambda: [0, 0, 0])
|
| 46 |
+
load_cells: list = field(default_factory=lambda: [0, 0, 0, 0])
|
| 47 |
+
thermal_presence: bool = False
|
| 48 |
+
|
| 49 |
+
# Derived metrics
|
| 50 |
+
pitch_deg: float = 0.0
|
| 51 |
+
roll_deg: float = 0.0
|
| 52 |
+
cop_x: float = 0.0
|
| 53 |
+
cop_y: float = 0.0
|
| 54 |
+
total_weight_kg: float = 0.0
|
| 55 |
+
|
| 56 |
+
# Risk & fatigue
|
| 57 |
+
rula_score: int = 1
|
| 58 |
+
risk_level: str = "negligible"
|
| 59 |
+
fatigue_level: str = "none"
|
| 60 |
+
drowsiness_risk: str = "low"
|
| 61 |
+
|
| 62 |
+
# Session
|
| 63 |
+
session_duration_min: float = 0.0
|
| 64 |
+
good_posture_pct: float = 100.0
|
| 65 |
+
posture_score: float = 100.0
|
| 66 |
+
|
| 67 |
+
# Gamification
|
| 68 |
+
points_today: int = 0
|
| 69 |
+
streak_days: int = 0
|
| 70 |
+
|
| 71 |
+
def to_json(self) -> str:
|
| 72 |
+
return json.dumps(asdict(self))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@dataclass
|
| 76 |
+
class AlertPayload:
|
| 77 |
+
"""Alert packet for critical events."""
|
| 78 |
+
device_id: str
|
| 79 |
+
timestamp: int
|
| 80 |
+
alert_type: str # "acute_risk", "fatigue", "break_needed", "chronic_risk"
|
| 81 |
+
severity: str # "info", "warning", "critical"
|
| 82 |
+
message: str
|
| 83 |
+
action: str = ""
|
| 84 |
+
user_id: str = "unknown"
|
| 85 |
+
|
| 86 |
+
def to_json(self) -> str:
|
| 87 |
+
return json.dumps(asdict(self))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class AWSIoTClient:
|
| 91 |
+
"""
|
| 92 |
+
MQTT client for AWS IoT Core communication.
|
| 93 |
+
|
| 94 |
+
Handles:
|
| 95 |
+
- Secure MQTT connection (TLS 1.2 with X.509 certificates)
|
| 96 |
+
- Telemetry publishing
|
| 97 |
+
- Alert publishing
|
| 98 |
+
- Device shadow updates
|
| 99 |
+
- Command subscription
|
| 100 |
+
|
| 101 |
+
Certificate setup:
|
| 102 |
+
1. Create Thing in AWS IoT Core console
|
| 103 |
+
2. Download: root-CA.pem, device.pem.crt, private.pem.key
|
| 104 |
+
3. Set paths via environment variables or config
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
def __init__(self, device_id: str,
|
| 108 |
+
endpoint: Optional[str] = None,
|
| 109 |
+
cert_dir: str = "./certs"):
|
| 110 |
+
self.device_id = device_id
|
| 111 |
+
self.endpoint = endpoint or os.getenv("AWS_IOT_ENDPOINT", "")
|
| 112 |
+
self.cert_dir = cert_dir
|
| 113 |
+
self.client = None
|
| 114 |
+
self.is_connected = False
|
| 115 |
+
self.command_callbacks = {}
|
| 116 |
+
|
| 117 |
+
# Topics
|
| 118 |
+
self.topic_telemetry = f"smartchair/{device_id}/telemetry"
|
| 119 |
+
self.topic_alerts = f"smartchair/{device_id}/alerts"
|
| 120 |
+
self.topic_commands = f"smartchair/{device_id}/commands"
|
| 121 |
+
self.topic_shadow = f"$aws/things/{device_id}/shadow/update"
|
| 122 |
+
|
| 123 |
+
def connect(self) -> bool:
|
| 124 |
+
"""
|
| 125 |
+
Establish MQTT connection to AWS IoT Core.
|
| 126 |
+
|
| 127 |
+
Returns True if connection successful, False otherwise.
|
| 128 |
+
|
| 129 |
+
Note: Requires paho-mqtt and valid AWS IoT certificates.
|
| 130 |
+
In production, install via: pip install paho-mqtt awsiotsdk
|
| 131 |
+
"""
|
| 132 |
+
try:
|
| 133 |
+
import paho.mqtt.client as mqtt
|
| 134 |
+
except ImportError:
|
| 135 |
+
print("β οΈ paho-mqtt not installed. Install with: pip install paho-mqtt")
|
| 136 |
+
print(" Running in offline/simulation mode.")
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
if not self.endpoint:
|
| 140 |
+
print("β οΈ AWS IoT endpoint not configured. Set AWS_IOT_ENDPOINT env var.")
|
| 141 |
+
return False
|
| 142 |
+
|
| 143 |
+
cert_path = os.path.join(self.cert_dir, "device.pem.crt")
|
| 144 |
+
key_path = os.path.join(self.cert_dir, "private.pem.key")
|
| 145 |
+
ca_path = os.path.join(self.cert_dir, "AmazonRootCA1.pem")
|
| 146 |
+
|
| 147 |
+
if not all(os.path.exists(p) for p in [cert_path, key_path, ca_path]):
|
| 148 |
+
print("β οΈ AWS IoT certificates not found in", self.cert_dir)
|
| 149 |
+
print(" Download from AWS IoT Core console β Manage β Things β Certificates")
|
| 150 |
+
return False
|
| 151 |
+
|
| 152 |
+
self.client = mqtt.Client(client_id=self.device_id, protocol=mqtt.MQTTv311)
|
| 153 |
+
self.client.tls_set(
|
| 154 |
+
ca_certs=ca_path,
|
| 155 |
+
certfile=cert_path,
|
| 156 |
+
keyfile=key_path,
|
| 157 |
+
tls_version=ssl.PROTOCOL_TLSv1_2
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
self.client.on_connect = self._on_connect
|
| 161 |
+
self.client.on_message = self._on_message
|
| 162 |
+
self.client.on_disconnect = self._on_disconnect
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
self.client.connect(self.endpoint, 8883, 60)
|
| 166 |
+
self.client.loop_start()
|
| 167 |
+
self.is_connected = True
|
| 168 |
+
print(f"β
Connected to AWS IoT Core: {self.endpoint}")
|
| 169 |
+
return True
|
| 170 |
+
except Exception as e:
|
| 171 |
+
print(f"β Connection failed: {e}")
|
| 172 |
+
return False
|
| 173 |
+
|
| 174 |
+
def _on_connect(self, client, userdata, flags, rc):
|
| 175 |
+
if rc == 0:
|
| 176 |
+
print(f"β
MQTT connected. Subscribing to {self.topic_commands}")
|
| 177 |
+
client.subscribe(self.topic_commands, qos=1)
|
| 178 |
+
else:
|
| 179 |
+
print(f"β MQTT connect failed with code {rc}")
|
| 180 |
+
|
| 181 |
+
def _on_message(self, client, userdata, msg):
|
| 182 |
+
try:
|
| 183 |
+
payload = json.loads(msg.payload.decode())
|
| 184 |
+
command = payload.get("command", "")
|
| 185 |
+
|
| 186 |
+
if command in self.command_callbacks:
|
| 187 |
+
self.command_callbacks[command](payload)
|
| 188 |
+
else:
|
| 189 |
+
print(f"π₯ Unknown command: {command}")
|
| 190 |
+
except Exception as e:
|
| 191 |
+
print(f"β Message parse error: {e}")
|
| 192 |
+
|
| 193 |
+
def _on_disconnect(self, client, userdata, rc):
|
| 194 |
+
self.is_connected = False
|
| 195 |
+
if rc != 0:
|
| 196 |
+
print(f"β οΈ Unexpected disconnect (rc={rc}). Attempting reconnect...")
|
| 197 |
+
|
| 198 |
+
def publish_telemetry(self, payload: TelemetryPayload):
|
| 199 |
+
"""Publish telemetry data to AWS IoT Core."""
|
| 200 |
+
if self.client and self.is_connected:
|
| 201 |
+
self.client.publish(self.topic_telemetry, payload.to_json(), qos=0)
|
| 202 |
+
else:
|
| 203 |
+
# Offline mode β log locally
|
| 204 |
+
self._log_offline(payload.to_json())
|
| 205 |
+
|
| 206 |
+
def publish_alert(self, payload: AlertPayload):
|
| 207 |
+
"""Publish alert to AWS IoT Core (higher QoS for reliability)."""
|
| 208 |
+
if self.client and self.is_connected:
|
| 209 |
+
self.client.publish(self.topic_alerts, payload.to_json(), qos=1)
|
| 210 |
+
else:
|
| 211 |
+
self._log_offline(payload.to_json())
|
| 212 |
+
|
| 213 |
+
def update_shadow(self, state: Dict):
|
| 214 |
+
"""Update device shadow with current state."""
|
| 215 |
+
shadow = {
|
| 216 |
+
"state": {
|
| 217 |
+
"reported": state
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
if self.client and self.is_connected:
|
| 221 |
+
self.client.publish(self.topic_shadow, json.dumps(shadow), qos=1)
|
| 222 |
+
|
| 223 |
+
def register_command(self, command: str, callback: Callable):
|
| 224 |
+
"""Register callback for remote commands."""
|
| 225 |
+
self.command_callbacks[command] = callback
|
| 226 |
+
|
| 227 |
+
def _log_offline(self, payload_json: str):
|
| 228 |
+
"""Store data locally when offline."""
|
| 229 |
+
log_dir = os.path.join(self.cert_dir, "..", "offline_logs")
|
| 230 |
+
os.makedirs(log_dir, exist_ok=True)
|
| 231 |
+
log_file = os.path.join(log_dir, f"{datetime.now().strftime('%Y%m%d')}.jsonl")
|
| 232 |
+
with open(log_file, 'a') as f:
|
| 233 |
+
f.write(payload_json + '\n')
|
| 234 |
+
|
| 235 |
+
def disconnect(self):
|
| 236 |
+
"""Clean disconnect."""
|
| 237 |
+
if self.client:
|
| 238 |
+
self.client.loop_stop()
|
| 239 |
+
self.client.disconnect()
|
| 240 |
+
self.is_connected = False
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 244 |
+
# AWS IoT Rules & Lambda Functions (Infrastructure as Code reference)
|
| 245 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 246 |
+
|
| 247 |
+
AWS_IOT_RULE_TELEMETRY_TO_DYNAMODB = """
|
| 248 |
+
-- AWS IoT Rule SQL: Store telemetry in DynamoDB
|
| 249 |
+
SELECT
|
| 250 |
+
device_id,
|
| 251 |
+
timestamp,
|
| 252 |
+
user_id,
|
| 253 |
+
posture_class, posture_name, posture_confidence,
|
| 254 |
+
rula_score, risk_level, fatigue_level,
|
| 255 |
+
cop_x, cop_y, total_weight_kg,
|
| 256 |
+
posture_score, session_duration_min
|
| 257 |
+
FROM 'smartchair/+/telemetry'
|
| 258 |
+
|
| 259 |
+
-- Action: DynamoDB PutItem
|
| 260 |
+
-- Table: SmartChairTelemetry
|
| 261 |
+
-- Partition Key: device_id (String)
|
| 262 |
+
-- Sort Key: timestamp (Number)
|
| 263 |
+
"""
|
| 264 |
+
|
| 265 |
+
AWS_IOT_RULE_ALERTS_TO_SNS = """
|
| 266 |
+
-- AWS IoT Rule SQL: Forward critical alerts to SNS
|
| 267 |
+
SELECT
|
| 268 |
+
device_id, user_id, alert_type, severity, message, action
|
| 269 |
+
FROM 'smartchair/+/alerts'
|
| 270 |
+
WHERE severity = 'critical'
|
| 271 |
+
|
| 272 |
+
-- Action: SNS Publish
|
| 273 |
+
-- Topic ARN: arn:aws:sns:region:account:SmartChairCriticalAlerts
|
| 274 |
+
"""
|
| 275 |
+
|
| 276 |
+
DYNAMODB_TABLE_SCHEMA = {
|
| 277 |
+
"TableName": "SmartChairTelemetry",
|
| 278 |
+
"KeySchema": [
|
| 279 |
+
{"AttributeName": "device_id", "KeyType": "HASH"},
|
| 280 |
+
{"AttributeName": "timestamp", "KeyType": "RANGE"},
|
| 281 |
+
],
|
| 282 |
+
"AttributeDefinitions": [
|
| 283 |
+
{"AttributeName": "device_id", "AttributeType": "S"},
|
| 284 |
+
{"AttributeName": "timestamp", "AttributeType": "N"},
|
| 285 |
+
],
|
| 286 |
+
"BillingMode": "PAY_PER_REQUEST",
|
| 287 |
+
"GlobalSecondaryIndexes": [
|
| 288 |
+
{
|
| 289 |
+
"IndexName": "UserIndex",
|
| 290 |
+
"KeySchema": [
|
| 291 |
+
{"AttributeName": "user_id", "KeyType": "HASH"},
|
| 292 |
+
{"AttributeName": "timestamp", "KeyType": "RANGE"},
|
| 293 |
+
],
|
| 294 |
+
"Projection": {"ProjectionType": "ALL"},
|
| 295 |
+
}
|
| 296 |
+
],
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
LAMBDA_DAILY_SCORE_CALCULATOR = '''
|
| 300 |
+
# AWS Lambda: Triggered daily by CloudWatch Events
|
| 301 |
+
# Computes and stores daily posture score per user
|
| 302 |
+
|
| 303 |
+
import boto3
|
| 304 |
+
from datetime import datetime, timedelta
|
| 305 |
+
import json
|
| 306 |
+
from decimal import Decimal
|
| 307 |
+
|
| 308 |
+
dynamodb = boto3.resource('dynamodb')
|
| 309 |
+
table = dynamodb.Table('SmartChairTelemetry')
|
| 310 |
+
scores_table = dynamodb.Table('SmartChairDailyScores')
|
| 311 |
+
|
| 312 |
+
def lambda_handler(event, context):
|
| 313 |
+
"""Calculate daily posture scores for all devices."""
|
| 314 |
+
yesterday = datetime.now() - timedelta(days=1)
|
| 315 |
+
start_ts = int(yesterday.replace(hour=0, minute=0).timestamp() * 1000)
|
| 316 |
+
end_ts = int(yesterday.replace(hour=23, minute=59).timestamp() * 1000)
|
| 317 |
+
|
| 318 |
+
# Query yesterday's data
|
| 319 |
+
response = table.query(
|
| 320 |
+
KeyConditionExpression='device_id = :did AND #ts BETWEEN :start AND :end',
|
| 321 |
+
ExpressionAttributeNames={'#ts': 'timestamp'},
|
| 322 |
+
ExpressionAttributeValues={
|
| 323 |
+
':did': event.get('device_id', 'chair_001'),
|
| 324 |
+
':start': start_ts,
|
| 325 |
+
':end': end_ts,
|
| 326 |
+
}
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
items = response['Items']
|
| 330 |
+
if not items:
|
| 331 |
+
return {'statusCode': 200, 'body': 'No data for yesterday'}
|
| 332 |
+
|
| 333 |
+
# Compute weighted score
|
| 334 |
+
posture_weights = {
|
| 335 |
+
'upright': 1.0, 'forward_lean': 0.4, 'backward_lean': 0.5,
|
| 336 |
+
'left_lean': 0.6, 'right_lean': 0.6, 'slouch': 0.2
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
total_weighted = sum(
|
| 340 |
+
posture_weights.get(item.get('posture_name', 'upright'), 0.5)
|
| 341 |
+
for item in items
|
| 342 |
+
)
|
| 343 |
+
score = (total_weighted / len(items)) * 100
|
| 344 |
+
|
| 345 |
+
# Store daily score
|
| 346 |
+
scores_table.put_item(Item={
|
| 347 |
+
'user_id': items[0].get('user_id', 'unknown'),
|
| 348 |
+
'date': yesterday.strftime('%Y-%m-%d'),
|
| 349 |
+
'score': Decimal(str(round(score, 1))),
|
| 350 |
+
'total_readings': len(items),
|
| 351 |
+
'device_id': event.get('device_id', 'chair_001'),
|
| 352 |
+
})
|
| 353 |
+
|
| 354 |
+
return {
|
| 355 |
+
'statusCode': 200,
|
| 356 |
+
'body': json.dumps({'date': yesterday.strftime('%Y-%m-%d'), 'score': round(score, 1)})
|
| 357 |
+
}
|
| 358 |
+
'''
|
smart_chair/config/__init__.py
ADDED
|
File without changes
|
smart_chair/config/settings.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair Configuration β All system constants and hardware parameters.
|
| 3 |
+
Based on literature: SitPose (arxiv:2412.12216), FusionActNet (arxiv:2310.02011),
|
| 4 |
+
MLSTM-FCN (arxiv:1801.04503), TinyNav (arxiv:2603.11071)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
# βββ Sensor Hardware βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
IMU_SENSOR = "MPU6050" # or ICM-42688 for better accuracy
|
| 9 |
+
IMU_SAMPLE_RATE_HZ = 50 # matches UCI-HAR standard
|
| 10 |
+
IMU_AXES = 6 # 3 accel + 3 gyro
|
| 11 |
+
|
| 12 |
+
LOAD_CELL_COUNT = 4 # FL, FR, RL, RR corners
|
| 13 |
+
LOAD_CELL_CAPACITY_KG = 200 # per cell, HX711 ADC
|
| 14 |
+
LOAD_CELL_SAMPLE_RATE_HZ = 10 # HX711 @ 10 SPS mode
|
| 15 |
+
|
| 16 |
+
THERMAL_SENSOR = "AMG8833" # 8x8 IR grid array
|
| 17 |
+
THERMAL_GRID_SIZE = (8, 8)
|
| 18 |
+
THERMAL_SAMPLE_RATE_HZ = 1 # presence detection only
|
| 19 |
+
THERMAL_PRESENCE_THRESHOLD_C = 28.0 # above ambient = human detected
|
| 20 |
+
|
| 21 |
+
# βββ Posture Classes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
POSTURE_CLASSES = {
|
| 23 |
+
0: "upright",
|
| 24 |
+
1: "forward_lean",
|
| 25 |
+
2: "backward_lean",
|
| 26 |
+
3: "left_lean",
|
| 27 |
+
4: "right_lean",
|
| 28 |
+
5: "slouch",
|
| 29 |
+
6: "absent"
|
| 30 |
+
}
|
| 31 |
+
NUM_POSTURE_CLASSES = len(POSTURE_CLASSES)
|
| 32 |
+
|
| 33 |
+
# βββ ML Model Parameters ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
# Sliding window for temporal models (from MLSTM-FCN paper)
|
| 35 |
+
WINDOW_SIZE = 128 # 128 samples @ 50Hz = 2.56 seconds
|
| 36 |
+
WINDOW_STRIDE = 64 # 50% overlap
|
| 37 |
+
INPUT_CHANNELS = 14 # 6 IMU + 4 load cells + 2 computed angles + 2 CoP
|
| 38 |
+
|
| 39 |
+
# Ensemble voting classifier (from SitPose: F1=98.2%)
|
| 40 |
+
ENSEMBLE_SVM_C = 10.0
|
| 41 |
+
ENSEMBLE_SVM_KERNEL = "rbf"
|
| 42 |
+
ENSEMBLE_DT_MAX_DEPTH = 10
|
| 43 |
+
ENSEMBLE_MLP_HIDDEN = (64, 32)
|
| 44 |
+
ENSEMBLE_MLP_MAX_ITER = 500
|
| 45 |
+
|
| 46 |
+
# MLSTM-FCN architecture (from arxiv:1801.04503)
|
| 47 |
+
MLSTM_FCN_FILTERS = [128, 256, 128]
|
| 48 |
+
MLSTM_FCN_KERNELS = [8, 5, 3]
|
| 49 |
+
MLSTM_FCN_LSTM_UNITS = 8
|
| 50 |
+
MLSTM_FCN_DROPOUT = 0.8
|
| 51 |
+
MLSTM_FCN_SE_RATIO = 16
|
| 52 |
+
|
| 53 |
+
# Training
|
| 54 |
+
TRAIN_EPOCHS = 100
|
| 55 |
+
TRAIN_BATCH_SIZE = 64
|
| 56 |
+
TRAIN_LR = 1e-3
|
| 57 |
+
TRAIN_VAL_SPLIT = 0.2
|
| 58 |
+
EARLY_STOPPING_PATIENCE = 10
|
| 59 |
+
|
| 60 |
+
# βββ Fatigue Detection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
FATIGUE_WINDOW_MINUTES = 30 # rolling window for fatigue analysis
|
| 62 |
+
FATIGUE_COP_VARIANCE_THRESHOLD = 0.15 # high CoP variance = restlessness/fatigue
|
| 63 |
+
FATIGUE_MICRO_MOVEMENT_THRESHOLD = 0.02 # below = fatigue
|
| 64 |
+
DROWSINESS_STILLNESS_SECONDS = 120 # 2 min stillness = possible drowsiness
|
| 65 |
+
|
| 66 |
+
# βββ Spine Risk (RULA-based, from arxiv:2205.03491) βββββββββββββββββββββββββββ
|
| 67 |
+
RULA_TRUNK_ANGLES = [10, 20, 60] # degree thresholds
|
| 68 |
+
RULA_LATERAL_THRESHOLD = 15 # degrees
|
| 69 |
+
RISK_LEVELS = {1: "negligible", 2: "low", 3: "medium", 4: "high",
|
| 70 |
+
5: "high", 6: "very_high", 7: "critical"}
|
| 71 |
+
|
| 72 |
+
# βββ Break Recommendation βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
MIN_BREAK_INTERVAL_MINUTES = 20
|
| 74 |
+
MAX_SITTING_MINUTES = 50
|
| 75 |
+
BREAK_DURATION_SECONDS = 120 # 2-minute micro-break
|
| 76 |
+
BREAK_URGENCY_LEVELS = ["gentle", "moderate", "urgent"]
|
| 77 |
+
|
| 78 |
+
# βββ Multi-User Recognition βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
USER_ENROLLMENT_SAMPLES = 5 # sit-down events for enrollment
|
| 80 |
+
USER_WEIGHT_TOLERANCE_KG = 3.0 # weight matching tolerance
|
| 81 |
+
USER_COP_TOLERANCE = 0.1 # CoP ratio matching tolerance
|
| 82 |
+
MAX_REGISTERED_USERS = 10
|
| 83 |
+
|
| 84 |
+
# βββ Gamification ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
POINTS_PER_GOOD_POSTURE_MINUTE = 10
|
| 86 |
+
POINTS_PER_BREAK_TAKEN = 50
|
| 87 |
+
POINTS_PER_EXERCISE_DONE = 100
|
| 88 |
+
STREAK_BONUS_MULTIPLIER = 1.5
|
| 89 |
+
BADGE_THRESHOLDS = {
|
| 90 |
+
"posture_novice": 500,
|
| 91 |
+
"posture_pro": 2000,
|
| 92 |
+
"posture_master": 5000,
|
| 93 |
+
"break_champion": 1000,
|
| 94 |
+
"streak_warrior": 3000,
|
| 95 |
+
"iron_spine": 10000
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# βββ Cloud / AWS IoT Core βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
AWS_IOT_ENDPOINT = "" # set via env var
|
| 100 |
+
AWS_IOT_TOPIC_TELEMETRY = "smartchair/{device_id}/telemetry"
|
| 101 |
+
AWS_IOT_TOPIC_ALERTS = "smartchair/{device_id}/alerts"
|
| 102 |
+
AWS_IOT_TOPIC_COMMANDS = "smartchair/{device_id}/commands"
|
| 103 |
+
MQTT_QOS = 1
|
| 104 |
+
TELEMETRY_PUBLISH_INTERVAL_S = 5 # publish every 5 seconds
|
| 105 |
+
|
| 106 |
+
# βββ Edge Deployment ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 107 |
+
MICROCONTROLLER = "ESP32-S3" # Xtensa LX7 dual-core, PSRAM
|
| 108 |
+
TFLITE_ARENA_SIZE_KB = 50 # tensor arena for TFLite Micro
|
| 109 |
+
TARGET_INFERENCE_LATENCY_MS = 30 # from TinyNav benchmarks
|
| 110 |
+
QUANTIZATION = "INT8" # post-training quantization
|
| 111 |
+
|
| 112 |
+
# βββ Dashboard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
DASHBOARD_REFRESH_INTERVAL_S = 5
|
| 114 |
+
POSTURE_SCORE_WEIGHTS = {
|
| 115 |
+
"upright": 1.0,
|
| 116 |
+
"forward_lean": 0.4,
|
| 117 |
+
"backward_lean": 0.5,
|
| 118 |
+
"left_lean": 0.6,
|
| 119 |
+
"right_lean": 0.6,
|
| 120 |
+
"slouch": 0.2,
|
| 121 |
+
"absent": 0.0
|
| 122 |
+
}
|
smart_chair/data_collection/__init__.py
ADDED
|
File without changes
|
smart_chair/data_collection/protocol.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair Data Collection Protocol.
|
| 3 |
+
|
| 4 |
+
Standardized procedure for collecting real sensor data to train production models.
|
| 5 |
+
Based on UCI-HAR and SitPose collection methodology.
|
| 6 |
+
|
| 7 |
+
Equipment needed:
|
| 8 |
+
- SmartChair hardware (ESP32-S3 + IMU + 4 load cells + thermal)
|
| 9 |
+
- Laptop running this script (USB serial connection to ESP32)
|
| 10 |
+
- Printed posture instruction cards for participants
|
| 11 |
+
- Consent forms
|
| 12 |
+
|
| 13 |
+
Protocol:
|
| 14 |
+
1. Participant sits on chair
|
| 15 |
+
2. System captures 5 seconds of baseline (upright)
|
| 16 |
+
3. Researcher instructs posture via visual cards
|
| 17 |
+
4. Each posture held for 60 seconds
|
| 18 |
+
5. 10-second rest between postures
|
| 19 |
+
6. Repeat all 6 postures (excluding absent)
|
| 20 |
+
7. Total per participant: ~8 minutes
|
| 21 |
+
8. Target: 20+ participants for 95%+ accuracy
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import json
|
| 25 |
+
import time
|
| 26 |
+
import os
|
| 27 |
+
import numpy as np
|
| 28 |
+
from datetime import datetime
|
| 29 |
+
from typing import Optional
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class DataCollectionSession:
|
| 33 |
+
"""Manages a single data collection session."""
|
| 34 |
+
|
| 35 |
+
POSTURES_TO_COLLECT = [
|
| 36 |
+
{"id": 0, "name": "upright", "instruction": "Sit with back straight, feet flat, shoulders relaxed."},
|
| 37 |
+
{"id": 1, "name": "forward_lean", "instruction": "Lean forward ~15Β° as if reading something close."},
|
| 38 |
+
{"id": 2, "name": "backward_lean", "instruction": "Lean back against the backrest, relaxed."},
|
| 39 |
+
{"id": 3, "name": "left_lean", "instruction": "Lean to the left, resting on left armrest."},
|
| 40 |
+
{"id": 4, "name": "right_lean", "instruction": "Lean to the right, resting on right armrest."},
|
| 41 |
+
{"id": 5, "name": "slouch", "instruction": "Slouch forward with rounded shoulders, C-spine."},
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
def __init__(self, participant_id: str, participant_weight_kg: float,
|
| 45 |
+
output_dir: str = "./collected_data"):
|
| 46 |
+
self.participant_id = participant_id
|
| 47 |
+
self.participant_weight = participant_weight_kg
|
| 48 |
+
self.output_dir = output_dir
|
| 49 |
+
self.session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 50 |
+
self.data = {
|
| 51 |
+
"metadata": {
|
| 52 |
+
"participant_id": participant_id,
|
| 53 |
+
"weight_kg": participant_weight_kg,
|
| 54 |
+
"session_id": self.session_id,
|
| 55 |
+
"collection_date": datetime.now().isoformat(),
|
| 56 |
+
"protocol_version": "1.0",
|
| 57 |
+
"imu_hz": 50,
|
| 58 |
+
"load_cell_hz": 10,
|
| 59 |
+
"thermal_hz": 1,
|
| 60 |
+
},
|
| 61 |
+
"recordings": [],
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 65 |
+
|
| 66 |
+
def run_collection(self, serial_port: Optional[str] = None):
|
| 67 |
+
"""
|
| 68 |
+
Run the full data collection protocol.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
serial_port: Serial port to read from ESP32 (e.g., '/dev/ttyUSB0')
|
| 72 |
+
If None, generates synthetic data for testing.
|
| 73 |
+
"""
|
| 74 |
+
print(f"\n{'='*60}")
|
| 75 |
+
print(f"DATA COLLECTION SESSION")
|
| 76 |
+
print(f"Participant: {self.participant_id} ({self.participant_weight} kg)")
|
| 77 |
+
print(f"Session ID: {self.session_id}")
|
| 78 |
+
print(f"{'='*60}\n")
|
| 79 |
+
|
| 80 |
+
for posture in self.POSTURES_TO_COLLECT:
|
| 81 |
+
print(f"\nπ POSTURE: {posture['name'].upper()}")
|
| 82 |
+
print(f" Instruction: {posture['instruction']}")
|
| 83 |
+
print(f" Recording for 60 seconds...")
|
| 84 |
+
|
| 85 |
+
input(" Press ENTER when participant is in position...")
|
| 86 |
+
|
| 87 |
+
# Countdown
|
| 88 |
+
for i in range(3, 0, -1):
|
| 89 |
+
print(f" Starting in {i}...")
|
| 90 |
+
time.sleep(1)
|
| 91 |
+
|
| 92 |
+
# Record data
|
| 93 |
+
recording = self._record_posture(posture, duration_seconds=60, serial_port=serial_port)
|
| 94 |
+
self.data["recordings"].append(recording)
|
| 95 |
+
|
| 96 |
+
print(f" β
Recorded {recording['n_imu_samples']} IMU + {recording['n_load_samples']} load samples")
|
| 97 |
+
|
| 98 |
+
print(" Rest for 10 seconds...")
|
| 99 |
+
time.sleep(2) # Shortened for demo
|
| 100 |
+
|
| 101 |
+
# Also record absent (empty chair)
|
| 102 |
+
print(f"\nπ POSTURE: ABSENT")
|
| 103 |
+
print(f" Instruction: Participant stands up. Chair must be empty.")
|
| 104 |
+
input(" Press ENTER when chair is empty...")
|
| 105 |
+
|
| 106 |
+
recording = self._record_posture(
|
| 107 |
+
{"id": 6, "name": "absent", "instruction": "Empty chair"},
|
| 108 |
+
duration_seconds=30, serial_port=serial_port
|
| 109 |
+
)
|
| 110 |
+
self.data["recordings"].append(recording)
|
| 111 |
+
|
| 112 |
+
# Save
|
| 113 |
+
self._save()
|
| 114 |
+
|
| 115 |
+
print(f"\n{'='*60}")
|
| 116 |
+
print(f"β
SESSION COMPLETE")
|
| 117 |
+
print(f" Total recordings: {len(self.data['recordings'])}")
|
| 118 |
+
print(f" Saved to: {self.output_dir}/{self.participant_id}_{self.session_id}.json")
|
| 119 |
+
print(f"{'='*60}\n")
|
| 120 |
+
|
| 121 |
+
def _record_posture(self, posture: dict, duration_seconds: int,
|
| 122 |
+
serial_port: Optional[str] = None) -> dict:
|
| 123 |
+
"""Record sensor data for one posture hold."""
|
| 124 |
+
start_time = time.time()
|
| 125 |
+
imu_samples = []
|
| 126 |
+
load_samples = []
|
| 127 |
+
|
| 128 |
+
if serial_port:
|
| 129 |
+
# Real hardware β read from ESP32 UART
|
| 130 |
+
import serial
|
| 131 |
+
ser = serial.Serial(serial_port, 115200, timeout=0.1)
|
| 132 |
+
|
| 133 |
+
while time.time() - start_time < duration_seconds:
|
| 134 |
+
line = ser.readline().decode().strip()
|
| 135 |
+
if line:
|
| 136 |
+
try:
|
| 137 |
+
data = json.loads(line)
|
| 138 |
+
if "imu" in data:
|
| 139 |
+
imu_samples.append(data["imu"])
|
| 140 |
+
if "load" in data:
|
| 141 |
+
load_samples.append(data["load"])
|
| 142 |
+
except json.JSONDecodeError:
|
| 143 |
+
pass
|
| 144 |
+
|
| 145 |
+
ser.close()
|
| 146 |
+
else:
|
| 147 |
+
# Synthetic data for protocol testing
|
| 148 |
+
from smart_chair.data_collection.synthetic_data_generator import (
|
| 149 |
+
generate_imu_data, generate_load_cell_data
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
n_imu = duration_seconds * 50 # 50 Hz
|
| 153 |
+
n_load = duration_seconds * 10 # 10 Hz
|
| 154 |
+
|
| 155 |
+
imu_data = generate_imu_data(posture["id"], n_imu, self.participant_weight)
|
| 156 |
+
load_data = generate_load_cell_data(posture["id"], n_load, self.participant_weight)
|
| 157 |
+
|
| 158 |
+
imu_samples = imu_data.tolist()
|
| 159 |
+
load_samples = load_data.tolist()
|
| 160 |
+
|
| 161 |
+
return {
|
| 162 |
+
"posture_id": posture["id"],
|
| 163 |
+
"posture_name": posture["name"],
|
| 164 |
+
"duration_seconds": duration_seconds,
|
| 165 |
+
"n_imu_samples": len(imu_samples),
|
| 166 |
+
"n_load_samples": len(load_samples),
|
| 167 |
+
"imu_data": imu_samples,
|
| 168 |
+
"load_data": load_samples,
|
| 169 |
+
"timestamp_start": datetime.fromtimestamp(start_time).isoformat(),
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def _save(self):
|
| 173 |
+
"""Save collected data to JSON."""
|
| 174 |
+
filename = f"{self.participant_id}_{self.session_id}.json"
|
| 175 |
+
filepath = os.path.join(self.output_dir, filename)
|
| 176 |
+
|
| 177 |
+
with open(filepath, 'w') as f:
|
| 178 |
+
json.dump(self.data, f)
|
| 179 |
+
|
| 180 |
+
# Also save as numpy arrays for direct ML training
|
| 181 |
+
for rec in self.data["recordings"]:
|
| 182 |
+
npy_dir = os.path.join(self.output_dir, "numpy", self.participant_id)
|
| 183 |
+
os.makedirs(npy_dir, exist_ok=True)
|
| 184 |
+
|
| 185 |
+
np.save(os.path.join(npy_dir, f"{rec['posture_name']}_imu.npy"),
|
| 186 |
+
np.array(rec['imu_data'], dtype=np.float32))
|
| 187 |
+
np.save(os.path.join(npy_dir, f"{rec['posture_name']}_load.npy"),
|
| 188 |
+
np.array(rec['load_data'], dtype=np.float32))
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
# DATASET PREPARATION
|
| 193 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 194 |
+
|
| 195 |
+
def prepare_training_dataset(data_dir: str = "./collected_data") -> dict:
|
| 196 |
+
"""
|
| 197 |
+
Load all collected data and prepare for model training.
|
| 198 |
+
Applies subject-based train/test split.
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
dict with train/test arrays + metadata
|
| 202 |
+
"""
|
| 203 |
+
import glob
|
| 204 |
+
|
| 205 |
+
all_imu = []
|
| 206 |
+
all_load = []
|
| 207 |
+
all_labels = []
|
| 208 |
+
all_subjects = []
|
| 209 |
+
subject_map = {}
|
| 210 |
+
|
| 211 |
+
json_files = glob.glob(os.path.join(data_dir, "*.json"))
|
| 212 |
+
|
| 213 |
+
for fpath in sorted(json_files):
|
| 214 |
+
with open(fpath) as f:
|
| 215 |
+
session = json.load(f)
|
| 216 |
+
|
| 217 |
+
pid = session["metadata"]["participant_id"]
|
| 218 |
+
if pid not in subject_map:
|
| 219 |
+
subject_map[pid] = len(subject_map)
|
| 220 |
+
sid = subject_map[pid]
|
| 221 |
+
|
| 222 |
+
for rec in session["recordings"]:
|
| 223 |
+
imu = np.array(rec["imu_data"], dtype=np.float32)
|
| 224 |
+
load = np.array(rec["load_data"], dtype=np.float32)
|
| 225 |
+
|
| 226 |
+
# Align lengths (IMU at 50Hz, load at 10Hz β upsample load)
|
| 227 |
+
n = min(len(imu), len(load) * 5)
|
| 228 |
+
imu = imu[:n]
|
| 229 |
+
|
| 230 |
+
# Repeat load cells to match IMU rate
|
| 231 |
+
load_upsampled = np.repeat(load, 5, axis=0)[:n]
|
| 232 |
+
|
| 233 |
+
labels = np.full(n, rec["posture_id"], dtype=np.int32)
|
| 234 |
+
subjects = np.full(n, sid, dtype=np.int32)
|
| 235 |
+
|
| 236 |
+
all_imu.append(imu)
|
| 237 |
+
all_load.append(load_upsampled)
|
| 238 |
+
all_labels.append(labels)
|
| 239 |
+
all_subjects.append(subjects)
|
| 240 |
+
|
| 241 |
+
if not all_imu:
|
| 242 |
+
print("β οΈ No data found. Run data collection first.")
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
dataset = {
|
| 246 |
+
"imu_data": np.concatenate(all_imu),
|
| 247 |
+
"load_data": np.concatenate(all_load),
|
| 248 |
+
"labels": np.concatenate(all_labels),
|
| 249 |
+
"subject_ids": np.concatenate(all_subjects),
|
| 250 |
+
"n_subjects": len(subject_map),
|
| 251 |
+
"subject_map": subject_map,
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
print(f"Loaded {len(json_files)} sessions, {len(subject_map)} participants")
|
| 255 |
+
print(f"Total samples: {len(dataset['labels'])}")
|
| 256 |
+
print(f"Class distribution: {np.bincount(dataset['labels'])}")
|
| 257 |
+
|
| 258 |
+
return dataset
|
smart_chair/data_collection/synthetic_data_generator.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Synthetic Data Generator for SmartChair Development & Testing.
|
| 3 |
+
Generates realistic IMU + Load Cell + Thermal sensor data for each posture class.
|
| 4 |
+
|
| 5 |
+
Physics-based: Each posture has characteristic sensor signatures derived from
|
| 6 |
+
biomechanical models of seated humans. Noise levels match real MPU6050 + HX711 specs.
|
| 7 |
+
|
| 8 |
+
Used for: model architecture validation, pipeline testing, and pre-training before
|
| 9 |
+
real data collection. Real data collection protocol is in data_collection/protocol.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from typing import Dict, Tuple, Optional
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# βββ Posture Sensor Signatures βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
# Based on biomechanics literature and SitPose angular distributions
|
| 18 |
+
# Format: (accel_mean_xyz, gyro_mean_xyz, load_ratios_FLFRRLRR)
|
| 19 |
+
POSTURE_SIGNATURES = {
|
| 20 |
+
0: { # Upright (good posture)
|
| 21 |
+
"name": "upright",
|
| 22 |
+
"accel_mean": [0.0, 0.0, 9.81], # gravity along z
|
| 23 |
+
"accel_std": [0.05, 0.05, 0.05],
|
| 24 |
+
"gyro_mean": [0.0, 0.0, 0.0],
|
| 25 |
+
"gyro_std": [0.02, 0.02, 0.02],
|
| 26 |
+
"load_ratios": [0.25, 0.25, 0.25, 0.25], # balanced
|
| 27 |
+
"load_std": [0.02, 0.02, 0.02, 0.02],
|
| 28 |
+
"thermal_center": (4, 4), # centered on 8x8 grid
|
| 29 |
+
"thermal_spread": 2.0,
|
| 30 |
+
},
|
| 31 |
+
1: { # Forward lean
|
| 32 |
+
"name": "forward_lean",
|
| 33 |
+
"accel_mean": [2.5, 0.0, 9.5], # tilted forward ~15Β°
|
| 34 |
+
"accel_std": [0.08, 0.05, 0.08],
|
| 35 |
+
"gyro_mean": [0.0, 0.3, 0.0], # slow forward rotation
|
| 36 |
+
"gyro_std": [0.03, 0.05, 0.03],
|
| 37 |
+
"load_ratios": [0.35, 0.35, 0.15, 0.15], # front-heavy
|
| 38 |
+
"load_std": [0.03, 0.03, 0.02, 0.02],
|
| 39 |
+
"thermal_center": (3, 4),
|
| 40 |
+
"thermal_spread": 2.5,
|
| 41 |
+
},
|
| 42 |
+
2: { # Backward lean
|
| 43 |
+
"name": "backward_lean",
|
| 44 |
+
"accel_mean": [-1.8, 0.0, 9.6], # tilted backward ~10Β°
|
| 45 |
+
"accel_std": [0.06, 0.05, 0.06],
|
| 46 |
+
"gyro_mean": [0.0, -0.2, 0.0],
|
| 47 |
+
"gyro_std": [0.03, 0.04, 0.03],
|
| 48 |
+
"load_ratios": [0.15, 0.15, 0.35, 0.35], # rear-heavy
|
| 49 |
+
"load_std": [0.02, 0.02, 0.03, 0.03],
|
| 50 |
+
"thermal_center": (5, 4),
|
| 51 |
+
"thermal_spread": 2.0,
|
| 52 |
+
},
|
| 53 |
+
3: { # Left lean
|
| 54 |
+
"name": "left_lean",
|
| 55 |
+
"accel_mean": [0.0, -2.0, 9.6], # tilted left ~12Β°
|
| 56 |
+
"accel_std": [0.05, 0.07, 0.06],
|
| 57 |
+
"gyro_mean": [0.2, 0.0, 0.0],
|
| 58 |
+
"gyro_std": [0.04, 0.03, 0.03],
|
| 59 |
+
"load_ratios": [0.35, 0.15, 0.35, 0.15], # left-heavy
|
| 60 |
+
"load_std": [0.03, 0.02, 0.03, 0.02],
|
| 61 |
+
"thermal_center": (4, 3),
|
| 62 |
+
"thermal_spread": 2.0,
|
| 63 |
+
},
|
| 64 |
+
4: { # Right lean
|
| 65 |
+
"name": "right_lean",
|
| 66 |
+
"accel_mean": [0.0, 2.0, 9.6], # tilted right ~12Β°
|
| 67 |
+
"accel_std": [0.05, 0.07, 0.06],
|
| 68 |
+
"gyro_mean": [-0.2, 0.0, 0.0],
|
| 69 |
+
"gyro_std": [0.04, 0.03, 0.03],
|
| 70 |
+
"load_ratios": [0.15, 0.35, 0.15, 0.35], # right-heavy
|
| 71 |
+
"load_std": [0.02, 0.03, 0.02, 0.03],
|
| 72 |
+
"thermal_center": (4, 5),
|
| 73 |
+
"thermal_spread": 2.0,
|
| 74 |
+
},
|
| 75 |
+
5: { # Slouch (worst posture β spine C-curve)
|
| 76 |
+
"name": "slouch",
|
| 77 |
+
"accel_mean": [3.2, 0.0, 9.2], # deep forward tilt ~20Β°
|
| 78 |
+
"accel_std": [0.1, 0.06, 0.1],
|
| 79 |
+
"gyro_mean": [0.0, 0.5, 0.0], # slow drift
|
| 80 |
+
"gyro_std": [0.04, 0.06, 0.04],
|
| 81 |
+
"load_ratios": [0.30, 0.30, 0.20, 0.20], # front-heavy + reclined
|
| 82 |
+
"load_std": [0.04, 0.04, 0.03, 0.03],
|
| 83 |
+
"thermal_center": (3, 4),
|
| 84 |
+
"thermal_spread": 3.0,
|
| 85 |
+
},
|
| 86 |
+
6: { # Absent (empty chair)
|
| 87 |
+
"name": "absent",
|
| 88 |
+
"accel_mean": [0.0, 0.0, 9.81], # pure gravity
|
| 89 |
+
"accel_std": [0.01, 0.01, 0.01], # very low noise
|
| 90 |
+
"gyro_mean": [0.0, 0.0, 0.0],
|
| 91 |
+
"gyro_std": [0.01, 0.01, 0.01],
|
| 92 |
+
"load_ratios": [0.0, 0.0, 0.0, 0.0], # no weight
|
| 93 |
+
"load_std": [0.005, 0.005, 0.005, 0.005],
|
| 94 |
+
"thermal_center": (4, 4),
|
| 95 |
+
"thermal_spread": 0.5,
|
| 96 |
+
},
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def generate_imu_data(posture_id: int, n_samples: int,
|
| 101 |
+
user_weight_kg: float = 70.0,
|
| 102 |
+
noise_scale: float = 1.0,
|
| 103 |
+
rng: Optional[np.random.Generator] = None) -> np.ndarray:
|
| 104 |
+
"""
|
| 105 |
+
Generate synthetic IMU data for a given posture.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
posture_id: 0-6 posture class
|
| 109 |
+
n_samples: number of time steps
|
| 110 |
+
user_weight_kg: affects micro-movement amplitude
|
| 111 |
+
noise_scale: multiplier for sensor noise (1.0 = realistic MPU6050)
|
| 112 |
+
rng: random generator for reproducibility
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
imu_data: shape (n_samples, 6) β [ax, ay, az, gx, gy, gz]
|
| 116 |
+
"""
|
| 117 |
+
if rng is None:
|
| 118 |
+
rng = np.random.default_rng(42)
|
| 119 |
+
|
| 120 |
+
sig = POSTURE_SIGNATURES[posture_id]
|
| 121 |
+
|
| 122 |
+
# Base signal + Gaussian noise + slow drift (realistic sensor behavior)
|
| 123 |
+
accel = np.tile(sig["accel_mean"], (n_samples, 1))
|
| 124 |
+
accel += rng.normal(0, sig["accel_std"], (n_samples, 3)) * noise_scale
|
| 125 |
+
|
| 126 |
+
# Add micro-movements (breathing, fidgeting) β frequency ~0.3Hz
|
| 127 |
+
t = np.arange(n_samples) / 50.0
|
| 128 |
+
breathing = 0.02 * np.sin(2 * np.pi * 0.3 * t)
|
| 129 |
+
accel[:, 0] += breathing * (user_weight_kg / 70.0)
|
| 130 |
+
accel[:, 2] += breathing * 0.5
|
| 131 |
+
|
| 132 |
+
# Gyroscope
|
| 133 |
+
gyro = np.tile(sig["gyro_mean"], (n_samples, 1))
|
| 134 |
+
gyro += rng.normal(0, sig["gyro_std"], (n_samples, 3)) * noise_scale
|
| 135 |
+
|
| 136 |
+
# Add slow drift (realistic IMU behavior)
|
| 137 |
+
drift = np.cumsum(rng.normal(0, 0.0005, (n_samples, 3)), axis=0)
|
| 138 |
+
gyro += drift
|
| 139 |
+
|
| 140 |
+
return np.concatenate([accel, gyro], axis=1).astype(np.float32)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def generate_load_cell_data(posture_id: int, n_samples: int,
|
| 144 |
+
user_weight_kg: float = 70.0,
|
| 145 |
+
noise_scale: float = 1.0,
|
| 146 |
+
rng: Optional[np.random.Generator] = None) -> np.ndarray:
|
| 147 |
+
"""
|
| 148 |
+
Generate synthetic load cell data for a given posture.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
posture_id: 0-6 posture class
|
| 152 |
+
n_samples: number of time steps
|
| 153 |
+
user_weight_kg: total body weight
|
| 154 |
+
noise_scale: HX711 noise multiplier
|
| 155 |
+
rng: random generator
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
load_data: shape (n_samples, 4) β [FL, FR, RL, RR] in kg
|
| 159 |
+
"""
|
| 160 |
+
if rng is None:
|
| 161 |
+
rng = np.random.default_rng(42)
|
| 162 |
+
|
| 163 |
+
sig = POSTURE_SIGNATURES[posture_id]
|
| 164 |
+
ratios = np.array(sig["load_ratios"])
|
| 165 |
+
|
| 166 |
+
# Base weight distribution
|
| 167 |
+
base_loads = ratios * user_weight_kg
|
| 168 |
+
|
| 169 |
+
# Temporal variation + noise
|
| 170 |
+
loads = np.tile(base_loads, (n_samples, 1))
|
| 171 |
+
loads += rng.normal(0, sig["load_std"], (n_samples, 4)) * user_weight_kg * noise_scale
|
| 172 |
+
|
| 173 |
+
# Add slow weight shifting (postural micro-adjustments ~0.1Hz)
|
| 174 |
+
t = np.arange(n_samples) / 10.0 # load cells at 10 Hz
|
| 175 |
+
shift = 0.5 * np.sin(2 * np.pi * 0.05 * t)
|
| 176 |
+
loads[:, 0] += shift
|
| 177 |
+
loads[:, 1] -= shift
|
| 178 |
+
|
| 179 |
+
# Ensure non-negative
|
| 180 |
+
loads = np.maximum(loads, 0)
|
| 181 |
+
|
| 182 |
+
return loads.astype(np.float32)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def generate_thermal_data(posture_id: int, n_samples: int,
|
| 186 |
+
ambient_temp: float = 22.0,
|
| 187 |
+
body_temp: float = 34.0,
|
| 188 |
+
rng: Optional[np.random.Generator] = None) -> np.ndarray:
|
| 189 |
+
"""
|
| 190 |
+
Generate synthetic AMG8833 thermal grid data.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
posture_id: 0-6 posture class
|
| 194 |
+
n_samples: number of frames (at 1 Hz)
|
| 195 |
+
ambient_temp: room temperature
|
| 196 |
+
body_temp: skin surface temperature
|
| 197 |
+
rng: random generator
|
| 198 |
+
|
| 199 |
+
Returns:
|
| 200 |
+
thermal_data: shape (n_samples, 8, 8) β temperature grid in Β°C
|
| 201 |
+
"""
|
| 202 |
+
if rng is None:
|
| 203 |
+
rng = np.random.default_rng(42)
|
| 204 |
+
|
| 205 |
+
sig = POSTURE_SIGNATURES[posture_id]
|
| 206 |
+
cy, cx = sig["thermal_center"]
|
| 207 |
+
spread = sig["thermal_spread"]
|
| 208 |
+
|
| 209 |
+
# Create 2D Gaussian heat map (person's thermal signature)
|
| 210 |
+
yy, xx = np.meshgrid(np.arange(8), np.arange(8), indexing='ij')
|
| 211 |
+
|
| 212 |
+
frames = []
|
| 213 |
+
for _ in range(n_samples):
|
| 214 |
+
if posture_id == 6: # absent β ambient only
|
| 215 |
+
frame = ambient_temp + rng.normal(0, 0.3, (8, 8))
|
| 216 |
+
else:
|
| 217 |
+
# Gaussian blob centered at posture-specific position
|
| 218 |
+
dist = np.sqrt((xx - cx)**2 + (yy - cy)**2)
|
| 219 |
+
heat = (body_temp - ambient_temp) * np.exp(-dist**2 / (2 * spread**2))
|
| 220 |
+
frame = ambient_temp + heat + rng.normal(0, 0.5, (8, 8))
|
| 221 |
+
|
| 222 |
+
frames.append(frame)
|
| 223 |
+
|
| 224 |
+
return np.array(frames, dtype=np.float32)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def generate_dataset(n_subjects: int = 20,
|
| 228 |
+
samples_per_posture_per_subject: int = 500,
|
| 229 |
+
posture_classes: int = 7,
|
| 230 |
+
seed: int = 42) -> Dict:
|
| 231 |
+
"""
|
| 232 |
+
Generate a complete synthetic dataset with multiple virtual subjects.
|
| 233 |
+
Each subject has a unique weight and sitting style.
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
dict with keys: imu_data, load_data, thermal_data, labels, subject_ids,
|
| 237 |
+
subject_weights, feature_names
|
| 238 |
+
"""
|
| 239 |
+
rng = np.random.default_rng(seed)
|
| 240 |
+
|
| 241 |
+
all_imu = []
|
| 242 |
+
all_load = []
|
| 243 |
+
all_thermal = []
|
| 244 |
+
all_labels = []
|
| 245 |
+
all_subjects = []
|
| 246 |
+
all_weights = []
|
| 247 |
+
|
| 248 |
+
for subj_id in range(n_subjects):
|
| 249 |
+
# Each subject has unique characteristics
|
| 250 |
+
weight = rng.normal(70, 15) # kg, mean=70, std=15
|
| 251 |
+
weight = np.clip(weight, 40, 120)
|
| 252 |
+
noise_scale = rng.uniform(0.8, 1.2) # individual sensor noise variation
|
| 253 |
+
|
| 254 |
+
for posture_id in range(posture_classes):
|
| 255 |
+
n = samples_per_posture_per_subject
|
| 256 |
+
|
| 257 |
+
imu = generate_imu_data(posture_id, n, weight, noise_scale, rng)
|
| 258 |
+
load = generate_load_cell_data(posture_id, n, weight, noise_scale, rng)
|
| 259 |
+
thermal = generate_thermal_data(posture_id, max(1, n // 50), rng=rng)
|
| 260 |
+
|
| 261 |
+
all_imu.append(imu)
|
| 262 |
+
all_load.append(load)
|
| 263 |
+
all_thermal.append(thermal)
|
| 264 |
+
all_labels.append(np.full(n, posture_id, dtype=np.int32))
|
| 265 |
+
all_subjects.append(np.full(n, subj_id, dtype=np.int32))
|
| 266 |
+
all_weights.append(weight)
|
| 267 |
+
|
| 268 |
+
return {
|
| 269 |
+
"imu_data": np.concatenate(all_imu, axis=0),
|
| 270 |
+
"load_data": np.concatenate(all_load, axis=0),
|
| 271 |
+
"thermal_data": np.concatenate(all_thermal, axis=0),
|
| 272 |
+
"labels": np.concatenate(all_labels, axis=0),
|
| 273 |
+
"subject_ids": np.concatenate(all_subjects, axis=0),
|
| 274 |
+
"subject_weights": np.array(all_weights),
|
| 275 |
+
"n_subjects": n_subjects,
|
| 276 |
+
"n_postures": posture_classes,
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def subject_based_split(dataset: Dict, train_ratio: float = 0.7, seed: int = 42):
|
| 281 |
+
"""
|
| 282 |
+
Split dataset by subject (NOT by sample) to prevent data leakage.
|
| 283 |
+
This is critical β per SSL-Wearables paper, random splits inflate accuracy 5-10%.
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
train_dataset, test_dataset (same dict structure)
|
| 287 |
+
"""
|
| 288 |
+
rng = np.random.default_rng(seed)
|
| 289 |
+
n_subjects = dataset["n_subjects"]
|
| 290 |
+
|
| 291 |
+
subject_ids = np.arange(n_subjects)
|
| 292 |
+
rng.shuffle(subject_ids)
|
| 293 |
+
|
| 294 |
+
n_train = int(n_subjects * train_ratio)
|
| 295 |
+
train_subjects = set(subject_ids[:n_train])
|
| 296 |
+
test_subjects = set(subject_ids[n_train:])
|
| 297 |
+
|
| 298 |
+
train_mask = np.isin(dataset["subject_ids"], list(train_subjects))
|
| 299 |
+
test_mask = np.isin(dataset["subject_ids"], list(test_subjects))
|
| 300 |
+
|
| 301 |
+
def apply_mask(d, mask):
|
| 302 |
+
return {
|
| 303 |
+
"imu_data": d["imu_data"][mask],
|
| 304 |
+
"load_data": d["load_data"][mask],
|
| 305 |
+
"labels": d["labels"][mask],
|
| 306 |
+
"subject_ids": d["subject_ids"][mask],
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
print(f"Subject-based split: {len(train_subjects)} train / {len(test_subjects)} test subjects")
|
| 310 |
+
print(f"Samples: {train_mask.sum()} train / {test_mask.sum()} test")
|
| 311 |
+
|
| 312 |
+
return apply_mask(dataset, train_mask), apply_mask(dataset, test_mask)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
if __name__ == "__main__":
|
| 316 |
+
print("Generating synthetic SmartChair dataset...")
|
| 317 |
+
dataset = generate_dataset(n_subjects=20, samples_per_posture_per_subject=500)
|
| 318 |
+
|
| 319 |
+
print(f"IMU shape: {dataset['imu_data'].shape}")
|
| 320 |
+
print(f"Load shape: {dataset['load_data'].shape}")
|
| 321 |
+
print(f"Labels shape: {dataset['labels'].shape}")
|
| 322 |
+
print(f"Subjects shape: {dataset['subject_ids'].shape}")
|
| 323 |
+
print(f"Class distribution: {np.bincount(dataset['labels'])}")
|
| 324 |
+
|
| 325 |
+
train, test = subject_based_split(dataset)
|
| 326 |
+
print(f"\nTrain: {train['imu_data'].shape[0]} samples")
|
| 327 |
+
print(f"Test: {test['imu_data'].shape[0]} samples")
|
smart_chair/firmware/__init__.py
ADDED
|
File without changes
|
smart_chair/firmware/esp32_firmware.ino
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* SmartChair ESP32-S3 Firmware
|
| 3 |
+
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
+
*
|
| 5 |
+
* Hardware:
|
| 6 |
+
* - ESP32-S3 (Xtensa LX7 dual-core, 240MHz, 8MB PSRAM)
|
| 7 |
+
* - MPU6050 IMU (I2C, 50Hz sampling)
|
| 8 |
+
* - 4Γ HX711 Load Cell Amplifiers (10 SPS each)
|
| 9 |
+
* - AMG8833 8Γ8 Thermal Grid (I2C, 1Hz)
|
| 10 |
+
* - WS2812B Status LED
|
| 11 |
+
* - Vibration Motor (GPIO PWM)
|
| 12 |
+
*
|
| 13 |
+
* ML Inference:
|
| 14 |
+
* - TFLite Micro INT8 posture classifier (<50KB, <30ms inference)
|
| 15 |
+
* - OR: micromlgen Random Forest (~5KB, <5ms inference)
|
| 16 |
+
*
|
| 17 |
+
* Communication:
|
| 18 |
+
* - UART to Raspberry Pi (for complex inference + cloud)
|
| 19 |
+
* - WiFi for direct MQTT (optional, if no RPi)
|
| 20 |
+
* - BLE for mobile app connection
|
| 21 |
+
*
|
| 22 |
+
* Based on: TinyNav ESP32 deployment (arxiv:2603.11071)
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
#include <Wire.h>
|
| 26 |
+
#include <WiFi.h>
|
| 27 |
+
#include <PubSubClient.h>
|
| 28 |
+
#include <ArduinoJson.h>
|
| 29 |
+
|
| 30 |
+
// βββ TFLite Micro (uncomment if using neural network) ββββββββββββββββββββββββ
|
| 31 |
+
// #include "tensorflow/lite/micro/all_ops_resolver.h"
|
| 32 |
+
// #include "tensorflow/lite/micro/micro_interpreter.h"
|
| 33 |
+
// #include "tensorflow/lite/micro/micro_error_reporter.h"
|
| 34 |
+
// #include "model_data.h" // Generated: xxd -i posture_model.tflite > model_data.h
|
| 35 |
+
|
| 36 |
+
// βββ micromlgen (uncomment if using Random Forest) βββββββββββββββββββββββββββ
|
| 37 |
+
// #include "posture_classifier.h" // Generated by micromlgen
|
| 38 |
+
|
| 39 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
// CONFIGURATION
|
| 41 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
|
| 43 |
+
// WiFi
|
| 44 |
+
const char* WIFI_SSID = "YOUR_WIFI_SSID";
|
| 45 |
+
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
|
| 46 |
+
|
| 47 |
+
// MQTT (AWS IoT Core or local Mosquitto)
|
| 48 |
+
const char* MQTT_BROKER = "YOUR_AWS_IOT_ENDPOINT.iot.region.amazonaws.com";
|
| 49 |
+
const int MQTT_PORT = 8883;
|
| 50 |
+
const char* DEVICE_ID = "chair_001";
|
| 51 |
+
|
| 52 |
+
// Pin assignments
|
| 53 |
+
#define IMU_SDA 21
|
| 54 |
+
#define IMU_SCL 22
|
| 55 |
+
#define HX711_DOUT_FL 32
|
| 56 |
+
#define HX711_SCK_FL 33
|
| 57 |
+
#define HX711_DOUT_FR 25
|
| 58 |
+
#define HX711_SCK_FR 26
|
| 59 |
+
#define HX711_DOUT_RL 27
|
| 60 |
+
#define HX711_SCK_RL 14
|
| 61 |
+
#define HX711_DOUT_RR 12
|
| 62 |
+
#define HX711_SCK_RR 13
|
| 63 |
+
#define LED_PIN 48 // WS2812B
|
| 64 |
+
#define VIBRATION_PIN 4 // PWM motor
|
| 65 |
+
#define THERMAL_SDA 21 // shared I2C bus
|
| 66 |
+
#define THERMAL_SCL 22
|
| 67 |
+
|
| 68 |
+
// Sensor sampling
|
| 69 |
+
#define IMU_SAMPLE_HZ 50
|
| 70 |
+
#define LOAD_SAMPLE_HZ 10
|
| 71 |
+
#define THERMAL_SAMPLE_HZ 1
|
| 72 |
+
#define INFERENCE_INTERVAL_MS 2560 // 2.56s window (128 samples @ 50Hz)
|
| 73 |
+
|
| 74 |
+
// ML Model
|
| 75 |
+
#define WINDOW_SIZE 128
|
| 76 |
+
#define NUM_FEATURES 11 // 6 IMU + 4 load + 1 total_weight (for tabular)
|
| 77 |
+
#define NUM_CLASSES 7
|
| 78 |
+
#define TFLITE_ARENA_KB 50
|
| 79 |
+
|
| 80 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
// GLOBAL STATE
|
| 82 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
+
|
| 84 |
+
// Sensor buffers
|
| 85 |
+
float imu_buffer[WINDOW_SIZE][6]; // [ax, ay, az, gx, gy, gz]
|
| 86 |
+
float load_buffer[WINDOW_SIZE][4]; // [FL, FR, RL, RR]
|
| 87 |
+
int buffer_idx = 0;
|
| 88 |
+
bool buffer_full = false;
|
| 89 |
+
|
| 90 |
+
// Current readings
|
| 91 |
+
float accel[3] = {0, 0, 9.81};
|
| 92 |
+
float gyro[3] = {0, 0, 0};
|
| 93 |
+
float load_cells[4] = {0, 0, 0, 0};
|
| 94 |
+
float thermal_max = 22.0;
|
| 95 |
+
|
| 96 |
+
// Inference state
|
| 97 |
+
int current_posture = 0; // 0-6
|
| 98 |
+
float posture_confidence = 0.0;
|
| 99 |
+
unsigned long last_inference_ms = 0;
|
| 100 |
+
unsigned long session_start_ms = 0;
|
| 101 |
+
|
| 102 |
+
// Posture names
|
| 103 |
+
const char* POSTURE_NAMES[] = {
|
| 104 |
+
"upright", "forward_lean", "backward_lean",
|
| 105 |
+
"left_lean", "right_lean", "slouch", "absent"
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
// MQTT
|
| 109 |
+
WiFiClient espClient;
|
| 110 |
+
PubSubClient mqtt(espClient);
|
| 111 |
+
|
| 112 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
// SENSOR READING
|
| 114 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
|
| 116 |
+
void initMPU6050() {
|
| 117 |
+
Wire.begin(IMU_SDA, IMU_SCL);
|
| 118 |
+
Wire.beginTransmission(0x68); // MPU6050 address
|
| 119 |
+
Wire.write(0x6B); // PWR_MGMT_1 register
|
| 120 |
+
Wire.write(0x00); // Wake up
|
| 121 |
+
Wire.endTransmission(true);
|
| 122 |
+
|
| 123 |
+
// Set accelerometer range to Β±4g
|
| 124 |
+
Wire.beginTransmission(0x68);
|
| 125 |
+
Wire.write(0x1C);
|
| 126 |
+
Wire.write(0x08); // Β±4g
|
| 127 |
+
Wire.endTransmission(true);
|
| 128 |
+
|
| 129 |
+
// Set gyroscope range to Β±500Β°/s
|
| 130 |
+
Wire.beginTransmission(0x68);
|
| 131 |
+
Wire.write(0x1B);
|
| 132 |
+
Wire.write(0x08); // Β±500Β°/s
|
| 133 |
+
Wire.endTransmission(true);
|
| 134 |
+
|
| 135 |
+
// Set sample rate to 50Hz
|
| 136 |
+
Wire.beginTransmission(0x68);
|
| 137 |
+
Wire.write(0x19);
|
| 138 |
+
Wire.write(0x09); // 1000/(1+9) = 100Hz internal, DLPF = 50Hz
|
| 139 |
+
Wire.endTransmission(true);
|
| 140 |
+
|
| 141 |
+
Serial.println("[IMU] MPU6050 initialized @ 50Hz, Β±4g, Β±500Β°/s");
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
void readMPU6050() {
|
| 145 |
+
Wire.beginTransmission(0x68);
|
| 146 |
+
Wire.write(0x3B); // Starting register
|
| 147 |
+
Wire.endTransmission(false);
|
| 148 |
+
Wire.requestFrom(0x68, 14, true);
|
| 149 |
+
|
| 150 |
+
// Accelerometer (m/sΒ²)
|
| 151 |
+
accel[0] = (Wire.read() << 8 | Wire.read()) / 8192.0 * 9.81; // Β±4g
|
| 152 |
+
accel[1] = (Wire.read() << 8 | Wire.read()) / 8192.0 * 9.81;
|
| 153 |
+
accel[2] = (Wire.read() << 8 | Wire.read()) / 8192.0 * 9.81;
|
| 154 |
+
|
| 155 |
+
// Temperature (skip)
|
| 156 |
+
Wire.read(); Wire.read();
|
| 157 |
+
|
| 158 |
+
// Gyroscope (rad/s)
|
| 159 |
+
gyro[0] = (Wire.read() << 8 | Wire.read()) / 65.5 * (M_PI / 180.0); // Β±500Β°/s
|
| 160 |
+
gyro[1] = (Wire.read() << 8 | Wire.read()) / 65.5 * (M_PI / 180.0);
|
| 161 |
+
gyro[2] = (Wire.read() << 8 | Wire.read()) / 65.5 * (M_PI / 180.0);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// βββ HX711 Load Cell Reading βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 165 |
+
|
| 166 |
+
long readHX711(int dout_pin, int sck_pin) {
|
| 167 |
+
// Wait for HX711 to be ready
|
| 168 |
+
while (digitalRead(dout_pin));
|
| 169 |
+
|
| 170 |
+
long value = 0;
|
| 171 |
+
for (int i = 0; i < 24; i++) {
|
| 172 |
+
digitalWrite(sck_pin, HIGH);
|
| 173 |
+
delayMicroseconds(1);
|
| 174 |
+
value = (value << 1) | digitalRead(dout_pin);
|
| 175 |
+
digitalWrite(sck_pin, LOW);
|
| 176 |
+
delayMicroseconds(1);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// 25th pulse: gain = 128 (channel A)
|
| 180 |
+
digitalWrite(sck_pin, HIGH);
|
| 181 |
+
delayMicroseconds(1);
|
| 182 |
+
digitalWrite(sck_pin, LOW);
|
| 183 |
+
delayMicroseconds(1);
|
| 184 |
+
|
| 185 |
+
// Sign extend
|
| 186 |
+
if (value & 0x800000) value |= 0xFF000000;
|
| 187 |
+
|
| 188 |
+
return value;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
void readLoadCells() {
|
| 192 |
+
static float calibration[4] = {420.0, 420.0, 420.0, 420.0}; // calibration factors
|
| 193 |
+
static long tare[4] = {0, 0, 0, 0};
|
| 194 |
+
|
| 195 |
+
long raw_fl = readHX711(HX711_DOUT_FL, HX711_SCK_FL);
|
| 196 |
+
long raw_fr = readHX711(HX711_DOUT_FR, HX711_SCK_FR);
|
| 197 |
+
long raw_rl = readHX711(HX711_DOUT_RL, HX711_SCK_RL);
|
| 198 |
+
long raw_rr = readHX711(HX711_DOUT_RR, HX711_SCK_RR);
|
| 199 |
+
|
| 200 |
+
load_cells[0] = (raw_fl - tare[0]) / calibration[0]; // FL in kg
|
| 201 |
+
load_cells[1] = (raw_fr - tare[1]) / calibration[1]; // FR in kg
|
| 202 |
+
load_cells[2] = (raw_rl - tare[2]) / calibration[2]; // RL in kg
|
| 203 |
+
load_cells[3] = (raw_rr - tare[3]) / calibration[3]; // RR in kg
|
| 204 |
+
|
| 205 |
+
// Clamp to reasonable range
|
| 206 |
+
for (int i = 0; i < 4; i++) {
|
| 207 |
+
load_cells[i] = max(0.0f, min(200.0f, load_cells[i]));
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// βββ Thermal Presence Detection ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
+
|
| 213 |
+
bool readThermalPresence() {
|
| 214 |
+
// AMG8833 at I2C address 0x69
|
| 215 |
+
Wire.beginTransmission(0x69);
|
| 216 |
+
Wire.write(0x80); // Pixel data start register
|
| 217 |
+
Wire.endTransmission(false);
|
| 218 |
+
|
| 219 |
+
float max_temp = 0;
|
| 220 |
+
for (int i = 0; i < 64; i++) {
|
| 221 |
+
Wire.requestFrom(0x69, 2);
|
| 222 |
+
int16_t raw = Wire.read() | (Wire.read() << 8);
|
| 223 |
+
float temp = raw * 0.25; // 0.25Β°C resolution
|
| 224 |
+
if (temp > max_temp) max_temp = temp;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
thermal_max = max_temp;
|
| 228 |
+
return max_temp > 28.0; // Human presence threshold
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
+
// FEATURE EXTRACTION (on-device)
|
| 233 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 234 |
+
|
| 235 |
+
void extractFeatures(float* features) {
|
| 236 |
+
// Tabular features for Random Forest / ensemble classifier
|
| 237 |
+
// 11 features: 6 IMU stats + 4 load cell ratios + 1 total weight
|
| 238 |
+
|
| 239 |
+
float acc_mean[3] = {0}, gyro_mean[3] = {0};
|
| 240 |
+
float total_weight_sum = 0;
|
| 241 |
+
|
| 242 |
+
for (int i = 0; i < WINDOW_SIZE; i++) {
|
| 243 |
+
for (int j = 0; j < 3; j++) {
|
| 244 |
+
acc_mean[j] += imu_buffer[i][j];
|
| 245 |
+
gyro_mean[j] += imu_buffer[i][j + 3];
|
| 246 |
+
}
|
| 247 |
+
for (int j = 0; j < 4; j++) {
|
| 248 |
+
total_weight_sum += load_buffer[i][j];
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
for (int j = 0; j < 3; j++) {
|
| 253 |
+
acc_mean[j] /= WINDOW_SIZE;
|
| 254 |
+
gyro_mean[j] /= WINDOW_SIZE;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
float total_weight = total_weight_sum / WINDOW_SIZE;
|
| 258 |
+
|
| 259 |
+
// Compute pitch and roll (degrees)
|
| 260 |
+
float pitch = atan2(acc_mean[0], sqrt(acc_mean[1]*acc_mean[1] + acc_mean[2]*acc_mean[2])) * 180.0 / M_PI;
|
| 261 |
+
float roll = atan2(acc_mean[1], sqrt(acc_mean[0]*acc_mean[0] + acc_mean[2]*acc_mean[2])) * 180.0 / M_PI;
|
| 262 |
+
|
| 263 |
+
// Load cell ratios (last reading)
|
| 264 |
+
float lc_total = load_cells[0] + load_cells[1] + load_cells[2] + load_cells[3] + 0.001;
|
| 265 |
+
|
| 266 |
+
// Pack features
|
| 267 |
+
features[0] = pitch;
|
| 268 |
+
features[1] = roll;
|
| 269 |
+
features[2] = acc_mean[2]; // vertical acceleration
|
| 270 |
+
features[3] = gyro_mean[0]; // roll rate
|
| 271 |
+
features[4] = gyro_mean[1]; // pitch rate
|
| 272 |
+
features[5] = gyro_mean[2]; // yaw rate
|
| 273 |
+
features[6] = load_cells[0] / lc_total; // FL ratio
|
| 274 |
+
features[7] = load_cells[1] / lc_total; // FR ratio
|
| 275 |
+
features[8] = load_cells[2] / lc_total; // RL ratio
|
| 276 |
+
features[9] = load_cells[3] / lc_total; // RR ratio
|
| 277 |
+
features[10] = total_weight;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 281 |
+
// ML INFERENCE
|
| 282 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 283 |
+
|
| 284 |
+
/*
|
| 285 |
+
* Option A: TFLite Micro (neural network)
|
| 286 |
+
* Uncomment the TFLite includes above and this function
|
| 287 |
+
*/
|
| 288 |
+
/*
|
| 289 |
+
static uint8_t tensor_arena[TFLITE_ARENA_KB * 1024];
|
| 290 |
+
|
| 291 |
+
void runTFLiteInference(float* features) {
|
| 292 |
+
static bool initialized = false;
|
| 293 |
+
static tflite::MicroInterpreter* interpreter = nullptr;
|
| 294 |
+
|
| 295 |
+
if (!initialized) {
|
| 296 |
+
static tflite::AllOpsResolver resolver;
|
| 297 |
+
const tflite::Model* model = tflite::GetModel(g_model);
|
| 298 |
+
static tflite::MicroInterpreter static_interpreter(
|
| 299 |
+
model, resolver, tensor_arena, TFLITE_ARENA_KB * 1024);
|
| 300 |
+
interpreter = &static_interpreter;
|
| 301 |
+
interpreter->AllocateTensors();
|
| 302 |
+
initialized = true;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// Copy features to input tensor
|
| 306 |
+
TfLiteTensor* input = interpreter->input(0);
|
| 307 |
+
for (int i = 0; i < NUM_FEATURES; i++) {
|
| 308 |
+
input->data.f[i] = features[i]; // or quantize for INT8
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// Run inference
|
| 312 |
+
interpreter->Invoke();
|
| 313 |
+
|
| 314 |
+
// Get output
|
| 315 |
+
TfLiteTensor* output = interpreter->output(0);
|
| 316 |
+
float max_prob = -1;
|
| 317 |
+
for (int i = 0; i < NUM_CLASSES; i++) {
|
| 318 |
+
float prob = output->data.f[i];
|
| 319 |
+
if (prob > max_prob) {
|
| 320 |
+
max_prob = prob;
|
| 321 |
+
current_posture = i;
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
posture_confidence = max_prob;
|
| 325 |
+
}
|
| 326 |
+
*/
|
| 327 |
+
|
| 328 |
+
/*
|
| 329 |
+
* Option B: micromlgen Random Forest (lighter, no framework needed)
|
| 330 |
+
* Uncomment posture_classifier.h include and this function
|
| 331 |
+
*/
|
| 332 |
+
/*
|
| 333 |
+
void runRFInference(float* features) {
|
| 334 |
+
current_posture = posture_classifier_predict(features);
|
| 335 |
+
posture_confidence = 0.95; // RF doesn't provide per-sample probabilities
|
| 336 |
+
}
|
| 337 |
+
*/
|
| 338 |
+
|
| 339 |
+
// Rule-based fallback (works without ML model, for testing)
|
| 340 |
+
void runRuleBasedInference(float* features) {
|
| 341 |
+
float pitch = features[0];
|
| 342 |
+
float roll = features[1];
|
| 343 |
+
float total_weight = features[10];
|
| 344 |
+
|
| 345 |
+
// No one sitting
|
| 346 |
+
if (total_weight < 5.0 || !readThermalPresence()) {
|
| 347 |
+
current_posture = 6; // absent
|
| 348 |
+
posture_confidence = 0.99;
|
| 349 |
+
return;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
// Rule-based posture detection from angles
|
| 353 |
+
if (abs(pitch) < 10 && abs(roll) < 10) {
|
| 354 |
+
current_posture = 0; // upright
|
| 355 |
+
posture_confidence = 0.85;
|
| 356 |
+
} else if (pitch > 20) {
|
| 357 |
+
current_posture = 5; // slouch (deep forward)
|
| 358 |
+
posture_confidence = 0.80;
|
| 359 |
+
} else if (pitch > 10) {
|
| 360 |
+
current_posture = 1; // forward lean
|
| 361 |
+
posture_confidence = 0.75;
|
| 362 |
+
} else if (pitch < -10) {
|
| 363 |
+
current_posture = 2; // backward lean
|
| 364 |
+
posture_confidence = 0.75;
|
| 365 |
+
} else if (roll < -10) {
|
| 366 |
+
current_posture = 3; // left lean
|
| 367 |
+
posture_confidence = 0.70;
|
| 368 |
+
} else if (roll > 10) {
|
| 369 |
+
current_posture = 4; // right lean
|
| 370 |
+
posture_confidence = 0.70;
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 375 |
+
// MQTT PUBLISHING
|
| 376 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 377 |
+
|
| 378 |
+
void publishTelemetry() {
|
| 379 |
+
StaticJsonDocument<512> doc;
|
| 380 |
+
|
| 381 |
+
doc["device_id"] = DEVICE_ID;
|
| 382 |
+
doc["timestamp"] = millis();
|
| 383 |
+
doc["posture"] = current_posture;
|
| 384 |
+
doc["posture_name"] = POSTURE_NAMES[current_posture];
|
| 385 |
+
doc["confidence"] = posture_confidence;
|
| 386 |
+
|
| 387 |
+
float lc_total = load_cells[0] + load_cells[1] + load_cells[2] + load_cells[3];
|
| 388 |
+
float cop_x = (load_cells[1] + load_cells[3] - load_cells[0] - load_cells[2]) / (lc_total + 0.001);
|
| 389 |
+
float cop_y = (load_cells[0] + load_cells[1] - load_cells[2] - load_cells[3]) / (lc_total + 0.001);
|
| 390 |
+
|
| 391 |
+
doc["cop_x"] = cop_x;
|
| 392 |
+
doc["cop_y"] = cop_y;
|
| 393 |
+
doc["weight_kg"] = lc_total;
|
| 394 |
+
doc["thermal_present"] = thermal_max > 28.0;
|
| 395 |
+
doc["session_min"] = (millis() - session_start_ms) / 60000.0;
|
| 396 |
+
|
| 397 |
+
char buffer[512];
|
| 398 |
+
serializeJson(doc, buffer);
|
| 399 |
+
|
| 400 |
+
// Publish to MQTT or send via UART to RPi
|
| 401 |
+
if (mqtt.connected()) {
|
| 402 |
+
char topic[64];
|
| 403 |
+
snprintf(topic, sizeof(topic), "smartchair/%s/telemetry", DEVICE_ID);
|
| 404 |
+
mqtt.publish(topic, buffer);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
// Always send to RPi via UART for complex processing
|
| 408 |
+
Serial.println(buffer);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 412 |
+
// HAPTIC & VISUAL FEEDBACK
|
| 413 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 414 |
+
|
| 415 |
+
void updateFeedback() {
|
| 416 |
+
// LED color based on posture quality
|
| 417 |
+
// Green = good, Yellow = moderate, Red = bad
|
| 418 |
+
switch(current_posture) {
|
| 419 |
+
case 0: // upright
|
| 420 |
+
// setLED(0, 255, 0); // green
|
| 421 |
+
break;
|
| 422 |
+
case 1: // forward lean
|
| 423 |
+
case 2: // backward lean
|
| 424 |
+
// setLED(255, 165, 0); // orange
|
| 425 |
+
break;
|
| 426 |
+
case 3: // left lean
|
| 427 |
+
case 4: // right lean
|
| 428 |
+
// setLED(255, 255, 0); // yellow
|
| 429 |
+
break;
|
| 430 |
+
case 5: // slouch
|
| 431 |
+
// setLED(255, 0, 0); // red
|
| 432 |
+
// Gentle vibration alert
|
| 433 |
+
analogWrite(VIBRATION_PIN, 128);
|
| 434 |
+
delay(200);
|
| 435 |
+
analogWrite(VIBRATION_PIN, 0);
|
| 436 |
+
break;
|
| 437 |
+
}
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 441 |
+
// MAIN SETUP & LOOP
|
| 442 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 443 |
+
|
| 444 |
+
void setup() {
|
| 445 |
+
Serial.begin(115200);
|
| 446 |
+
Serial.println("\nββββββββββββββββββββββββββββββββββββ");
|
| 447 |
+
Serial.println("β SmartChair ESP32-S3 Firmware β");
|
| 448 |
+
Serial.println("β v1.0 β Posture Intelligence β");
|
| 449 |
+
Serial.println("ββββββββββββββββββββββββββββββββββββ\n");
|
| 450 |
+
|
| 451 |
+
// Initialize I2C
|
| 452 |
+
Wire.begin(IMU_SDA, IMU_SCL);
|
| 453 |
+
Wire.setClock(400000); // 400kHz I2C
|
| 454 |
+
|
| 455 |
+
// Initialize sensors
|
| 456 |
+
initMPU6050();
|
| 457 |
+
|
| 458 |
+
// HX711 pins
|
| 459 |
+
pinMode(HX711_DOUT_FL, INPUT);
|
| 460 |
+
pinMode(HX711_SCK_FL, OUTPUT);
|
| 461 |
+
pinMode(HX711_DOUT_FR, INPUT);
|
| 462 |
+
pinMode(HX711_SCK_FR, OUTPUT);
|
| 463 |
+
pinMode(HX711_DOUT_RL, INPUT);
|
| 464 |
+
pinMode(HX711_SCK_RL, OUTPUT);
|
| 465 |
+
pinMode(HX711_DOUT_RR, INPUT);
|
| 466 |
+
pinMode(HX711_SCK_RR, OUTPUT);
|
| 467 |
+
|
| 468 |
+
// Feedback pins
|
| 469 |
+
pinMode(VIBRATION_PIN, OUTPUT);
|
| 470 |
+
|
| 471 |
+
// WiFi
|
| 472 |
+
WiFi.begin(WIFI_SSID, WIFI_PASS);
|
| 473 |
+
int wifi_attempts = 0;
|
| 474 |
+
while (WiFi.status() != WL_CONNECTED && wifi_attempts < 20) {
|
| 475 |
+
delay(500);
|
| 476 |
+
Serial.print(".");
|
| 477 |
+
wifi_attempts++;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
if (WiFi.status() == WL_CONNECTED) {
|
| 481 |
+
Serial.printf("\n[WiFi] Connected: %s\n", WiFi.localIP().toString().c_str());
|
| 482 |
+
|
| 483 |
+
// MQTT setup
|
| 484 |
+
mqtt.setServer(MQTT_BROKER, MQTT_PORT);
|
| 485 |
+
if (mqtt.connect(DEVICE_ID)) {
|
| 486 |
+
Serial.println("[MQTT] Connected to broker");
|
| 487 |
+
}
|
| 488 |
+
} else {
|
| 489 |
+
Serial.println("\n[WiFi] Offline mode β data sent via UART only");
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
session_start_ms = millis();
|
| 493 |
+
Serial.println("[Ready] SmartChair sensor fusion active\n");
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
void loop() {
|
| 497 |
+
unsigned long now = millis();
|
| 498 |
+
|
| 499 |
+
// ββ IMU Sampling (50 Hz) βββββββββββββββββββββββββββββββββββββββββββββ
|
| 500 |
+
static unsigned long last_imu = 0;
|
| 501 |
+
if (now - last_imu >= 20) { // 20ms = 50Hz
|
| 502 |
+
readMPU6050();
|
| 503 |
+
|
| 504 |
+
imu_buffer[buffer_idx][0] = accel[0];
|
| 505 |
+
imu_buffer[buffer_idx][1] = accel[1];
|
| 506 |
+
imu_buffer[buffer_idx][2] = accel[2];
|
| 507 |
+
imu_buffer[buffer_idx][3] = gyro[0];
|
| 508 |
+
imu_buffer[buffer_idx][4] = gyro[1];
|
| 509 |
+
imu_buffer[buffer_idx][5] = gyro[2];
|
| 510 |
+
|
| 511 |
+
last_imu = now;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
// ββ Load Cell Sampling (10 Hz) βββββββββββββββββββββββββββββββββββββββ
|
| 515 |
+
static unsigned long last_load = 0;
|
| 516 |
+
if (now - last_load >= 100) { // 100ms = 10Hz
|
| 517 |
+
readLoadCells();
|
| 518 |
+
|
| 519 |
+
load_buffer[buffer_idx][0] = load_cells[0];
|
| 520 |
+
load_buffer[buffer_idx][1] = load_cells[1];
|
| 521 |
+
load_buffer[buffer_idx][2] = load_cells[2];
|
| 522 |
+
load_buffer[buffer_idx][3] = load_cells[3];
|
| 523 |
+
|
| 524 |
+
last_load = now;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
// ββ Buffer Management ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 528 |
+
buffer_idx++;
|
| 529 |
+
if (buffer_idx >= WINDOW_SIZE) {
|
| 530 |
+
buffer_idx = 0;
|
| 531 |
+
buffer_full = true;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
// ββ ML Inference (every 2.56 seconds) ββββββββββββββββββββββββββββββββ
|
| 535 |
+
if (buffer_full && (now - last_inference_ms >= INFERENCE_INTERVAL_MS)) {
|
| 536 |
+
float features[NUM_FEATURES];
|
| 537 |
+
extractFeatures(features);
|
| 538 |
+
|
| 539 |
+
// Choose inference method:
|
| 540 |
+
// runTFLiteInference(features); // Option A: Neural network
|
| 541 |
+
// runRFInference(features); // Option B: Random Forest
|
| 542 |
+
runRuleBasedInference(features); // Option C: Rule-based fallback
|
| 543 |
+
|
| 544 |
+
updateFeedback();
|
| 545 |
+
|
| 546 |
+
last_inference_ms = now;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
// ββ Telemetry Publishing (every 5 seconds) βββββββββββββββββββββββββββ
|
| 550 |
+
static unsigned long last_publish = 0;
|
| 551 |
+
if (now - last_publish >= 5000) {
|
| 552 |
+
publishTelemetry();
|
| 553 |
+
last_publish = now;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
// ββ MQTT Maintenance βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 557 |
+
if (mqtt.connected()) {
|
| 558 |
+
mqtt.loop();
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
// Small delay to prevent watchdog timeout
|
| 562 |
+
delay(1);
|
| 563 |
+
}
|
smart_chair/main.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair Main Orchestrator β Integrates all subsystems.
|
| 3 |
+
|
| 4 |
+
This is the Raspberry Pi main loop that:
|
| 5 |
+
1. Receives sensor data from ESP32 via UART
|
| 6 |
+
2. Runs posture classification (ensemble or MLSTM-FCN)
|
| 7 |
+
3. Computes RULA risk score
|
| 8 |
+
4. Detects fatigue and drowsiness
|
| 9 |
+
5. Recognizes the current user
|
| 10 |
+
6. Manages break recommendations
|
| 11 |
+
7. Suggests exercises
|
| 12 |
+
8. Updates gamification scores
|
| 13 |
+
9. Publishes to AWS IoT Core
|
| 14 |
+
10. Feeds the dashboard
|
| 15 |
+
|
| 16 |
+
Architecture:
|
| 17 |
+
ESP32 β UART (115200) β RPi Main Loop β AWS IoT Core
|
| 18 |
+
β Local Dashboard (Flask/Streamlit)
|
| 19 |
+
β SQLite (local history)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import time
|
| 24 |
+
import numpy as np
|
| 25 |
+
from datetime import datetime
|
| 26 |
+
from typing import Dict, Optional
|
| 27 |
+
|
| 28 |
+
# SmartChair modules
|
| 29 |
+
import sys
|
| 30 |
+
sys.path.insert(0, '/app/smart_chair')
|
| 31 |
+
sys.path.insert(0, '/app')
|
| 32 |
+
|
| 33 |
+
from config.settings import *
|
| 34 |
+
from utils.feature_engineering import compute_imu_angles, compute_center_of_pressure
|
| 35 |
+
from ml_models.posture_classifier import EnsemblePostureClassifier
|
| 36 |
+
from ml_models.spine_risk_predictor import RULAScorer, SpineRiskPredictor, FatigueDetector, InjuryRiskAlertSystem
|
| 37 |
+
from ml_models.user_recognition import MultiUserRecognizer, PersonalizedSittingModel
|
| 38 |
+
from ml_models.break_recommendation import AdaptiveBreakEngine, ExerciseSuggestionEngine
|
| 39 |
+
from ml_models.gamification import PostureScorer, GamificationEngine
|
| 40 |
+
from cloud.aws_iot_integration import AWSIoTClient, TelemetryPayload, AlertPayload
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class SmartChairSystem:
|
| 44 |
+
"""
|
| 45 |
+
Main system orchestrator. Coordinates all subsystems in real-time.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def __init__(self, device_id: str = "chair_001"):
|
| 49 |
+
print("ββββββββββββββββββββββββββββββββββββββββββββββββ")
|
| 50 |
+
print("β SmartChair AI System β Initializing... β")
|
| 51 |
+
print("ββββββββββββββββββββββββββββββββββββββββββββββββ")
|
| 52 |
+
|
| 53 |
+
self.device_id = device_id
|
| 54 |
+
self.current_user = "unknown"
|
| 55 |
+
self.session_start = datetime.now()
|
| 56 |
+
self.current_posture = "upright"
|
| 57 |
+
self.current_posture_id = 0
|
| 58 |
+
self.posture_start_time = datetime.now()
|
| 59 |
+
|
| 60 |
+
# ββ Initialize Subsystems ββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
print("[1/8] Loading posture classifier...")
|
| 62 |
+
self.classifier = EnsemblePostureClassifier()
|
| 63 |
+
|
| 64 |
+
print("[2/8] Initializing RULA scorer...")
|
| 65 |
+
self.rula_scorer = RULAScorer()
|
| 66 |
+
|
| 67 |
+
print("[3/8] Initializing spine risk predictor...")
|
| 68 |
+
self.risk_predictor = SpineRiskPredictor()
|
| 69 |
+
|
| 70 |
+
print("[4/8] Initializing fatigue detector...")
|
| 71 |
+
self.fatigue_detector = FatigueDetector()
|
| 72 |
+
|
| 73 |
+
print("[5/8] Initializing user recognizer...")
|
| 74 |
+
self.user_recognizer = MultiUserRecognizer()
|
| 75 |
+
self.user_models = {} # user_id β PersonalizedSittingModel
|
| 76 |
+
|
| 77 |
+
print("[6/8] Initializing break engine...")
|
| 78 |
+
self.break_engine = AdaptiveBreakEngine()
|
| 79 |
+
self.exercise_engine = ExerciseSuggestionEngine()
|
| 80 |
+
|
| 81 |
+
print("[7/8] Initializing gamification...")
|
| 82 |
+
self.posture_scorer = PostureScorer()
|
| 83 |
+
self.gamification = {} # user_id β GamificationEngine
|
| 84 |
+
|
| 85 |
+
print("[8/8] Initializing injury alert system...")
|
| 86 |
+
self.alert_system = InjuryRiskAlertSystem()
|
| 87 |
+
|
| 88 |
+
# AWS IoT (optional)
|
| 89 |
+
self.iot_client = AWSIoTClient(device_id)
|
| 90 |
+
|
| 91 |
+
print("\nβ
SmartChair AI System ready!\n")
|
| 92 |
+
|
| 93 |
+
def process_sensor_reading(self, imu_data: np.ndarray, load_data: np.ndarray,
|
| 94 |
+
thermal_present: bool = True) -> Dict:
|
| 95 |
+
"""
|
| 96 |
+
Process a single sensor reading through the full pipeline.
|
| 97 |
+
Called at the inference rate (every ~2.56 seconds).
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
imu_data: shape (128, 6) β window of IMU readings
|
| 101 |
+
load_data: shape (128, 4) β window of load cell readings
|
| 102 |
+
thermal_present: whether thermal sensor detects a person
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Full system state dict with all subsystem outputs
|
| 106 |
+
"""
|
| 107 |
+
now = datetime.now()
|
| 108 |
+
result = {"timestamp": now.isoformat(), "device_id": self.device_id}
|
| 109 |
+
|
| 110 |
+
# ββ 1. Human Presence Detection βββββββββββββββββββββββββββββββ
|
| 111 |
+
total_weight = np.mean(np.sum(load_data, axis=1))
|
| 112 |
+
if not thermal_present or total_weight < 5.0:
|
| 113 |
+
self.current_posture = "absent"
|
| 114 |
+
self.current_posture_id = 6
|
| 115 |
+
result["posture"] = {"class": 6, "name": "absent", "confidence": 0.99}
|
| 116 |
+
return result
|
| 117 |
+
|
| 118 |
+
# ββ 2. Posture Classification ββββββββββοΏ½οΏ½ββββββββββββββββββββββ
|
| 119 |
+
if self.classifier.is_trained:
|
| 120 |
+
posture_id = self.classifier.predict(imu_data, load_data)
|
| 121 |
+
proba = self.classifier.predict_proba(imu_data, load_data)
|
| 122 |
+
confidence = float(np.max(proba))
|
| 123 |
+
else:
|
| 124 |
+
# Rule-based fallback
|
| 125 |
+
pitch, roll = compute_imu_angles(imu_data[:, :3])
|
| 126 |
+
avg_pitch = np.mean(pitch)
|
| 127 |
+
avg_roll = np.mean(roll)
|
| 128 |
+
posture_id, confidence = self._rule_based_classify(avg_pitch, avg_roll, total_weight)
|
| 129 |
+
proba = np.zeros(NUM_POSTURE_CLASSES)
|
| 130 |
+
proba[posture_id] = confidence
|
| 131 |
+
|
| 132 |
+
posture_name = POSTURE_CLASSES[posture_id]
|
| 133 |
+
|
| 134 |
+
# Track posture change timing
|
| 135 |
+
if posture_id != self.current_posture_id:
|
| 136 |
+
self.posture_start_time = now
|
| 137 |
+
|
| 138 |
+
self.current_posture_id = posture_id
|
| 139 |
+
self.current_posture = posture_name
|
| 140 |
+
|
| 141 |
+
posture_duration_min = (now - self.posture_start_time).total_seconds() / 60
|
| 142 |
+
|
| 143 |
+
result["posture"] = {
|
| 144 |
+
"class": int(posture_id),
|
| 145 |
+
"name": posture_name,
|
| 146 |
+
"confidence": round(confidence, 3),
|
| 147 |
+
"duration_minutes": round(posture_duration_min, 1),
|
| 148 |
+
"probabilities": {POSTURE_CLASSES[i]: round(float(p), 3) for i, p in enumerate(proba)},
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
# ββ 3. RULA Ergonomic Risk Score ββββββββββββββββββββββββββββββ
|
| 152 |
+
pitch, roll = compute_imu_angles(imu_data[:, :3])
|
| 153 |
+
avg_pitch = float(np.mean(pitch))
|
| 154 |
+
avg_roll = float(np.mean(roll))
|
| 155 |
+
avg_yaw_rate = float(np.mean(imu_data[:, 5]))
|
| 156 |
+
|
| 157 |
+
rula = self.rula_scorer.compute_rula_score(
|
| 158 |
+
avg_pitch, avg_roll, avg_yaw_rate,
|
| 159 |
+
posture_duration_min, is_static=True
|
| 160 |
+
)
|
| 161 |
+
result["rula"] = rula
|
| 162 |
+
|
| 163 |
+
# ββ 4. Spine Risk Prediction ββββββββββββββββββββββββββββββββββ
|
| 164 |
+
self.risk_predictor.record_posture(posture_name, rula["rula_score"])
|
| 165 |
+
session_risk = self.risk_predictor.compute_session_risk(lookback_minutes=60)
|
| 166 |
+
result["spine_risk"] = session_risk
|
| 167 |
+
|
| 168 |
+
# ββ 5. Fatigue & Drowsiness Detection βββββββββββββββββββββββββ
|
| 169 |
+
for i in range(min(10, len(load_data))):
|
| 170 |
+
self.fatigue_detector.update(load_data[i], imu_data[i * 5] if i * 5 < len(imu_data) else imu_data[-1])
|
| 171 |
+
|
| 172 |
+
fatigue = self.fatigue_detector.analyze_fatigue()
|
| 173 |
+
result["fatigue"] = fatigue
|
| 174 |
+
|
| 175 |
+
# ββ 6. User Recognition βββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
if self.user_recognizer.is_trained:
|
| 177 |
+
user_result = self.user_recognizer.recognize(load_data[:50])
|
| 178 |
+
self.current_user = user_result["user_id"]
|
| 179 |
+
result["user"] = user_result
|
| 180 |
+
else:
|
| 181 |
+
result["user"] = {"user_id": "unknown", "confidence": 0}
|
| 182 |
+
|
| 183 |
+
# ββ 7. Break Recommendation βββββββββββββββββββββββββββββββββββ
|
| 184 |
+
self.break_engine.update_posture(posture_name, rula["rula_score"])
|
| 185 |
+
|
| 186 |
+
personal_interval = None
|
| 187 |
+
if self.current_user in self.user_models:
|
| 188 |
+
personal_interval = self.user_models[self.current_user].get_optimal_break_interval()
|
| 189 |
+
|
| 190 |
+
break_rec = self.break_engine.compute_urgency(
|
| 191 |
+
fatigue_level=fatigue["fatigue_level"],
|
| 192 |
+
current_rula=rula["rula_score"],
|
| 193 |
+
personal_interval=personal_interval,
|
| 194 |
+
)
|
| 195 |
+
result["break_recommendation"] = break_rec
|
| 196 |
+
|
| 197 |
+
# ββ 8. Exercise Suggestions (when break is needed) ββββββββββββ
|
| 198 |
+
if break_rec["urgency_level"] in ["moderate", "urgent"]:
|
| 199 |
+
session_min = (now - self.session_start).total_seconds() / 60
|
| 200 |
+
exercises = self.exercise_engine.suggest_exercises(
|
| 201 |
+
dominant_posture=posture_name,
|
| 202 |
+
rula_score=rula["rula_score"],
|
| 203 |
+
session_duration_min=session_min,
|
| 204 |
+
break_type="micro" if break_rec["urgency_level"] == "moderate" else "full",
|
| 205 |
+
)
|
| 206 |
+
result["exercise_suggestions"] = exercises
|
| 207 |
+
|
| 208 |
+
# ββ 9. Gamification Update ββββββββββββββββββββββββββββββββββββ
|
| 209 |
+
self.posture_scorer.record_posture(posture_name, 2.56 / 60) # ~2.56s per reading
|
| 210 |
+
|
| 211 |
+
if self.current_user != "unknown" and self.current_user in self.gamification:
|
| 212 |
+
if posture_name == "upright":
|
| 213 |
+
self.gamification[self.current_user].award_posture_points(2.56 / 60)
|
| 214 |
+
|
| 215 |
+
daily_score = self.posture_scorer.compute_daily_score()
|
| 216 |
+
result["posture_score"] = daily_score
|
| 217 |
+
|
| 218 |
+
# ββ 10. Injury Risk Alerts ββββββββββββββββββββββββββββββββββββ
|
| 219 |
+
acute_alert = self.alert_system.check_acute_risk(
|
| 220 |
+
rula["rula_score"], posture_name, posture_duration_min
|
| 221 |
+
)
|
| 222 |
+
if acute_alert:
|
| 223 |
+
result["alert"] = acute_alert
|
| 224 |
+
# Publish alert to cloud
|
| 225 |
+
self._publish_alert(acute_alert)
|
| 226 |
+
|
| 227 |
+
# ββ 11. Publish to Cloud ββββββββββββββββββββββββββββββββββββββ
|
| 228 |
+
self._publish_telemetry(result)
|
| 229 |
+
|
| 230 |
+
return result
|
| 231 |
+
|
| 232 |
+
def _rule_based_classify(self, pitch: float, roll: float, weight: float):
|
| 233 |
+
"""Fallback rule-based classification when ML model not loaded."""
|
| 234 |
+
if weight < 5.0:
|
| 235 |
+
return 6, 0.99 # absent
|
| 236 |
+
if abs(pitch) < 10 and abs(roll) < 10:
|
| 237 |
+
return 0, 0.85 # upright
|
| 238 |
+
if pitch > 20:
|
| 239 |
+
return 5, 0.80 # slouch
|
| 240 |
+
if pitch > 10:
|
| 241 |
+
return 1, 0.75 # forward lean
|
| 242 |
+
if pitch < -10:
|
| 243 |
+
return 2, 0.75 # backward lean
|
| 244 |
+
if roll < -10:
|
| 245 |
+
return 3, 0.70 # left lean
|
| 246 |
+
if roll > 10:
|
| 247 |
+
return 4, 0.70 # right lean
|
| 248 |
+
return 0, 0.60 # default upright
|
| 249 |
+
|
| 250 |
+
def _publish_telemetry(self, result: Dict):
|
| 251 |
+
"""Publish processed data to AWS IoT Core."""
|
| 252 |
+
payload = TelemetryPayload(
|
| 253 |
+
device_id=self.device_id,
|
| 254 |
+
timestamp=int(time.time() * 1000),
|
| 255 |
+
user_id=self.current_user,
|
| 256 |
+
posture_class=result["posture"]["class"],
|
| 257 |
+
posture_name=result["posture"]["name"],
|
| 258 |
+
posture_confidence=result["posture"]["confidence"],
|
| 259 |
+
rula_score=result["rula"]["rula_score"],
|
| 260 |
+
risk_level=result["rula"]["risk_level"],
|
| 261 |
+
fatigue_level=result["fatigue"]["fatigue_level"],
|
| 262 |
+
session_duration_min=result["break_recommendation"]["session_duration_min"],
|
| 263 |
+
)
|
| 264 |
+
self.iot_client.publish_telemetry(payload)
|
| 265 |
+
|
| 266 |
+
def _publish_alert(self, alert: Dict):
|
| 267 |
+
"""Publish alert to AWS IoT Core."""
|
| 268 |
+
payload = AlertPayload(
|
| 269 |
+
device_id=self.device_id,
|
| 270 |
+
timestamp=int(time.time() * 1000),
|
| 271 |
+
alert_type=alert["type"],
|
| 272 |
+
severity=alert["severity"],
|
| 273 |
+
message=alert["message"],
|
| 274 |
+
action=alert.get("action", ""),
|
| 275 |
+
user_id=self.current_user,
|
| 276 |
+
)
|
| 277 |
+
self.iot_client.publish_alert(payload)
|
| 278 |
+
|
| 279 |
+
def enroll_user(self, user_id: str, sit_events: list):
|
| 280 |
+
"""Enroll a new user for recognition."""
|
| 281 |
+
result = self.user_recognizer.enroll_user(user_id, sit_events)
|
| 282 |
+
if result["status"] == "enrolled":
|
| 283 |
+
self.user_models[user_id] = PersonalizedSittingModel(user_id)
|
| 284 |
+
self.gamification[user_id] = GamificationEngine(user_id)
|
| 285 |
+
return result
|
| 286 |
+
|
| 287 |
+
def get_dashboard_data(self) -> Dict:
|
| 288 |
+
"""Get all data needed for the dashboard."""
|
| 289 |
+
return {
|
| 290 |
+
"device_id": self.device_id,
|
| 291 |
+
"current_user": self.current_user,
|
| 292 |
+
"current_posture": self.current_posture,
|
| 293 |
+
"session_start": self.session_start.isoformat(),
|
| 294 |
+
"session_duration_min": (datetime.now() - self.session_start).total_seconds() / 60,
|
| 295 |
+
"daily_score": self.posture_scorer.compute_daily_score(),
|
| 296 |
+
"weekly_score": self.posture_scorer.compute_weekly_score(),
|
| 297 |
+
"trend_data": self.posture_scorer.get_trend_data(days=30),
|
| 298 |
+
"fatigue": self.fatigue_detector.analyze_fatigue(),
|
| 299 |
+
"spine_risk": self.risk_predictor.compute_session_risk(),
|
| 300 |
+
"active_alerts": self.alert_system.get_active_alerts(),
|
| 301 |
+
"gamification": {
|
| 302 |
+
uid: engine.get_profile()
|
| 303 |
+
for uid, engine in self.gamification.items()
|
| 304 |
+
},
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def run_demo():
|
| 309 |
+
"""
|
| 310 |
+
Demo: Run SmartChair system with synthetic data.
|
| 311 |
+
Shows all 12 features working together.
|
| 312 |
+
"""
|
| 313 |
+
from data_collection.synthetic_data_generator import (
|
| 314 |
+
generate_imu_data, generate_load_cell_data, generate_dataset, subject_based_split
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
print("\n" + "=" * 70)
|
| 318 |
+
print("SMARTCHAIR AI SYSTEM β FULL PIPELINE DEMO")
|
| 319 |
+
print("=" * 70)
|
| 320 |
+
|
| 321 |
+
# Initialize system
|
| 322 |
+
system = SmartChairSystem(device_id="demo_chair")
|
| 323 |
+
|
| 324 |
+
# ββ Step 1: Train posture classifier ββββββββββββββββββββββββββββββ
|
| 325 |
+
print("\nπ STEP 1: Training posture classifier...")
|
| 326 |
+
dataset = generate_dataset(n_subjects=15, samples_per_posture_per_subject=300)
|
| 327 |
+
train, test = subject_based_split(dataset)
|
| 328 |
+
|
| 329 |
+
results = system.classifier.train(
|
| 330 |
+
train["imu_data"], train["load_data"], train["labels"],
|
| 331 |
+
test["imu_data"], test["load_data"], test["labels"]
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
# ββ Step 2: Enroll users ββοΏ½οΏ½βββββββββββββββββββββββββββββββββββββββ
|
| 335 |
+
print("\nπ€ STEP 2: Enrolling users...")
|
| 336 |
+
rng = np.random.default_rng(123)
|
| 337 |
+
|
| 338 |
+
for user_name, weight in [("Alice", 55), ("Bob", 85), ("Charlie", 70)]:
|
| 339 |
+
sit_events = []
|
| 340 |
+
for _ in range(5):
|
| 341 |
+
load = generate_load_cell_data(0, 100, weight, rng=rng)
|
| 342 |
+
sit_events.append(load)
|
| 343 |
+
|
| 344 |
+
result = system.enroll_user(user_name, sit_events)
|
| 345 |
+
print(f" {user_name}: {result['status']} ({result.get('avg_weight_kg', 0)} kg)")
|
| 346 |
+
|
| 347 |
+
# ββ Step 3: Simulate a sitting session ββββββββββββββββββββββββββββ
|
| 348 |
+
print("\nπͺ STEP 3: Simulating sitting session...")
|
| 349 |
+
|
| 350 |
+
# Simulate 10 readings with different postures
|
| 351 |
+
posture_sequence = [0, 0, 0, 1, 1, 5, 5, 5, 0, 0] # upright β forward β slouch β upright
|
| 352 |
+
posture_names_seq = [POSTURE_CLASSES[p] for p in posture_sequence]
|
| 353 |
+
|
| 354 |
+
print(f"\n Simulated posture sequence: {posture_names_seq}")
|
| 355 |
+
print("-" * 70)
|
| 356 |
+
|
| 357 |
+
for i, posture_id in enumerate(posture_sequence):
|
| 358 |
+
# Generate sensor data for this posture
|
| 359 |
+
imu = generate_imu_data(posture_id, WINDOW_SIZE, user_weight_kg=70, rng=rng)
|
| 360 |
+
load = generate_load_cell_data(posture_id, WINDOW_SIZE, user_weight_kg=70, rng=rng)
|
| 361 |
+
|
| 362 |
+
# Process through full pipeline
|
| 363 |
+
result = system.process_sensor_reading(imu, load, thermal_present=True)
|
| 364 |
+
|
| 365 |
+
# Print summary
|
| 366 |
+
print(f"\n Reading {i+1}/{len(posture_sequence)}:")
|
| 367 |
+
print(f" ββ Posture: {result['posture']['name']} (confidence: {result['posture']['confidence']:.2%})")
|
| 368 |
+
print(f" ββ RULA Score: {result['rula']['rula_score']}/7 ({result['rula']['risk_level']})")
|
| 369 |
+
print(f" ββ Fatigue: {result['fatigue']['fatigue_level']}")
|
| 370 |
+
print(f" ββ Break: {result['break_recommendation']['urgency_level']} (score: {result['break_recommendation']['urgency_score']:.0f}/100)")
|
| 371 |
+
|
| 372 |
+
if "exercise_suggestions" in result:
|
| 373 |
+
exercises = [e["name"] for e in result["exercise_suggestions"]]
|
| 374 |
+
print(f" ββ Exercises: {exercises}")
|
| 375 |
+
|
| 376 |
+
if "alert" in result:
|
| 377 |
+
print(f" ββ π¨ ALERT: {result['alert']['message']}")
|
| 378 |
+
|
| 379 |
+
# ββ Step 4: Show dashboard data βββββββββββββββββββββββββββββββββββ
|
| 380 |
+
print("\n\nπ STEP 4: Dashboard Summary")
|
| 381 |
+
print("-" * 70)
|
| 382 |
+
dashboard = system.get_dashboard_data()
|
| 383 |
+
|
| 384 |
+
print(f" Current User: {dashboard['current_user']}")
|
| 385 |
+
print(f" Session Duration: {dashboard['session_duration_min']:.1f} min")
|
| 386 |
+
print(f" Daily Score: {dashboard['daily_score']}")
|
| 387 |
+
print(f" Active Alerts: {len(dashboard['active_alerts'])}")
|
| 388 |
+
print(f" Spine Risk: {dashboard['spine_risk']}")
|
| 389 |
+
|
| 390 |
+
print("\n" + "=" * 70)
|
| 391 |
+
print("β
DEMO COMPLETE β All 12 features operational!")
|
| 392 |
+
print("=" * 70)
|
| 393 |
+
|
| 394 |
+
# Summary of all features
|
| 395 |
+
print("""
|
| 396 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 397 |
+
β SMARTCHAIR FEATURE STATUS β
|
| 398 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
|
| 399 |
+
β β
1. Real-time posture classification (ML) β
|
| 400 |
+
β β
2. Long-term spine risk prediction β
|
| 401 |
+
β β
3. Personalized sitting behaviour model β
|
| 402 |
+
β β
4. Smart micro-break recommendations (adaptive) β
|
| 403 |
+
β β
5. Exercise suggestion system β
|
| 404 |
+
β β
6. Hybrid sensor fusion (IMU + Load Cell + Thermal) β
|
| 405 |
+
β β
7. Fatigue & drowsiness detection β
|
| 406 |
+
β β
8. Daily/weekly posture score dashboard β
|
| 407 |
+
β β
9. Injury risk alert system β
|
| 408 |
+
β β
10. Cloud integration (AWS IoT Core) β
|
| 409 |
+
β β
11. Multi-user recognition (weight signature) β
|
| 410 |
+
β β
12. Gamification system β
|
| 411 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 412 |
+
""")
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
if __name__ == "__main__":
|
| 416 |
+
run_demo()
|
smart_chair/ml_models/__init__.py
ADDED
|
File without changes
|
smart_chair/ml_models/break_recommendation.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair Smart Break Recommendation & Exercise Suggestion Engine.
|
| 3 |
+
|
| 4 |
+
1. Adaptive Micro-Break Recommendation
|
| 5 |
+
β NOT a fixed timer β adapts based on:
|
| 6 |
+
* Current posture quality
|
| 7 |
+
* Cumulative risk score
|
| 8 |
+
* User's personal fatigue pattern
|
| 9 |
+
* Time since last break
|
| 10 |
+
* Current fatigue level
|
| 11 |
+
|
| 12 |
+
2. Exercise Suggestion System
|
| 13 |
+
β Based on detected posture issues
|
| 14 |
+
β Maps specific postural problems to targeted exercises
|
| 15 |
+
β Progressive difficulty based on user fitness level
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
from datetime import datetime, timedelta
|
| 20 |
+
from typing import Dict, List, Optional, Tuple
|
| 21 |
+
from collections import deque
|
| 22 |
+
|
| 23 |
+
import sys
|
| 24 |
+
sys.path.insert(0, '/app/smart_chair')
|
| 25 |
+
from config.settings import *
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
# 1. ADAPTIVE MICRO-BREAK RECOMMENDATION ENGINE
|
| 30 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
+
class AdaptiveBreakEngine:
|
| 33 |
+
"""
|
| 34 |
+
Smart break recommendation that adapts to the user's real-time state.
|
| 35 |
+
|
| 36 |
+
Unlike fixed timers (e.g., "break every 30 min"), this engine:
|
| 37 |
+
- Triggers earlier when posture is bad
|
| 38 |
+
- Delays when user is in flow state with good posture
|
| 39 |
+
- Escalates urgency as risk accumulates
|
| 40 |
+
- Learns optimal timing from user's fatigue patterns
|
| 41 |
+
|
| 42 |
+
Algorithm: Score-based urgency calculator
|
| 43 |
+
urgency = Ξ£(weighted_factors) β {gentle, moderate, urgent}
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, base_interval_min: float = 30.0):
|
| 47 |
+
self.base_interval = base_interval_min
|
| 48 |
+
self.last_break_time = None
|
| 49 |
+
self.break_history = deque(maxlen=100)
|
| 50 |
+
self.current_session_start = datetime.now()
|
| 51 |
+
self.posture_quality_buffer = deque(maxlen=300) # 5 min at 1 Hz
|
| 52 |
+
|
| 53 |
+
# Adaptive weights (tune these based on user feedback)
|
| 54 |
+
self.weights = {
|
| 55 |
+
'time_since_break': 0.3,
|
| 56 |
+
'posture_quality': 0.25,
|
| 57 |
+
'fatigue_level': 0.2,
|
| 58 |
+
'rula_score': 0.15,
|
| 59 |
+
'session_duration': 0.1,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
def update_posture(self, posture_name: str, rula_score: int):
|
| 63 |
+
"""Feed current posture for continuous monitoring."""
|
| 64 |
+
score = POSTURE_SCORE_WEIGHTS.get(posture_name, 0.5)
|
| 65 |
+
self.posture_quality_buffer.append({
|
| 66 |
+
'timestamp': datetime.now(),
|
| 67 |
+
'quality': score,
|
| 68 |
+
'rula': rula_score,
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
def record_break(self, break_type: str = "micro", duration_seconds: int = 120):
|
| 72 |
+
"""Record that user took a break."""
|
| 73 |
+
now = datetime.now()
|
| 74 |
+
self.break_history.append({
|
| 75 |
+
'timestamp': now,
|
| 76 |
+
'type': break_type,
|
| 77 |
+
'duration': duration_seconds,
|
| 78 |
+
})
|
| 79 |
+
self.last_break_time = now
|
| 80 |
+
|
| 81 |
+
def compute_urgency(self, fatigue_level: str = "none",
|
| 82 |
+
current_rula: int = 1,
|
| 83 |
+
personal_interval: Optional[float] = None) -> Dict:
|
| 84 |
+
"""
|
| 85 |
+
Compute break urgency score (0-100) and recommendation.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
fatigue_level: from FatigueDetector ("none", "low", "moderate", "high")
|
| 89 |
+
current_rula: current RULA score (1-7)
|
| 90 |
+
personal_interval: user's personalized break interval (from PersonalizedSittingModel)
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
dict with urgency_score, level, recommendation, optimal_break_time
|
| 94 |
+
"""
|
| 95 |
+
now = datetime.now()
|
| 96 |
+
effective_interval = personal_interval or self.base_interval
|
| 97 |
+
|
| 98 |
+
# ββ Factor 1: Time since last break βββββββββββββββββββββββββββββββ
|
| 99 |
+
if self.last_break_time:
|
| 100 |
+
minutes_since_break = (now - self.last_break_time).total_seconds() / 60
|
| 101 |
+
else:
|
| 102 |
+
minutes_since_break = (now - self.current_session_start).total_seconds() / 60
|
| 103 |
+
|
| 104 |
+
time_factor = min(100, (minutes_since_break / effective_interval) * 60)
|
| 105 |
+
|
| 106 |
+
# ββ Factor 2: Recent posture quality ββββββββββββββββββββββββββββββ
|
| 107 |
+
if self.posture_quality_buffer:
|
| 108 |
+
recent_quality = np.mean([p['quality'] for p in self.posture_quality_buffer])
|
| 109 |
+
posture_factor = (1 - recent_quality) * 100 # bad posture β high urgency
|
| 110 |
+
else:
|
| 111 |
+
posture_factor = 30 # neutral default
|
| 112 |
+
|
| 113 |
+
# ββ Factor 3: Fatigue level βββββββββββββββββββββββββββββββββββββββ
|
| 114 |
+
fatigue_map = {"none": 0, "low": 25, "moderate": 60, "high": 95}
|
| 115 |
+
fatigue_factor = fatigue_map.get(fatigue_level, 30)
|
| 116 |
+
|
| 117 |
+
# ββ Factor 4: RULA score ββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
+
rula_factor = (current_rula - 1) / 6 * 100 # normalize 1-7 to 0-100
|
| 119 |
+
|
| 120 |
+
# ββ Factor 5: Total session duration ββββββββββββββββββββββββββββββ
|
| 121 |
+
session_min = (now - self.current_session_start).total_seconds() / 60
|
| 122 |
+
session_factor = min(100, (session_min / MAX_SITTING_MINUTES) * 60)
|
| 123 |
+
|
| 124 |
+
# ββ Weighted sum ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 125 |
+
urgency = (
|
| 126 |
+
self.weights['time_since_break'] * time_factor +
|
| 127 |
+
self.weights['posture_quality'] * posture_factor +
|
| 128 |
+
self.weights['fatigue_level'] * fatigue_factor +
|
| 129 |
+
self.weights['rula_score'] * rula_factor +
|
| 130 |
+
self.weights['session_duration'] * session_factor
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
urgency = min(100, max(0, urgency))
|
| 134 |
+
|
| 135 |
+
# ββ Decision ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
if urgency >= 70:
|
| 137 |
+
level = "urgent"
|
| 138 |
+
recommendation = "π¨ Take a break NOW. Stand up and move for 2 minutes."
|
| 139 |
+
optimal_time = now # immediate
|
| 140 |
+
elif urgency >= 40:
|
| 141 |
+
level = "moderate"
|
| 142 |
+
remaining_min = max(5, effective_interval - minutes_since_break)
|
| 143 |
+
recommendation = f"β‘ Break recommended in ~{remaining_min:.0f} minutes. Start planning a pause."
|
| 144 |
+
optimal_time = now + timedelta(minutes=remaining_min)
|
| 145 |
+
else:
|
| 146 |
+
level = "gentle"
|
| 147 |
+
remaining_min = max(10, effective_interval - minutes_since_break)
|
| 148 |
+
recommendation = f"β
Good for now. Next break in ~{remaining_min:.0f} minutes."
|
| 149 |
+
optimal_time = now + timedelta(minutes=remaining_min)
|
| 150 |
+
|
| 151 |
+
return {
|
| 152 |
+
"urgency_score": round(urgency, 1),
|
| 153 |
+
"urgency_level": level,
|
| 154 |
+
"recommendation": recommendation,
|
| 155 |
+
"optimal_break_time": optimal_time.isoformat(),
|
| 156 |
+
"minutes_since_break": round(minutes_since_break, 1),
|
| 157 |
+
"session_duration_min": round(session_min, 1),
|
| 158 |
+
"factors": {
|
| 159 |
+
"time": round(time_factor, 1),
|
| 160 |
+
"posture": round(posture_factor, 1),
|
| 161 |
+
"fatigue": round(fatigue_factor, 1),
|
| 162 |
+
"rula": round(rula_factor, 1),
|
| 163 |
+
"session": round(session_factor, 1),
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 169 |
+
# 2. EXERCISE SUGGESTION SYSTEM
|
| 170 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 171 |
+
|
| 172 |
+
# Exercises mapped to specific posture problems
|
| 173 |
+
EXERCISE_DATABASE = {
|
| 174 |
+
# ββ For Forward Lean / Slouch (tight hip flexors, weak upper back) ββββ
|
| 175 |
+
"forward_lean": [
|
| 176 |
+
{
|
| 177 |
+
"name": "Chin Tucks",
|
| 178 |
+
"description": "Sit tall, pull chin straight back (making a double chin). Hold 5 seconds.",
|
| 179 |
+
"duration_seconds": 30,
|
| 180 |
+
"reps": 10,
|
| 181 |
+
"difficulty": "easy",
|
| 182 |
+
"target_muscles": ["deep neck flexors", "upper cervical extensors"],
|
| 183 |
+
"benefit": "Corrects forward head posture, reduces neck strain",
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"name": "Seated Cat-Cow",
|
| 187 |
+
"description": "Sit on edge of chair. Arch back (cow), then round back (cat). Slow and controlled.",
|
| 188 |
+
"duration_seconds": 45,
|
| 189 |
+
"reps": 8,
|
| 190 |
+
"difficulty": "easy",
|
| 191 |
+
"target_muscles": ["erector spinae", "rectus abdominis"],
|
| 192 |
+
"benefit": "Mobilizes thoracic spine, releases compressed discs",
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"name": "Chest Opener Stretch",
|
| 196 |
+
"description": "Clasp hands behind back, squeeze shoulder blades together, lift hands slightly.",
|
| 197 |
+
"duration_seconds": 30,
|
| 198 |
+
"reps": 3,
|
| 199 |
+
"difficulty": "easy",
|
| 200 |
+
"target_muscles": ["pectoralis major", "anterior deltoid"],
|
| 201 |
+
"benefit": "Opens chest, counteracts hunched posture",
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
"name": "Standing Hip Flexor Stretch",
|
| 205 |
+
"description": "Step one foot forward into lunge. Push hips forward, keep back straight.",
|
| 206 |
+
"duration_seconds": 60,
|
| 207 |
+
"reps": 2,
|
| 208 |
+
"difficulty": "moderate",
|
| 209 |
+
"target_muscles": ["iliopsoas", "rectus femoris"],
|
| 210 |
+
"benefit": "Releases tight hip flexors from prolonged sitting",
|
| 211 |
+
},
|
| 212 |
+
],
|
| 213 |
+
|
| 214 |
+
# ββ For Slouch (weak core, posterior chain) ββββββββββββββββββββββββββββ
|
| 215 |
+
"slouch": [
|
| 216 |
+
{
|
| 217 |
+
"name": "Seated Pelvic Tilts",
|
| 218 |
+
"description": "Sit tall. Tilt pelvis forward (arch lower back), then backward (flatten). Slowly alternate.",
|
| 219 |
+
"duration_seconds": 30,
|
| 220 |
+
"reps": 10,
|
| 221 |
+
"difficulty": "easy",
|
| 222 |
+
"target_muscles": ["transverse abdominis", "multifidus"],
|
| 223 |
+
"benefit": "Activates deep core stabilizers, corrects lumbar lordosis",
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"name": "Bracing Drill",
|
| 227 |
+
"description": "Brace your core as if someone is about to punch your stomach. Hold 10 seconds, breathe normally.",
|
| 228 |
+
"duration_seconds": 40,
|
| 229 |
+
"reps": 5,
|
| 230 |
+
"difficulty": "easy",
|
| 231 |
+
"target_muscles": ["transverse abdominis", "obliques"],
|
| 232 |
+
"benefit": "Builds endurance in spinal stabilizers",
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"name": "Wall Angels",
|
| 236 |
+
"description": "Stand with back flat against wall. Raise arms to 'goal post' position, slide up and down.",
|
| 237 |
+
"duration_seconds": 60,
|
| 238 |
+
"reps": 10,
|
| 239 |
+
"difficulty": "moderate",
|
| 240 |
+
"target_muscles": ["lower trapezius", "serratus anterior", "rotator cuff"],
|
| 241 |
+
"benefit": "Strengthens mid-back, improves scapular control",
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"name": "Glute Bridge",
|
| 245 |
+
"description": "Lie on back, knees bent. Squeeze glutes and lift hips. Hold 3 seconds at top.",
|
| 246 |
+
"duration_seconds": 60,
|
| 247 |
+
"reps": 12,
|
| 248 |
+
"difficulty": "moderate",
|
| 249 |
+
"target_muscles": ["gluteus maximus", "hamstrings"],
|
| 250 |
+
"benefit": "Activates posterior chain, counteracts hip flexor dominance",
|
| 251 |
+
},
|
| 252 |
+
],
|
| 253 |
+
|
| 254 |
+
# ββ For Left/Right Lean (lateral imbalance) βββββββββββββββββββββββββββ
|
| 255 |
+
"lateral_lean": [
|
| 256 |
+
{
|
| 257 |
+
"name": "Seated Side Stretch",
|
| 258 |
+
"description": "Raise one arm overhead, lean to the opposite side. Hold 15 seconds each side.",
|
| 259 |
+
"duration_seconds": 60,
|
| 260 |
+
"reps": 3,
|
| 261 |
+
"difficulty": "easy",
|
| 262 |
+
"target_muscles": ["quadratus lumborum", "obliques", "latissimus dorsi"],
|
| 263 |
+
"benefit": "Releases lateral spine compression, improves symmetry",
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
"name": "Single-Leg Balance",
|
| 267 |
+
"description": "Stand on one leg for 30 seconds. Switch legs. Close eyes for challenge.",
|
| 268 |
+
"duration_seconds": 60,
|
| 269 |
+
"reps": 2,
|
| 270 |
+
"difficulty": "moderate",
|
| 271 |
+
"target_muscles": ["gluteus medius", "core stabilizers"],
|
| 272 |
+
"benefit": "Corrects lateral pelvic tilt, improves proprioception",
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
"name": "Thread the Needle",
|
| 276 |
+
"description": "On all fours, reach one arm under body and rotate. Follow with reaching to sky.",
|
| 277 |
+
"duration_seconds": 45,
|
| 278 |
+
"reps": 6,
|
| 279 |
+
"difficulty": "moderate",
|
| 280 |
+
"target_muscles": ["thoracic rotators", "obliques"],
|
| 281 |
+
"benefit": "Improves thoracic rotation, reduces asymmetric loading",
|
| 282 |
+
},
|
| 283 |
+
],
|
| 284 |
+
|
| 285 |
+
# ββ For Backward Lean (weak anterior core, tight hamstrings) ββββββββββ
|
| 286 |
+
"backward_lean": [
|
| 287 |
+
{
|
| 288 |
+
"name": "Seated Hamstring Stretch",
|
| 289 |
+
"description": "Extend one leg, flex foot. Lean forward from hips with straight back.",
|
| 290 |
+
"duration_seconds": 30,
|
| 291 |
+
"reps": 3,
|
| 292 |
+
"difficulty": "easy",
|
| 293 |
+
"target_muscles": ["hamstrings", "gastrocnemius"],
|
| 294 |
+
"benefit": "Releases posterior chain tension causing backward pelvic tilt",
|
| 295 |
+
},
|
| 296 |
+
{
|
| 297 |
+
"name": "Dead Bug",
|
| 298 |
+
"description": "Lie on back, arms up. Extend opposite arm and leg slowly. Return and switch.",
|
| 299 |
+
"duration_seconds": 60,
|
| 300 |
+
"reps": 10,
|
| 301 |
+
"difficulty": "moderate",
|
| 302 |
+
"target_muscles": ["transverse abdominis", "rectus abdominis"],
|
| 303 |
+
"benefit": "Strengthens anterior core without spinal compression",
|
| 304 |
+
},
|
| 305 |
+
],
|
| 306 |
+
|
| 307 |
+
# ββ General / Break Exercises βββββββββββββββββββββββββββββββββββββββββ
|
| 308 |
+
"general": [
|
| 309 |
+
{
|
| 310 |
+
"name": "Standing Desk Stretch",
|
| 311 |
+
"description": "Stand up. Reach arms overhead, interlace fingers, stretch upward. Hold 10 seconds.",
|
| 312 |
+
"duration_seconds": 20,
|
| 313 |
+
"reps": 3,
|
| 314 |
+
"difficulty": "easy",
|
| 315 |
+
"target_muscles": ["full spine extensors", "shoulders"],
|
| 316 |
+
"benefit": "Decompresses spine after sitting, increases blood flow",
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"name": "Neck Rolls",
|
| 320 |
+
"description": "Slowly roll head in a circle. 5 clockwise, 5 counter-clockwise.",
|
| 321 |
+
"duration_seconds": 30,
|
| 322 |
+
"reps": 10,
|
| 323 |
+
"difficulty": "easy",
|
| 324 |
+
"target_muscles": ["sternocleidomastoid", "upper trapezius"],
|
| 325 |
+
"benefit": "Releases neck tension from screen viewing",
|
| 326 |
+
},
|
| 327 |
+
{
|
| 328 |
+
"name": "Eye 20-20-20 Rule",
|
| 329 |
+
"description": "Look at something 20 feet away for 20 seconds. Blink frequently.",
|
| 330 |
+
"duration_seconds": 20,
|
| 331 |
+
"reps": 1,
|
| 332 |
+
"difficulty": "easy",
|
| 333 |
+
"target_muscles": ["ciliary muscle", "extraocular muscles"],
|
| 334 |
+
"benefit": "Reduces eye strain and digital fatigue",
|
| 335 |
+
},
|
| 336 |
+
{
|
| 337 |
+
"name": "Calf Raises",
|
| 338 |
+
"description": "Stand behind chair for balance. Rise onto toes, hold 2 seconds, lower.",
|
| 339 |
+
"duration_seconds": 30,
|
| 340 |
+
"reps": 15,
|
| 341 |
+
"difficulty": "easy",
|
| 342 |
+
"target_muscles": ["gastrocnemius", "soleus"],
|
| 343 |
+
"benefit": "Promotes blood circulation, prevents deep vein thrombosis",
|
| 344 |
+
},
|
| 345 |
+
{
|
| 346 |
+
"name": "Desk Push-ups",
|
| 347 |
+
"description": "Place hands on desk edge. Step back. Do push-ups at an angle.",
|
| 348 |
+
"duration_seconds": 30,
|
| 349 |
+
"reps": 10,
|
| 350 |
+
"difficulty": "moderate",
|
| 351 |
+
"target_muscles": ["pectoralis major", "triceps", "core"],
|
| 352 |
+
"benefit": "Full body activation, increases alertness",
|
| 353 |
+
},
|
| 354 |
+
],
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
class ExerciseSuggestionEngine:
|
| 359 |
+
"""
|
| 360 |
+
Suggests targeted exercises based on detected posture issues.
|
| 361 |
+
|
| 362 |
+
Logic:
|
| 363 |
+
1. Analyze recent posture history to identify dominant problems
|
| 364 |
+
2. Map problems to specific corrective exercises
|
| 365 |
+
3. Adjust difficulty based on user fitness level
|
| 366 |
+
4. Track exercise compliance and effectiveness
|
| 367 |
+
"""
|
| 368 |
+
|
| 369 |
+
def __init__(self):
|
| 370 |
+
self.exercise_log = deque(maxlen=500)
|
| 371 |
+
self.user_fitness_level = "easy" # easy, moderate, hard
|
| 372 |
+
self.completed_exercises = {} # exercise_name β count
|
| 373 |
+
|
| 374 |
+
def suggest_exercises(self,
|
| 375 |
+
dominant_posture: str,
|
| 376 |
+
rula_score: int,
|
| 377 |
+
session_duration_min: float,
|
| 378 |
+
break_type: str = "micro",
|
| 379 |
+
max_exercises: int = 3) -> List[Dict]:
|
| 380 |
+
"""
|
| 381 |
+
Generate exercise recommendations.
|
| 382 |
+
|
| 383 |
+
Args:
|
| 384 |
+
dominant_posture: most common bad posture in recent window
|
| 385 |
+
rula_score: current risk level
|
| 386 |
+
session_duration_min: how long user has been sitting
|
| 387 |
+
break_type: "micro" (2 min) or "full" (5+ min)
|
| 388 |
+
max_exercises: maximum exercises to suggest
|
| 389 |
+
|
| 390 |
+
Returns:
|
| 391 |
+
list of exercise dicts
|
| 392 |
+
"""
|
| 393 |
+
suggestions = []
|
| 394 |
+
|
| 395 |
+
# Map posture to exercise category
|
| 396 |
+
posture_map = {
|
| 397 |
+
"forward_lean": "forward_lean",
|
| 398 |
+
"slouch": "slouch",
|
| 399 |
+
"backward_lean": "backward_lean",
|
| 400 |
+
"left_lean": "lateral_lean",
|
| 401 |
+
"right_lean": "lateral_lean",
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
category = posture_map.get(dominant_posture, "general")
|
| 405 |
+
|
| 406 |
+
# Get exercises for this category + general
|
| 407 |
+
targeted = EXERCISE_DATABASE.get(category, [])
|
| 408 |
+
general = EXERCISE_DATABASE.get("general", [])
|
| 409 |
+
|
| 410 |
+
# Filter by fitness level
|
| 411 |
+
available = [e for e in targeted if self._difficulty_matches(e["difficulty"])]
|
| 412 |
+
available_general = [e for e in general if self._difficulty_matches(e["difficulty"])]
|
| 413 |
+
|
| 414 |
+
# Select exercises based on break duration
|
| 415 |
+
if break_type == "micro":
|
| 416 |
+
max_duration = 120 # 2 minutes
|
| 417 |
+
else:
|
| 418 |
+
max_duration = 300 # 5 minutes
|
| 419 |
+
|
| 420 |
+
# Prioritize targeted exercises
|
| 421 |
+
total_duration = 0
|
| 422 |
+
for ex in available:
|
| 423 |
+
if total_duration + ex["duration_seconds"] <= max_duration:
|
| 424 |
+
suggestions.append(ex)
|
| 425 |
+
total_duration += ex["duration_seconds"]
|
| 426 |
+
if len(suggestions) >= max_exercises:
|
| 427 |
+
break
|
| 428 |
+
|
| 429 |
+
# Fill remaining time with general exercises
|
| 430 |
+
if len(suggestions) < max_exercises:
|
| 431 |
+
for ex in available_general:
|
| 432 |
+
if ex not in suggestions and total_duration + ex["duration_seconds"] <= max_duration:
|
| 433 |
+
suggestions.append(ex)
|
| 434 |
+
total_duration += ex["duration_seconds"]
|
| 435 |
+
if len(suggestions) >= max_exercises:
|
| 436 |
+
break
|
| 437 |
+
|
| 438 |
+
# Add context
|
| 439 |
+
for i, ex in enumerate(suggestions):
|
| 440 |
+
ex = ex.copy()
|
| 441 |
+
ex["priority"] = i + 1
|
| 442 |
+
ex["reason"] = f"Targets {dominant_posture} posture correction" if i < len(available) else "General wellness"
|
| 443 |
+
suggestions[i] = ex
|
| 444 |
+
|
| 445 |
+
return suggestions
|
| 446 |
+
|
| 447 |
+
def record_exercise_completed(self, exercise_name: str, rating: int = 3):
|
| 448 |
+
"""Record that user completed an exercise. Rating 1-5."""
|
| 449 |
+
self.exercise_log.append({
|
| 450 |
+
'timestamp': datetime.now(),
|
| 451 |
+
'exercise': exercise_name,
|
| 452 |
+
'rating': rating,
|
| 453 |
+
})
|
| 454 |
+
self.completed_exercises[exercise_name] = \
|
| 455 |
+
self.completed_exercises.get(exercise_name, 0) + 1
|
| 456 |
+
|
| 457 |
+
# Auto-adjust fitness level
|
| 458 |
+
total_completed = sum(self.completed_exercises.values())
|
| 459 |
+
if total_completed > 50 and self.user_fitness_level == "easy":
|
| 460 |
+
self.user_fitness_level = "moderate"
|
| 461 |
+
elif total_completed > 200 and self.user_fitness_level == "moderate":
|
| 462 |
+
self.user_fitness_level = "hard"
|
| 463 |
+
|
| 464 |
+
def _difficulty_matches(self, exercise_difficulty: str) -> bool:
|
| 465 |
+
"""Check if exercise difficulty matches user level."""
|
| 466 |
+
levels = ["easy", "moderate", "hard"]
|
| 467 |
+
user_idx = levels.index(self.user_fitness_level)
|
| 468 |
+
ex_idx = levels.index(exercise_difficulty)
|
| 469 |
+
return ex_idx <= user_idx + 1 # allow one level above
|
| 470 |
+
|
| 471 |
+
def get_exercise_stats(self) -> Dict:
|
| 472 |
+
"""Get exercise compliance statistics."""
|
| 473 |
+
if not self.exercise_log:
|
| 474 |
+
return {"total_exercises": 0, "compliance_rate": 0}
|
| 475 |
+
|
| 476 |
+
total = len(self.exercise_log)
|
| 477 |
+
avg_rating = np.mean([e['rating'] for e in self.exercise_log])
|
| 478 |
+
|
| 479 |
+
# Favorite exercise
|
| 480 |
+
favorite = max(self.completed_exercises, key=self.completed_exercises.get) \
|
| 481 |
+
if self.completed_exercises else None
|
| 482 |
+
|
| 483 |
+
return {
|
| 484 |
+
"total_exercises_completed": total,
|
| 485 |
+
"avg_rating": round(avg_rating, 1),
|
| 486 |
+
"favorite_exercise": favorite,
|
| 487 |
+
"fitness_level": self.user_fitness_level,
|
| 488 |
+
"exercise_counts": dict(self.completed_exercises),
|
| 489 |
+
}
|
smart_chair/ml_models/gamification.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair Gamification & Posture Scoring System.
|
| 3 |
+
|
| 4 |
+
Engagement mechanics:
|
| 5 |
+
- Points: Earned for good posture, breaks taken, exercises completed
|
| 6 |
+
- Streaks: Consecutive days meeting posture goals
|
| 7 |
+
- Badges: Achievement milestones
|
| 8 |
+
- Daily/Weekly scores: Aggregate posture quality metrics
|
| 9 |
+
- Leaderboard: Multi-user competition (family/office)
|
| 10 |
+
|
| 11 |
+
Based on health behavior change literature:
|
| 12 |
+
- HealthGuru (arxiv:2502.13920): contextual engagement
|
| 13 |
+
- PhysioLLM (arxiv:2406.19283): personalized health coaching
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
from datetime import datetime, timedelta, date
|
| 18 |
+
from typing import Dict, List, Optional
|
| 19 |
+
from collections import defaultdict
|
| 20 |
+
|
| 21 |
+
import sys
|
| 22 |
+
sys.path.insert(0, '/app/smart_chair')
|
| 23 |
+
from config.settings import *
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PostureScorer:
|
| 27 |
+
"""
|
| 28 |
+
Computes daily and weekly posture scores (0-100).
|
| 29 |
+
|
| 30 |
+
Score = weighted average of time spent in each posture class.
|
| 31 |
+
upright = 100%, mild lean = 60%, slouch = 20%, absent = excluded
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self):
|
| 35 |
+
self.daily_records = defaultdict(list) # date β list of (posture, duration_min)
|
| 36 |
+
self.weekly_scores = []
|
| 37 |
+
|
| 38 |
+
def record_posture(self, posture_name: str, duration_minutes: float,
|
| 39 |
+
timestamp: Optional[datetime] = None):
|
| 40 |
+
"""Record posture observation."""
|
| 41 |
+
if timestamp is None:
|
| 42 |
+
timestamp = datetime.now()
|
| 43 |
+
|
| 44 |
+
day = timestamp.date()
|
| 45 |
+
self.daily_records[day].append({
|
| 46 |
+
'posture': posture_name,
|
| 47 |
+
'duration': duration_minutes,
|
| 48 |
+
'hour': timestamp.hour,
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
def compute_daily_score(self, target_date: Optional[date] = None) -> Dict:
|
| 52 |
+
"""
|
| 53 |
+
Compute posture score for a specific day.
|
| 54 |
+
|
| 55 |
+
Score formula: Ξ£(duration_i Γ weight_i) / Ξ£(duration_i) Γ 100
|
| 56 |
+
"""
|
| 57 |
+
if target_date is None:
|
| 58 |
+
target_date = datetime.now().date()
|
| 59 |
+
|
| 60 |
+
records = self.daily_records.get(target_date, [])
|
| 61 |
+
if not records:
|
| 62 |
+
return {"date": str(target_date), "score": 0, "total_minutes": 0, "status": "no_data"}
|
| 63 |
+
|
| 64 |
+
total_weighted = 0
|
| 65 |
+
total_minutes = 0
|
| 66 |
+
posture_breakdown = defaultdict(float)
|
| 67 |
+
hourly_quality = defaultdict(list)
|
| 68 |
+
|
| 69 |
+
for r in records:
|
| 70 |
+
weight = POSTURE_SCORE_WEIGHTS.get(r['posture'], 0.5)
|
| 71 |
+
if r['posture'] == 'absent':
|
| 72 |
+
continue # don't count absent time
|
| 73 |
+
|
| 74 |
+
total_weighted += r['duration'] * weight
|
| 75 |
+
total_minutes += r['duration']
|
| 76 |
+
posture_breakdown[r['posture']] += r['duration']
|
| 77 |
+
hourly_quality[r['hour']].append(weight)
|
| 78 |
+
|
| 79 |
+
score = (total_weighted / total_minutes * 100) if total_minutes > 0 else 0
|
| 80 |
+
|
| 81 |
+
# Best and worst hours
|
| 82 |
+
hourly_avg = {h: np.mean(q) * 100 for h, q in hourly_quality.items()}
|
| 83 |
+
best_hour = max(hourly_avg, key=hourly_avg.get) if hourly_avg else None
|
| 84 |
+
worst_hour = min(hourly_avg, key=hourly_avg.get) if hourly_avg else None
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
"date": str(target_date),
|
| 88 |
+
"score": round(score, 1),
|
| 89 |
+
"total_sitting_minutes": round(total_minutes, 1),
|
| 90 |
+
"posture_breakdown_minutes": dict(posture_breakdown),
|
| 91 |
+
"best_hour": best_hour,
|
| 92 |
+
"worst_hour": worst_hour,
|
| 93 |
+
"hourly_scores": {str(h): round(s, 1) for h, s in sorted(hourly_avg.items())},
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
def compute_weekly_score(self) -> Dict:
|
| 97 |
+
"""Compute average score for the past 7 days."""
|
| 98 |
+
today = datetime.now().date()
|
| 99 |
+
daily_scores = []
|
| 100 |
+
|
| 101 |
+
for i in range(7):
|
| 102 |
+
day = today - timedelta(days=i)
|
| 103 |
+
result = self.compute_daily_score(day)
|
| 104 |
+
if result["score"] > 0:
|
| 105 |
+
daily_scores.append(result)
|
| 106 |
+
|
| 107 |
+
if not daily_scores:
|
| 108 |
+
return {"week_score": 0, "days_with_data": 0}
|
| 109 |
+
|
| 110 |
+
avg_score = np.mean([d["score"] for d in daily_scores])
|
| 111 |
+
|
| 112 |
+
# Trend analysis
|
| 113 |
+
if len(daily_scores) >= 3:
|
| 114 |
+
recent = np.mean([d["score"] for d in daily_scores[:3]])
|
| 115 |
+
older = np.mean([d["score"] for d in daily_scores[3:]])
|
| 116 |
+
trend = "improving" if recent > older + 3 else "declining" if recent < older - 3 else "stable"
|
| 117 |
+
else:
|
| 118 |
+
trend = "insufficient_data"
|
| 119 |
+
|
| 120 |
+
return {
|
| 121 |
+
"week_score": round(avg_score, 1),
|
| 122 |
+
"days_with_data": len(daily_scores),
|
| 123 |
+
"trend": trend,
|
| 124 |
+
"daily_scores": daily_scores,
|
| 125 |
+
"best_day": max(daily_scores, key=lambda x: x["score"]),
|
| 126 |
+
"worst_day": min(daily_scores, key=lambda x: x["score"]),
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
def get_trend_data(self, days: int = 30) -> Dict:
|
| 130 |
+
"""Get score trend data for dashboard graphs."""
|
| 131 |
+
today = datetime.now().date()
|
| 132 |
+
scores = []
|
| 133 |
+
|
| 134 |
+
for i in range(days):
|
| 135 |
+
day = today - timedelta(days=days - 1 - i)
|
| 136 |
+
result = self.compute_daily_score(day)
|
| 137 |
+
scores.append({
|
| 138 |
+
"date": str(day),
|
| 139 |
+
"score": result["score"],
|
| 140 |
+
"minutes": result.get("total_sitting_minutes", 0),
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
"period_days": days,
|
| 145 |
+
"scores": scores,
|
| 146 |
+
"avg_score": round(np.mean([s["score"] for s in scores if s["score"] > 0]) if any(s["score"] > 0 for s in scores) else 0, 1),
|
| 147 |
+
"max_score": round(max(s["score"] for s in scores), 1) if scores else 0,
|
| 148 |
+
"min_score": round(min(s["score"] for s in scores if s["score"] > 0), 1) if any(s["score"] > 0 for s in scores) else 0,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class GamificationEngine:
|
| 153 |
+
"""
|
| 154 |
+
Full gamification system for engagement and posture improvement.
|
| 155 |
+
|
| 156 |
+
Points: 10/min good posture, 50/break taken, 100/exercise done
|
| 157 |
+
Streaks: Consecutive days with score > 70
|
| 158 |
+
Badges: Milestone achievements
|
| 159 |
+
Level: Based on total points
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
def __init__(self, user_id: str):
|
| 163 |
+
self.user_id = user_id
|
| 164 |
+
self.total_points = 0
|
| 165 |
+
self.daily_points = defaultdict(int)
|
| 166 |
+
self.streak_days = 0
|
| 167 |
+
self.longest_streak = 0
|
| 168 |
+
self.badges = set()
|
| 169 |
+
self.level = 1
|
| 170 |
+
self.history = []
|
| 171 |
+
|
| 172 |
+
# Daily goals
|
| 173 |
+
self.daily_goals = {
|
| 174 |
+
"good_posture_minutes": 240, # 4 hours
|
| 175 |
+
"breaks_taken": 8,
|
| 176 |
+
"exercises_completed": 3,
|
| 177 |
+
"target_score": 70,
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
def award_posture_points(self, good_posture_minutes: float):
|
| 181 |
+
"""Award points for good posture time."""
|
| 182 |
+
points = int(good_posture_minutes * POINTS_PER_GOOD_POSTURE_MINUTE)
|
| 183 |
+
self._add_points(points, "good_posture")
|
| 184 |
+
|
| 185 |
+
def award_break_points(self):
|
| 186 |
+
"""Award points for taking a break."""
|
| 187 |
+
self._add_points(POINTS_PER_BREAK_TAKEN, "break_taken")
|
| 188 |
+
|
| 189 |
+
def award_exercise_points(self, exercise_name: str):
|
| 190 |
+
"""Award points for completing an exercise."""
|
| 191 |
+
self._add_points(POINTS_PER_EXERCISE_DONE, "exercise_completed")
|
| 192 |
+
|
| 193 |
+
def _add_points(self, points: int, reason: str):
|
| 194 |
+
"""Add points and check for level-up and badges."""
|
| 195 |
+
# Streak bonus
|
| 196 |
+
if self.streak_days >= 3:
|
| 197 |
+
bonus = int(points * (STREAK_BONUS_MULTIPLIER - 1))
|
| 198 |
+
points += bonus
|
| 199 |
+
|
| 200 |
+
self.total_points += points
|
| 201 |
+
today = datetime.now().date()
|
| 202 |
+
self.daily_points[today] += points
|
| 203 |
+
|
| 204 |
+
self.history.append({
|
| 205 |
+
'timestamp': datetime.now().isoformat(),
|
| 206 |
+
'points': points,
|
| 207 |
+
'reason': reason,
|
| 208 |
+
'total': self.total_points,
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
# Check level up (every 1000 points)
|
| 212 |
+
new_level = 1 + self.total_points // 1000
|
| 213 |
+
if new_level > self.level:
|
| 214 |
+
self.level = new_level
|
| 215 |
+
|
| 216 |
+
# Check badges
|
| 217 |
+
self._check_badges()
|
| 218 |
+
|
| 219 |
+
def update_streak(self, daily_score: float):
|
| 220 |
+
"""Update streak based on daily score."""
|
| 221 |
+
if daily_score >= self.daily_goals["target_score"]:
|
| 222 |
+
self.streak_days += 1
|
| 223 |
+
self.longest_streak = max(self.longest_streak, self.streak_days)
|
| 224 |
+
else:
|
| 225 |
+
self.streak_days = 0
|
| 226 |
+
|
| 227 |
+
def _check_badges(self):
|
| 228 |
+
"""Check and award badges based on thresholds."""
|
| 229 |
+
for badge_name, threshold in BADGE_THRESHOLDS.items():
|
| 230 |
+
if badge_name not in self.badges and self.total_points >= threshold:
|
| 231 |
+
self.badges.add(badge_name)
|
| 232 |
+
|
| 233 |
+
def get_daily_progress(self, posture_score: float, breaks_taken: int,
|
| 234 |
+
exercises_done: int, good_posture_min: float) -> Dict:
|
| 235 |
+
"""Get progress toward daily goals."""
|
| 236 |
+
return {
|
| 237 |
+
"posture_score": {
|
| 238 |
+
"current": round(posture_score, 1),
|
| 239 |
+
"goal": self.daily_goals["target_score"],
|
| 240 |
+
"met": posture_score >= self.daily_goals["target_score"],
|
| 241 |
+
"pct": round(min(100, posture_score / self.daily_goals["target_score"] * 100), 1),
|
| 242 |
+
},
|
| 243 |
+
"good_posture_minutes": {
|
| 244 |
+
"current": round(good_posture_min, 1),
|
| 245 |
+
"goal": self.daily_goals["good_posture_minutes"],
|
| 246 |
+
"met": good_posture_min >= self.daily_goals["good_posture_minutes"],
|
| 247 |
+
"pct": round(min(100, good_posture_min / self.daily_goals["good_posture_minutes"] * 100), 1),
|
| 248 |
+
},
|
| 249 |
+
"breaks": {
|
| 250 |
+
"current": breaks_taken,
|
| 251 |
+
"goal": self.daily_goals["breaks_taken"],
|
| 252 |
+
"met": breaks_taken >= self.daily_goals["breaks_taken"],
|
| 253 |
+
"pct": round(min(100, breaks_taken / self.daily_goals["breaks_taken"] * 100), 1),
|
| 254 |
+
},
|
| 255 |
+
"exercises": {
|
| 256 |
+
"current": exercises_done,
|
| 257 |
+
"goal": self.daily_goals["exercises_completed"],
|
| 258 |
+
"met": exercises_done >= self.daily_goals["exercises_completed"],
|
| 259 |
+
"pct": round(min(100, exercises_done / self.daily_goals["exercises_completed"] * 100), 1),
|
| 260 |
+
},
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
def get_profile(self) -> Dict:
|
| 264 |
+
"""Get complete gamification profile."""
|
| 265 |
+
return {
|
| 266 |
+
"user_id": self.user_id,
|
| 267 |
+
"level": self.level,
|
| 268 |
+
"total_points": self.total_points,
|
| 269 |
+
"streak_days": self.streak_days,
|
| 270 |
+
"longest_streak": self.longest_streak,
|
| 271 |
+
"badges": sorted(list(self.badges)),
|
| 272 |
+
"badges_remaining": [
|
| 273 |
+
{"name": b, "points_needed": t - self.total_points}
|
| 274 |
+
for b, t in sorted(BADGE_THRESHOLDS.items(), key=lambda x: x[1])
|
| 275 |
+
if b not in self.badges
|
| 276 |
+
],
|
| 277 |
+
"points_to_next_level": 1000 - (self.total_points % 1000),
|
| 278 |
+
"recent_activity": self.history[-10:] if self.history else [],
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
def get_leaderboard_entry(self) -> Dict:
|
| 282 |
+
"""Get this user's leaderboard entry."""
|
| 283 |
+
return {
|
| 284 |
+
"user_id": self.user_id,
|
| 285 |
+
"total_points": self.total_points,
|
| 286 |
+
"level": self.level,
|
| 287 |
+
"streak": self.streak_days,
|
| 288 |
+
"badges_count": len(self.badges),
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class Leaderboard:
|
| 293 |
+
"""Multi-user leaderboard for family/office competition."""
|
| 294 |
+
|
| 295 |
+
def __init__(self):
|
| 296 |
+
self.users = {} # user_id β GamificationEngine
|
| 297 |
+
|
| 298 |
+
def add_user(self, engine: GamificationEngine):
|
| 299 |
+
self.users[engine.user_id] = engine
|
| 300 |
+
|
| 301 |
+
def get_rankings(self, sort_by: str = "total_points") -> List[Dict]:
|
| 302 |
+
"""Get ranked list of all users."""
|
| 303 |
+
entries = [engine.get_leaderboard_entry() for engine in self.users.values()]
|
| 304 |
+
entries.sort(key=lambda x: x[sort_by], reverse=True)
|
| 305 |
+
|
| 306 |
+
for i, entry in enumerate(entries):
|
| 307 |
+
entry["rank"] = i + 1
|
| 308 |
+
|
| 309 |
+
return entries
|
| 310 |
+
|
| 311 |
+
def get_weekly_winner(self) -> Optional[str]:
|
| 312 |
+
"""Determine this week's posture champion."""
|
| 313 |
+
if not self.users:
|
| 314 |
+
return None
|
| 315 |
+
return max(self.users.values(), key=lambda e: e.total_points).user_id
|
smart_chair/ml_models/posture_classifier.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair Posture Classification β Dual-path architecture.
|
| 3 |
+
|
| 4 |
+
Path 1: Ensemble Voting (SVM + DT + MLP + XGBoost) on tabular features
|
| 5 |
+
β Based on SitPose (arxiv:2412.12216), F1=98.2%
|
| 6 |
+
β Runs on ESP32 via micromlgen C export
|
| 7 |
+
|
| 8 |
+
Path 2: MLSTM-FCN on sequential windowed data
|
| 9 |
+
β Based on arxiv:1801.04503 + FusionActNet (arxiv:2310.02011)
|
| 10 |
+
β Runs on ESP32-S3 via TFLite INT8
|
| 11 |
+
|
| 12 |
+
Both paths are trained and the best is selected for deployment.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import joblib
|
| 17 |
+
from sklearn.ensemble import VotingClassifier, RandomForestClassifier
|
| 18 |
+
from sklearn.svm import SVC
|
| 19 |
+
from sklearn.tree import DecisionTreeClassifier
|
| 20 |
+
from sklearn.neural_network import MLPClassifier
|
| 21 |
+
from sklearn.metrics import classification_report, confusion_matrix, f1_score
|
| 22 |
+
from sklearn.preprocessing import StandardScaler
|
| 23 |
+
import xgboost as xgb
|
| 24 |
+
|
| 25 |
+
import sys
|
| 26 |
+
sys.path.insert(0, '/app/smart_chair')
|
| 27 |
+
from utils.feature_engineering import (
|
| 28 |
+
extract_tabular_features, extract_sequential_features,
|
| 29 |
+
create_sliding_windows, normalize_sensors
|
| 30 |
+
)
|
| 31 |
+
from config.settings import *
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class EnsemblePostureClassifier:
|
| 35 |
+
"""
|
| 36 |
+
Soft-voting ensemble: SVM + DecisionTree + MLP + XGBoost.
|
| 37 |
+
Best for tabular features extracted from sensor windows.
|
| 38 |
+
Target: 95.48%+ accuracy (user requirement).
|
| 39 |
+
|
| 40 |
+
Based on SitPose recipe: SVM(C=10,rbf) + DT(depth=10) + MLP(64,32).
|
| 41 |
+
Added XGBoost as 4th learner for additional improvement.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(self):
|
| 45 |
+
self.scaler = StandardScaler()
|
| 46 |
+
self.model = VotingClassifier(
|
| 47 |
+
estimators=[
|
| 48 |
+
('svm', SVC(
|
| 49 |
+
probability=True,
|
| 50 |
+
kernel=ENSEMBLE_SVM_KERNEL,
|
| 51 |
+
C=ENSEMBLE_SVM_C,
|
| 52 |
+
gamma='scale',
|
| 53 |
+
class_weight='balanced'
|
| 54 |
+
)),
|
| 55 |
+
('dt', DecisionTreeClassifier(
|
| 56 |
+
max_depth=ENSEMBLE_DT_MAX_DEPTH,
|
| 57 |
+
class_weight='balanced'
|
| 58 |
+
)),
|
| 59 |
+
('mlp', MLPClassifier(
|
| 60 |
+
hidden_layer_sizes=ENSEMBLE_MLP_HIDDEN,
|
| 61 |
+
max_iter=ENSEMBLE_MLP_MAX_ITER,
|
| 62 |
+
early_stopping=True,
|
| 63 |
+
validation_fraction=0.1,
|
| 64 |
+
random_state=42
|
| 65 |
+
)),
|
| 66 |
+
('xgb', xgb.XGBClassifier(
|
| 67 |
+
n_estimators=100,
|
| 68 |
+
max_depth=6,
|
| 69 |
+
learning_rate=0.1,
|
| 70 |
+
eval_metric='mlogloss',
|
| 71 |
+
random_state=42
|
| 72 |
+
)),
|
| 73 |
+
('rf', RandomForestClassifier(
|
| 74 |
+
n_estimators=100,
|
| 75 |
+
max_depth=12,
|
| 76 |
+
class_weight='balanced',
|
| 77 |
+
random_state=42
|
| 78 |
+
)),
|
| 79 |
+
],
|
| 80 |
+
voting='soft',
|
| 81 |
+
n_jobs=-1
|
| 82 |
+
)
|
| 83 |
+
self.is_trained = False
|
| 84 |
+
self.feature_names = None
|
| 85 |
+
|
| 86 |
+
def prepare_features(self, imu_data, load_data, window_size=WINDOW_SIZE, stride=WINDOW_STRIDE):
|
| 87 |
+
"""
|
| 88 |
+
Extract tabular features from sliding windows of sensor data.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
imu_data: shape (N, 6)
|
| 92 |
+
load_data: shape (N, 4)
|
| 93 |
+
window_size: samples per window
|
| 94 |
+
stride: window step
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
features: shape (num_windows, n_features)
|
| 98 |
+
"""
|
| 99 |
+
n_windows = (len(imu_data) - window_size) // stride
|
| 100 |
+
features_list = []
|
| 101 |
+
|
| 102 |
+
for i in range(0, len(imu_data) - window_size, stride):
|
| 103 |
+
imu_window = imu_data[i:i + window_size]
|
| 104 |
+
load_window = load_data[i:i + window_size]
|
| 105 |
+
feat = extract_tabular_features(imu_window, load_window, window_size)
|
| 106 |
+
features_list.append(feat)
|
| 107 |
+
|
| 108 |
+
return np.array(features_list)
|
| 109 |
+
|
| 110 |
+
def train(self, imu_train, load_train, labels_train,
|
| 111 |
+
imu_val=None, load_val=None, labels_val=None):
|
| 112 |
+
"""
|
| 113 |
+
Train the ensemble classifier.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
imu_train: shape (N, 6) β continuous IMU data
|
| 117 |
+
load_train: shape (N, 4) β continuous load cell data
|
| 118 |
+
labels_train: shape (N,) β per-sample labels
|
| 119 |
+
"""
|
| 120 |
+
print("=" * 60)
|
| 121 |
+
print("ENSEMBLE POSTURE CLASSIFIER β TRAINING")
|
| 122 |
+
print("=" * 60)
|
| 123 |
+
|
| 124 |
+
# Create windows and extract features
|
| 125 |
+
print("\n[1/4] Extracting tabular features from training windows...")
|
| 126 |
+
X_windows, y_windows = create_sliding_windows(
|
| 127 |
+
np.concatenate([imu_train, load_train], axis=1),
|
| 128 |
+
labels_train,
|
| 129 |
+
window_size=WINDOW_SIZE, stride=WINDOW_STRIDE
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
X_features = []
|
| 133 |
+
for w in X_windows:
|
| 134 |
+
imu_w = w[:, :6]
|
| 135 |
+
load_w = w[:, 6:10]
|
| 136 |
+
X_features.append(extract_tabular_features(imu_w, load_w))
|
| 137 |
+
X_features = np.array(X_features)
|
| 138 |
+
|
| 139 |
+
print(f" Training samples: {len(X_features)}, Features: {X_features.shape[1]}")
|
| 140 |
+
print(f" Class distribution: {np.bincount(y_windows)}")
|
| 141 |
+
|
| 142 |
+
# Handle NaN/Inf
|
| 143 |
+
X_features = np.nan_to_num(X_features, nan=0, posinf=0, neginf=0)
|
| 144 |
+
|
| 145 |
+
# Scale features
|
| 146 |
+
print("\n[2/4] Scaling features...")
|
| 147 |
+
X_scaled = self.scaler.fit_transform(X_features)
|
| 148 |
+
|
| 149 |
+
# Train ensemble
|
| 150 |
+
print("\n[3/4] Training ensemble (SVM + DT + MLP + XGBoost + RF)...")
|
| 151 |
+
self.model.fit(X_scaled, y_windows)
|
| 152 |
+
self.is_trained = True
|
| 153 |
+
|
| 154 |
+
# Evaluate on training set
|
| 155 |
+
train_pred = self.model.predict(X_scaled)
|
| 156 |
+
train_f1 = f1_score(y_windows, train_pred, average='weighted')
|
| 157 |
+
print(f"\n Training F1 (weighted): {train_f1:.4f}")
|
| 158 |
+
|
| 159 |
+
# Evaluate on validation set if provided
|
| 160 |
+
if imu_val is not None:
|
| 161 |
+
print("\n[4/4] Evaluating on validation set...")
|
| 162 |
+
X_val_windows, y_val_windows = create_sliding_windows(
|
| 163 |
+
np.concatenate([imu_val, load_val], axis=1),
|
| 164 |
+
labels_val,
|
| 165 |
+
window_size=WINDOW_SIZE, stride=WINDOW_STRIDE
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
X_val_features = []
|
| 169 |
+
for w in X_val_windows:
|
| 170 |
+
X_val_features.append(extract_tabular_features(w[:, :6], w[:, 6:10]))
|
| 171 |
+
X_val_features = np.nan_to_num(np.array(X_val_features), nan=0, posinf=0, neginf=0)
|
| 172 |
+
X_val_scaled = self.scaler.transform(X_val_features)
|
| 173 |
+
|
| 174 |
+
val_pred = self.model.predict(X_val_scaled)
|
| 175 |
+
val_f1 = f1_score(y_val_windows, val_pred, average='weighted')
|
| 176 |
+
|
| 177 |
+
print(f"\n Validation F1 (weighted): {val_f1:.4f}")
|
| 178 |
+
print(f"\n Classification Report:")
|
| 179 |
+
print(classification_report(
|
| 180 |
+
y_val_windows, val_pred,
|
| 181 |
+
target_names=[POSTURE_CLASSES[i] for i in sorted(POSTURE_CLASSES.keys())]
|
| 182 |
+
))
|
| 183 |
+
print(f"\n Confusion Matrix:")
|
| 184 |
+
print(confusion_matrix(y_val_windows, val_pred))
|
| 185 |
+
|
| 186 |
+
return {"train_f1": train_f1, "val_f1": val_f1}
|
| 187 |
+
|
| 188 |
+
return {"train_f1": train_f1}
|
| 189 |
+
|
| 190 |
+
def predict(self, imu_window, load_window):
|
| 191 |
+
"""Single window inference."""
|
| 192 |
+
feat = extract_tabular_features(imu_window, load_window)
|
| 193 |
+
feat = np.nan_to_num(feat.reshape(1, -1), nan=0, posinf=0, neginf=0)
|
| 194 |
+
feat_scaled = self.scaler.transform(feat)
|
| 195 |
+
return self.model.predict(feat_scaled)[0]
|
| 196 |
+
|
| 197 |
+
def predict_proba(self, imu_window, load_window):
|
| 198 |
+
"""Single window inference with probability."""
|
| 199 |
+
feat = extract_tabular_features(imu_window, load_window)
|
| 200 |
+
feat = np.nan_to_num(feat.reshape(1, -1), nan=0, posinf=0, neginf=0)
|
| 201 |
+
feat_scaled = self.scaler.transform(feat)
|
| 202 |
+
return self.model.predict_proba(feat_scaled)[0]
|
| 203 |
+
|
| 204 |
+
def save(self, path):
|
| 205 |
+
"""Save model + scaler."""
|
| 206 |
+
joblib.dump({
|
| 207 |
+
'model': self.model,
|
| 208 |
+
'scaler': self.scaler,
|
| 209 |
+
'posture_classes': POSTURE_CLASSES,
|
| 210 |
+
}, path)
|
| 211 |
+
print(f"Model saved to {path}")
|
| 212 |
+
|
| 213 |
+
def load(self, path):
|
| 214 |
+
"""Load model + scaler."""
|
| 215 |
+
data = joblib.load(path)
|
| 216 |
+
self.model = data['model']
|
| 217 |
+
self.scaler = data['scaler']
|
| 218 |
+
self.is_trained = True
|
| 219 |
+
print(f"Model loaded from {path}")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def build_mlstm_fcn(n_timesteps=WINDOW_SIZE, n_channels=INPUT_CHANNELS,
|
| 223 |
+
n_classes=NUM_POSTURE_CLASSES):
|
| 224 |
+
"""
|
| 225 |
+
Build MLSTM-FCN model for sequential sensor data.
|
| 226 |
+
Architecture from arxiv:1801.04503 with Squeeze-Excite blocks.
|
| 227 |
+
|
| 228 |
+
Input: (batch, timesteps, channels) β windowed sensor data
|
| 229 |
+
Output: (batch, n_classes) β posture probabilities
|
| 230 |
+
"""
|
| 231 |
+
import tensorflow as tf
|
| 232 |
+
from tensorflow.keras import layers, Model
|
| 233 |
+
|
| 234 |
+
inputs = layers.Input(shape=(n_timesteps, n_channels), name='sensor_input')
|
| 235 |
+
|
| 236 |
+
# ββ FCN Branch with Squeeze-Excite ββββββββββββββββββββββββββββββββββββ
|
| 237 |
+
x = inputs
|
| 238 |
+
for filters, kernel in zip(MLSTM_FCN_FILTERS, MLSTM_FCN_KERNELS):
|
| 239 |
+
x = layers.Conv1D(filters, kernel, padding='same')(x)
|
| 240 |
+
x = layers.BatchNormalization(momentum=0.99, epsilon=0.001)(x)
|
| 241 |
+
x = layers.ReLU()(x)
|
| 242 |
+
|
| 243 |
+
# Squeeze-Excite block (channel attention)
|
| 244 |
+
se = layers.GlobalAveragePooling1D()(x)
|
| 245 |
+
se = layers.Dense(filters // MLSTM_FCN_SE_RATIO, activation='relu')(se)
|
| 246 |
+
se = layers.Dense(filters, activation='sigmoid')(se)
|
| 247 |
+
se = layers.Reshape((1, filters))(se)
|
| 248 |
+
x = layers.Multiply()([x, se])
|
| 249 |
+
|
| 250 |
+
fcn_out = layers.GlobalAveragePooling1D(name='fcn_features')(x)
|
| 251 |
+
|
| 252 |
+
# ββ LSTM Branch (dimension shuffle) βββββββββββββββββββββββββββββββββββ
|
| 253 |
+
# Permute: (batch, timesteps, channels) β (batch, channels, timesteps)
|
| 254 |
+
# LSTM sees each channel as a "timestep" over all time positions
|
| 255 |
+
shuffled = layers.Permute((2, 1))(inputs)
|
| 256 |
+
lstm_out = layers.LSTM(MLSTM_FCN_LSTM_UNITS, name='lstm_features')(shuffled)
|
| 257 |
+
lstm_out = layers.Dropout(MLSTM_FCN_DROPOUT)(lstm_out)
|
| 258 |
+
|
| 259 |
+
# ββ Merge βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 260 |
+
merged = layers.Concatenate(name='fused_features')([fcn_out, lstm_out])
|
| 261 |
+
|
| 262 |
+
# Classification head
|
| 263 |
+
output = layers.Dense(n_classes, activation='softmax', name='posture_output')(merged)
|
| 264 |
+
|
| 265 |
+
model = Model(inputs, output, name='MLSTM_FCN_Posture')
|
| 266 |
+
model.compile(
|
| 267 |
+
optimizer=tf.keras.optimizers.Adam(learning_rate=TRAIN_LR),
|
| 268 |
+
loss='sparse_categorical_crossentropy',
|
| 269 |
+
metrics=['accuracy']
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
return model
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def train_mlstm_fcn(model, X_train, y_train, X_val, y_val):
|
| 276 |
+
"""
|
| 277 |
+
Train MLSTM-FCN with early stopping and learning rate reduction.
|
| 278 |
+
"""
|
| 279 |
+
import tensorflow as tf
|
| 280 |
+
|
| 281 |
+
callbacks = [
|
| 282 |
+
tf.keras.callbacks.EarlyStopping(
|
| 283 |
+
monitor='val_accuracy', patience=EARLY_STOPPING_PATIENCE,
|
| 284 |
+
restore_best_weights=True, mode='max'
|
| 285 |
+
),
|
| 286 |
+
tf.keras.callbacks.ReduceLROnPlateau(
|
| 287 |
+
monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6
|
| 288 |
+
),
|
| 289 |
+
]
|
| 290 |
+
|
| 291 |
+
history = model.fit(
|
| 292 |
+
X_train, y_train,
|
| 293 |
+
validation_data=(X_val, y_val),
|
| 294 |
+
epochs=TRAIN_EPOCHS,
|
| 295 |
+
batch_size=TRAIN_BATCH_SIZE,
|
| 296 |
+
callbacks=callbacks,
|
| 297 |
+
verbose=1
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
return history
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def convert_to_tflite(model, representative_data, output_path='posture_model.tflite'):
|
| 304 |
+
"""
|
| 305 |
+
Convert Keras model to INT8 TFLite for ESP32-S3 deployment.
|
| 306 |
+
Based on TinyNav (arxiv:2603.11071) quantization recipe.
|
| 307 |
+
"""
|
| 308 |
+
import tensorflow as tf
|
| 309 |
+
|
| 310 |
+
def representative_dataset():
|
| 311 |
+
for sample in representative_data[:200]:
|
| 312 |
+
yield [sample[np.newaxis].astype(np.float32)]
|
| 313 |
+
|
| 314 |
+
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
| 315 |
+
converter.optimizations = [tf.lite.Optimize.DEFAULT]
|
| 316 |
+
converter.representative_dataset = representative_dataset
|
| 317 |
+
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
|
| 318 |
+
converter.inference_input_type = tf.int8
|
| 319 |
+
converter.inference_output_type = tf.int8
|
| 320 |
+
|
| 321 |
+
tflite_model = converter.convert()
|
| 322 |
+
|
| 323 |
+
with open(output_path, 'wb') as f:
|
| 324 |
+
f.write(tflite_model)
|
| 325 |
+
|
| 326 |
+
print(f"TFLite model saved: {output_path} ({len(tflite_model)/1024:.1f} KB)")
|
| 327 |
+
return tflite_model
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
if __name__ == "__main__":
|
| 331 |
+
from data_collection.synthetic_data_generator import generate_dataset, subject_based_split
|
| 332 |
+
|
| 333 |
+
# Generate synthetic data
|
| 334 |
+
print("Generating synthetic dataset...")
|
| 335 |
+
dataset = generate_dataset(n_subjects=20, samples_per_posture_per_subject=500)
|
| 336 |
+
train, test = subject_based_split(dataset)
|
| 337 |
+
|
| 338 |
+
# Train Ensemble Classifier
|
| 339 |
+
print("\n" + "=" * 60)
|
| 340 |
+
print("TRAINING ENSEMBLE CLASSIFIER")
|
| 341 |
+
print("=" * 60)
|
| 342 |
+
|
| 343 |
+
clf = EnsemblePostureClassifier()
|
| 344 |
+
results = clf.train(
|
| 345 |
+
train["imu_data"], train["load_data"], train["labels"],
|
| 346 |
+
test["imu_data"], test["load_data"], test["labels"]
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
print(f"\nFinal Results β Ensemble: {results}")
|
| 350 |
+
clf.save("/app/smart_chair/models/ensemble_posture.joblib")
|
smart_chair/ml_models/spine_risk_predictor.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair Spine Risk Prediction & Fatigue Detection.
|
| 3 |
+
|
| 4 |
+
1. RULA-based Ergonomic Risk Score (real-time)
|
| 5 |
+
β Based on DULA/DEBA (arxiv:2205.03491): differentiable RULA scoring
|
| 6 |
+
β Maps IMU angles to 1-7 risk scale per RULA standard
|
| 7 |
+
|
| 8 |
+
2. Cumulative Spine Risk Prediction (long-term)
|
| 9 |
+
β Accumulates posture history β predicts injury risk trajectory
|
| 10 |
+
β Uses exponential decay weighting (recent bad posture = higher risk)
|
| 11 |
+
|
| 12 |
+
3. Fatigue & Drowsiness Detection (pressure patterns)
|
| 13 |
+
β Based on CoP variance trends over 30-min windows
|
| 14 |
+
β Increasing variability = restlessness β fatigue
|
| 15 |
+
β Decreasing micro-movement = drowsiness/falling asleep
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
from collections import deque
|
| 20 |
+
from datetime import datetime, timedelta
|
| 21 |
+
from typing import Dict, List, Optional, Tuple
|
| 22 |
+
|
| 23 |
+
import sys
|
| 24 |
+
sys.path.insert(0, '/app/smart_chair')
|
| 25 |
+
from utils.feature_engineering import compute_imu_angles, compute_center_of_pressure
|
| 26 |
+
from config.settings import *
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
# 1. RULA-BASED REAL-TIME ERGONOMIC RISK SCORE
|
| 31 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
|
| 33 |
+
class RULAScorer:
|
| 34 |
+
"""
|
| 35 |
+
Rapid Upper Limb Assessment (RULA) adapted for seated posture.
|
| 36 |
+
Maps trunk pitch/roll angles to a 1-7 risk score.
|
| 37 |
+
|
| 38 |
+
Score interpretation (EN ISO 11226 + RULA):
|
| 39 |
+
1-2: Acceptable posture
|
| 40 |
+
3-4: Investigate further, possible intervention needed
|
| 41 |
+
5-6: Investigate and implement changes soon
|
| 42 |
+
7: Investigate and implement changes immediately
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(self):
|
| 46 |
+
self.trunk_angle_thresholds = RULA_TRUNK_ANGLES # [10, 20, 60] degrees
|
| 47 |
+
self.lateral_threshold = RULA_LATERAL_THRESHOLD # 15 degrees
|
| 48 |
+
|
| 49 |
+
def score_trunk(self, pitch_deg: float) -> int:
|
| 50 |
+
"""Score trunk forward/backward flexion (1-4 base score)."""
|
| 51 |
+
deviation = abs(pitch_deg)
|
| 52 |
+
if deviation < self.trunk_angle_thresholds[0]: # < 10Β°
|
| 53 |
+
return 1
|
| 54 |
+
elif deviation < self.trunk_angle_thresholds[1]: # 10-20Β°
|
| 55 |
+
return 2
|
| 56 |
+
elif deviation < self.trunk_angle_thresholds[2]: # 20-60Β°
|
| 57 |
+
return 3
|
| 58 |
+
else: # > 60Β°
|
| 59 |
+
return 4
|
| 60 |
+
|
| 61 |
+
def score_lateral(self, roll_deg: float) -> int:
|
| 62 |
+
"""Score lateral trunk deviation (0-1 modifier)."""
|
| 63 |
+
return 1 if abs(roll_deg) > self.lateral_threshold else 0
|
| 64 |
+
|
| 65 |
+
def score_twist(self, yaw_rate: float) -> int:
|
| 66 |
+
"""Score trunk twist from gyroscope yaw rate (0-1 modifier)."""
|
| 67 |
+
return 1 if abs(yaw_rate) > 0.5 else 0 # rad/s threshold
|
| 68 |
+
|
| 69 |
+
def compute_rula_score(self, pitch_deg: float, roll_deg: float,
|
| 70 |
+
yaw_rate: float = 0.0,
|
| 71 |
+
duration_minutes: float = 0.0,
|
| 72 |
+
is_static: bool = True) -> Dict:
|
| 73 |
+
"""
|
| 74 |
+
Compute full RULA score for seated posture.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
pitch_deg: trunk forward/backward angle (from IMU)
|
| 78 |
+
roll_deg: trunk lateral angle (from IMU)
|
| 79 |
+
yaw_rate: trunk rotation rate (from gyroscope z-axis)
|
| 80 |
+
duration_minutes: how long in this posture
|
| 81 |
+
is_static: True if posture held > 1 minute
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
dict with score, risk_level, recommendations
|
| 85 |
+
"""
|
| 86 |
+
# Base trunk score
|
| 87 |
+
trunk_score = self.score_trunk(pitch_deg)
|
| 88 |
+
trunk_score += self.score_lateral(roll_deg)
|
| 89 |
+
trunk_score += self.score_twist(yaw_rate)
|
| 90 |
+
|
| 91 |
+
# Static posture modifier (held > 1 min adds +1)
|
| 92 |
+
if is_static and duration_minutes > 1:
|
| 93 |
+
trunk_score += 1
|
| 94 |
+
|
| 95 |
+
# Duration escalation (long-held bad posture is worse)
|
| 96 |
+
if duration_minutes > 30:
|
| 97 |
+
trunk_score += 1
|
| 98 |
+
if duration_minutes > 60:
|
| 99 |
+
trunk_score += 1
|
| 100 |
+
|
| 101 |
+
# Clamp to 1-7
|
| 102 |
+
final_score = max(1, min(7, trunk_score))
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
"rula_score": final_score,
|
| 106 |
+
"risk_level": RISK_LEVELS.get(final_score, "unknown"),
|
| 107 |
+
"trunk_base": self.score_trunk(pitch_deg),
|
| 108 |
+
"lateral_mod": self.score_lateral(roll_deg),
|
| 109 |
+
"twist_mod": self.score_twist(yaw_rate),
|
| 110 |
+
"duration_minutes": duration_minutes,
|
| 111 |
+
"pitch_deg": pitch_deg,
|
| 112 |
+
"roll_deg": roll_deg,
|
| 113 |
+
"recommendations": self._get_recommendations(final_score, pitch_deg, roll_deg)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
def _get_recommendations(self, score: int, pitch: float, roll: float) -> List[str]:
|
| 117 |
+
"""Generate actionable recommendations based on risk score."""
|
| 118 |
+
recs = []
|
| 119 |
+
if score <= 2:
|
| 120 |
+
recs.append("Good posture! Keep it up.")
|
| 121 |
+
if score >= 3:
|
| 122 |
+
recs.append("Adjust your sitting position β try moving back in the chair.")
|
| 123 |
+
if abs(pitch) > 20:
|
| 124 |
+
recs.append(f"Forward lean detected ({abs(pitch):.0f}Β°). Sit upright and engage core.")
|
| 125 |
+
if abs(roll) > 15:
|
| 126 |
+
side = "left" if roll < 0 else "right"
|
| 127 |
+
recs.append(f"Leaning {side} ({abs(roll):.0f}Β°). Center your weight evenly.")
|
| 128 |
+
if score >= 5:
|
| 129 |
+
recs.append("β οΈ HIGH RISK: Take a break immediately and do spine stretches.")
|
| 130 |
+
if score >= 7:
|
| 131 |
+
recs.append("π¨ CRITICAL: Prolonged poor posture detected. Stand up NOW.")
|
| 132 |
+
return recs
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
# 2. CUMULATIVE SPINE RISK PREDICTOR
|
| 137 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
+
|
| 139 |
+
class SpineRiskPredictor:
|
| 140 |
+
"""
|
| 141 |
+
Long-term spine risk prediction using accumulated session data.
|
| 142 |
+
|
| 143 |
+
Model: Exponential decay weighted accumulator.
|
| 144 |
+
Recent bad posture has more impact than historical bad posture.
|
| 145 |
+
|
| 146 |
+
Risk score = Ξ£ (posture_penalty Γ decay^(time_elapsed)) / normalization
|
| 147 |
+
|
| 148 |
+
Outputs:
|
| 149 |
+
- Daily risk score (0-100)
|
| 150 |
+
- Weekly trend (improving / stable / deteriorating)
|
| 151 |
+
- Predicted injury risk category
|
| 152 |
+
"""
|
| 153 |
+
|
| 154 |
+
def __init__(self, decay_rate: float = 0.95):
|
| 155 |
+
self.decay_rate = decay_rate
|
| 156 |
+
self.history = deque(maxlen=7 * 24 * 60) # 1 week of per-minute records
|
| 157 |
+
self.daily_scores = deque(maxlen=30) # 30 days of daily scores
|
| 158 |
+
|
| 159 |
+
# Posture penalty weights (higher = worse for spine)
|
| 160 |
+
self.posture_penalties = {
|
| 161 |
+
"upright": 0.0,
|
| 162 |
+
"forward_lean": 0.6,
|
| 163 |
+
"backward_lean": 0.3,
|
| 164 |
+
"left_lean": 0.4,
|
| 165 |
+
"right_lean": 0.4,
|
| 166 |
+
"slouch": 1.0, # worst posture for spine
|
| 167 |
+
"absent": 0.0,
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
def record_posture(self, posture_name: str, rula_score: int,
|
| 171 |
+
duration_minutes: float = 1.0, timestamp: Optional[datetime] = None):
|
| 172 |
+
"""Record a posture observation."""
|
| 173 |
+
if timestamp is None:
|
| 174 |
+
timestamp = datetime.now()
|
| 175 |
+
|
| 176 |
+
self.history.append({
|
| 177 |
+
"timestamp": timestamp,
|
| 178 |
+
"posture": posture_name,
|
| 179 |
+
"rula_score": rula_score,
|
| 180 |
+
"duration": duration_minutes,
|
| 181 |
+
"penalty": self.posture_penalties.get(posture_name, 0.5),
|
| 182 |
+
})
|
| 183 |
+
|
| 184 |
+
def compute_session_risk(self, lookback_minutes: int = 60) -> Dict:
|
| 185 |
+
"""
|
| 186 |
+
Compute risk score for the current session.
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
dict with risk_score (0-100), trend, breakdown
|
| 190 |
+
"""
|
| 191 |
+
now = datetime.now()
|
| 192 |
+
cutoff = now - timedelta(minutes=lookback_minutes)
|
| 193 |
+
|
| 194 |
+
recent = [r for r in self.history if r["timestamp"] >= cutoff]
|
| 195 |
+
|
| 196 |
+
if not recent:
|
| 197 |
+
return {"risk_score": 0, "trend": "no_data", "minutes_analyzed": 0}
|
| 198 |
+
|
| 199 |
+
# Weighted risk accumulation
|
| 200 |
+
total_risk = 0
|
| 201 |
+
total_weight = 0
|
| 202 |
+
posture_breakdown = {}
|
| 203 |
+
|
| 204 |
+
for record in recent:
|
| 205 |
+
elapsed = (now - record["timestamp"]).total_seconds() / 60
|
| 206 |
+
weight = self.decay_rate ** elapsed # exponential decay
|
| 207 |
+
risk = record["penalty"] * record["rula_score"] * record["duration"]
|
| 208 |
+
|
| 209 |
+
total_risk += risk * weight
|
| 210 |
+
total_weight += weight * record["duration"]
|
| 211 |
+
|
| 212 |
+
p = record["posture"]
|
| 213 |
+
posture_breakdown[p] = posture_breakdown.get(p, 0) + record["duration"]
|
| 214 |
+
|
| 215 |
+
# Normalize to 0-100 scale
|
| 216 |
+
max_possible_risk = 7 * 1.0 * lookback_minutes # max RULA Γ max penalty Γ all minutes
|
| 217 |
+
risk_score = min(100, (total_risk / (total_weight + 1e-6)) * 100 / 7)
|
| 218 |
+
|
| 219 |
+
return {
|
| 220 |
+
"risk_score": round(risk_score, 1),
|
| 221 |
+
"risk_category": self._categorize_risk(risk_score),
|
| 222 |
+
"minutes_analyzed": sum(r["duration"] for r in recent),
|
| 223 |
+
"posture_breakdown": posture_breakdown,
|
| 224 |
+
"dominant_posture": max(posture_breakdown, key=posture_breakdown.get) if posture_breakdown else "unknown",
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
def compute_daily_score(self) -> float:
|
| 228 |
+
"""Compute and store daily posture score (0-100, higher = better)."""
|
| 229 |
+
session = self.compute_session_risk(lookback_minutes=24*60)
|
| 230 |
+
# Invert: low risk = high score
|
| 231 |
+
daily_score = max(0, 100 - session["risk_score"])
|
| 232 |
+
self.daily_scores.append({
|
| 233 |
+
"date": datetime.now().date(),
|
| 234 |
+
"score": daily_score,
|
| 235 |
+
})
|
| 236 |
+
return daily_score
|
| 237 |
+
|
| 238 |
+
def get_weekly_trend(self) -> Dict:
|
| 239 |
+
"""Analyze 7-day trend."""
|
| 240 |
+
if len(self.daily_scores) < 2:
|
| 241 |
+
return {"trend": "insufficient_data", "scores": []}
|
| 242 |
+
|
| 243 |
+
scores = [s["score"] for s in self.daily_scores]
|
| 244 |
+
recent_3 = np.mean(scores[-3:]) if len(scores) >= 3 else scores[-1]
|
| 245 |
+
older_3 = np.mean(scores[-6:-3]) if len(scores) >= 6 else scores[0]
|
| 246 |
+
|
| 247 |
+
if recent_3 > older_3 + 5:
|
| 248 |
+
trend = "improving"
|
| 249 |
+
elif recent_3 < older_3 - 5:
|
| 250 |
+
trend = "deteriorating"
|
| 251 |
+
else:
|
| 252 |
+
trend = "stable"
|
| 253 |
+
|
| 254 |
+
return {
|
| 255 |
+
"trend": trend,
|
| 256 |
+
"current_avg": round(recent_3, 1),
|
| 257 |
+
"previous_avg": round(older_3, 1),
|
| 258 |
+
"scores": [{"date": str(s["date"]), "score": s["score"]} for s in self.daily_scores],
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
def predict_injury_risk(self, weeks_ahead: int = 4) -> Dict:
|
| 262 |
+
"""
|
| 263 |
+
Predict future injury risk based on current trajectory.
|
| 264 |
+
|
| 265 |
+
Uses linear extrapolation of daily scores to estimate
|
| 266 |
+
when risk crosses critical thresholds.
|
| 267 |
+
"""
|
| 268 |
+
if len(self.daily_scores) < 7:
|
| 269 |
+
return {"prediction": "need_more_data", "days_collected": len(self.daily_scores)}
|
| 270 |
+
|
| 271 |
+
scores = [s["score"] for s in self.daily_scores]
|
| 272 |
+
x = np.arange(len(scores))
|
| 273 |
+
slope, intercept = np.polyfit(x, scores, 1)
|
| 274 |
+
|
| 275 |
+
# Project forward
|
| 276 |
+
future_x = np.arange(len(scores), len(scores) + weeks_ahead * 7)
|
| 277 |
+
projected = slope * future_x + intercept
|
| 278 |
+
|
| 279 |
+
# Find when score drops below thresholds
|
| 280 |
+
alerts = []
|
| 281 |
+
if slope < -0.5: # declining at > 0.5 points/day
|
| 282 |
+
days_to_danger = int((40 - intercept) / slope) if slope != 0 else None
|
| 283 |
+
if days_to_danger and 0 < days_to_danger < weeks_ahead * 7:
|
| 284 |
+
alerts.append(f"β οΈ At current rate, posture score will reach DANGER zone in {days_to_danger} days")
|
| 285 |
+
|
| 286 |
+
return {
|
| 287 |
+
"slope": round(slope, 3), # points per day
|
| 288 |
+
"direction": "improving" if slope > 0 else "declining",
|
| 289 |
+
"projected_4week_score": round(projected[-1], 1),
|
| 290 |
+
"current_score": round(scores[-1], 1),
|
| 291 |
+
"alerts": alerts,
|
| 292 |
+
"recommendation": "Maintain current habits" if slope >= 0 else
|
| 293 |
+
"Increase break frequency and do corrective exercises"
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
def _categorize_risk(self, risk_score: float) -> str:
|
| 297 |
+
if risk_score < 20: return "low"
|
| 298 |
+
elif risk_score < 40: return "moderate"
|
| 299 |
+
elif risk_score < 60: return "elevated"
|
| 300 |
+
elif risk_score < 80: return "high"
|
| 301 |
+
else: return "critical"
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 305 |
+
# 3. FATIGUE & DROWSINESS DETECTION
|
| 306 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 307 |
+
|
| 308 |
+
class FatigueDetector:
|
| 309 |
+
"""
|
| 310 |
+
Detects physical fatigue and drowsiness from pressure/load cell patterns.
|
| 311 |
+
|
| 312 |
+
Fatigue indicators (from literature):
|
| 313 |
+
1. Increasing CoP variance (restlessness, frequent shifting)
|
| 314 |
+
2. Decreasing micro-movement amplitude (stillness = drowsiness)
|
| 315 |
+
3. Gradual forward lean (torso muscle fatigue β slouch)
|
| 316 |
+
4. Weight redistribution pattern changes
|
| 317 |
+
|
| 318 |
+
Uses rolling window analysis over 30 minutes.
|
| 319 |
+
"""
|
| 320 |
+
|
| 321 |
+
def __init__(self, window_minutes: int = FATIGUE_WINDOW_MINUTES):
|
| 322 |
+
self.window_minutes = window_minutes
|
| 323 |
+
self.cop_history = deque(maxlen=window_minutes * 60 * 10) # 10 Hz
|
| 324 |
+
self.movement_history = deque(maxlen=window_minutes * 60 * 50) # 50 Hz IMU
|
| 325 |
+
self.posture_sequence = deque(maxlen=window_minutes) # per-minute
|
| 326 |
+
self.alert_cooldown_seconds = 300 # 5 min between alerts
|
| 327 |
+
self.last_alert_time = None
|
| 328 |
+
|
| 329 |
+
def update(self, load_cells: np.ndarray, imu_data: np.ndarray,
|
| 330 |
+
timestamp: Optional[datetime] = None):
|
| 331 |
+
"""
|
| 332 |
+
Add new sensor readings. Call at sensor sampling rate.
|
| 333 |
+
|
| 334 |
+
Args:
|
| 335 |
+
load_cells: shape (4,) β [FL, FR, RL, RR]
|
| 336 |
+
imu_data: shape (6,) β [ax, ay, az, gx, gy, gz]
|
| 337 |
+
"""
|
| 338 |
+
if timestamp is None:
|
| 339 |
+
timestamp = datetime.now()
|
| 340 |
+
|
| 341 |
+
# Compute CoP
|
| 342 |
+
total = np.sum(load_cells) + 1e-6
|
| 343 |
+
cop_x = (load_cells[1] + load_cells[3] - load_cells[0] - load_cells[2]) / total
|
| 344 |
+
cop_y = (load_cells[0] + load_cells[1] - load_cells[2] - load_cells[3]) / total
|
| 345 |
+
|
| 346 |
+
self.cop_history.append({
|
| 347 |
+
"timestamp": timestamp,
|
| 348 |
+
"cop_x": cop_x,
|
| 349 |
+
"cop_y": cop_y,
|
| 350 |
+
"total_weight": total,
|
| 351 |
+
})
|
| 352 |
+
|
| 353 |
+
# Compute acceleration magnitude (micro-movement indicator)
|
| 354 |
+
accel_mag = np.linalg.norm(imu_data[:3])
|
| 355 |
+
self.movement_history.append({
|
| 356 |
+
"timestamp": timestamp,
|
| 357 |
+
"accel_mag": accel_mag,
|
| 358 |
+
"gyro_mag": np.linalg.norm(imu_data[3:]),
|
| 359 |
+
})
|
| 360 |
+
|
| 361 |
+
def analyze_fatigue(self) -> Dict:
|
| 362 |
+
"""
|
| 363 |
+
Analyze current fatigue state from accumulated sensor history.
|
| 364 |
+
|
| 365 |
+
Returns:
|
| 366 |
+
dict with fatigue_level, drowsiness_risk, indicators
|
| 367 |
+
"""
|
| 368 |
+
if len(self.cop_history) < 100:
|
| 369 |
+
return {"fatigue_level": "unknown", "drowsiness_risk": "unknown",
|
| 370 |
+
"reason": "insufficient_data"}
|
| 371 |
+
|
| 372 |
+
# ββ CoP Variance Analysis (restlessness) ββββββββββββββββββββββββββ
|
| 373 |
+
cop_x_values = [r["cop_x"] for r in self.cop_history]
|
| 374 |
+
cop_y_values = [r["cop_y"] for r in self.cop_history]
|
| 375 |
+
|
| 376 |
+
# Split into 5-minute windows
|
| 377 |
+
n = len(cop_x_values)
|
| 378 |
+
window_5min = min(3000, n // 3) # ~5 min at 10 Hz
|
| 379 |
+
|
| 380 |
+
early_cop_var = np.var(cop_x_values[:window_5min]) + np.var(cop_y_values[:window_5min])
|
| 381 |
+
recent_cop_var = np.var(cop_x_values[-window_5min:]) + np.var(cop_y_values[-window_5min:])
|
| 382 |
+
|
| 383 |
+
cop_trend = recent_cop_var / (early_cop_var + 1e-6)
|
| 384 |
+
|
| 385 |
+
# ββ Micro-Movement Analysis (drowsiness) βββββββββββββββββββββββββ
|
| 386 |
+
accel_values = [r["accel_mag"] for r in self.movement_history]
|
| 387 |
+
n_mov = len(accel_values)
|
| 388 |
+
window_2min = min(6000, n_mov // 3) # ~2 min at 50 Hz
|
| 389 |
+
|
| 390 |
+
recent_movement = np.std(accel_values[-window_2min:])
|
| 391 |
+
early_movement = np.std(accel_values[:window_2min])
|
| 392 |
+
|
| 393 |
+
movement_decline = recent_movement / (early_movement + 1e-6)
|
| 394 |
+
|
| 395 |
+
# ββ Weight Shift Analysis βββββββββββββββββββββββββββββββββββββββββ
|
| 396 |
+
weights = [r["total_weight"] for r in self.cop_history]
|
| 397 |
+
weight_stability = np.std(weights[-window_5min:])
|
| 398 |
+
|
| 399 |
+
# ββ Fatigue Classification ββββββββββββββββββββββββββββββββββββββββ
|
| 400 |
+
fatigue_score = 0
|
| 401 |
+
indicators = []
|
| 402 |
+
|
| 403 |
+
# Restlessness (high CoP variance trend)
|
| 404 |
+
if cop_trend > 2.0:
|
| 405 |
+
fatigue_score += 3
|
| 406 |
+
indicators.append(f"Restlessness: CoP variance increased {cop_trend:.1f}x")
|
| 407 |
+
elif cop_trend > 1.5:
|
| 408 |
+
fatigue_score += 1
|
| 409 |
+
indicators.append(f"Mild restlessness: CoP variance increased {cop_trend:.1f}x")
|
| 410 |
+
|
| 411 |
+
# Drowsiness (decreasing movement)
|
| 412 |
+
if movement_decline < 0.3:
|
| 413 |
+
fatigue_score += 4
|
| 414 |
+
indicators.append(f"β οΈ Possible drowsiness: movement dropped to {movement_decline:.1%} of baseline")
|
| 415 |
+
elif movement_decline < 0.6:
|
| 416 |
+
fatigue_score += 2
|
| 417 |
+
indicators.append(f"Reduced activity: movement at {movement_decline:.1%} of baseline")
|
| 418 |
+
|
| 419 |
+
# Weight instability
|
| 420 |
+
if weight_stability > 2.0:
|
| 421 |
+
fatigue_score += 1
|
| 422 |
+
indicators.append("Frequent weight shifting detected")
|
| 423 |
+
|
| 424 |
+
# Classify
|
| 425 |
+
if fatigue_score >= 5:
|
| 426 |
+
fatigue_level = "high"
|
| 427 |
+
elif fatigue_score >= 3:
|
| 428 |
+
fatigue_level = "moderate"
|
| 429 |
+
elif fatigue_score >= 1:
|
| 430 |
+
fatigue_level = "low"
|
| 431 |
+
else:
|
| 432 |
+
fatigue_level = "none"
|
| 433 |
+
|
| 434 |
+
# Drowsiness specifically
|
| 435 |
+
drowsiness_risk = "high" if movement_decline < 0.3 else \
|
| 436 |
+
"moderate" if movement_decline < 0.5 else "low"
|
| 437 |
+
|
| 438 |
+
return {
|
| 439 |
+
"fatigue_level": fatigue_level,
|
| 440 |
+
"fatigue_score": fatigue_score,
|
| 441 |
+
"drowsiness_risk": drowsiness_risk,
|
| 442 |
+
"cop_variance_trend": round(cop_trend, 2),
|
| 443 |
+
"movement_decline_ratio": round(movement_decline, 3),
|
| 444 |
+
"indicators": indicators,
|
| 445 |
+
"recommendation": self._get_fatigue_recommendation(fatigue_level, drowsiness_risk),
|
| 446 |
+
"minutes_analyzed": len(self.cop_history) / 600, # at 10 Hz
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
def _get_fatigue_recommendation(self, fatigue: str, drowsiness: str) -> str:
|
| 450 |
+
if drowsiness == "high":
|
| 451 |
+
return "π¨ DROWSINESS ALERT: Stand up, walk, and get fresh air immediately!"
|
| 452 |
+
if fatigue == "high":
|
| 453 |
+
return "β οΈ High fatigue detected. Take a 5-minute active break with stretching."
|
| 454 |
+
if fatigue == "moderate":
|
| 455 |
+
return "Moderate fatigue. Consider a micro-break in the next 10 minutes."
|
| 456 |
+
return "Looking good! No fatigue issues detected."
|
| 457 |
+
|
| 458 |
+
def should_alert(self) -> bool:
|
| 459 |
+
"""Check if enough time has passed since last alert."""
|
| 460 |
+
if self.last_alert_time is None:
|
| 461 |
+
return True
|
| 462 |
+
elapsed = (datetime.now() - self.last_alert_time).total_seconds()
|
| 463 |
+
return elapsed > self.alert_cooldown_seconds
|
| 464 |
+
|
| 465 |
+
def send_alert(self):
|
| 466 |
+
"""Mark alert as sent."""
|
| 467 |
+
self.last_alert_time = datetime.now()
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 471 |
+
# 4. INJURY RISK ALERT SYSTEM
|
| 472 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 473 |
+
|
| 474 |
+
class InjuryRiskAlertSystem:
|
| 475 |
+
"""
|
| 476 |
+
Monitors long-term postural habits and generates injury risk alerts.
|
| 477 |
+
|
| 478 |
+
Alert types:
|
| 479 |
+
- Acute: Dangerous posture held too long (immediate)
|
| 480 |
+
- Chronic: Pattern of bad posture over days/weeks
|
| 481 |
+
- Trend: Deteriorating posture score trajectory
|
| 482 |
+
"""
|
| 483 |
+
|
| 484 |
+
def __init__(self):
|
| 485 |
+
self.alerts = deque(maxlen=1000)
|
| 486 |
+
self.acknowledged = set()
|
| 487 |
+
|
| 488 |
+
def check_acute_risk(self, rula_score: int, posture_name: str,
|
| 489 |
+
duration_minutes: float) -> Optional[Dict]:
|
| 490 |
+
"""Check for immediate dangerous posture."""
|
| 491 |
+
alert = None
|
| 492 |
+
|
| 493 |
+
if rula_score >= 6 and duration_minutes > 5:
|
| 494 |
+
alert = {
|
| 495 |
+
"type": "acute",
|
| 496 |
+
"severity": "critical",
|
| 497 |
+
"message": f"π¨ CRITICAL: {posture_name} posture (RULA={rula_score}) "
|
| 498 |
+
f"held for {duration_minutes:.0f} min. Risk of acute back strain.",
|
| 499 |
+
"action": "Stand up and do gentle back extension stretches immediately.",
|
| 500 |
+
"timestamp": datetime.now().isoformat(),
|
| 501 |
+
}
|
| 502 |
+
elif rula_score >= 4 and duration_minutes > 20:
|
| 503 |
+
alert = {
|
| 504 |
+
"type": "acute",
|
| 505 |
+
"severity": "warning",
|
| 506 |
+
"message": f"β οΈ WARNING: {posture_name} posture (RULA={rula_score}) "
|
| 507 |
+
f"for {duration_minutes:.0f} min. Spine compression accumulating.",
|
| 508 |
+
"action": "Adjust posture and take a micro-break within 10 minutes.",
|
| 509 |
+
"timestamp": datetime.now().isoformat(),
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
if alert:
|
| 513 |
+
self.alerts.append(alert)
|
| 514 |
+
return alert
|
| 515 |
+
|
| 516 |
+
def check_chronic_risk(self, daily_scores: List[float]) -> Optional[Dict]:
|
| 517 |
+
"""Check for chronic poor posture patterns."""
|
| 518 |
+
if len(daily_scores) < 7:
|
| 519 |
+
return None
|
| 520 |
+
|
| 521 |
+
week_avg = np.mean(daily_scores[-7:])
|
| 522 |
+
|
| 523 |
+
if week_avg < 30:
|
| 524 |
+
alert = {
|
| 525 |
+
"type": "chronic",
|
| 526 |
+
"severity": "critical",
|
| 527 |
+
"message": f"π¨ CHRONIC RISK: Weekly posture score = {week_avg:.0f}/100. "
|
| 528 |
+
f"Persistent poor posture increases herniated disc risk by 4x.",
|
| 529 |
+
"action": "Schedule ergonomic assessment. Consider standing desk alternation.",
|
| 530 |
+
"timestamp": datetime.now().isoformat(),
|
| 531 |
+
}
|
| 532 |
+
self.alerts.append(alert)
|
| 533 |
+
return alert
|
| 534 |
+
|
| 535 |
+
if week_avg < 50:
|
| 536 |
+
alert = {
|
| 537 |
+
"type": "chronic",
|
| 538 |
+
"severity": "warning",
|
| 539 |
+
"message": f"β οΈ CONCERN: Weekly posture score = {week_avg:.0f}/100. "
|
| 540 |
+
f"Consistent moderate risk detected.",
|
| 541 |
+
"action": "Increase break frequency and add core strengthening exercises.",
|
| 542 |
+
"timestamp": datetime.now().isoformat(),
|
| 543 |
+
}
|
| 544 |
+
self.alerts.append(alert)
|
| 545 |
+
return alert
|
| 546 |
+
|
| 547 |
+
return None
|
| 548 |
+
|
| 549 |
+
def get_active_alerts(self) -> List[Dict]:
|
| 550 |
+
"""Get all unacknowledged alerts."""
|
| 551 |
+
return [a for i, a in enumerate(self.alerts) if i not in self.acknowledged]
|
| 552 |
+
|
| 553 |
+
def acknowledge_alert(self, alert_index: int):
|
| 554 |
+
"""Mark alert as acknowledged."""
|
| 555 |
+
self.acknowledged.add(alert_index)
|
smart_chair/ml_models/user_recognition.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartChair Multi-User Recognition via Weight Signature Patterns.
|
| 3 |
+
|
| 4 |
+
Based on pressure biometrics literature + AuthentiSense (arxiv:2302.02740):
|
| 5 |
+
- Uses load cell weight distribution as biometric signature
|
| 6 |
+
- Few-shot learning: 5 sit-down events per user for enrollment
|
| 7 |
+
- k-NN matching with Mahalanobis distance for robustness
|
| 8 |
+
- Siamese network option for improved accuracy with more data
|
| 9 |
+
|
| 10 |
+
Weight signature features:
|
| 11 |
+
- Total weight (strongest discriminator, Β±3kg tolerance)
|
| 12 |
+
- Front/back weight ratio (sitting style)
|
| 13 |
+
- Left/right weight ratio (postural asymmetry)
|
| 14 |
+
- CoP pattern during sit-down transition (unique to individual)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
from collections import defaultdict
|
| 19 |
+
from typing import Dict, List, Optional, Tuple
|
| 20 |
+
from sklearn.neighbors import KNeighborsClassifier
|
| 21 |
+
from sklearn.preprocessing import StandardScaler
|
| 22 |
+
import joblib
|
| 23 |
+
|
| 24 |
+
import sys
|
| 25 |
+
sys.path.insert(0, '/app/smart_chair')
|
| 26 |
+
from utils.feature_engineering import compute_center_of_pressure
|
| 27 |
+
from config.settings import *
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class WeightSignature:
|
| 31 |
+
"""Represents a user's unique weight distribution pattern."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, load_readings: np.ndarray):
|
| 34 |
+
"""
|
| 35 |
+
Extract weight signature from a sequence of load cell readings.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
load_readings: shape (N, 4) β [FL, FR, RL, RR] over ~10 seconds of sitting
|
| 39 |
+
"""
|
| 40 |
+
total = np.sum(load_readings, axis=1, keepdims=True) + 1e-6
|
| 41 |
+
normalized = load_readings / total
|
| 42 |
+
|
| 43 |
+
self.total_weight = np.mean(total)
|
| 44 |
+
self.total_weight_std = np.std(total)
|
| 45 |
+
|
| 46 |
+
# Per-cell ratios (unique to how a person sits)
|
| 47 |
+
self.fl_ratio = np.mean(normalized[:, 0])
|
| 48 |
+
self.fr_ratio = np.mean(normalized[:, 1])
|
| 49 |
+
self.rl_ratio = np.mean(normalized[:, 2])
|
| 50 |
+
self.rr_ratio = np.mean(normalized[:, 3])
|
| 51 |
+
|
| 52 |
+
# Ratio variabilities (how stable their sitting is)
|
| 53 |
+
self.fl_std = np.std(normalized[:, 0])
|
| 54 |
+
self.fr_std = np.std(normalized[:, 1])
|
| 55 |
+
self.rl_std = np.std(normalized[:, 2])
|
| 56 |
+
self.rr_std = np.std(normalized[:, 3])
|
| 57 |
+
|
| 58 |
+
# Derived ratios
|
| 59 |
+
front = normalized[:, 0] + normalized[:, 1]
|
| 60 |
+
back = normalized[:, 2] + normalized[:, 3]
|
| 61 |
+
left = normalized[:, 0] + normalized[:, 2]
|
| 62 |
+
right = normalized[:, 1] + normalized[:, 3]
|
| 63 |
+
|
| 64 |
+
self.front_back_ratio = np.mean(front / (back + 1e-6))
|
| 65 |
+
self.left_right_ratio = np.mean(left / (right + 1e-6))
|
| 66 |
+
|
| 67 |
+
# CoP signature
|
| 68 |
+
cop_x, cop_y = compute_center_of_pressure(load_readings)
|
| 69 |
+
self.cop_x_mean = np.mean(cop_x)
|
| 70 |
+
self.cop_y_mean = np.mean(cop_y)
|
| 71 |
+
self.cop_x_std = np.std(cop_x)
|
| 72 |
+
self.cop_y_std = np.std(cop_y)
|
| 73 |
+
|
| 74 |
+
# Sit-down dynamics (how quickly weight stabilizes)
|
| 75 |
+
if len(load_readings) > 20:
|
| 76 |
+
weight_diff = np.diff(total.flatten())
|
| 77 |
+
self.sitdown_speed = np.mean(np.abs(weight_diff[:20]))
|
| 78 |
+
self.stability_time = np.argmax(np.abs(weight_diff) < 0.5) if np.any(np.abs(weight_diff) < 0.5) else len(weight_diff)
|
| 79 |
+
else:
|
| 80 |
+
self.sitdown_speed = 0
|
| 81 |
+
self.stability_time = 0
|
| 82 |
+
|
| 83 |
+
def to_vector(self) -> np.ndarray:
|
| 84 |
+
"""Convert to feature vector for ML models."""
|
| 85 |
+
return np.array([
|
| 86 |
+
self.total_weight,
|
| 87 |
+
self.fl_ratio, self.fr_ratio, self.rl_ratio, self.rr_ratio,
|
| 88 |
+
self.fl_std, self.fr_std, self.rl_std, self.rr_std,
|
| 89 |
+
self.front_back_ratio, self.left_right_ratio,
|
| 90 |
+
self.cop_x_mean, self.cop_y_mean,
|
| 91 |
+
self.cop_x_std, self.cop_y_std,
|
| 92 |
+
self.sitdown_speed, self.stability_time,
|
| 93 |
+
], dtype=np.float32)
|
| 94 |
+
|
| 95 |
+
@staticmethod
|
| 96 |
+
def feature_names() -> List[str]:
|
| 97 |
+
return [
|
| 98 |
+
"total_weight",
|
| 99 |
+
"fl_ratio", "fr_ratio", "rl_ratio", "rr_ratio",
|
| 100 |
+
"fl_std", "fr_std", "rl_std", "rr_std",
|
| 101 |
+
"front_back_ratio", "left_right_ratio",
|
| 102 |
+
"cop_x_mean", "cop_y_mean",
|
| 103 |
+
"cop_x_std", "cop_y_std",
|
| 104 |
+
"sitdown_speed", "stability_time",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class MultiUserRecognizer:
|
| 109 |
+
"""
|
| 110 |
+
Recognizes seated users from load cell weight distribution patterns.
|
| 111 |
+
|
| 112 |
+
Enrollment: User sits down 5 times, system captures weight signatures.
|
| 113 |
+
Recognition: Compare new sitting event to enrolled profiles.
|
| 114 |
+
|
| 115 |
+
Uses k-NN with k=3 (from AuthentiSense recipe for few-shot biometrics).
|
| 116 |
+
Falls back to weight-based nearest-neighbor if k-NN confidence is low.
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
def __init__(self, n_enrollment_samples: int = USER_ENROLLMENT_SAMPLES):
|
| 120 |
+
self.n_enrollment_samples = n_enrollment_samples
|
| 121 |
+
self.enrolled_users = {} # user_id β list of WeightSignatures
|
| 122 |
+
self.knn_model = None
|
| 123 |
+
self.scaler = StandardScaler()
|
| 124 |
+
self.is_trained = False
|
| 125 |
+
self.confidence_threshold = 0.6 # minimum confidence for recognition
|
| 126 |
+
|
| 127 |
+
def enroll_user(self, user_id: str, load_readings_list: List[np.ndarray]) -> Dict:
|
| 128 |
+
"""
|
| 129 |
+
Enroll a new user with multiple sit-down events.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
user_id: unique identifier (e.g., "user_alice")
|
| 133 |
+
load_readings_list: list of (N, 4) arrays, one per sit-down event
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
enrollment status dict
|
| 137 |
+
"""
|
| 138 |
+
if len(load_readings_list) < self.n_enrollment_samples:
|
| 139 |
+
return {
|
| 140 |
+
"status": "incomplete",
|
| 141 |
+
"message": f"Need {self.n_enrollment_samples} sit-down samples, "
|
| 142 |
+
f"got {len(load_readings_list)}",
|
| 143 |
+
"user_id": user_id,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
signatures = [WeightSignature(readings) for readings in load_readings_list]
|
| 147 |
+
self.enrolled_users[user_id] = signatures
|
| 148 |
+
|
| 149 |
+
# Retrain classifier
|
| 150 |
+
self._train_knn()
|
| 151 |
+
|
| 152 |
+
avg_weight = np.mean([s.total_weight for s in signatures])
|
| 153 |
+
return {
|
| 154 |
+
"status": "enrolled",
|
| 155 |
+
"user_id": user_id,
|
| 156 |
+
"n_samples": len(signatures),
|
| 157 |
+
"avg_weight_kg": round(float(avg_weight), 1),
|
| 158 |
+
"total_enrolled": len(self.enrolled_users),
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
def recognize(self, load_readings: np.ndarray) -> Dict:
|
| 162 |
+
"""
|
| 163 |
+
Identify which enrolled user is sitting.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
load_readings: shape (N, 4) β recent load cell data (~10 sec)
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
dict with recognized user_id, confidence, and top candidates
|
| 170 |
+
"""
|
| 171 |
+
if not self.is_trained:
|
| 172 |
+
return {"user_id": "unknown", "confidence": 0, "reason": "no_enrolled_users"}
|
| 173 |
+
|
| 174 |
+
sig = WeightSignature(load_readings)
|
| 175 |
+
features = sig.to_vector().reshape(1, -1)
|
| 176 |
+
features_scaled = self.scaler.transform(features)
|
| 177 |
+
|
| 178 |
+
# k-NN prediction with probability
|
| 179 |
+
proba = self.knn_model.predict_proba(features_scaled)[0]
|
| 180 |
+
predicted_class = self.knn_model.classes_[np.argmax(proba)]
|
| 181 |
+
confidence = np.max(proba)
|
| 182 |
+
|
| 183 |
+
# Weight-based sanity check
|
| 184 |
+
weight_match = self._weight_based_match(sig.total_weight)
|
| 185 |
+
|
| 186 |
+
# Final decision
|
| 187 |
+
if confidence >= self.confidence_threshold:
|
| 188 |
+
user_id = predicted_class
|
| 189 |
+
method = "knn"
|
| 190 |
+
elif weight_match["confidence"] > confidence:
|
| 191 |
+
user_id = weight_match["user_id"]
|
| 192 |
+
confidence = weight_match["confidence"]
|
| 193 |
+
method = "weight_fallback"
|
| 194 |
+
else:
|
| 195 |
+
user_id = "unknown"
|
| 196 |
+
method = "below_threshold"
|
| 197 |
+
|
| 198 |
+
# Top candidates
|
| 199 |
+
top_k = sorted(zip(self.knn_model.classes_, proba), key=lambda x: -x[1])[:3]
|
| 200 |
+
|
| 201 |
+
return {
|
| 202 |
+
"user_id": user_id,
|
| 203 |
+
"confidence": round(float(confidence), 3),
|
| 204 |
+
"method": method,
|
| 205 |
+
"top_candidates": [{"id": uid, "probability": round(float(p), 3)} for uid, p in top_k],
|
| 206 |
+
"detected_weight_kg": round(float(sig.total_weight), 1),
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
def _weight_based_match(self, weight: float) -> Dict:
|
| 210 |
+
"""Simple weight-based user matching as fallback."""
|
| 211 |
+
best_match = "unknown"
|
| 212 |
+
best_diff = float('inf')
|
| 213 |
+
|
| 214 |
+
for user_id, signatures in self.enrolled_users.items():
|
| 215 |
+
avg_weight = np.mean([s.total_weight for s in signatures])
|
| 216 |
+
diff = abs(weight - avg_weight)
|
| 217 |
+
|
| 218 |
+
if diff < best_diff:
|
| 219 |
+
best_diff = diff
|
| 220 |
+
best_match = user_id
|
| 221 |
+
|
| 222 |
+
# Confidence based on weight difference (3kg tolerance)
|
| 223 |
+
if best_diff < USER_WEIGHT_TOLERANCE_KG:
|
| 224 |
+
confidence = 1.0 - (best_diff / USER_WEIGHT_TOLERANCE_KG) * 0.5
|
| 225 |
+
else:
|
| 226 |
+
confidence = max(0, 0.5 - (best_diff - USER_WEIGHT_TOLERANCE_KG) / 20)
|
| 227 |
+
|
| 228 |
+
return {"user_id": best_match, "confidence": confidence, "weight_diff_kg": best_diff}
|
| 229 |
+
|
| 230 |
+
def _train_knn(self):
|
| 231 |
+
"""Train k-NN model on all enrolled user signatures."""
|
| 232 |
+
if len(self.enrolled_users) < 2:
|
| 233 |
+
# Need at least 2 users for classification
|
| 234 |
+
if len(self.enrolled_users) == 1:
|
| 235 |
+
# Single user mode
|
| 236 |
+
self.is_trained = False
|
| 237 |
+
return
|
| 238 |
+
return
|
| 239 |
+
|
| 240 |
+
X, y = [], []
|
| 241 |
+
for user_id, signatures in self.enrolled_users.items():
|
| 242 |
+
for sig in signatures:
|
| 243 |
+
X.append(sig.to_vector())
|
| 244 |
+
y.append(user_id)
|
| 245 |
+
|
| 246 |
+
X = np.array(X)
|
| 247 |
+
y = np.array(y)
|
| 248 |
+
|
| 249 |
+
# Scale features
|
| 250 |
+
X_scaled = self.scaler.fit_transform(X)
|
| 251 |
+
|
| 252 |
+
# Train k-NN (k=3, as per AuthentiSense for few-shot)
|
| 253 |
+
k = min(3, len(X) // len(self.enrolled_users))
|
| 254 |
+
k = max(1, k)
|
| 255 |
+
|
| 256 |
+
self.knn_model = KNeighborsClassifier(
|
| 257 |
+
n_neighbors=k,
|
| 258 |
+
metric='mahalanobis' if len(X) > X.shape[1] else 'euclidean',
|
| 259 |
+
metric_params={'V': np.cov(X_scaled.T)} if len(X) > X.shape[1] else None,
|
| 260 |
+
weights='distance',
|
| 261 |
+
)
|
| 262 |
+
self.knn_model.fit(X_scaled, y)
|
| 263 |
+
self.is_trained = True
|
| 264 |
+
|
| 265 |
+
def remove_user(self, user_id: str) -> bool:
|
| 266 |
+
"""Remove a user from the system."""
|
| 267 |
+
if user_id in self.enrolled_users:
|
| 268 |
+
del self.enrolled_users[user_id]
|
| 269 |
+
if len(self.enrolled_users) >= 2:
|
| 270 |
+
self._train_knn()
|
| 271 |
+
else:
|
| 272 |
+
self.is_trained = False
|
| 273 |
+
return True
|
| 274 |
+
return False
|
| 275 |
+
|
| 276 |
+
def save(self, path: str):
|
| 277 |
+
"""Save recognizer state."""
|
| 278 |
+
joblib.dump({
|
| 279 |
+
'enrolled_users': self.enrolled_users,
|
| 280 |
+
'scaler': self.scaler,
|
| 281 |
+
'knn_model': self.knn_model,
|
| 282 |
+
'is_trained': self.is_trained,
|
| 283 |
+
}, path)
|
| 284 |
+
|
| 285 |
+
def load(self, path: str):
|
| 286 |
+
"""Load recognizer state."""
|
| 287 |
+
data = joblib.load(path)
|
| 288 |
+
self.enrolled_users = data['enrolled_users']
|
| 289 |
+
self.scaler = data['scaler']
|
| 290 |
+
self.knn_model = data['knn_model']
|
| 291 |
+
self.is_trained = data['is_trained']
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
class PersonalizedSittingModel:
|
| 295 |
+
"""
|
| 296 |
+
Learns individual user's sitting patterns over time.
|
| 297 |
+
|
| 298 |
+
Tracks:
|
| 299 |
+
- Preferred postures by time of day
|
| 300 |
+
- Typical session duration
|
| 301 |
+
- Break compliance patterns
|
| 302 |
+
- Fatigue onset timing
|
| 303 |
+
- Posture quality trends
|
| 304 |
+
|
| 305 |
+
Used for: personalized break timing, adaptive alerts, trend analysis.
|
| 306 |
+
"""
|
| 307 |
+
|
| 308 |
+
def __init__(self, user_id: str):
|
| 309 |
+
self.user_id = user_id
|
| 310 |
+
self.sessions = [] # list of session records
|
| 311 |
+
self.hourly_posture_dist = defaultdict(lambda: defaultdict(float)) # hour β posture β minutes
|
| 312 |
+
self.break_compliance = [] # (recommended, taken) booleans
|
| 313 |
+
self.fatigue_onsets = [] # minutes into session when fatigue detected
|
| 314 |
+
|
| 315 |
+
def record_session(self, start_time, end_time, posture_timeline: List[Dict],
|
| 316 |
+
breaks_taken: int, fatigue_onset_min: Optional[float] = None):
|
| 317 |
+
"""
|
| 318 |
+
Record a complete sitting session.
|
| 319 |
+
|
| 320 |
+
Args:
|
| 321 |
+
posture_timeline: list of {"posture": str, "duration_min": float, "rula_score": int}
|
| 322 |
+
"""
|
| 323 |
+
duration = (end_time - start_time).total_seconds() / 60
|
| 324 |
+
|
| 325 |
+
# Session summary
|
| 326 |
+
good_posture_min = sum(
|
| 327 |
+
p["duration_min"] for p in posture_timeline
|
| 328 |
+
if p["posture"] in ["upright"]
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
session = {
|
| 332 |
+
"start": start_time,
|
| 333 |
+
"end": end_time,
|
| 334 |
+
"duration_min": duration,
|
| 335 |
+
"good_posture_pct": good_posture_min / duration * 100 if duration > 0 else 0,
|
| 336 |
+
"breaks_taken": breaks_taken,
|
| 337 |
+
"posture_timeline": posture_timeline,
|
| 338 |
+
}
|
| 339 |
+
self.sessions.append(session)
|
| 340 |
+
|
| 341 |
+
# Update hourly distribution
|
| 342 |
+
for p in posture_timeline:
|
| 343 |
+
hour = start_time.hour # simplified β could interpolate
|
| 344 |
+
self.hourly_posture_dist[hour][p["posture"]] += p["duration_min"]
|
| 345 |
+
|
| 346 |
+
# Fatigue timing
|
| 347 |
+
if fatigue_onset_min is not None:
|
| 348 |
+
self.fatigue_onsets.append(fatigue_onset_min)
|
| 349 |
+
|
| 350 |
+
def get_typical_fatigue_onset(self) -> Optional[float]:
|
| 351 |
+
"""Predict when this user typically gets fatigued."""
|
| 352 |
+
if len(self.fatigue_onsets) < 3:
|
| 353 |
+
return None
|
| 354 |
+
return np.median(self.fatigue_onsets)
|
| 355 |
+
|
| 356 |
+
def get_optimal_break_interval(self) -> float:
|
| 357 |
+
"""
|
| 358 |
+
Calculate personalized break interval based on user's fatigue patterns.
|
| 359 |
+
|
| 360 |
+
Returns: recommended minutes between breaks
|
| 361 |
+
"""
|
| 362 |
+
typical_fatigue = self.get_typical_fatigue_onset()
|
| 363 |
+
|
| 364 |
+
if typical_fatigue is not None:
|
| 365 |
+
# Break should come BEFORE fatigue (80% of fatigue onset time)
|
| 366 |
+
return max(MIN_BREAK_INTERVAL_MINUTES, min(MAX_SITTING_MINUTES, typical_fatigue * 0.8))
|
| 367 |
+
|
| 368 |
+
# Default: start conservative, adjust as data accumulates
|
| 369 |
+
return 30.0
|
| 370 |
+
|
| 371 |
+
def get_worst_time_of_day(self) -> Optional[int]:
|
| 372 |
+
"""Find the hour of day when posture is typically worst."""
|
| 373 |
+
if not self.hourly_posture_dist:
|
| 374 |
+
return None
|
| 375 |
+
|
| 376 |
+
worst_hour = None
|
| 377 |
+
worst_score = float('inf')
|
| 378 |
+
|
| 379 |
+
for hour, postures in self.hourly_posture_dist.items():
|
| 380 |
+
total = sum(postures.values())
|
| 381 |
+
if total < 10:
|
| 382 |
+
continue
|
| 383 |
+
good = postures.get("upright", 0)
|
| 384 |
+
score = good / total
|
| 385 |
+
if score < worst_score:
|
| 386 |
+
worst_score = score
|
| 387 |
+
worst_hour = hour
|
| 388 |
+
|
| 389 |
+
return worst_hour
|
| 390 |
+
|
| 391 |
+
def get_profile_summary(self) -> Dict:
|
| 392 |
+
"""Get comprehensive user profile summary."""
|
| 393 |
+
if not self.sessions:
|
| 394 |
+
return {"status": "no_data", "user_id": self.user_id}
|
| 395 |
+
|
| 396 |
+
total_sessions = len(self.sessions)
|
| 397 |
+
avg_duration = np.mean([s["duration_min"] for s in self.sessions])
|
| 398 |
+
avg_good_pct = np.mean([s["good_posture_pct"] for s in self.sessions])
|
| 399 |
+
|
| 400 |
+
return {
|
| 401 |
+
"user_id": self.user_id,
|
| 402 |
+
"total_sessions": total_sessions,
|
| 403 |
+
"avg_session_duration_min": round(avg_duration, 1),
|
| 404 |
+
"avg_good_posture_pct": round(avg_good_pct, 1),
|
| 405 |
+
"typical_fatigue_onset_min": round(self.get_typical_fatigue_onset() or 0, 1),
|
| 406 |
+
"recommended_break_interval_min": round(self.get_optimal_break_interval(), 1),
|
| 407 |
+
"worst_time_of_day": self.get_worst_time_of_day(),
|
| 408 |
+
"total_sitting_hours": round(sum(s["duration_min"] for s in self.sessions) / 60, 1),
|
| 409 |
+
}
|
smart_chair/utils/__init__.py
ADDED
|
File without changes
|
smart_chair/utils/feature_engineering.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feature Engineering Pipeline for SmartChair Sensor Fusion.
|
| 3 |
+
Extracts features from IMU (6-axis), Load Cells (4), and Thermal (8x8) sensors.
|
| 4 |
+
|
| 5 |
+
References:
|
| 6 |
+
- SitPose (arxiv:2412.12216): Joint angle features β 98.2% F1
|
| 7 |
+
- SSL-Wearables (arxiv:2206.02909): Hand-crafted features for RF baseline
|
| 8 |
+
- FusionActNet (arxiv:2310.02011): Separable temporal features
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from itertools import combinations
|
| 13 |
+
from scipy import stats as scipy_stats
|
| 14 |
+
from scipy.fft import rfft, rfftfreq
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def compute_imu_angles(accel_xyz):
|
| 18 |
+
"""
|
| 19 |
+
Compute pitch and roll from accelerometer data.
|
| 20 |
+
Pitch = rotation around Y-axis (forward/backward lean)
|
| 21 |
+
Roll = rotation around X-axis (left/right lean)
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
accel_xyz: array of shape (N, 3) β [ax, ay, az] in m/sΒ²
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
pitch, roll in degrees, each shape (N,)
|
| 28 |
+
"""
|
| 29 |
+
ax, ay, az = accel_xyz[:, 0], accel_xyz[:, 1], accel_xyz[:, 2]
|
| 30 |
+
pitch = np.degrees(np.arctan2(ax, np.sqrt(ay**2 + az**2)))
|
| 31 |
+
roll = np.degrees(np.arctan2(ay, np.sqrt(ax**2 + az**2)))
|
| 32 |
+
return pitch, roll
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def compute_center_of_pressure(load_cells):
|
| 36 |
+
"""
|
| 37 |
+
Compute Center of Pressure (CoP) from 4 load cells at chair corners.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
load_cells: array shape (N, 4) β [FL, FR, RL, RR] in kg
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
cop_x, cop_y β normalized CoP coordinates in [-1, 1]
|
| 44 |
+
"""
|
| 45 |
+
fl, fr, rl, rr = load_cells[:, 0], load_cells[:, 1], load_cells[:, 2], load_cells[:, 3]
|
| 46 |
+
total = fl + fr + rl + rr + 1e-6 # avoid division by zero
|
| 47 |
+
|
| 48 |
+
# CoP_x: positive = right, negative = left
|
| 49 |
+
cop_x = (fr + rr - fl - rl) / total
|
| 50 |
+
# CoP_y: positive = front, negative = rear
|
| 51 |
+
cop_y = (fl + fr - rl - rr) / total
|
| 52 |
+
|
| 53 |
+
return cop_x, cop_y
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def extract_tabular_features(imu_data, load_data, window_size=128):
|
| 57 |
+
"""
|
| 58 |
+
Extract hand-crafted features for ensemble classifier (SitPose recipe).
|
| 59 |
+
Operates on a single window of data.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
imu_data: shape (window_size, 6) β [ax, ay, az, gx, gy, gz]
|
| 63 |
+
load_data: shape (window_size, 4) β [FL, FR, RL, RR]
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
feature_vector: 1D numpy array of extracted features
|
| 67 |
+
"""
|
| 68 |
+
features = []
|
| 69 |
+
|
| 70 |
+
# ββ IMU Features ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
# Per-axis statistics (from SSL-Wearables paper)
|
| 72 |
+
for ch in range(imu_data.shape[1]):
|
| 73 |
+
sig = imu_data[:, ch]
|
| 74 |
+
features.extend([
|
| 75 |
+
np.mean(sig),
|
| 76 |
+
np.std(sig),
|
| 77 |
+
np.ptp(sig), # range (peak-to-peak)
|
| 78 |
+
np.median(sig),
|
| 79 |
+
scipy_stats.kurtosis(sig),
|
| 80 |
+
scipy_stats.skew(sig),
|
| 81 |
+
])
|
| 82 |
+
# Percentiles
|
| 83 |
+
features.extend(np.percentile(sig, [10, 25, 50, 75, 90]).tolist())
|
| 84 |
+
|
| 85 |
+
# Euclidean norm of acceleration
|
| 86 |
+
accel_norm = np.linalg.norm(imu_data[:, :3], axis=1)
|
| 87 |
+
features.extend([
|
| 88 |
+
np.mean(accel_norm),
|
| 89 |
+
np.std(accel_norm),
|
| 90 |
+
np.ptp(accel_norm),
|
| 91 |
+
np.median(np.abs(accel_norm - np.median(accel_norm))), # MAD
|
| 92 |
+
scipy_stats.kurtosis(accel_norm),
|
| 93 |
+
scipy_stats.skew(accel_norm),
|
| 94 |
+
])
|
| 95 |
+
|
| 96 |
+
# FFT features (top 2 frequency components + spectral power in 4 bands)
|
| 97 |
+
fft_vals = np.abs(rfft(accel_norm))
|
| 98 |
+
freqs = rfftfreq(len(accel_norm), d=1.0/50) # 50 Hz
|
| 99 |
+
sorted_idx = np.argsort(fft_vals)[::-1]
|
| 100 |
+
features.extend([
|
| 101 |
+
freqs[sorted_idx[0]] if len(sorted_idx) > 0 else 0,
|
| 102 |
+
fft_vals[sorted_idx[0]] if len(sorted_idx) > 0 else 0,
|
| 103 |
+
freqs[sorted_idx[1]] if len(sorted_idx) > 1 else 0,
|
| 104 |
+
fft_vals[sorted_idx[1]] if len(sorted_idx) > 1 else 0,
|
| 105 |
+
])
|
| 106 |
+
# Spectral power in 4 bands: [0-3Hz], [3-6Hz], [6-12Hz], [12-25Hz]
|
| 107 |
+
band_edges = [0, 3, 6, 12, 25]
|
| 108 |
+
for i in range(len(band_edges) - 1):
|
| 109 |
+
mask = (freqs >= band_edges[i]) & (freqs < band_edges[i+1])
|
| 110 |
+
features.append(np.sum(fft_vals[mask]**2) if np.any(mask) else 0)
|
| 111 |
+
|
| 112 |
+
# Cross-axis correlations
|
| 113 |
+
for i, j in combinations(range(imu_data.shape[1]), 2):
|
| 114 |
+
corr = np.corrcoef(imu_data[:, i], imu_data[:, j])[0, 1]
|
| 115 |
+
features.append(corr if not np.isnan(corr) else 0)
|
| 116 |
+
|
| 117 |
+
# IMU angles (pitch, roll)
|
| 118 |
+
pitch, roll = compute_imu_angles(imu_data[:, :3])
|
| 119 |
+
features.extend([
|
| 120 |
+
np.mean(pitch), np.std(pitch), np.mean(roll), np.std(roll),
|
| 121 |
+
np.max(np.abs(pitch)), np.max(np.abs(roll)),
|
| 122 |
+
])
|
| 123 |
+
|
| 124 |
+
# ββ Load Cell Features ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 125 |
+
total_weight = np.sum(load_data, axis=1)
|
| 126 |
+
features.append(np.mean(total_weight))
|
| 127 |
+
|
| 128 |
+
# Normalized per-cell percentages
|
| 129 |
+
norm_load = load_data / (total_weight[:, np.newaxis] + 1e-6)
|
| 130 |
+
for ch in range(4):
|
| 131 |
+
features.extend([
|
| 132 |
+
np.mean(norm_load[:, ch]),
|
| 133 |
+
np.std(norm_load[:, ch]),
|
| 134 |
+
])
|
| 135 |
+
|
| 136 |
+
# Center of Pressure
|
| 137 |
+
cop_x, cop_y = compute_center_of_pressure(load_data)
|
| 138 |
+
features.extend([
|
| 139 |
+
np.mean(cop_x), np.std(cop_x),
|
| 140 |
+
np.mean(cop_y), np.std(cop_y),
|
| 141 |
+
np.ptp(cop_x), np.ptp(cop_y), # CoP sway range
|
| 142 |
+
])
|
| 143 |
+
|
| 144 |
+
# Weight distribution variance (asymmetry indicator)
|
| 145 |
+
features.append(np.mean(np.var(norm_load, axis=1)))
|
| 146 |
+
|
| 147 |
+
# Front/back and left/right ratios
|
| 148 |
+
front = norm_load[:, 0] + norm_load[:, 1]
|
| 149 |
+
back = norm_load[:, 2] + norm_load[:, 3]
|
| 150 |
+
left = norm_load[:, 0] + norm_load[:, 2]
|
| 151 |
+
right = norm_load[:, 1] + norm_load[:, 3]
|
| 152 |
+
features.extend([
|
| 153 |
+
np.mean(front / (back + 1e-6)),
|
| 154 |
+
np.mean(left / (right + 1e-6)),
|
| 155 |
+
])
|
| 156 |
+
|
| 157 |
+
return np.array(features, dtype=np.float32)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def extract_sequential_features(imu_data, load_data):
|
| 161 |
+
"""
|
| 162 |
+
Prepare sequential (windowed) data for MLSTM-FCN / 1D-CNN.
|
| 163 |
+
Combines all sensor channels into a unified time-series tensor.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
imu_data: shape (window_size, 6) β raw IMU
|
| 167 |
+
load_data: shape (window_size, 4) β raw load cells
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
tensor: shape (window_size, 14) β all channels concatenated
|
| 171 |
+
Channels: [ax, ay, az, gx, gy, gz, pitch, roll, FL, FR, RL, RR, cop_x, cop_y]
|
| 172 |
+
"""
|
| 173 |
+
# Compute derived channels
|
| 174 |
+
pitch, roll = compute_imu_angles(imu_data[:, :3])
|
| 175 |
+
cop_x, cop_y = compute_center_of_pressure(load_data)
|
| 176 |
+
|
| 177 |
+
# Stack all channels
|
| 178 |
+
derived = np.column_stack([pitch, roll, cop_x, cop_y])
|
| 179 |
+
tensor = np.concatenate([imu_data, load_data, derived], axis=1)
|
| 180 |
+
|
| 181 |
+
return tensor.astype(np.float32)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def create_sliding_windows(data, labels, window_size=128, stride=64):
|
| 185 |
+
"""
|
| 186 |
+
Create sliding windows from continuous sensor data.
|
| 187 |
+
Label is assigned by majority vote within window.
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
data: shape (N, channels) β continuous sensor data
|
| 191 |
+
labels: shape (N,) β per-sample labels
|
| 192 |
+
window_size: samples per window
|
| 193 |
+
stride: step between windows
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
X: shape (num_windows, window_size, channels)
|
| 197 |
+
y: shape (num_windows,)
|
| 198 |
+
"""
|
| 199 |
+
X, y = [], []
|
| 200 |
+
for i in range(0, len(data) - window_size, stride):
|
| 201 |
+
window_data = data[i:i + window_size]
|
| 202 |
+
window_labels = labels[i:i + window_size]
|
| 203 |
+
|
| 204 |
+
X.append(window_data)
|
| 205 |
+
y.append(np.bincount(window_labels.astype(int)).argmax())
|
| 206 |
+
|
| 207 |
+
return np.array(X), np.array(y)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def normalize_sensors(data, scaler=None):
|
| 211 |
+
"""
|
| 212 |
+
Per-channel z-score normalization.
|
| 213 |
+
Returns normalized data and fitted scaler.
|
| 214 |
+
"""
|
| 215 |
+
from sklearn.preprocessing import StandardScaler
|
| 216 |
+
|
| 217 |
+
original_shape = data.shape
|
| 218 |
+
flat = data.reshape(-1, data.shape[-1])
|
| 219 |
+
|
| 220 |
+
if scaler is None:
|
| 221 |
+
scaler = StandardScaler()
|
| 222 |
+
flat_norm = scaler.fit_transform(flat)
|
| 223 |
+
else:
|
| 224 |
+
flat_norm = scaler.transform(flat)
|
| 225 |
+
|
| 226 |
+
return flat_norm.reshape(original_shape), scaler
|