emrp:ws2025:amt
Differences
This shows you the differences between two versions of the page.
| Both sides previous revisionPrevious revisionNext revision | Previous revision | ||
| emrp:ws2025:amt [2026/02/26 04:16] – 36502_students.hsrw | emrp:ws2025:amt [2026/02/26 21:31] (current) – 37554_students.hsrw | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| ====== Animal Movement Tracker ====== | ====== Animal Movement Tracker ====== | ||
| + | **Authors: | ||
| ===== 1. Introduction & Problem Statement ===== | ===== 1. Introduction & Problem Statement ===== | ||
| Line 42: | Line 43: | ||
| |**//Figure 3//** Interfacing of the MCU and MPU6050| | |**//Figure 3//** Interfacing of the MCU and MPU6050| | ||
| - | To facilitate easier normalisation for IMU measurements, | + | To facilitate easier normalisation for IMU measurements, |
| === 2.2.3 Power Management === | === 2.2.3 Power Management === | ||
| Line 123: | Line 124: | ||
| |**//Figure 11// | |**//Figure 11// | ||
| - | === 2.4.2 Gateway System (RPi) === | + | === 2.4.2 Gesture Model === |
| The motion classification model has a three-class structure: | The motion classification model has a three-class structure: | ||
| * **Move:** The animal' | * **Move:** The animal' | ||
| Line 355: | Line 356: | ||
| main() | main() | ||
| - | if _name_ == "_main_": | + | if _name_ == "__main__": |
| main() | main() | ||
| </ | </ | ||
| - | |**//Figure 16//** Terminal Output Indicating LoRa Configuration bytes transmission on the Gateway Side| | ||
| {{ : | {{ : | ||
| + | |**//Figure 16//** Terminal Output Indicating LoRa Configuration bytes transmission on the Gateway Side| | ||
| ==== 3.2 Window Generation ==== | ==== 3.2 Window Generation ==== | ||
| Line 431: | Line 432: | ||
| && | && | ||
| { | { | ||
| - | // Clear the motion flag now (we are servicing it) | + | // Clear the motion flag now |
| motion_pending = 0; | motion_pending = 0; | ||
| Line 449: | Line 450: | ||
| </ | </ | ||
| - | The controller then collected a fixed-length IMU data window consisting of a total of 96 samples at a sampling frequency of 100 Hz. Each sample contained a total of six 16-bit raw measurements: | + | The controller then collected a fixed-length IMU data window consisting of a total of 96 samples at a sampling frequency of 100 Hz on the gateway side (a bit lower considering the transmission latency) |
| The collected window data was not sent in a single piece but was divided into fixed-length frames, taking into account the maximum packet size of the LoRa module. Each frame consisted of a header and a payload section. The header section contained address information, | The collected window data was not sent in a single piece but was divided into fixed-length frames, taking into account the maximum packet size of the LoRa module. Each frame consisted of a header and a payload section. The header section contained address information, | ||
| Line 617: | Line 618: | ||
| uint8_t who = 0; | uint8_t who = 0; | ||
| if (mpu_read(hi2c, | if (mpu_read(hi2c, | ||
| - | if (who != 0x68) return -2; // WHO_AM_I beklenen | + | if (who != 0x68) return -2; |
| // Wake up (disable sleep bit) | // Wake up (disable sleep bit) | ||
| Line 624: | Line 625: | ||
| // Sample rate = Gyro output / (1 + SMPLRT_DIV). Gyro output default 8kHz (DLPF=0) or 1kHz (DLPF!=0) | // Sample rate = Gyro output / (1 + SMPLRT_DIV). Gyro output default 8kHz (DLPF=0) or 1kHz (DLPF!=0) | ||
| - | mpu_write(hi2c, | + | mpu_write(hi2c, |
| mpu_write(hi2c, | mpu_write(hi2c, | ||
| mpu_write(hi2c, | mpu_write(hi2c, | ||
| Line 1266: | Line 1267: | ||
| raise ValueError(f" | raise ValueError(f" | ||
| - | # Return the window | + | # Return the window |
| return out | return out | ||
| </ | </ | ||
| Line 1279: | Line 1280: | ||
| ser, | ser, | ||
| A=96, | A=96, | ||
| - | samples_per_frame=4, | + | samples_per_frame=4, |
| expect_addh=0x00, | expect_addh=0x00, | ||
| expect_addl=0x00, | expect_addl=0x00, | ||
| Line 1311: | Line 1312: | ||
| ==== 3.3 Gesture Model ==== | ==== 3.3 Gesture Model ==== | ||
| === 3.3.1 Dataset Creation === | === 3.3.1 Dataset Creation === | ||
| - | The dataset was collected directly on the Raspberry Pi via the IMU (MPU6050) for training the motion classification model. During the data collection process, a total of 6 channels were read from the sensor: 3-axis acceleration (AX, AY, AZ) and 3-axis gyroscope (GX, GY, GZ). The collection process was designed based on windows, and each window consisted of A=96 consecutive samples. The sampling frequency Fs=100 Hz was selected, so each window represented a time interval of approximately 0.96 s. To increase temporal continuity and enhance data diversity, inter-window stride was applied, and S=48 with 50% overlapping windows was implemented. This structure ensured better capture of short-term motion transitions and increased boundary examples between classes. | + | The dataset was collected directly on the Raspberry Pi via the IMU (MPU6050) for training the motion classification model. During the data collection process, a total of 6 channels were read from the sensor: 3-axis acceleration (AX, AY, AZ) and 3-axis gyroscope (GX, GY, GZ). The collection process was designed based on windows, and each window consisted of A=96 consecutive samples. The sampling frequency Fs=100 Hz was selected |
| <file python record.py> | <file python record.py> | ||
| Line 1334: | Line 1335: | ||
| Where each x*_axis is a signed int16 raw value from MPU6050: | Where each x*_axis is a signed int16 raw value from MPU6050: | ||
| ax, ay, az, gx, gy, gz | ax, ay, az, gx, gy, gz | ||
| - | |||
| - | All prints/ | ||
| """ | """ | ||
| Line 1353: | Line 1352: | ||
| FS = 100.0 # target sampling rate (Hz) | FS = 100.0 # target sampling rate (Hz) | ||
| D = 150.0 # seconds per class recording | D = 150.0 # seconds per class recording | ||
| - | K = 200 # windows per class (kept at your earlier plan) | + | K = 200 # windows per class |
| SEED = 42 | SEED = 42 | ||
| Line 1635: | Line 1634: | ||
| {{ : | {{ : | ||
| |Recording Outputs (Change their extensions as .csv)| | |Recording Outputs (Change their extensions as .csv)| | ||
| + | |||
| + | To verify the quality of the collected data, the saturation ratio and basic statistics were calculated for each class. Saturation was assessed based on the 16-bit limits (±32768) of the IMU raw measurements, | ||
| + | |||
| + | <file python record_analyzer.py> | ||
| + | # | ||
| + | """ | ||
| + | Dataset quality analyzer for IMU windows stored in CSV format. | ||
| + | |||
| + | Expected CSV columns: | ||
| + | - label, window_index (optional but commonly present) | ||
| + | - x0_ax, x0_ay, x0_az, x0_gx, x0_gy, x0_gz | ||
| + | - ... | ||
| + | - x(A-1)_ax ... x(A-1)_gz | ||
| + | |||
| + | This script prints: | ||
| + | - Saturation percentage (near int16 limits) | ||
| + | - Per-axis min/ | ||
| + | - Mean magnitude of accel and gyro vectors | ||
| + | - Class-to-class ratios based on gyro magnitude mean | ||
| + | """ | ||
| + | |||
| + | import argparse | ||
| + | import numpy as np | ||
| + | import pandas as pd | ||
| + | |||
| + | AXES = [" | ||
| + | |||
| + | def load_windows(csv_path: | ||
| + | df = pd.read_csv(csv_path) | ||
| + | |||
| + | # Validate columns quickly | ||
| + | missing = [] | ||
| + | for t in range(A): | ||
| + | for a in AXES: | ||
| + | c = f" | ||
| + | if c not in df.columns: | ||
| + | missing.append(c) | ||
| + | if missing: | ||
| + | raise ValueError(f" | ||
| + | |||
| + | arr = np.zeros((df.shape[0], | ||
| + | for t in range(A): | ||
| + | for j, a in enumerate(AXES): | ||
| + | col = f" | ||
| + | arr[:, t, j] = df[col].astype(np.int16).values | ||
| + | return arr | ||
| + | |||
| + | def compute_stats(arr_i16: | ||
| + | x = arr_i16.astype(np.int32).reshape(-1, | ||
| + | |||
| + | stats = {} | ||
| + | stats[" | ||
| + | stats[" | ||
| + | |||
| + | stats[" | ||
| + | stats[" | ||
| + | stats[" | ||
| + | stats[" | ||
| + | stats[" | ||
| + | |||
| + | acc = x[:, 0: | ||
| + | gyr = x[:, 3: | ||
| + | stats[" | ||
| + | stats[" | ||
| + | |||
| + | # Saturation detection | ||
| + | sat_mask = (np.abs(x) >= sat_threshold) | (x == -32768) | (x == 32767) | ||
| + | stats[" | ||
| + | stats[" | ||
| + | |||
| + | sat_axis = sat_mask.sum(axis=0) | ||
| + | stats[" | ||
| + | stats[" | ||
| + | |||
| + | return stats | ||
| + | |||
| + | def print_report(name: | ||
| + | print(f" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | |||
| + | print(" | ||
| + | for i, a in enumerate(AXES): | ||
| + | print( | ||
| + | f" | ||
| + | f" | ||
| + | f" | ||
| + | ) | ||
| + | |||
| + | print(" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | |||
| + | def main(): | ||
| + | ap = argparse.ArgumentParser() | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | args = ap.parse_args() | ||
| + | |||
| + | datasets = { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | |||
| + | stats = {} | ||
| + | for name, arr in datasets.items(): | ||
| + | stats[name] = compute_stats(arr, | ||
| + | print_report(name, | ||
| + | |||
| + | print(" | ||
| + | def ratio(a, b): | ||
| + | return stats[a][" | ||
| + | |||
| + | print(f" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | |||
| + | if _name_ == " | ||
| + | main() | ||
| + | </ | ||
| + | |||
| + | The analysis yielded 200 windows (19,200 samples in total) for each class. As each window contained 96×6 values, a total of 115,200 raw channel values per class were analysed. The saturation results were found to be consistent as follows: | ||
| + | Move: 0.009% (10/105984 values) | ||
| + | Rest: 0.004% | ||
| + | Shake: 0.359% | ||
| + | |||
| + | These results show that, as expected, higher dynamic range usage occurs, particularly in the Shake class, but saturation remains at a low level. Furthermore, | ||
| + | |||
| + | * Shake produced approximately 9.57x higher gyroscope magnitude than Rest. | ||
| + | * Move produced approximately 3.78x higher gyroscope magnitude than Rest. | ||
| + | * Shake produced approximately 2.54xhigher gyroscope magnitude than Move. | ||
| + | |||
| + | <file text analysis_output.txt> | ||
| + | (venv) esen@raspberrypi: | ||
| + | |||
| + | === Move === | ||
| + | Total samples: 17664 | Total values: 105984 | ||
| + | Saturation: 0.009% | ||
| + | |||
| + | Per-axis stats (min / max / mean / std / mean_abs / sat%): | ||
| + | ax: -16048 / 17680 / 2371.3 / 3701.1 / 3588.5 / 0.000% | ||
| + | ay: -24540 / 29644 / 1680.3 / 5091.2 / 3695.3 / 0.000% | ||
| + | az: 2952 / 32767 / 15526.3 / 2600.6 / 15526.3 / 0.057% | ||
| + | gx: -25496 / 31428 / -436.0 / 3804.3 / 2625.6 / 0.000% | ||
| + | gy: -28154 / 30259 / -127.7 / 3766.8 / 2263.5 / 0.000% | ||
| + | gz: -22986 / 24749 / 224.7 / 3494.1 / 2175.1 / 0.000% | ||
| + | |||
| + | Vector magnitude means: | ||
| + | accel |a| mean: 16928.8 | ||
| + | gyro |g| mean: 4875.2 | ||
| + | |||
| + | === Shake === | ||
| + | Total samples: 19200 | Total values: 115200 | ||
| + | Saturation: 0.359% | ||
| + | |||
| + | Per-axis stats (min / max / mean / std / mean_abs / sat%): | ||
| + | ax: -32768 / 18592 / 701.5 / 5301.3 / 3477.3 / 0.005% | ||
| + | ay: -25896 / 28652 / 2384.7 / 9255.8 / 7477.5 / 0.000% | ||
| + | az: -32768 / 32767 / 2365.2 / 12097.7 / 10861.0 / 0.016% | ||
| + | gx: -32768 / 32767 / -254.1 / 10248.6 / 6799.1 / 1.797% | ||
| + | gy: -32768 / 28633 / -533.3 / 5753.5 / 3891.1 / 0.021% | ||
| + | gz: -32768 / 32767 / 282.1 / 8503.2 / 5873.7 / 0.312% | ||
| + | |||
| + | Vector magnitude means: | ||
| + | accel |a| mean: 16450.8 | ||
| + | gyro |g| mean: 12360.4 | ||
| + | |||
| + | === Rest === | ||
| + | Total samples: 18912 | Total values: 113472 | ||
| + | Saturation: 0.004% | ||
| + | |||
| + | Per-axis stats (min / max / mean / std / mean_abs / sat%): | ||
| + | ax: -12824 / 9428 / 467.5 / 740.1 / 642.6 / 0.000% | ||
| + | ay: -23120 / 22732 / -5412.3 / 11541.5 / 10199.4 / 0.000% | ||
| + | az: -32768 / 32767 / -1294.9 / 10360.1 / 7069.2 / 0.021% | ||
| + | gx: -22595 / 25477 / -660.5 / 1716.4 / 899.3 / 0.000% | ||
| + | gy: -23317 / 19743 / -161.1 / 1448.9 / 510.9 / 0.000% | ||
| + | gz: -18340 / 15152 / 154.7 / 819.1 / 361.8 / 0.000% | ||
| + | |||
| + | Vector magnitude means: | ||
| + | accel |a| mean: 16490.4 | ||
| + | gyro |g| mean: 1291.4 | ||
| + | |||
| + | === Class-to-class ratios (gyro magnitude mean) === | ||
| + | Shake / Rest: 9.57x | ||
| + | Move / Rest: 3.78x | ||
| + | Shake / Move: 2.54x | ||
| + | </ | ||
| + | |||
| + | These ratios indicate that the " | ||
| + | |||
| + | === 3.3.2 Model Training === | ||
| + | **Overview** | ||
| + | The model training process followed an iterative development path driven by real constraints encountered at each stage. The work began with a small two-class dataset, passed through a series of targeted experiments, | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 17//** Model development and training pipeline — from raw IMU data to deployed TFLite model.| | ||
| + | |||
| + | **Phase 1 — Initial Dataset and First Model (v1)** | ||
| + | The first dataset was recorded using the ESP8266/ | ||
| + | |||
| + | A compact 1D-CNN was trained on this dataset: | ||
| + | // Input (119×6) | ||
| + | |||
| + | The baseline result was poor: training loss converged rapidly while validation loss began rising after epoch 10, a clear sign of overfitting. Validation accuracy plateaued at approximately 75%, and the confusion matrix showed all 4 test windows predicted as Shake — indicating the model had not learned a meaningful decision boundary, but rather a class bias. | ||
| + | |||
| + | **Model v1 Improvement Experiments** | ||
| + | To overcome these limitations without collecting new data, five targeted experiments were conducted on the v1 dataset: | ||
| + | | ||
| + | * Exp 2 (+Dropout 0.10): | ||
| + | * Exp 3 (Filters 24-24-32): | ||
| + | * Exp 4A (lr=5e-4, bs=16): | ||
| + | * Exp 4B (lr=2e-3, bs=8): | ||
| + | * Exp 5 (+GaussianNoise): | ||
| + | |||
| + | Despite five experiments, | ||
| + | |||
| + | Figure 18 shows the training curves and confusion matrix from the best v1 configuration (Exp 5: Dropout + GaussianNoise). The overfitting pattern and collapsed predictions confirm that the data constraint could not be overcome through hyperparameter tuning alone. | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 18//** Model v1 training results — loss (left), accuracy (centre), and confusion matrix (right, 4-window test set). Val loss rises from epoch 12 (overfitting); | ||
| + | |||
| + | **Phase 2 — New Dataset Collection and Cleaning (v2)** | ||
| + | Based on the limitations identified in Phase 1, a new dataset was requested and recorded: 150 seconds per class at 100 Hz, using a 96-sample window with 50% overlap, targeting 200 windows per class across three classes: Move, Rest, and Shake. Four raw files were recorded: Move.csv, Rest.csv, Shake_New.csv, | ||
| + | |||
| + | **Saturation Analysis** | ||
| + | Before training, a saturation analysis was performed on all files. The MPU-6050 gyroscope encodes angular velocity as signed 16-bit integers; values exceeding the ±500°/s hardware range clip at ±32,767 (sensor saturation). A window was flagged if any gyroscope sample satisfied |value| ≥ 32700. Figure 19 shows the results. | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 19//** Left — Saturation analysis per recording file. Right — Final cleaned dataset distribution (581 windows).| | ||
| + | |||
| + | The cleaning decisions required class-specific reasoning. For Move (16/200 saturated) and Rest (3/200), saturated windows were removed as they represent recording artefacts inconsistent with the class behaviour. For Shake_New (53/200 saturated), all windows were retained: saturation reflects genuine peak angular velocities inherent to vigorous shaking — this is a physical property of the gesture, not an error. Shake_Legacy was excluded entirely. After applying the threshold, only 24 of 200 windows survived (88% removal rate), creating a severe 1:8 class imbalance. A model trained on this data achieved ~50% validation accuracy, confirming that the surviving data was insufficient. | ||
| + | |||
| + | The final cleaned dataset: Move 184 windows (31.7%), Rest 197 windows (33.9%), Shake 200 windows (34.4%), Total 581 windows. | ||
| + | |||
| + | **Preprocessing: | ||
| + | Raw int16 sensor values span a large numerical range (accelerometer: | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 20//** Per-channel z-score normalisation — raw int16 signal (left) vs normalised signal clipped to [−5, +5] (right).| | ||
| + | |||
| + | The 12 normalisation parameters (6 means, 6 standard deviations) were saved to normalization.json. This file is loaded by the gateway inference server to ensure identical preprocessing is applied to incoming int16 windows at runtime. | ||
| + | |||
| + | **Model v2 Architecture** | ||
| + | The v2 architecture is an updated 1D-CNN designed for three-class classification and TFLite deployment on Raspberry Pi One-dimensional CNNs have been shown to be effective for classifying temporal sensor signals with limited training data [R3]. Figure 21 shows the full architecture. | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 21//** 1D-CNN model architecture (v2). Input: (96×6). Output: [move_prob, rest_prob, shake_prob].| | ||
| + | |||
| + | The architecture was updated from v1 in three respects: window size reduced from 119 to 96 samples to match the new dataset; output layer extended from 2 to 3 classes; Dropout increased from 0.10 to 0.30 reflecting the larger model capacity relative to dataset size. GaussianNoise(σ=0.02) at input provides training-time data augmentation and is automatically disabled during inference. GlobalAveragePooling replaces Flatten to reduce parameter count and improve generalisation. | ||
| + | |||
| + | **Model v1 vs v2 — Direct Comparison** | ||
| + | Figure 22 places v1 and v2 side by side for direct comparison. The contrast is stark: v1 shows classic overfitting — training loss continues falling while validation loss rises from epoch 12 onward, and all test predictions collapse to a single class. v2 shows both curves decreasing together without divergence, and the confusion matrix shows near-perfect classification across all three classes. | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 22//** Model v1 (top) vs Model v2 final (bottom). Left to right: training loss, training accuracy, confusion matrix. v1 shows clear overfitting and collapsed predictions; | ||
| + | |||
| + | The quantitative improvement is significant: | ||
| + | * Test set size: 4 windows (v1) → 117 windows (v2) — results are now statistically meaningful | ||
| + | * Classes: 2 (Move, Shake) → 3 (Move, Rest, Shake) — Rest detection now possible | ||
| + | * Test accuracy: ~50% effective (v1) → 96.6% (v2 final) — dramatic improvement | ||
| + | * Confusion matrix: all predictions collapsed to one class (v1) → perfect Move and Rest recall, 89% Shake recall (v2) | ||
| + | * Overfitting: | ||
| + | The improvement is attributable primarily to the 10× increase in training data and the addition of the Rest class, not to architectural changes. This underscores a key principle of applied machine learning: in the small-data regime, data quality and quantity have a larger impact on performance than model architecture. | ||
| + | |||
| + | **Phase 3 — Model v2 Improvement Experiments** | ||
| + | With a working baseline at 95.7% test accuracy, three targeted experiments were conducted sequentially to further improve the model. Each experiment changed one element at a time. | ||
| + | |||
| + | **Experiment 1 — Batch Normalization** | ||
| + | Batch Normalization (BN) layers were added immediately after each of the three Conv1D layers. Batch normalization stabilizes the distribution of layer activations during training and can reduce the need for Dropout regularization [R4]. In our experiments, | ||
| + | |||
| + | **Result:** EarlyStopping triggered at epoch ~37 compared to ~80 for the baseline — convergence speed was more than halved. Test accuracy remained unchanged at 95.7%. Validation loss showed slightly more stable behaviour. The significant reduction in training time with no accuracy cost made this an easy decision. | ||
| + | **Decision: | ||
| + | |||
| + | **Experiment 2 — Reduce LR On Plateau** | ||
| + | A learning rate callback was added: when validation loss does not improve for 10 consecutive epochs, the learning rate is multiplied by 0.5 (minimum: 1×10⁻⁶). This allows the model to take large gradient steps early in training and progressively finer steps as it approaches a local optimum. | ||
| + | |||
| + | **Result:** Test accuracy improved from 95.7% to 96.6% (113/117 correct). Shake recall improved from 41/46 to 42/46. Validation loss reached a lower minimum and remained more stable than in Experiment 1. EarlyStopping triggered at ~40 epochs. The improvement was measurable and consistent.This configuration was further improved by augmentation in the final step. | ||
| + | **Decision: | ||
| + | |||
| + | **Experiment 3 — Offline Data Augmentation** | ||
| + | To further increase the effective training set size, each training window was duplicated with a perturbed copy: Gaussian noise (σ=0.05 in normalised space) was added, and a random amplitude scale factor drawn from U(0.9, 1.1) was applied. The training set grew from 348 to 696 windows. The validation and test sets were not augmented. | ||
| + | |||
| + | **Result:** Test accuracy of 95.7% (112/117) was achieved, with Shake errors at 5. Validation accuracy remained stable throughout training without upward drift, confirming that augmentation did not cause overfitting at this scale. Given the statistically small test set, the difference relative to Experiment 2 is within noise, but augmentation is retained as it improves training set diversity and helps generalisation. | ||
| + | **Decision: | ||
| + | |||
| + | Figure 23 summarises all three experiments across the three key metrics: test accuracy, epochs to convergence, | ||
| + | |||
| + | {{ : | ||
| + | | **//Figure 23// | ||
| + | |||
| + | **Final Model Selection** | ||
| + | The final model is the v2 + BatchNorm + ReduceLROnPlateau + Augmentation configuration. It achieves 95.7% test accuracy (112/117 correct) with efficient convergence (~40 epochs) and stable training dynamics. | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 24//** Final model training curves — loss (left) and accuracy (right). Both curves converge without divergence.| | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 25//** Final model confusion matrix (117-window test set). Move: 34/34, Rest: 37/37, Shake: 41/46. Test accuracy: 95.7%.| | ||
| + | |||
| + | {{ : | ||
| + | |**//Figure 26//** Softmax output probabilities for all 117 test windows. Near-binary outputs confirm high model confidence across all classes.| | ||
| + | |||
| + | The five Shake→Move misclassifications occur at the behavioural boundary where moderate-intensity shake gestures produce acceleration signatures similar to vigorous movement. This boundary ambiguity is inherent to the gesture and is mitigated at deployment by Exponential Moving Average (EMA) smoothing (α=0.3) applied across consecutive window predictions on the gateway. Since genuine shake events span multiple consecutive windows, EMA smoothing suppresses isolated misclassified windows without introducing significant latency. | ||
| + | |||
| + | **Model Export and Deployment** | ||
| + | After training, the final model was exported as gesture_model.tflite (float32 TensorFlow Lite), accompanied by normalization.json containing the 12 per-channel z-score parameters. A numerical sanity check confirmed that the TFLite output is identical to the Keras model output (maximum absolute difference < 10⁻⁸). On the gateway, incoming int16 IMU windows are cast to float32, normalised using normalization.json, | ||
| + | |||
| + | <file python train.py> | ||
| + | """ | ||
| + | Training script for a tiny 1D-CNN on (96 x 6) IMU windows. | ||
| + | Dataset format: pre-windowed CSVs with columns label, window_index, | ||
| + | |||
| + | - Classes | ||
| + | - Window size : 96 samples @ 100 Hz (~0.96 s) | ||
| + | - Features | ||
| + | - Preprocess | ||
| + | - Model : Conv1D(16, | ||
| + | -> GAP -> Dense(24) -> Dropout(0.30) -> Softmax(3) | ||
| + | + GaussianNoise(0.02) at input (training-time only) | ||
| + | - EarlyStopping: | ||
| + | - Export | ||
| + | """ | ||
| + | |||
| + | import os | ||
| + | import json | ||
| + | import numpy as np | ||
| + | import pandas as pd | ||
| + | import matplotlib.pyplot as plt | ||
| + | import tensorflow as tf | ||
| + | from tensorflow.keras import layers, models | ||
| + | |||
| + | try: | ||
| + | from sklearn.metrics import confusion_matrix, | ||
| + | SKLEARN_OK = True | ||
| + | except ImportError: | ||
| + | SKLEARN_OK = False | ||
| + | |||
| + | print(f" | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 0) Reproducibility & constants | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | SEED = 1337 | ||
| + | np.random.seed(SEED) | ||
| + | tf.random.set_seed(SEED) | ||
| + | |||
| + | GESTURES | ||
| + | NUM_GESTURES | ||
| + | SAMPLES_PER_GESTURE = 96 | ||
| + | FEATS = 6 # aX, aY, aZ, gX, gY, gZ | ||
| + | |||
| + | # ── Paths ──────────────────────────────────────────────────────────────────── | ||
| + | # Put your three clean CSV files in DATA_DIR, or adjust paths below. | ||
| + | DATA_DIR = " | ||
| + | OUT_DIR | ||
| + | os.makedirs(OUT_DIR, | ||
| + | |||
| + | DATASET_FILES = { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 1) Load pre-windowed CSVs | ||
| + | # Each row = one window. | ||
| + | # We reshape each row into (96, 6) = [aX, aY, aZ, gX, gY, gZ] per time step. | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | CHANNEL_SUFFIXES = [" | ||
| + | |||
| + | ONE_HOT | ||
| + | inputs_list, | ||
| + | |||
| + | for g_idx, gesture in enumerate(GESTURES): | ||
| + | path = DATASET_FILES[gesture] | ||
| + | df = pd.read_csv(path) | ||
| + | |||
| + | # Build ordered feature columns: x0_ax, x0_ay, ..., x95_gz | ||
| + | feat_cols = [] | ||
| + | for t in range(SAMPLES_PER_GESTURE): | ||
| + | for ch in CHANNEL_SUFFIXES: | ||
| + | feat_cols.append(f" | ||
| + | |||
| + | windows = df[feat_cols].values.astype(np.float32) | ||
| + | labels | ||
| + | |||
| + | inputs_list.append(windows) | ||
| + | outputs_list.append(labels) | ||
| + | print(f" | ||
| + | |||
| + | inputs | ||
| + | outputs = np.concatenate(outputs_list, | ||
| + | print(f" | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 2) Shuffle + split 60 / 20 / 20 | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | idx = np.random.permutation(len(inputs)) | ||
| + | inputs | ||
| + | outputs = outputs[idx] | ||
| + | |||
| + | n = len(inputs) | ||
| + | n_train = int(0.60 * n) | ||
| + | n_val = int(0.20 * n) | ||
| + | |||
| + | X_flat_train = inputs[: | ||
| + | X_flat_val | ||
| + | X_flat_test | ||
| + | |||
| + | y_train = outputs[: | ||
| + | y_val = outputs[n_train : n_train + n_val] | ||
| + | y_test | ||
| + | |||
| + | print(f" | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 3) Per-channel z-score normalisation (fit on TRAIN only) | ||
| + | # Shape trick: flatten -> (N, 96, 6) -> compute mean/std per channel axis | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | train_ts = X_flat_train.reshape(-1, | ||
| + | ch_mean | ||
| + | ch_std | ||
| + | |||
| + | def zscore(x_flat: | ||
| + | """ | ||
| + | x_ts = x_flat.reshape(-1, | ||
| + | x_ts = (x_ts - ch_mean) / ch_std | ||
| + | x_ts = np.clip(x_ts, | ||
| + | return x_ts | ||
| + | |||
| + | X_train = zscore(X_flat_train) | ||
| + | X_val = zscore(X_flat_val) | ||
| + | X_test | ||
| + | |||
| + | print(f" | ||
| + | rng = np.random.default_rng(SEED) | ||
| + | |||
| + | noise = rng.normal(0, | ||
| + | scale = rng.uniform(0.9, | ||
| + | X_aug = np.clip(X_train * scale + noise, -5.0, 5.0) | ||
| + | y_aug = y_train.copy() | ||
| + | |||
| + | X_train = np.concatenate([X_train, | ||
| + | y_train = np.concatenate([y_train, | ||
| + | |||
| + | |||
| + | shuffle_idx = rng.permutation(len(X_train)) | ||
| + | X_train = X_train[shuffle_idx] | ||
| + | y_train = y_train[shuffle_idx] | ||
| + | |||
| + | print(f" | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 4) Model definition | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | def build_model(input_shape=(SAMPLES_PER_GESTURE, | ||
| + | inp = layers.Input(shape=input_shape, | ||
| + | |||
| + | # GaussianNoise: | ||
| + | x = layers.GaussianNoise(0.02)(inp) | ||
| + | |||
| + | x = layers.Conv1D(16, | ||
| + | x = layers.BatchNormalization()(x) | ||
| + | |||
| + | x = layers.Conv1D(16, | ||
| + | x = layers.BatchNormalization()(x) | ||
| + | |||
| + | x = layers.MaxPooling1D(2)(x) | ||
| + | |||
| + | x = layers.Conv1D(24, | ||
| + | x = layers.BatchNormalization()(x) | ||
| + | x = layers.GlobalAveragePooling1D()(x) | ||
| + | x = layers.Dense(24, | ||
| + | x = layers.Dropout(0.30)(x) | ||
| + | out = layers.Dense(num_classes, | ||
| + | |||
| + | model = models.Model(inp, | ||
| + | model.compile( | ||
| + | optimizer=tf.keras.optimizers.Adam(1e-3), | ||
| + | loss=" | ||
| + | metrics=[" | ||
| + | ) | ||
| + | return model | ||
| + | |||
| + | model = build_model() | ||
| + | model.summary() | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 5) Training | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | early = tf.keras.callbacks.EarlyStopping( | ||
| + | monitor=" | ||
| + | patience=25, | ||
| + | restore_best_weights=True | ||
| + | ) | ||
| + | |||
| + | # ReduceLROnPlateau | ||
| + | reduce_lr = tf.keras.callbacks.ReduceLROnPlateau( | ||
| + | monitor=" | ||
| + | factor=0.5, | ||
| + | patience=10, | ||
| + | min_lr=1e-6, | ||
| + | verbose=1 | ||
| + | ) | ||
| + | |||
| + | history = model.fit( | ||
| + | X_train, y_train, | ||
| + | validation_data=(X_val, | ||
| + | epochs=200, | ||
| + | batch_size=8, | ||
| + | callbacks=[early, | ||
| + | verbose=2 | ||
| + | ) | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 6) Learning curves | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | fig, axes = plt.subplots(1, | ||
| + | |||
| + | axes[0].plot(history.history[" | ||
| + | axes[0].plot(history.history[" | ||
| + | axes[0].set_title(" | ||
| + | axes[0].grid(True); | ||
| + | |||
| + | axes[1].plot(history.history[" | ||
| + | axes[1].plot(history.history[" | ||
| + | axes[1].set_title(" | ||
| + | axes[1].grid(True); | ||
| + | |||
| + | plt.tight_layout() | ||
| + | plt.savefig(os.path.join(OUT_DIR, | ||
| + | plt.show() | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 7) Test evaluation + confusion matrix | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | test_loss, test_acc = model.evaluate(X_test, | ||
| + | best_val_acc | ||
| + | best_val_loss = float(np.min(history.history.get(" | ||
| + | |||
| + | print(f" | ||
| + | print(f" | ||
| + | |||
| + | y_prob = model.predict(X_test, | ||
| + | y_pred = y_prob.argmax(axis=1) | ||
| + | y_true = y_test.argmax(axis=1) | ||
| + | |||
| + | if SKLEARN_OK: | ||
| + | cm = confusion_matrix(y_true, | ||
| + | disp = ConfusionMatrixDisplay(confusion_matrix=cm, | ||
| + | disp.plot(cmap=" | ||
| + | plt.title(" | ||
| + | plt.savefig(os.path.join(OUT_DIR, | ||
| + | plt.show() | ||
| + | print(" | ||
| + | print(cm) | ||
| + | else: | ||
| + | print(" | ||
| + | |||
| + | # Class probabilities over test windows (sanity check) | ||
| + | plt.figure(figsize=(14, | ||
| + | for c_idx, cname in enumerate(GESTURES): | ||
| + | plt.plot(y_prob[:, | ||
| + | plt.title(" | ||
| + | plt.xlabel(" | ||
| + | plt.grid(True); | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 8) Export: TFLite (float32) + normalization.json | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # --- TFLite --- | ||
| + | converter | ||
| + | tflite_bytes = converter.convert() | ||
| + | |||
| + | tflite_path = os.path.join(OUT_DIR, | ||
| + | with open(tflite_path, | ||
| + | f.write(tflite_bytes) | ||
| + | print(f" | ||
| + | |||
| + | # --- normalization.json --- | ||
| + | # The model server MUST apply the same per-channel z-score before inference. | ||
| + | # Load this file on the RPi and apply: | ||
| + | # | ||
| + | norm = { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | |||
| + | norm_path = os.path.join(OUT_DIR, | ||
| + | with open(norm_path, | ||
| + | json.dump(norm, | ||
| + | print(f" | ||
| + | |||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | # 9) Quick inference sanity check | ||
| + | # Verifies the exported TFLite model gives identical outputs to Keras. | ||
| + | # ───────────────────────────────────────────────────────────────────────────── | ||
| + | interp = tf.lite.Interpreter(model_path=tflite_path) | ||
| + | interp.allocate_tensors() | ||
| + | inp_det | ||
| + | out_det | ||
| + | |||
| + | sample | ||
| + | keras_out = model.predict(sample, | ||
| + | |||
| + | interp.set_tensor(inp_det[" | ||
| + | interp.invoke() | ||
| + | tflite_out = interp.get_tensor(out_det[" | ||
| + | |||
| + | print(f" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | print(" | ||
| + | </ | ||
| + | |||
| + | === 3.3.3 Model & Server Testing === | ||
| + | The model testing process is designed around receiving real-time IMU data frames sent from the controller via LoRa on the gateway side and feeding them directly into the trained TensorFlow Lite model. At this stage, the test code performed frame-based verification by reading the frames coming from the LoRa module via the serial port. For each frame, the header (address, channel, and MAGIC field) was checked, the frame counter field was verified, and when all frames were received intact, the relevant window was reconstructed. | ||
| + | After the window was successfully created, the raw 16-bit IMU data was converted to float32 format and normalised using the global mean and standard deviation values used during the training phase. This ensured that the model input distribution during the testing phase was consistent with the data distribution during the training phase. The (1, 96, 6) dimensional data tensor obtained after normalisation was fed directly into the TensorFlow Lite model. | ||
| + | |||
| + | The model produced three probability values for each window: | ||
| + | * move_prob | ||
| + | * rest_prob | ||
| + | * shake_prob. | ||
| + | |||
| + | After all, these outputs are generated with their timestamps, the one with higher probability is assigned as " | ||
| + | |||
| + | |||
| + | These values were obtained as softmax outputs and printed to the terminal screen after each window. In the controlled motion scenarios performed during the testing process, it was observed that the model outputs were consistent with the motion patterns during the data collection phase. In the rest state, the rest_prob value was dominant; in the Move scenario, move_prob increased significantly; | ||
| + | |||
| + | The consistency of the probability values after each window and their alignment with the expected physical behaviour demonstrated the quality of the data set and the accuracy of the model training. Additionally, | ||
| + | |||
| + | <file python server.py> | ||
| + | # | ||
| + | import sys | ||
| + | import struct | ||
| + | import time | ||
| + | import json | ||
| + | import csv | ||
| + | import argparse | ||
| + | import serial | ||
| + | import RPi.GPIO as GPIO | ||
| + | from typing import List, Tuple, Optional | ||
| + | import numpy as np | ||
| + | from smbus2 import SMBus | ||
| + | import tensorflow as tf | ||
| + | import firebase_admin | ||
| + | from firebase_admin import credentials, | ||
| + | |||
| + | #LoRa Globals | ||
| + | PIN_LORA_M0 = 11 | ||
| + | PIN_LORA_M1 = 13 | ||
| + | PIN_LORA_AUX = 15 | ||
| + | |||
| + | lora_params = bytes([0xC0, | ||
| + | cmd_version = bytes([0xC3, | ||
| + | cmd_params = bytes([0xC1, | ||
| + | |||
| + | uart = serial.Serial( | ||
| + | port= "/ | ||
| + | baudrate = 9600, | ||
| + | timeout = 1 | ||
| + | ) | ||
| + | MAGIC = b" | ||
| + | |||
| + | # IMU Window Frame format: | ||
| + | # ADDH(1) ADDL(1) CHAN(1) MAGIC(2) FID(1) FC(1) PAYLOAD(48) | ||
| + | HEADER_LEN = 1 + 1 + 1 + 2 + 1 + 1 | ||
| + | PAYLOAD_LEN = 48 | ||
| + | FRAME_LEN = HEADER_LEN + PAYLOAD_LEN | ||
| + | |||
| + | #HELPERS | ||
| + | def raise_error(msg: | ||
| + | print(f" | ||
| + | final_act(*args, | ||
| + | raise SystemExit(code) | ||
| + | |||
| + | def wait_until_pin(pin, | ||
| + | t0 = time.monotonic() | ||
| + | while (time.monotonic() - t0) < timeout_s: | ||
| + | if GPIO.input(pin) == state: | ||
| + | return True | ||
| + | time.sleep(0.002) | ||
| + | return False | ||
| + | |||
| + | # ---------------- MPU6050 minimal I2C driver ---------------- | ||
| + | |||
| + | MPU_ADDR_DEFAULT = 0x68 | ||
| + | |||
| + | REG_PWR_MGMT_1 | ||
| + | REG_CONFIG | ||
| + | REG_SMPLRT_DIV | ||
| + | REG_GYRO_CONFIG | ||
| + | REG_ACCEL_CONFIG | ||
| + | REG_ACCEL_XOUT_H | ||
| + | |||
| + | |||
| + | class MPU6050: | ||
| + | def __init__(self, | ||
| + | self.addr = addr | ||
| + | self.bus = SMBus(bus_id) | ||
| + | |||
| + | def write_reg(self, | ||
| + | self.bus.write_byte_data(self.addr, | ||
| + | |||
| + | def read_block(self, | ||
| + | return self.bus.read_i2c_block_data(self.addr, | ||
| + | |||
| + | @staticmethod | ||
| + | def _to_i16(msb, | ||
| + | v = (msb << 8) | lsb | ||
| + | return v - 65536 if v & 0x8000 else v | ||
| + | |||
| + | def initialize(self, | ||
| + | # Wake up device | ||
| + | self.write_reg(REG_PWR_MGMT_1, | ||
| + | time.sleep(0.05) | ||
| + | |||
| + | # Moderate DLPF (helps noise) | ||
| + | self.write_reg(REG_CONFIG, | ||
| + | |||
| + | # Sample rate divider (software loop enforces Fs) | ||
| + | self.write_reg(REG_SMPLRT_DIV, | ||
| + | |||
| + | accel_map = {" | ||
| + | gyro_map | ||
| + | |||
| + | if accel_range not in accel_map: | ||
| + | raise ValueError(" | ||
| + | if gyro_range not in gyro_map: | ||
| + | raise ValueError(" | ||
| + | |||
| + | self.write_reg(REG_ACCEL_CONFIG, | ||
| + | self.write_reg(REG_GYRO_CONFIG, | ||
| + | |||
| + | def read_raw6(self): | ||
| + | b = self.read_block(REG_ACCEL_XOUT_H, | ||
| + | ax = self._to_i16(b[0], | ||
| + | ay = self._to_i16(b[2], | ||
| + | az = self._to_i16(b[4], | ||
| + | gx = self._to_i16(b[8], | ||
| + | gy = self._to_i16(b[10], | ||
| + | gz = self._to_i16(b[12], | ||
| + | return ax, ay, az, gx, gy, gz | ||
| + | |||
| + | def close(self): | ||
| + | try: | ||
| + | self.bus.close() | ||
| + | except Exception: | ||
| + | pass | ||
| + | |||
| + | |||
| + | # ---------------- TFLite engine ---------------- | ||
| + | class TFLiteEngine: | ||
| + | def __init__(self, | ||
| + | self.interpreter = tf.lite.Interpreter(model_path=model_path) | ||
| + | self.interpreter.allocate_tensors() | ||
| + | self.in_details = self.interpreter.get_input_details()[0] | ||
| + | self.out_details = self.interpreter.get_output_details()[0] | ||
| + | self.in_idx = self.in_details[" | ||
| + | self.out_idx = self.out_details[" | ||
| + | |||
| + | if show_io: | ||
| + | print(" | ||
| + | print(" | ||
| + | |||
| + | def predict(self, | ||
| + | # x must be float32 with shape (1,96,6) | ||
| + | self.interpreter.set_tensor(self.in_idx, | ||
| + | self.interpreter.invoke() | ||
| + | y = self.interpreter.get_tensor(self.out_idx) | ||
| + | return np.asarray(y[0], | ||
| + | |||
| + | |||
| + | # ---------------- Helpers ---------------- | ||
| + | def cleanup(): | ||
| + | uart.close() | ||
| + | GPIO.cleanup() | ||
| + | |||
| + | def int16_to_float_acc(raw: | ||
| + | # MPU6050: ±2g => 16384 LSB/g, ±4g => 8192, ±8g => 4096, ±16g => 2048 | ||
| + | lsb_per_g = {2: 16384.0, 4: 8192.0, 8: 4096.0, 16: 2048.0}[accel_range_g] | ||
| + | return raw / lsb_per_g | ||
| + | |||
| + | |||
| + | def int16_to_float_gyro(raw: | ||
| + | # MPU6050: ±250 => 131 LSB/(°/s), ±500 => 65.5, ±1000 => 32.8, ±2000 => 16.4 | ||
| + | lsb_per_dps = {250: 131.0, 500: 65.5, 1000: 32.8, 2000: 16.4}[gyro_range_dps] | ||
| + | return raw / lsb_per_dps | ||
| + | |||
| + | |||
| + | def _read_exact(ser: | ||
| + | """ | ||
| + | deadline = time.time() + timeout_s | ||
| + | buf = bytearray() | ||
| + | while len(buf) < n: | ||
| + | if time.time() > deadline: | ||
| + | raise TimeoutError(f" | ||
| + | chunk = ser.read(n - len(buf)) | ||
| + | if chunk: | ||
| + | buf.extend(chunk) | ||
| + | return bytes(buf) | ||
| + | |||
| + | |||
| + | def _sync_to_magic(ser: | ||
| + | """ | ||
| + | Resync by searching for MAGIC in stream and positioning read so that | ||
| + | next reads start at frame boundary (best-effort). | ||
| + | """ | ||
| + | deadline = time.time() + timeout_s | ||
| + | window = bytearray() | ||
| + | |||
| + | while time.time() < deadline: | ||
| + | b = ser.read(1) | ||
| + | if not b: | ||
| + | continue | ||
| + | window += b | ||
| + | if len(window) > 64: | ||
| + | window = window[-64: | ||
| + | |||
| + | idx = window.find(MAGIC) | ||
| + | if idx == -1: | ||
| + | continue | ||
| + | |||
| + | # MAGIC is at offset 3 in a well-formed frame: | ||
| + | # [0]=ADDH [1]=ADDL [2]=CHAN [3..4]=MAGIC | ||
| + | # If it is found that MAGIC somewhere in the sliding window, attempt to align. | ||
| + | # window are the tail of previous stream; after detecting, just return. | ||
| + | return | ||
| + | |||
| + | raise TimeoutError(" | ||
| + | |||
| + | |||
| + | def recv_imu_window( | ||
| + | ser: serial.Serial, | ||
| + | A: int = 96, | ||
| + | samples_per_frame: | ||
| + | expect_addh: | ||
| + | expect_addl: | ||
| + | expect_chan: | ||
| + | timeout_s: float = 2.0, | ||
| + | ) -> List[Tuple[int, | ||
| + | """ | ||
| + | Receives a full IMU window and returns it as a list of A samples. | ||
| + | Each sample is (ax, ay, az, gx, gy, gz) in int16 raw. | ||
| + | |||
| + | Frame checks: | ||
| + | - ADDH/ | ||
| + | - MAGIC == b" | ||
| + | - FC sequence (0..frame_count-1) | ||
| + | - Consistent FID across frames | ||
| + | """ | ||
| + | if (PAYLOAD_LEN % (6 * 2)) != 0: | ||
| + | raise ValueError(" | ||
| + | |||
| + | frame_count = (A + samples_per_frame - 1) // samples_per_frame | ||
| + | out: List[Tuple[int, | ||
| + | |||
| + | # Try to sync first (best-effort) | ||
| + | _sync_to_magic(ser, | ||
| + | |||
| + | fid_expected: | ||
| + | samples_written = 0 | ||
| + | |||
| + | for fc_expected in range(frame_count): | ||
| + | # Read one full frame | ||
| + | frame = _read_exact(ser, | ||
| + | |||
| + | addh, addl, chan = frame[0], frame[1], frame[2] | ||
| + | magic = frame[3:5] | ||
| + | fid = frame[5] | ||
| + | fc = frame[6] | ||
| + | payload = frame[7:7 + PAYLOAD_LEN] | ||
| + | |||
| + | # Validate header | ||
| + | if (addh != expect_addh) or (addl != expect_addl) or (chan != expect_chan) or (magic != MAGIC): | ||
| + | raise ValueError( | ||
| + | f" | ||
| + | f" | ||
| + | ) | ||
| + | |||
| + | # Validate FID continuity | ||
| + | if fid_expected is None: | ||
| + | fid_expected = fid | ||
| + | elif fid != fid_expected: | ||
| + | raise ValueError(f" | ||
| + | |||
| + | # Validate FC ordering | ||
| + | if fc != fc_expected: | ||
| + | raise ValueError(f" | ||
| + | |||
| + | # Parse payload: little-endian 6x int16 per sample | ||
| + | # Each sample is 12 bytes -> '< | ||
| + | offset = 0 | ||
| + | for _ in range(samples_per_frame): | ||
| + | if samples_written >= A: | ||
| + | break | ||
| + | ax, ay, az, gx, gy, gz = struct.unpack_from("< | ||
| + | out[samples_written] = (ax, ay, az, gx, gy, gz) # type: ignore | ||
| + | samples_written += 1 | ||
| + | offset += 12 | ||
| + | |||
| + | if samples_written != A: | ||
| + | raise ValueError(f" | ||
| + | |||
| + | # Return the window safter the transfer is successful | ||
| + | return out | ||
| + | |||
| + | # Confirmed output order: | ||
| + | LABELS = [" | ||
| + | |||
| + | def load_norm_json(path: | ||
| + | """ | ||
| + | Expects JSON with channel_mean and channel_std arrays of length 6. | ||
| + | """ | ||
| + | with open(path, " | ||
| + | obj = json.load(f) | ||
| + | |||
| + | # Try a few common key names to be robust | ||
| + | for mean_key in [" | ||
| + | if mean_key in obj: | ||
| + | channel_mean = obj[mean_key] | ||
| + | break | ||
| + | else: | ||
| + | raise KeyError(" | ||
| + | |||
| + | for std_key in [" | ||
| + | if std_key in obj: | ||
| + | channel_std = obj[std_key] | ||
| + | break | ||
| + | else: | ||
| + | raise KeyError(" | ||
| + | |||
| + | mean = np.array(channel_mean, | ||
| + | std = np.array(channel_std, | ||
| + | |||
| + | if mean.shape[-1] != 6 or std.shape[-1] != 6: | ||
| + | raise ValueError(" | ||
| + | |||
| + | # Avoid division by zero | ||
| + | std = np.where(std == 0.0, 1e-6, std) | ||
| + | return mean, std | ||
| + | |||
| + | def window_summary(win_i16: | ||
| + | w = win_i16.astype(np.int32) | ||
| + | mn = w.min(axis=0) | ||
| + | mx = w.max(axis=0) | ||
| + | mean = w.mean(axis=0) | ||
| + | return ( | ||
| + | f" | ||
| + | f" | ||
| + | f" | ||
| + | ) | ||
| + | |||
| + | def classify(probs: | ||
| + | i = int(np.argmax(probs)) | ||
| + | return LABELS[i], float(probs[i]) | ||
| + | |||
| + | |||
| + | # ---------------- Main ---------------- | ||
| + | def main(): | ||
| + | #Parse initial arguments | ||
| + | ap = argparse.ArgumentParser() | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | ap.add_argument(" | ||
| + | args = ap.parse_args() | ||
| + | |||
| + | #Initialize LoRa control ports (M0-M1, AUX) | ||
| + | GPIO.setmode(GPIO.BOARD) | ||
| + | GPIO.setwarnings(False) | ||
| + | GPIO.setup(PIN_LORA_M0, | ||
| + | GPIO.setup(PIN_LORA_M1, | ||
| + | GPIO.setup(PIN_LORA_AUX, | ||
| + | |||
| + | #Wait until AUX is high (Module is ready) | ||
| + | if not wait_until_pin(PIN_LORA_AUX, | ||
| + | raise_error(" | ||
| + | else: | ||
| + | print(" | ||
| + | |||
| + | #Set module mode (11) sleep | ||
| + | GPIO.output(PIN_LORA_M0, | ||
| + | GPIO.output(PIN_LORA_M1, | ||
| + | if not wait_until_pin(PIN_LORA_AUX, | ||
| + | raise_error(" | ||
| + | else: | ||
| + | print(" | ||
| + | |||
| + | #Send lora configuration parameters | ||
| + | uart.reset_input_buffer() | ||
| + | print(" | ||
| + | uart.write(lora_params) | ||
| + | uart.flush() | ||
| + | time.sleep(0.1) | ||
| + | |||
| + | #Validate lora configuration parameters | ||
| + | uart.reset_input_buffer() | ||
| + | print(" | ||
| + | uart.write(cmd_params) | ||
| + | uart.flush() | ||
| + | rx = uart.read(6) | ||
| + | if rx != lora_params: | ||
| + | raise_error(" | ||
| + | else: | ||
| + | print(" | ||
| + | time.sleep(0.1) | ||
| + | |||
| + | #Set module mode (00) transceiver | ||
| + | if not wait_until_pin(PIN_LORA_AUX, | ||
| + | raise_error(" | ||
| + | else: | ||
| + | print(" | ||
| + | GPIO.output(PIN_LORA_M0, | ||
| + | GPIO.output(PIN_LORA_M1, | ||
| + | time.sleep(0.01) | ||
| + | |||
| + | #INIT FIREBASE SERVER | ||
| + | #Fetch the service account key | ||
| + | cred = credentials.Certificate(" | ||
| + | |||
| + | #Initialize the app with a service account then grant admin privileges | ||
| + | firebase_admin.initialize_app(cred, | ||
| + | ' | ||
| + | }) | ||
| + | | ||
| + | #Get imu reference | ||
| + | controller_ref = db.reference(" | ||
| + | |||
| + | if not args.model.lower().endswith(" | ||
| + | raise ValueError(" | ||
| + | |||
| + | if args.A != 96: | ||
| + | print(" | ||
| + | |||
| + | # Load global normalization | ||
| + | channel_mean, | ||
| + | # channel_mean/ | ||
| + | |||
| + | # Init IMU | ||
| + | mpu = MPU6050(bus_id=args.bus, | ||
| + | mpu.initialize(accel_range=" | ||
| + | |||
| + | # Init model | ||
| + | engine = TFLiteEngine(args.model, | ||
| + | |||
| + | # Optional CSV logging | ||
| + | csv_f = None | ||
| + | csv_w = None | ||
| + | if args.log: | ||
| + | csv_f = open(args.log, | ||
| + | csv_w = csv.writer(csv_f) | ||
| + | csv_w.writerow([" | ||
| + | |||
| + | A = args.A | ||
| + | fs = args.fs | ||
| + | period = 1.0 / fs | ||
| + | | ||
| + | window_id = 0 | ||
| + | |||
| + | print(" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | print(" | ||
| + | if args.log: | ||
| + | print(f" | ||
| + | print(" | ||
| + | |||
| + | try: | ||
| + | next_t = time.perf_counter() | ||
| + | |||
| + | while True: | ||
| + | win = recv_imu_window( | ||
| + | ser, | ||
| + | A=96, | ||
| + | samples_per_frame=4, | ||
| + | expect_addh=0x00, | ||
| + | expect_addl=0x00, | ||
| + | expect_chan=0x06, | ||
| + | timeout_s=3.0, | ||
| + | ) | ||
| + | |||
| + | """ | ||
| + | ax, ay, az, gx, gy, gz = win[0] | ||
| + | |||
| + | ax_f = int16_to_float_acc(ax, | ||
| + | ay_f = int16_to_float_acc(ay, | ||
| + | az_f = int16_to_float_acc(az, | ||
| + | |||
| + | gx_f = int16_to_float_gyro(gx, | ||
| + | gy_f = int16_to_float_gyro(gy, | ||
| + | gz_f = int16_to_float_gyro(gz, | ||
| + | |||
| + | print( | ||
| + | f"Data received! : " | ||
| + | f" | ||
| + | f" | ||
| + | ) | ||
| + | """ | ||
| + | # Enforce sampling rate | ||
| + | now = time.perf_counter() | ||
| + | if now < next_t: | ||
| + | time.sleep(next_t - now) | ||
| + | next_t += period | ||
| + | |||
| + | # Global normalization (per-channel) | ||
| + | win_f32 = win.astype(np.float32).reshape(1, | ||
| + | x = (win_f32 - channel_mean) / channel_std | ||
| + | |||
| + | # Inference (already softmax) | ||
| + | probs = engine.predict(x) | ||
| + | if probs.size != 3: | ||
| + | raise RuntimeError(f" | ||
| + | |||
| + | move_prob, rest_prob, shake_prob = float(probs[0]), | ||
| + | pred, conf = classify(probs) | ||
| + | |||
| + | ts = time.time() | ||
| + | |||
| + | print(f" | ||
| + | print(f" | ||
| + | print(f" | ||
| + | print(" | ||
| + | | ||
| + | #Send the updated status to database | ||
| + | controller_ref.update({ | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | }) | ||
| + | |||
| + | if csv_w is not None: | ||
| + | csv_w.writerow([f" | ||
| + | f" | ||
| + | csv_f.flush() | ||
| + | |||
| + | window_id += 1 | ||
| + | |||
| + | except KeyboardInterrupt: | ||
| + | print(" | ||
| + | finally: | ||
| + | mpu.close() | ||
| + | if csv_f: | ||
| + | csv_f.close() | ||
| + | |||
| + | |||
| + | if __name__ == " | ||
| + | main() | ||
| + | |||
| + | </ | ||
| + | |||
| + | {{ : | ||
| + | |Test Outputs (Change their extensions as .csv)| | ||
| + | |||
| + | {{ : | ||
| + | |**//Video 5//** Gateway & Server Test Video| | ||
| + | |||
| + | ===== 4.Discussion ===== | ||
| + | The implemented system has successfully demonstrated an end-to-end motion classification architecture. On the controller side, low-power mode standby, window-based data collection when motion is detected, and transmission to the gateway via LoRa; on the gateway side, real-time model execution and writing results to the cloud have all operated stably. | ||
| + | |||
| + | A clear distinction was observed between the move, rest, and shake classes in the model outputs. In particular, the model consistently distinguished between low-variance acceleration data in the rest state and high angular velocity values in the shake state. As the inference being performed on the gateway, raw IMU data was not sent to the cloud; only classification outputs were transmitted. This approach reduced bandwidth usage and increased the system' | ||
| + | |||
| + | The dataset created during the project process contains a limited number of windows. Saturation problems were observed, particularly in the shake class, and the sensor range settings were optimised. The small data set is a factor that could limit the model' | ||
| + | |||
| + | Furthermore, | ||
| + | |||
| + | The system functionality can be improved in various ways. For example, ZigBee, 2.4 GHz RF or Wi-Fi-based solutions can be used instead of LoRa. Particularly in short-range applications, | ||
| + | |||
| + | For model training, several directions could further improve the model in future iterations. The current dataset was recorded by a single person in a controlled desk environment. With a larger dataset covering multiple users, gesture intensities, | ||
| + | |||
| + | The current TFLite model uses float32 weights. Quantisation-aware training could produce an INT8 model, reducing memory footprint by approximately 4× and accelerating inference on the Raspberry Pi. This would also enable deployment on more constrained microcontrollers. The expected accuracy drop is 1–2 percentage points, which is acceptable given the current 96.6% baseline. | ||
| + | |||
| + | The current model is trained offline and deployed as a fixed artefact. For deployment on actual animal subjects, where motion dynamics differ from handheld recording, a lightweight fine-tuning step using a small number of animal-specific windows could substantially improve accuracy without full retraining. Transfer learning — freezing the convolutional layers and retraining only the dense head on new data — would be a practical approach given the limited compute available on the gateway. | ||
| + | |||
| + | The three-class model (Move, Rest, Shake) covers the primary behavioural states of interest. Future versions could extend to additional classes such as Eat, Play, or Sleep, provided sufficient labelled data is available. The modular architecture makes extension straightforward: | ||
| + | |||
| + | ===== 5. Conclusion & Outlook ===== | ||
| + | In this study, a low-power IMU-based motion classification system was designed and an end-to-end IoT architecture was implemented. The system integrates sensor measurement, | ||
| + | |||
| + | Future work plans include improving model performance with larger datasets, evaluating different communication technologies, | ||
| + | |||
| + | ===== 6. References ===== | ||
| + | European Telecommunications Standards Institute (ETSI). 2018. “Short Range Devices (SRD) operating in the frequency range 25 MHz to 1 000 MHz; Part 2: Harmonised Standard for access to radio spectrum for non specific radio equipment”. Visited: 09.02.2026. Available at: https:// | ||
| + | |||
| + | M. Alsaaod, J.J. Niederhauser, | ||
| + | “Development and validation of a novel pedometer algorithm to quantify extended characteristics of the locomotor behavior of dairy cows, Journal of Dairy Science”. Volume 98. Issue 9. | ||
| + | pp. 6236-6242. ISSN 0022-0302. DOI: https:// | ||
| + | |||
| + | Kiranyaz, S., Avci, O., Abdeljaber, O., Ince, T., Gabbouj, M., & Inman, D. J. (2021). 1D convolutional neural networks and applications: | ||
| + | |||
| + | Ioffe, S., & Szegedy, C. (2015). Batch normalization: | ||
| + | |||
| + | |||
| + | |||
| + | |||
| + | |||
emrp/ws2025/amt.1772075808.txt.gz · Last modified: 2026/02/26 04:16 by 36502_students.hsrw