Hardware components | ||||||
![]() |
| × | 1 | |||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
Software apps and online services | ||||||
![]() |
| |||||
| ||||||
PunchSense is a wearable punch evaluation system built with an M5Stack Core2 and an IMU Pro Mini Unit. It measures acceleration and angular velocity during punching motions and provides immediate feedback through a score, an S-to-D rank, a gauge, a facial expression, and a short comment on the M5Stack display.
This system evaluates motion characteristics rather than directly measuring impact force.
Demonstration of the IMU mounted on the boxing glove and real-time personalized feedback displayed on the M5Stack Core2.
Why I Built ItA large acceleration value alone does not always represent a good punch. During early experiments, a short wrist-snapping motion could receive a high score even when the arm was not extended properly.
I therefore wanted to evaluate not only acceleration, but also fist rotation, acceleration rise, and the coordination between translational motion and rotation. I also wanted the evaluation to reflect the user's own perception of a good punch.
System OverviewThe IMU Pro Mini Unit is attached firmly to the glove and connected to the M5Stack Core2 through an I2C Grove cable. The BMI270 sensor measures three-axis acceleration and angular velocity at a target sampling rate of 100 Hz.
The M5Stack detects each punch, calculates motion features, and displays the result immediately. After the calibrated parameters are embedded in the firmware, the system can perform scoring without a connected computer.
Punch DetectionThe magnitude of acceleration is calculated as:
|a| = sqrt(ax^2 + ay^2 + az^2)
The dynamic acceleration is calculated by removing the effect of approximately 1 g of gravity:
dynamic = ||a| - 1 g|
A punch candidate is detected when the dynamic acceleration reaches a local peak of at least 1.5 g. Consecutive detected peaks must also be separated by at least 0.6 seconds.
Evaluation FeaturesEach detected punch is evaluated using the following features:
- Dynamic acceleration peak
- Acceleration rise before the peak
- Peak fist rotation speed
- Timing relationship between acceleration and rotation
- Ratio and balance between acceleration and rotation
- Coordination of acceleration, rotation, and timing
These features allow PunchSense to evaluate the overall motion pattern instead of relying only on acceleration.
Experimental Punch-Arrival EstimationAfter each acceleration peak, the program analyzes the rapid decrease in dynamic acceleration. The black triangles indicate the post-peak dynamic minima, and the blue diamonds indicate the estimated punch-arrival timing.
This arrival proxy is intended to approximate the timing at which the arm reaches extension or the punch motion changes rapidly. However, it has not yet been validated against an external reference such as a high-speed camera or an impact sensor. Therefore, it is currently treated as an experimental feature rather than an exact measurement.
Measured acceleration and angular velocity waveforms. Red circles show detected punch peaks, black triangles show post-peak dynamic minima, and blue diamonds show experimental punch-arrival estimates.
Personalized ScoringThe Python script scripts/read_imu.py records the IMU data and saves the measurements as CSV files and waveform images.
The user then assigns a subjective score from 1 to 5 to each detected punch. The user_label and comment columns are also used to describe the punch, while user_score is used as the numerical calibration target.
The script scripts/calibrate_subjective_score.py calculates the relationship between each motion feature and the subjective scores. Pearson correlation coefficients are used to derive the feature weights, and the resulting personalized model is saved as subjective_score_config.json.
The calibrated model produces a learned score from 0 to 100. The score is converted into five ranks:
S: 85-100
A: 70-84.9
B: 50-69.9
C: 30-49.9
D: below 30
Real-Time FeedbackAfter a punch is detected, the M5Stack Core2 displays:
- Personalized score
- S-to-D rank
- Score gauge
- Acceleration in m/s^2
- Rank-dependent facial expression
- Short Japanese feedback comment
The display gives the user immediate and intuitive feedback without requiring them to inspect raw sensor data.
SoftwareThe M5Stack firmware was developed using PlatformIO and the Arduino framework. The project uses M5Unified and the M5Stack IMU Pro Mini library.
Python is used for serial data recording, waveform visualization, subjective-score calibration, and real-time analysis during development.
Build Instructions1. Connect the IMU Pro Mini Unit to Port A of the M5Stack Core2 using a Grove cable.
2. Secure the IMU firmly to the glove so that its position and orientation do not change during punching motions.
3. Open the project in Visual Studio Code with PlatformIO installed.
4. Build and upload the firmware using the m5stack-core2 environment defined in platformio.ini.
5. Upload the SPIFFS filesystem image to install the S-to-D facial-expression images.
6. Power on the Core2 and wait for the IMU initialization to complete.
7. Perform a punch. After detecting the acceleration peak, the Core2 calculates the motion features and displays the score, rank, gauge, acceleration, facial expression, and feedback comment.
For data collection and model calibration, install the Python dependencies and run:
pip install -r scripts/requirements.txt
python scripts/read_imu.py
Add a user_score from 1 to 5 to each detected punch in the generated summary CSV, then run:
python scripts/calibrate_subjective_score.py data\*_summary.csv
The calibrated model can be tested using:
python scripts/realtime_punch_score.py
Current Results and Future WorkPunchSense can detect punch motions and visualize clear acceleration and rotation peaks. Testing different sampling-rate settings showed that 100 Hz was a practical operating point for this implementation.
The next steps are to collect more labeled punch data, improve the personalized scoring accuracy, validate the estimated punch-arrival timing using an external reference such as video or an impact sensor, and improve the method used to secure the sensor to the glove.
SafetyThis project is intended for motion analysis and training support. It does not directly measure impact force and should not be used as a medical or professional safety device.
M5Stack Core2 Firmware
C/C++#include <Arduino.h>
#include <SPIFFS.h>
#include <M5Unified.h>
#include <M5_IMU_PRO.h>
#include <math.h>
static const uint8_t BMI270_SENSOR_ADDR = 0x68;
static const uint8_t BMI270_SENSOR_ADDR_ALT = 0x69;
static const int SAMPLE_HZ = 100;
static const uint32_t SAMPLE_INTERVAL_US = (1000000UL + SAMPLE_HZ / 2) / SAMPLE_HZ;
static const int IMU_RETRY_INTERVAL_MS = 2000;
static const int IMU_POWER_SETTLE_MS = 2000;
static const int I2C_SETTLE_MS = 500;
static const int SERIAL_BAUD = 921600;
static const int ON_DEVICE_BUFFER_SECONDS = 12;
static const int ON_DEVICE_BUFFER_SIZE = SAMPLE_HZ * ON_DEVICE_BUFFER_SECONDS;
static const bool ARRIVAL_ONLY_MODE = false;
static const float PEAK_THRESHOLD_G = 1.5f;
static const float PEAK_MIN_INTERVAL_SEC = 0.6f;
static const float PEAK_SETTLE_SEC = 0.35f;
static const float G_TO_MS2 = 9.80665f;
static const uint8_t BMI270_CHIP_ID_REG = 0x00;
static const uint8_t BMI270_EXPECTED_CHIP_ID = 0x24;
static const uint8_t BMI270_CMD_REG = 0x7E;
static const uint8_t BMI270_SOFT_RESET_CMD = 0xB6;
static const uint8_t BMI270_ACC_CONF_REG = 0x40;
static const uint8_t BMI270_GYR_CONF_REG = 0x42;
static const uint8_t BMI270_ODR_MASK = 0x0F;
static const uint8_t BMI270_ODR_100HZ = 0x08;
static const char *PROJECT_LABEL = "IMU_Sokutei";
static const char *FIRMWARE_LABEL = "IMU_Sokutei Punch IMU v2 100Hz gyro";
BMI270::BMI270 bmi270;
uint32_t sampleIndex = 0;
uint32_t firstSampleMs = 0;
uint32_t lastSampleUs = 0;
uint32_t lastRetryMs = 0;
bool imuReady = false;
bool gyroReadWarned = false;
bool filesystemReady = false;
String serialCommand;
uint32_t arrivalShownMs = 0;
bool scoreDisplayReady = false;
float lastDisplayedScore = 0.0f;
char lastDisplayedLabel = 'D';
float lastDisplayedAccelMs2 = -1.0f;
struct OnDeviceSample {
float timeSec;
float dynamicG;
float rotationDps;
};
OnDeviceSample onDeviceSamples[ON_DEVICE_BUFFER_SIZE];
int onDeviceSampleCount = 0;
float lastEvaluatedTimeSec = -1.0f;
float lastPunchTimeSec = -10.0f;
static uint8_t readBmiChipId(uint8_t addr) {
return M5.Ex_I2C.readRegister8(addr, BMI270_CHIP_ID_REG, 100000);
}
static void softResetBmi(uint8_t addr) {
bool resetWritten = M5.Ex_I2C.writeRegister8(addr, BMI270_CMD_REG, BMI270_SOFT_RESET_CMD, 100000);
Serial.printf("# BMI270 soft reset addr=0x%02X written=%d\n", addr, resetWritten);
delay(250);
}
static bool configureBmi100Hz(uint8_t addr) {
uint8_t accConf = M5.Ex_I2C.readRegister8(addr, BMI270_ACC_CONF_REG, 100000);
uint8_t gyrConf = M5.Ex_I2C.readRegister8(addr, BMI270_GYR_CONF_REG, 100000);
uint8_t accConf100Hz = (accConf & ~BMI270_ODR_MASK) | BMI270_ODR_100HZ;
uint8_t gyrConf100Hz = (gyrConf & ~BMI270_ODR_MASK) | BMI270_ODR_100HZ;
bool accWritten = M5.Ex_I2C.writeRegister8(addr, BMI270_ACC_CONF_REG, accConf100Hz, 100000);
bool gyrWritten = M5.Ex_I2C.writeRegister8(addr, BMI270_GYR_CONF_REG, gyrConf100Hz, 100000);
delay(20);
uint8_t accVerify = M5.Ex_I2C.readRegister8(addr, BMI270_ACC_CONF_REG, 100000);
uint8_t gyrVerify = M5.Ex_I2C.readRegister8(addr, BMI270_GYR_CONF_REG, 100000);
bool verified =
(accVerify & BMI270_ODR_MASK) == BMI270_ODR_100HZ &&
(gyrVerify & BMI270_ODR_MASK) == BMI270_ODR_100HZ;
Serial.printf(
"# BMI270 ODR 100Hz acc=0x%02X gyro=0x%02X write_acc=%d write_gyro=%d verified=%d\n",
accVerify,
gyrVerify,
accWritten,
gyrWritten,
verified);
return accWritten && gyrWritten && verified;
}
static void drawStatus(const char *message) {
M5.Display.fillScreen(TFT_BLACK);
M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
M5.Display.setTextSize(2);
M5.Display.setCursor(8, 8);
M5.Display.println(PROJECT_LABEL);
M5.Display.setCursor(8, 48);
M5.Display.println(message);
M5.Display.setTextSize(1);
M5.Display.setCursor(8, 110);
M5.Display.printf("Firmware: %s\n", FIRMWARE_LABEL);
M5.Display.printf("Serial: %d bps\n", SERIAL_BAUD);
M5.Display.println("Standalone scoring enabled");
}
static uint16_t labelColor(char label) {
switch (label) {
case 'S':
return TFT_GOLD;
case 'A':
return TFT_GREEN;
case 'B':
return TFT_CYAN;
case 'C':
return TFT_ORANGE;
case 'D':
default:
return TFT_RED;
}
}
static float clampFloat(float value, float low, float high) {
return max(low, min(value, high));
}
static float normalizeFeature(float value, float low, float high) {
if (high <= low) {
return 0.5f;
}
return clampFloat((value - low) / (high - low), 0.0f, 1.0f);
}
static char scoreLabel(float score) {
if (score >= 85.0f) return 'S';
if (score >= 70.0f) return 'A';
if (score >= 50.0f) return 'B';
if (score >= 30.0f) return 'C';
return 'D';
}
static void drawSweatDrop(int x, int y, int size) {
uint16_t sweat = M5.Display.color565(70, 180, 255);
M5.Display.fillCircle(x, y + size / 3, size / 2, sweat);
M5.Display.fillTriangle(x, y - size, x - size / 2, y + size / 4, x + size / 2, y + size / 4, sweat);
M5.Display.drawLine(x - size / 3, y, x, y - size / 2, TFT_WHITE);
}
static void drawImpactLines(int centerX, int centerY, int radius, char label) {
if (label == 'C' || label == 'D') {
return;
}
uint16_t lineColor = (label == 'S') ? TFT_RED : TFT_ORANGE;
int outer = radius + 18;
int inner = radius + 6;
for (int i = 0; i < 12; i++) {
float angle = (float)i * 0.5235988f;
int x1 = centerX + (int)(cosf(angle) * inner);
int y1 = centerY + (int)(sinf(angle) * inner);
int x2 = centerX + (int)(cosf(angle) * outer);
int y2 = centerY + (int)(sinf(angle) * outer);
M5.Display.drawLine(x1, y1, x2, y2, lineColor);
}
}
static void drawSpiralEye(int x, int y, int size) {
M5.Display.drawCircle(x, y, size, TFT_BLACK);
M5.Display.drawArc(x, y, size, size - 2, 25, 300, TFT_BLACK);
M5.Display.drawArc(x, y, size - 5, size - 7, 45, 330, TFT_BLACK);
M5.Display.fillCircle(x + size / 5, y, 2, TFT_BLACK);
}
static const char *scoreComment(char label) {
switch (label) {
case 'S':
return "";
case 'A':
return "";
case 'B':
return "";
case 'C':
return "";
case 'D':
default:
return "";
}
}
static void drawFace(char label, int centerX, int centerY, int radius) {
uint16_t faceColor = labelColor(label);
uint16_t shadowColor = M5.Display.color565(35, 35, 35);
uint16_t cheekColor = M5.Display.color565(255, 150, 145);
drawImpactLines(centerX, centerY, radius, label);
M5.Display.fillCircle(centerX + 4, centerY + 6, radius, shadowColor);
M5.Display.fillCircle(centerX, centerY, radius, faceColor);
M5.Display.drawCircle(centerX, centerY, radius, TFT_WHITE);
M5.Display.drawCircle(centerX, centerY, radius - 1, M5.Display.color565(255, 255, 210));
M5.Display.fillCircle(centerX - radius / 3, centerY - radius / 3, radius / 5, M5.Display.color565(255, 255, 255));
int leftEyeX = centerX - radius / 3;
int rightEyeX = centerX + radius / 3;
int eyeY = centerY - radius / 5;
int mouthY = centerY + radius / 5;
if (label == 'S') {
M5.Display.fillCircle(leftEyeX - radius / 8, eyeY - radius / 10, radius / 5, TFT_WHITE);
M5.Display.fillCircle(rightEyeX + radius / 8, eyeY - radius / 10, radius / 5, TFT_WHITE);
M5.Display.drawCircle(leftEyeX - radius / 8, eyeY - radius / 10, radius / 5, TFT_BLACK);
M5.Display.drawCircle(rightEyeX + radius / 8, eyeY - radius / 10, radius / 5, TFT_BLACK);
M5.Display.fillCircle(leftEyeX - radius / 8, eyeY - radius / 10, radius / 12, TFT_BLACK);
M5.Display.fillCircle(rightEyeX + radius / 8, eyeY - radius / 10, radius / 12, TFT_BLACK);
M5.Display.drawLine(leftEyeX - radius / 3, eyeY - radius / 3, leftEyeX + radius / 8, eyeY - radius / 4, TFT_BLACK);
M5.Display.drawLine(rightEyeX + radius / 3, eyeY - radius / 3, rightEyeX - radius / 8, eyeY - radius / 4, TFT_BLACK);
M5.Display.fillCircle(centerX - radius / 2, centerY + radius / 8, radius / 9, cheekColor);
M5.Display.fillCircle(centerX + radius / 2, centerY + radius / 8, radius / 9, cheekColor);
M5.Display.fillCircle(centerX, mouthY + radius / 6, radius / 5, TFT_BLACK);
M5.Display.fillCircle(centerX, mouthY + radius / 5, radius / 10, M5.Display.color565(150, 20, 40));
drawSweatDrop(centerX + radius / 2, centerY - radius / 10, radius / 5);
} else if (label == 'A') {
M5.Display.fillCircle(leftEyeX - radius / 12, eyeY, radius / 7, TFT_WHITE);
M5.Display.fillCircle(rightEyeX + radius / 12, eyeY, radius / 7, TFT_WHITE);
M5.Display.drawCircle(leftEyeX - radius / 12, eyeY, radius / 7, TFT_BLACK);
M5.Display.drawCircle(rightEyeX + radius / 12, eyeY, radius / 7, TFT_BLACK);
M5.Display.fillCircle(leftEyeX - radius / 12, eyeY, radius / 14, TFT_BLACK);
M5.Display.fillCircle(rightEyeX + radius / 12, eyeY, radius / 14, TFT_BLACK);
M5.Display.fillRoundRect(centerX - radius / 5, mouthY + radius / 10, radius / 2, radius / 4, 6, TFT_BLACK);
drawSweatDrop(centerX + radius / 2, centerY, radius / 6);
} else if (label == 'B') {
M5.Display.fillCircle(leftEyeX, eyeY, radius / 10, TFT_BLACK);
M5.Display.fillCircle(rightEyeX, eyeY, radius / 10, TFT_BLACK);
M5.Display.drawLine(leftEyeX - radius / 5, eyeY - radius / 5, leftEyeX + radius / 5, eyeY - radius / 8, TFT_BLACK);
M5.Display.drawLine(rightEyeX + radius / 5, eyeY - radius / 5, rightEyeX - radius / 5, eyeY - radius / 8, TFT_BLACK);
M5.Display.drawLine(centerX - radius / 3, mouthY + radius / 8, centerX + radius / 3, mouthY + radius / 8, TFT_BLACK);
M5.Display.drawLine(centerX - radius / 3, mouthY + radius / 8 + 1, centerX + radius / 3, mouthY + radius / 8 + 1, TFT_BLACK);
} else if (label == 'C') {
M5.Display.fillCircle(leftEyeX, eyeY, radius / 9, TFT_BLACK);
M5.Display.fillCircle(rightEyeX, eyeY, radius / 9, TFT_BLACK);
M5.Display.fillCircle(leftEyeX + 2, eyeY - 2, radius / 24, TFT_WHITE);
M5.Display.fillCircle(rightEyeX + 2, eyeY - 2, radius / 24, TFT_WHITE);
M5.Display.drawArc(centerX, mouthY, radius / 2, radius / 2 - 3, 25, 155, TFT_BLACK);
M5.Display.drawArc(centerX, mouthY + 1, radius / 2, radius / 2 - 3, 25, 155, TFT_BLACK);
} else {
M5.Display.fillCircle(leftEyeX, eyeY, radius / 8, TFT_BLACK);
M5.Display.fillCircle(rightEyeX, eyeY, radius / 8, TFT_BLACK);
M5.Display.fillCircle(leftEyeX + 2, eyeY - 2, radius / 20, TFT_WHITE);
M5.Display.fillCircle(rightEyeX + 2, eyeY - 2, radius / 20, TFT_WHITE);
M5.Display.fillCircle(centerX - radius / 2, centerY + radius / 8, radius / 9, cheekColor);
M5.Display.fillCircle(centerX + radius / 2, centerY + radius / 8, radius / 9, cheekColor);
M5.Display.fillArc(centerX, mouthY, radius / 2, radius / 3, 15, 165, TFT_BLACK);
M5.Display.fillArc(centerX, mouthY - 4, radius / 2, radius / 3, 15, 165, faceColor);
M5.Display.drawArc(centerX, mouthY, radius / 2, radius / 2 - 4, 20, 160, TFT_WHITE);
}
}
static const char *faceImagePath(char label) {
switch (label) {
case 'S':
return "/S.jpg";
case 'A':
return "/A.jpg";
case 'B':
return "/B.jpg";
case 'C':
return "/C.jpg";
case 'D':
default:
return "/D.jpg";
}
}
static bool drawFaceImage(char label, int x, int y, int maxWidth, int maxHeight) {
if (!filesystemReady) {
return false;
}
const char *path = faceImagePath(label);
if (!SPIFFS.exists(path)) {
Serial.printf("# face image not found: %s\n", path);
return false;
}
bool drawn = M5.Display.drawJpgFile(SPIFFS, path, x, y, maxWidth, maxHeight, 0, 0, 1.0f);
if (!drawn) {
Serial.printf("# face image draw failed: %s\n", path);
}
return drawn;
}
static void drawScore(float score, char label, float accelMs2) {
lastDisplayedScore = score;
lastDisplayedLabel = label;
lastDisplayedAccelMs2 = accelMs2;
scoreDisplayReady = true;
M5.Display.fillScreen(TFT_BLACK);
uint16_t color = labelColor(label);
M5.Display.fillRect(0, 0, 320, 34, color);
M5.Display.fillRect(0, 206, 320, 34, M5.Display.color565(20, 20, 20));
M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
M5.Display.setCursor(8, 8);
M5.Display.setTextSize(2);
M5.Display.println("Punch Score");
M5.Display.setTextSize(5);
M5.Display.setCursor(12, 52);
M5.Display.printf("%.1f", score);
M5.Display.drawRoundRect(8, 42, 160, 74, 8, color);
M5.Display.drawRoundRect(9, 43, 158, 72, 8, TFT_WHITE);
M5.Display.setTextColor(color, TFT_BLACK);
M5.Display.setTextSize(4);
M5.Display.setCursor(190, 58);
M5.Display.printf("%c", label);
if (!drawFaceImage(label, 196, 92, 108, 108)) {
drawFace(label, 250, 148, 54);
}
int barWidth = (int)max(0.0f, min(100.0f, score)) * 3;
M5.Display.drawRect(8, 190, 304, 10, TFT_DARKGREY);
M5.Display.fillRect(10, 192, barWidth, 6, color);
if (accelMs2 >= 0.0f) {
M5.Display.setFont(&fonts::Font0);
M5.Display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
M5.Display.setTextSize(2);
M5.Display.setCursor(8, 140);
M5.Display.printf("%.1f m/s^2", accelMs2);
}
M5.Display.setFont(&fonts::efontJA_16_b);
M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
M5.Display.setTextSize(1);
M5.Display.setCursor(8, 166);
M5.Display.println(scoreComment(label));
M5.Display.setFont(&fonts::Font0);
M5.Display.setTextColor(TFT_LIGHTGREY, M5.Display.color565(20, 20, 20));
M5.Display.setTextSize(1);
M5.Display.setCursor(8, 218);
M5.Display.println("Realtime subjective score");
}
static void drawArrivalOverlay() {
M5.Display.fillRoundRect(214, 162, 96, 22, 5, TFT_BLUE);
M5.Display.drawRoundRect(214, 162, 96, 22, 5, TFT_WHITE);
M5.Display.setFont(&fonts::Font0);
M5.Display.setTextColor(TFT_WHITE, TFT_BLUE);
M5.Display.setTextSize(1);
M5.Display.setCursor(226, 169);
M5.Display.println("ARRIVAL");
}
static void showArrivalOverlay() {
if (!scoreDisplayReady) {
drawScore(0.0f, 'D', -1.0f);
}
drawArrivalOverlay();
arrivalShownMs = millis();
}
static void drawArrivalOnlyMeasuring() {
M5.Display.fillScreen(TFT_BLACK);
M5.Display.fillRect(0, 0, 320, 38, TFT_DARKGREY);
M5.Display.setTextColor(TFT_WHITE, TFT_DARKGREY);
M5.Display.setTextSize(2);
M5.Display.setCursor(8, 9);
M5.Display.println("Arrival Check");
M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
M5.Display.setTextSize(4);
M5.Display.setCursor(28, 76);
M5.Display.println("MEASURING");
M5.Display.setFont(&fonts::efontJA_16_b);
M5.Display.setTextSize(2);
M5.Display.setCursor(42, 138);
M5.Display.println("...");
M5.Display.setFont(&fonts::Font0);
M5.Display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
M5.Display.setTextSize(1);
M5.Display.setCursor(20, 214);
M5.Display.println("dynamic peak -> steep drop only");
}
static void drawArrivalOnly(float arrivalTimeSec, float peakToArrivalMs, float displayDelayMs) {
M5.Display.fillScreen(TFT_BLACK);
M5.Display.fillRect(0, 0, 320, 38, TFT_BLUE);
M5.Display.setTextColor(TFT_WHITE, TFT_BLUE);
M5.Display.setTextSize(2);
M5.Display.setCursor(8, 9);
M5.Display.println("Arrival Check");
M5.Display.setTextColor(TFT_WHITE, TFT_BLACK);
M5.Display.setTextSize(5);
M5.Display.setCursor(20, 58);
M5.Display.println("ARRIVAL");
M5.Display.setTextSize(2);
M5.Display.setCursor(22, 132);
M5.Display.printf("t = %.3f s", arrivalTimeSec);
M5.Display.setCursor(22, 162);
M5.Display.printf("Peak + %.0f ms", peakToArrivalMs);
M5.Display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
M5.Display.setCursor(22, 198);
M5.Display.printf("display lag %.0f ms", displayDelayMs);
arrivalShownMs = millis();
}
static void handleCommand(const String &command) {
if (!command.startsWith("DISPLAY_SCORE,")) {
return;
}
int firstComma = command.indexOf(',');
int secondComma = command.indexOf(',', firstComma + 1);
if (firstComma < 0 || secondComma < 0) {
return;
}
float score = command.substring(firstComma + 1, secondComma).toFloat();
char label = command.charAt(secondComma + 1);
int thirdComma = command.indexOf(',', secondComma + 1);
float accelMs2 = -1.0f;
if (thirdComma >= 0) {
accelMs2 = command.substring(thirdComma + 1).toFloat();
}
drawScore(score, label, accelMs2);
if (!ARRIVAL_ONLY_MODE && arrivalShownMs > 0) {
drawArrivalOverlay();
}
}
static void readSerialCommands() {
while (Serial.available() > 0) {
char ch = (char)Serial.read();
if (ch == '\n' || ch == '\r') {
if (serialCommand.length() > 0) {
handleCommand(serialCommand);
serialCommand = "";
}
} else if (serialCommand.length() < 80) {
serialCommand += ch;
} else {
serialCommand = "";
}
}
}
static void resetOnDeviceScoring() {
onDeviceSampleCount = 0;
lastEvaluatedTimeSec = -1.0f;
lastPunchTimeSec = -10.0f;
firstSampleMs = 0;
}
static void addOnDeviceSample(float timeSec, float dynamicG, float rotationDps) {
if (onDeviceSampleCount < ON_DEVICE_BUFFER_SIZE) {
onDeviceSamples[onDeviceSampleCount++] = {timeSec, dynamicG, rotationDps};
return;
}
for (int i = 1; i < ON_DEVICE_BUFFER_SIZE; i++) {
onDeviceSamples[i - 1] = onDeviceSamples[i];
}
onDeviceSamples[ON_DEVICE_BUFFER_SIZE - 1] = {timeSec, dynamicG, rotationDps};
}
static float learnedScoreOnDevice(
float dynamicPeakG,
float accelRiseG,
float rotationPeakDps,
float timingOffsetSec) {
float dynamicNorm = normalizeFeature(dynamicPeakG, 3.056457f, 5.447207f);
float rotationNorm = normalizeFeature(rotationPeakDps, 467.510712f, 2093.918701f);
float timingCloseness = clampFloat(1.0f - fabsf(timingOffsetSec) / 0.20f, 0.0f, 1.0f);
float rotationPerDynamic = rotationPeakDps / max(dynamicPeakG, 0.001f);
float rotationSpeedBalance = 1.0f - fabsf(rotationNorm - dynamicNorm);
float coordinatedRotation = sqrtf(dynamicNorm * rotationNorm) * timingCloseness;
float score = 0.0f;
score += 0.23676454f * normalizeFeature(dynamicPeakG, 3.056457f, 5.447207f);
score += 0.22083254f * normalizeFeature(accelRiseG, 3.044513f, 5.199883f);
score += 0.15590642f * normalizeFeature(rotationPeakDps, 467.510712f, 2093.918701f);
score += 0.02399183f * normalizeFeature(timingCloseness, 0.600000f, 1.0f);
score += 0.08833107f * normalizeFeature(rotationPerDynamic, 116.864239f, 493.294178f);
score += 0.12434466f * normalizeFeature(rotationSpeedBalance, 0.258137f, 0.998745f);
score += 0.14982893f * normalizeFeature(coordinatedRotation, 0.0f, 0.862647f);
return clampFloat(score * 100.0f, 0.0f, 100.0f);
}
static void evaluateOnDevice(float latestTimeSec) {
if (onDeviceSampleCount < 4) {
return;
}
float targetTimeSec = latestTimeSec - PEAK_SETTLE_SEC;
int peakIndex = -1;
float bestTimeDistance = 999.0f;
for (int i = 1; i < onDeviceSampleCount - 1; i++) {
float distance = fabsf(onDeviceSamples[i].timeSec - targetTimeSec);
if (distance < bestTimeDistance) {
bestTimeDistance = distance;
peakIndex = i;
}
}
if (peakIndex < 1 || peakIndex >= onDeviceSampleCount - 1) {
return;
}
OnDeviceSample peak = onDeviceSamples[peakIndex];
if (peak.timeSec <= lastEvaluatedTimeSec) {
return;
}
lastEvaluatedTimeSec = peak.timeSec;
if (peak.dynamicG < PEAK_THRESHOLD_G) {
return;
}
if (peak.dynamicG < onDeviceSamples[peakIndex - 1].dynamicG ||
peak.dynamicG < onDeviceSamples[peakIndex + 1].dynamicG) {
return;
}
if (peak.timeSec - lastPunchTimeSec < PEAK_MIN_INTERVAL_SEC) {
return;
}
float baselineDynamic = peak.dynamicG;
float rotationPeakDps = peak.rotationDps;
float rotationTimeSec = peak.timeSec;
float decelDynamicG = peak.dynamicG;
float decelTimeSec = peak.timeSec;
for (int i = 0; i < onDeviceSampleCount; i++) {
float dt = onDeviceSamples[i].timeSec - peak.timeSec;
if (dt >= -0.25f && dt <= 0.0f) {
baselineDynamic = min(baselineDynamic, onDeviceSamples[i].dynamicG);
}
if (dt >= -0.25f && dt <= 0.08f) {
float rotation = fabsf(onDeviceSamples[i].rotationDps);
if (rotation > rotationPeakDps) {
rotationPeakDps = rotation;
rotationTimeSec = onDeviceSamples[i].timeSec;
}
}
if (dt >= 0.04f && dt <= 0.20f) {
if (onDeviceSamples[i].dynamicG < decelDynamicG) {
decelDynamicG = onDeviceSamples[i].dynamicG;
decelTimeSec = onDeviceSamples[i].timeSec;
}
}
}
float arrivalTimeSec = decelTimeSec;
float arrivalDynamicG = decelDynamicG;
float steepestDrop = 0.0f;
for (int i = 1; i < onDeviceSampleCount; i++) {
float dtFromPeak = onDeviceSamples[i].timeSec - peak.timeSec;
if (dtFromPeak < 0.02f || dtFromPeak > 0.20f) {
continue;
}
float dt = onDeviceSamples[i].timeSec - onDeviceSamples[i - 1].timeSec;
if (dt <= 0.0f) {
continue;
}
float slope = (onDeviceSamples[i].dynamicG - onDeviceSamples[i - 1].dynamicG) / dt;
if (slope < steepestDrop) {
steepestDrop = slope;
arrivalTimeSec = onDeviceSamples[i].timeSec;
arrivalDynamicG = onDeviceSamples[i].dynamicG;
}
}
float accelRiseG = max(0.0f, peak.dynamicG - baselineDynamic);
float timingOffsetSec = rotationTimeSec - peak.timeSec;
float decelOffsetSec = rotationTimeSec - decelTimeSec;
float arrivalOffsetSec = rotationTimeSec - arrivalTimeSec;
if (ARRIVAL_ONLY_MODE) {
drawArrivalOnly(
arrivalTimeSec,
(arrivalTimeSec - peak.timeSec) * 1000.0f,
(latestTimeSec - arrivalTimeSec) * 1000.0f);
lastPunchTimeSec = peak.timeSec;
Serial.printf(
"# ARRIVAL_ONLY peak_time=%.3f arrival_time=%.3f peak_to_arrival_ms=%.0f dynamic=%.3f\n",
peak.timeSec,
arrivalTimeSec,
(arrivalTimeSec - peak.timeSec) * 1000.0f,
peak.dynamicG);
return;
}
float score = learnedScoreOnDevice(peak.dynamicG, accelRiseG, rotationPeakDps, arrivalOffsetSec);
char label = scoreLabel(score);
drawScore(score, label, peak.dynamicG * G_TO_MS2);
if (!ARRIVAL_ONLY_MODE) {
showArrivalOverlay();
}
lastPunchTimeSec = peak.timeSec;
Serial.printf(
"# ON_DEVICE_SCORE time=%.3f score=%.1f label=%c dynamic=%.3f rise=%.3f rotation=%.1f peak_offset=%.3f decel_offset=%.3f arrival_offset=%.3f arrival_time=%.3f\n",
peak.timeSec,
score,
label,
peak.dynamicG,
accelRiseG,
rotationPeakDps,
timingOffsetSec,
decelOffsetSec,
arrivalOffsetSec,
arrivalTimeSec);
}
static bool initImu() {
bool i2cStarted = M5.Ex_I2C.begin();
delay(I2C_SETTLE_MS);
bool bmiFound = M5.Ex_I2C.scanID(BMI270_SENSOR_ADDR);
bool bmiAltFound = M5.Ex_I2C.scanID(BMI270_SENSOR_ADDR_ALT);
Serial.print("# I2C found:");
bool anyFound = false;
for (uint8_t addr = 0x08; addr <= 0x77; addr++) {
if (M5.Ex_I2C.scanID(addr)) {
Serial.printf(" 0x%02X", addr);
anyFound = true;
}
}
if (!anyFound) {
Serial.print(" none");
}
Serial.println();
Serial.printf(
"# Ex_I2C begin=%d port=%d sda=%d scl=%d bmi270_addr_0x%02X=%d bmi270_addr_0x%02X=%d\n",
i2cStarted,
(int)M5.Ex_I2C.getPort(),
M5.Ex_I2C.getSDA(),
M5.Ex_I2C.getSCL(),
BMI270_SENSOR_ADDR,
bmiFound,
BMI270_SENSOR_ADDR_ALT,
bmiAltFound);
if (!bmiFound && !bmiAltFound) {
Serial.println("# BMI270 address not found yet; waiting for sensor power");
return false;
}
uint8_t bmiAddr = bmiFound ? BMI270_SENSOR_ADDR : BMI270_SENSOR_ADDR_ALT;
uint8_t chipIdBefore = readBmiChipId(bmiAddr);
Serial.printf("# BMI270 chip_id before reset addr=0x%02X value=0x%02X expected=0x%02X\n",
bmiAddr,
chipIdBefore,
BMI270_EXPECTED_CHIP_ID);
softResetBmi(bmiAddr);
delay(I2C_SETTLE_MS);
uint8_t chipIdAfter = readBmiChipId(bmiAddr);
Serial.printf("# BMI270 chip_id after reset addr=0x%02X value=0x%02X expected=0x%02X\n",
bmiAddr,
chipIdAfter,
BMI270_EXPECTED_CHIP_ID);
delay(I2C_SETTLE_MS);
int result = bmi270.init(I2C_NUM_0, bmiAddr);
Serial.printf("# bmi270.init result=%d addr=0x%02X\n", result, bmiAddr);
bool odrConfigured = configureBmi100Hz(bmiAddr);
if (!odrConfigured) {
Serial.println("# BMI270 100Hz configuration failed; retrying init");
return false;
}
if (result != 0) {
return true;
}
float testAx = 0;
float testAy = 0;
float testAz = 0;
delay(100);
bool accelReadable = bmi270.readAcceleration(testAx, testAy, testAz);
Serial.printf(
"# bmi270.init fallback accel_readable=%d ax=%.3f ay=%.3f az=%.3f\n",
accelReadable,
testAx,
testAy,
testAz);
return bmiFound && accelReadable;
}
void setup() {
auto cfg = M5.config();
cfg.output_power = true;
cfg.internal_imu = false;
M5.begin(cfg);
M5.Power.setExtOutput(false);
delay(250);
M5.Power.setExtOutput(true);
Serial.begin(SERIAL_BAUD);
delay(IMU_POWER_SETTLE_MS);
M5.Display.setRotation(1);
filesystemReady = SPIFFS.begin(true);
Serial.printf("# %s sample_hz=%d\n", FIRMWARE_LABEL, SAMPLE_HZ);
Serial.printf("# SPIFFS begin=%d\n", filesystemReady);
drawStatus("Initializing...");
resetOnDeviceScoring();
imuReady = initImu();
if (!imuReady) {
drawStatus("IMU not found");
Serial.println("# IMU init failed; reconnect sensor and reset or wait for retry");
lastRetryMs = millis();
return;
}
Serial.println("CSV_HEADER,sample_index,device_ms,ax_g,ay_g,az_g,mag_g,dynamic_g,gx_dps,gy_dps,gz_dps,rotation_dps");
Serial.printf("# %s sample_hz=%d streaming=1\n", FIRMWARE_LABEL, SAMPLE_HZ);
if (ARRIVAL_ONLY_MODE) {
drawArrivalOnlyMeasuring();
} else {
drawStatus("Streaming...");
}
}
void loop() {
M5.update();
readSerialCommands();
uint32_t now = millis();
if (ARRIVAL_ONLY_MODE && arrivalShownMs > 0 && now - arrivalShownMs >= 700) {
arrivalShownMs = 0;
drawArrivalOnlyMeasuring();
} else if (!ARRIVAL_ONLY_MODE && arrivalShownMs > 0 && now - arrivalShownMs >= 500) {
arrivalShownMs = 0;
if (scoreDisplayReady) {
drawScore(lastDisplayedScore, lastDisplayedLabel, lastDisplayedAccelMs2);
}
}
if (!imuReady) {
if (now - lastRetryMs >= IMU_RETRY_INTERVAL_MS) {
lastRetryMs = now;
Serial.println("# Retrying IMU init...");
imuReady = initImu();
if (imuReady) {
sampleIndex = 0;
resetOnDeviceScoring();
lastSampleUs = micros();
gyroReadWarned = false;
Serial.println("CSV_HEADER,sample_index,device_ms,ax_g,ay_g,az_g,mag_g,dynamic_g,gx_dps,gy_dps,gz_dps,rotation_dps");
if (ARRIVAL_ONLY_MODE) {
drawArrivalOnlyMeasuring();
} else {
drawStatus("Streaming...");
}
}
}
return;
}
uint32_t nowUs = micros();
uint32_t elapsedUs = nowUs - lastSampleUs;
if (elapsedUs < SAMPLE_INTERVAL_US) return;
lastSampleUs += SAMPLE_INTERVAL_US;
if (elapsedUs >= SAMPLE_INTERVAL_US * 2) {
lastSampleUs = nowUs;
}
float ax = 0;
float ay = 0;
float az = 0;
float gx = 0;
float gy = 0;
float gz = 0;
if (!bmi270.readAcceleration(ax, ay, az)) {
imuReady = false;
drawStatus("IMU read failed");
Serial.println("# IMU read failed; retrying init");
lastRetryMs = now;
return;
}
if (!bmi270.readGyroscope(gx, gy, gz)) {
if (!gyroReadWarned) {
Serial.println("# IMU gyro read failed; continuing with gyro=0");
gyroReadWarned = true;
}
gx = 0;
gy = 0;
gz = 0;
}
float mag = sqrtf(ax * ax + ay * ay + az * az);
float dynamicG = fabsf(mag - 1.0f);
float rotationDps = sqrtf(gx * gx + gy * gy + gz * gz);
if (firstSampleMs == 0) {
firstSampleMs = now;
}
float timeSec = (float)(now - firstSampleMs) / 1000.0f;
Serial.printf(
"%lu,%lu,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f\n",
(unsigned long)sampleIndex,
(unsigned long)now,
ax,
ay,
az,
mag,
dynamicG,
gx,
gy,
gz,
rotationDps);
addOnDeviceSample(timeSec, dynamicG, rotationDps);
evaluateOnDevice(timeSec);
sampleIndex++;
}
#!/usr/bin/env python3
"""Receive IMU CSV data over serial, then save CSV and waveform on Ctrl+C."""
from __future__ import annotations
import argparse
import csv
import json
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
try:
import serial
from serial.tools import list_ports
except ImportError as exc:
raise SystemExit(
"pyserial is required. Install it with: python -m pip install pyserial"
) from exc
try:
import matplotlib.pyplot as plt
except ImportError as exc:
raise SystemExit(
"matplotlib is required. Install it with: python -m pip install matplotlib"
) from exc
from calibrate_subjective_score import predict
plt.rcParams["font.family"] = [
"Yu Gothic",
"Meiryo",
"MS Gothic",
"Noto Sans CJK JP",
"sans-serif",
]
plt.rcParams["axes.unicode_minus"] = False
FIELDS = [
"sample_index",
"device_ms",
"time_sec",
"ax_g",
"ay_g",
"az_g",
"mag_g",
"dynamic_g",
"gx_dps",
"gy_dps",
"gz_dps",
"rotation_dps",
]
@dataclass
class Sample:
sample_index: int
device_ms: int
time_sec: float
ax_g: float
ay_g: float
az_g: float
mag_g: float
dynamic_g: float
gx_dps: float = 0.0
gy_dps: float = 0.0
gz_dps: float = 0.0
rotation_dps: float = 0.0
def as_row(self) -> dict[str, int | float]:
return {
"sample_index": self.sample_index,
"device_ms": self.device_ms,
"time_sec": self.time_sec,
"ax_g": self.ax_g,
"ay_g": self.ay_g,
"az_g": self.az_g,
"mag_g": self.mag_g,
"dynamic_g": self.dynamic_g,
"gx_dps": self.gx_dps,
"gy_dps": self.gy_dps,
"gz_dps": self.gz_dps,
"rotation_dps": self.rotation_dps,
}
@dataclass
class Peak:
sample: Sample
value: float
@dataclass
class PunchAnalysis:
number: int
peak: Peak
accel_rise_g: float
rotation_peak_dps: float
rotation_time_sec: float
timing_offset_sec: float
decel_time_sec: float
decel_dynamic_g: float
decel_offset_sec: float
arrival_time_sec: float
arrival_dynamic_g: float
arrival_offset_sec: float
score: float
def auto_detect_port() -> str | None:
ports = list(list_ports.comports())
if not ports:
return None
usb_ports = [
port
for port in ports
if "USB" in (port.description or "").upper()
or "UART" in (port.description or "").upper()
or "SERIAL" in (port.description or "").upper()
]
return (usb_ports[0] if usb_ports else ports[0]).device
def parse_sample(line: str, first_device_ms: int | None) -> tuple[Sample | None, int | None]:
text = line.strip()
if not text or text.startswith("#") or text.startswith("CSV_HEADER"):
return None, first_device_ms
parts = text.split(",")
if len(parts) not in (7, 11):
return None, first_device_ms
try:
sample_index = int(parts[0])
device_ms = int(parts[1])
ax_g = float(parts[2])
ay_g = float(parts[3])
az_g = float(parts[4])
mag_g = float(parts[5])
dynamic_g = float(parts[6])
gx_dps = float(parts[7]) if len(parts) == 11 else 0.0
gy_dps = float(parts[8]) if len(parts) == 11 else 0.0
gz_dps = float(parts[9]) if len(parts) == 11 else 0.0
rotation_dps = float(parts[10]) if len(parts) == 11 else 0.0
except ValueError:
return None, first_device_ms
if first_device_ms is None:
first_device_ms = device_ms
sample = Sample(
sample_index=sample_index,
device_ms=device_ms,
time_sec=(device_ms - first_device_ms) / 1000.0,
ax_g=ax_g,
ay_g=ay_g,
az_g=az_g,
mag_g=mag_g,
dynamic_g=dynamic_g,
gx_dps=gx_dps,
gy_dps=gy_dps,
gz_dps=gz_dps,
rotation_dps=rotation_dps,
)
return sample, first_device_ms
def sample_value(sample: Sample, field: str) -> float:
return float(getattr(sample, field))
def detect_peaks(
samples: list[Sample],
field: str,
threshold: float,
min_interval_sec: float,
max_peaks: int,
max_neighbor_gap_sec: float,
) -> list[Peak]:
if len(samples) < 3:
return []
candidates: list[Peak] = []
for index in range(1, len(samples) - 1):
prev_gap = samples[index].time_sec - samples[index - 1].time_sec
next_gap = samples[index + 1].time_sec - samples[index].time_sec
prev_value = (
sample_value(samples[index - 1], field)
if prev_gap <= max_neighbor_gap_sec
else float("-inf")
)
value = sample_value(samples[index], field)
next_value = (
sample_value(samples[index + 1], field)
if next_gap <= max_neighbor_gap_sec
else float("-inf")
)
if value >= threshold and value >= prev_value and value >= next_value:
candidates.append(Peak(samples[index], value))
selected: list[Peak] = []
for peak in sorted(candidates, key=lambda item: item.value, reverse=True):
if all(
abs(peak.sample.time_sec - chosen.sample.time_sec) >= min_interval_sec
for chosen in selected
):
selected.append(peak)
if len(selected) >= max_peaks:
break
return sorted(selected, key=lambda item: item.sample.time_sec)
def window_samples(
samples: list[Sample],
center_sec: float,
before_sec: float,
after_sec: float,
) -> list[Sample]:
start = center_sec - before_sec
end = center_sec + after_sec
return [sample for sample in samples if start <= sample.time_sec <= end]
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(value, high))
def load_subjective_model(model_path: Path) -> dict | None:
if not model_path.exists():
return None
return json.loads(model_path.read_text(encoding="utf-8-sig"))
def analysis_row(analysis: PunchAnalysis) -> dict[str, str]:
return {
"score": f"{analysis.score}",
"dynamic_peak_g": f"{analysis.peak.value}",
"accel_rise_g": f"{analysis.accel_rise_g}",
"rotation_peak_dps": f"{analysis.rotation_peak_dps}",
"timing_offset_sec": f"{analysis.timing_offset_sec}",
"decel_offset_sec": f"{analysis.decel_offset_sec}",
"arrival_offset_sec": f"{analysis.arrival_offset_sec}",
}
def display_score(analysis: PunchAnalysis, model: dict | None) -> float:
if model is None:
return analysis.score
return predict(analysis_row(analysis), model)
def analyze_punches(
samples: list[Sample],
peaks: list[Peak],
rotation_field: str,
) -> list[PunchAnalysis]:
analyses: list[PunchAnalysis] = []
for number, peak in enumerate(peaks, start=1):
pre_samples = window_samples(samples, peak.sample.time_sec, 0.25, 0.0)
impact_samples = window_samples(samples, peak.sample.time_sec, 0.25, 0.08)
baseline_dynamic = min(
(sample.dynamic_g for sample in pre_samples),
default=peak.value,
)
accel_rise_g = max(0.0, peak.value - baseline_dynamic)
rotation_sample = max(
impact_samples,
key=lambda sample: abs(sample_value(sample, rotation_field)),
default=peak.sample,
)
rotation_peak_dps = abs(sample_value(rotation_sample, rotation_field))
timing_offset_sec = rotation_sample.time_sec - peak.sample.time_sec
decel_start_sec = peak.sample.time_sec + 0.04
decel_end_sec = peak.sample.time_sec + 0.20
decel_samples = [
sample
for sample in samples
if decel_start_sec <= sample.time_sec <= decel_end_sec
]
decel_sample = min(
decel_samples,
key=lambda sample: sample.dynamic_g,
default=peak.sample,
)
decel_offset_sec = rotation_sample.time_sec - decel_sample.time_sec
arrival_sample = decel_sample
steepest_drop = 0.0
for prev_sample, current_sample in zip(samples, samples[1:]):
if not (peak.sample.time_sec + 0.02 <= current_sample.time_sec <= peak.sample.time_sec + 0.20):
continue
dt = current_sample.time_sec - prev_sample.time_sec
if dt <= 0:
continue
slope = (current_sample.dynamic_g - prev_sample.dynamic_g) / dt
if slope < steepest_drop:
steepest_drop = slope
arrival_sample = current_sample
arrival_offset_sec = rotation_sample.time_sec - arrival_sample.time_sec
accel_score = clamp(peak.value / 2.0, 0.0, 1.0) * 40.0
rise_score = clamp(accel_rise_g / 1.0, 0.0, 1.0) * 25.0
rotation_score = clamp(rotation_peak_dps / 800.0, 0.0, 1.0) * 25.0
timing_score = clamp(1.0 - abs(arrival_offset_sec) / 0.20, 0.0, 1.0) * 10.0
score = accel_score + rise_score + rotation_score + timing_score
analyses.append(
PunchAnalysis(
number=number,
peak=peak,
accel_rise_g=accel_rise_g,
rotation_peak_dps=rotation_peak_dps,
rotation_time_sec=rotation_sample.time_sec,
timing_offset_sec=timing_offset_sec,
decel_time_sec=decel_sample.time_sec,
decel_dynamic_g=decel_sample.dynamic_g,
decel_offset_sec=decel_offset_sec,
arrival_time_sec=arrival_sample.time_sec,
arrival_dynamic_g=arrival_sample.dynamic_g,
arrival_offset_sec=arrival_offset_sec,
score=score,
)
)
return analyses
def load_csv(csv_path: Path) -> list[Sample]:
samples: list[Sample] = []
with csv_path.open(newline="", encoding="utf-8") as fp:
reader = csv.DictReader(fp)
for row in reader:
sample = Sample(
sample_index=int(row["sample_index"]),
device_ms=int(row["device_ms"]),
time_sec=float(row["time_sec"]),
ax_g=float(row["ax_g"]),
ay_g=float(row["ay_g"]),
az_g=float(row["az_g"]),
mag_g=float(row["mag_g"]),
dynamic_g=float(row["dynamic_g"]),
gx_dps=float(row.get("gx_dps") or 0.0),
gy_dps=float(row.get("gy_dps") or 0.0),
gz_dps=float(row.get("gz_dps") or 0.0),
rotation_dps=float(row.get("rotation_dps") or 0.0),
)
samples.append(sample)
return samples
def prepare_live_plot() -> tuple[plt.Figure, plt.Axes]:
plt.ion()
fig, ax = plt.subplots(figsize=(10, 5))
fig.canvas.manager.set_window_title("IMU")
ax.set_title("IMU", fontsize=16)
ax.set_xlabel(" [s]", fontsize=13)
ax.set_ylabel(" [g]", fontsize=13)
ax.grid(True, alpha=0.3)
return fig, ax
def update_plot(ax: plt.Axes, samples: list[Sample], window_sec: float) -> None:
if not samples:
return
latest_time = samples[-1].time_sec
start_time = max(0.0, latest_time - window_sec)
visible = [sample for sample in samples if sample.time_sec >= start_time]
t = [sample.time_sec for sample in visible]
ax.clear()
ax.plot(t, [sample.dynamic_g for sample in visible], label="dynamic", linewidth=2.5)
ax.plot(t, [sample.rotation_dps / 1000.0 for sample in visible], label="rotation / 1000", linewidth=2)
ax.plot(t, [sample.ax_g for sample in visible], label="ax", alpha=0.25)
ax.plot(t, [sample.ay_g for sample in visible], label="ay", alpha=0.25)
ax.plot(t, [sample.az_g for sample in visible], label="az", alpha=0.25)
ax.set_title("IMU", fontsize=16)
ax.set_xlabel(" [s]", fontsize=13)
ax.set_ylabel(" [g]", fontsize=13)
ax.tick_params(axis="both", labelsize=11)
ax.grid(True, alpha=0.3)
ax.legend(loc="upper right")
ax.figure.tight_layout()
ax.figure.canvas.draw_idle()
plt.pause(0.001)
def save_csv(samples: list[Sample], csv_path: Path) -> None:
csv_path.parent.mkdir(parents=True, exist_ok=True)
with csv_path.open("w", newline="", encoding="utf-8") as fp:
writer = csv.DictWriter(fp, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(sample.as_row() for sample in samples)
def save_plot(
samples: list[Sample],
image_path: Path,
peak_field: str,
peak_threshold: float,
peak_min_interval_sec: float,
max_peaks: int,
rotation_field: str,
peak_neighbor_gap_sec: float,
) -> list[PunchAnalysis]:
image_path.parent.mkdir(parents=True, exist_ok=True)
fig, (ax_accel, ax_rotation) = plt.subplots(
2,
1,
figsize=(12, 8),
sharex=True,
gridspec_kw={"height_ratios": [2, 1]},
)
t = [sample.time_sec for sample in samples]
peaks = detect_peaks(
samples,
peak_field,
peak_threshold,
peak_min_interval_sec,
max_peaks,
peak_neighbor_gap_sec,
)
analyses = analyze_punches(samples, peaks, rotation_field)
ax_accel.plot(t, [sample.ax_g for sample in samples], label="ax", alpha=0.25)
ax_accel.plot(t, [sample.ay_g for sample in samples], label="ay", alpha=0.25)
ax_accel.plot(t, [sample.az_g for sample in samples], label="az", alpha=0.25)
ax_accel.plot(t, [sample.mag_g for sample in samples], label="|a|", color="0.35", alpha=0.55)
ax_accel.plot(
t,
[sample.dynamic_g for sample in samples],
label="dynamic",
color="tab:purple",
linewidth=2.5,
)
ax_accel.axhline(
peak_threshold,
color="tab:purple",
linestyle=":",
linewidth=1,
alpha=0.8,
label=f"peak threshold ({peak_threshold:g}g)",
)
if peaks:
peak_t = [peak.sample.time_sec for peak in peaks]
peak_y = [peak.value for peak in peaks]
ax_accel.scatter(
peak_t,
peak_y,
s=80,
color="tab:red",
edgecolors="white",
linewidths=1.5,
zorder=5,
label="punch peak",
)
ax_accel.scatter(
[analysis.decel_time_sec for analysis in analyses],
[analysis.decel_dynamic_g for analysis in analyses],
s=55,
color="black",
marker="v",
edgecolors="white",
linewidths=1.0,
zorder=5,
label="post-peak dynamic min",
)
ax_accel.scatter(
[analysis.arrival_time_sec for analysis in analyses],
[analysis.arrival_dynamic_g for analysis in analyses],
s=60,
color="tab:blue",
marker="D",
edgecolors="white",
linewidths=1.0,
zorder=6,
label="arrival proxy",
)
for analysis in analyses:
ax_accel.annotate(
f"{analysis.number}",
xy=(analysis.peak.sample.time_sec, analysis.peak.value),
xytext=(0, 12),
textcoords="offset points",
ha="center",
va="bottom",
fontsize=9,
color="tab:red",
)
rotation_values = [abs(sample_value(sample, rotation_field)) for sample in samples]
ax_rotation.plot(
t,
rotation_values,
label=rotation_field,
color="tab:orange",
linewidth=2,
)
if analyses:
ax_rotation.scatter(
[analysis.rotation_time_sec for analysis in analyses],
[analysis.rotation_peak_dps for analysis in analyses],
s=60,
color="tab:red",
edgecolors="white",
linewidths=1.2,
zorder=5,
label="rotation peak",
)
ax_accel.set_title("IMU", fontsize=18)
ax_accel.set_ylabel(" [g]", fontsize=14)
ax_accel.grid(True, alpha=0.3)
ax_accel.legend(loc="upper right")
ax_rotation.set_xlabel(" [s]", fontsize=14)
ax_rotation.set_ylabel(" [deg/s]", fontsize=14)
ax_accel.tick_params(axis="both", labelsize=12)
ax_rotation.tick_params(axis="both", labelsize=12)
ax_rotation.grid(True, alpha=0.3)
ax_rotation.legend(loc="upper right")
fig.tight_layout()
fig.savefig(image_path, dpi=150)
plt.close(fig)
return analyses
def build_paths(output_dir: Path) -> tuple[Path, Path]:
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return output_dir / f"imu_{stamp}.csv", output_dir / f"imu_{stamp}.png"
def print_analysis(
analyses: list[PunchAnalysis], rotation_field: str, model: dict | None
) -> None:
if not analyses:
print("Marked 0 punch peaks")
return
print(f"Marked {len(analyses)} punch peaks")
print(
"No. time[s] score old dynamic[g] rise[g] rotation[dps] "
"peak_offset[s] decel_offset[s] arrival_offset[s]"
)
for analysis in analyses:
score = display_score(analysis, model)
print(
f"{analysis.number:>2} "
f"{analysis.peak.sample.time_sec:>7.3f} "
f"{score:>5.1f} "
f"{analysis.score:>5.1f} "
f"{analysis.peak.value:>10.3f} "
f"{analysis.accel_rise_g:>7.3f} "
f"{analysis.rotation_peak_dps:>13.1f} "
f"{analysis.timing_offset_sec:>14.3f} "
f"{analysis.decel_offset_sec:>15.3f} "
f"{analysis.arrival_offset_sec:>17.3f}"
)
print(f"Rotation field: {rotation_field}")
def save_analysis_csv(
analyses: list[PunchAnalysis], csv_path: Path, model: dict | None
) -> None:
csv_path.parent.mkdir(parents=True, exist_ok=True)
with csv_path.open("w", newline="", encoding="utf-8") as fp:
fieldnames = [
"punch_number",
"time_sec",
"score",
"old_score",
"dynamic_peak_g",
"accel_rise_g",
"rotation_peak_dps",
"rotation_time_sec",
"timing_offset_sec",
"decel_time_sec",
"decel_dynamic_g",
"decel_offset_sec",
"arrival_time_sec",
"arrival_dynamic_g",
"arrival_offset_sec",
]
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for analysis in analyses:
score = display_score(analysis, model)
writer.writerow(
{
"punch_number": analysis.number,
"time_sec": analysis.peak.sample.time_sec,
"score": score,
"old_score": analysis.score,
"dynamic_peak_g": analysis.peak.value,
"accel_rise_g": analysis.accel_rise_g,
"rotation_peak_dps": analysis.rotation_peak_dps,
"rotation_time_sec": analysis.rotation_time_sec,
"timing_offset_sec": analysis.timing_offset_sec,
"decel_time_sec": analysis.decel_time_sec,
"decel_dynamic_g": analysis.decel_dynamic_g,
"decel_offset_sec": analysis.decel_offset_sec,
"arrival_time_sec": analysis.arrival_time_sec,
"arrival_dynamic_g": analysis.arrival_dynamic_g,
"arrival_offset_sec": analysis.arrival_offset_sec,
}
)
def print_sample_rate_warning(samples: list[Sample], expected_hz: float) -> None:
if len(samples) < 2:
return
duration_sec = samples[-1].time_sec - samples[0].time_sec
if duration_sec <= 0:
return
actual_hz = (len(samples) - 1) / duration_sec
print(f"Recorded sample rate: {actual_hz:.1f} Hz ({len(samples)} samples)")
if actual_hz < expected_hz * 0.5:
print(
"Warning: sample rate is very low. Punch peaks may be missed. "
"Close other serial monitors and upload the latest firmware."
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Read IMU serial CSV from M5Stack and save CSV/plot on Ctrl+C."
)
parser.add_argument("-p", "--port", help="Serial port, for example COM3")
parser.add_argument("-b", "--baud", type=int, default=921600, help="Serial baud rate")
parser.add_argument("-o", "--output-dir", default="data", help="Output directory")
parser.add_argument(
"--from-csv",
type=Path,
help="Create a peak-marked PNG from an existing CSV instead of recording serial data",
)
parser.add_argument(
"--image-path",
type=Path,
help="Output PNG path when using --from-csv",
)
parser.add_argument(
"--model",
type=Path,
default=Path("data/subjective_score_config.json"),
help="Subjective score model used for the summary score column",
)
parser.add_argument(
"--peak-field",
choices=["dynamic_g", "mag_g"],
default="dynamic_g",
help="Signal used for punch peak detection",
)
parser.add_argument(
"--rotation-field",
choices=["gx_dps", "gy_dps", "gz_dps", "rotation_dps"],
default="rotation_dps",
help="Gyro signal used for fist rotation evaluation",
)
parser.add_argument(
"--peak-threshold",
type=float,
default=1.5,
help="Minimum peak value in g",
)
parser.add_argument(
"--peak-min-interval-sec",
type=float,
default=0.6,
help="Minimum time between separate punch peaks",
)
parser.add_argument(
"--max-peaks",
type=int,
default=30,
help="Maximum number of peaks to mark",
)
parser.add_argument(
"--peak-neighbor-gap-sec",
type=float,
default=0.25,
help="Do not compare peak neighbors across larger time gaps",
)
parser.add_argument(
"--expected-hz",
type=float,
default=100.0,
help="Expected sensor sample rate for warning messages",
)
parser.add_argument(
"--window-sec",
type=float,
default=10.0,
help="Live plot time window in seconds",
)
parser.add_argument(
"--plot-interval",
type=float,
default=0.2,
help="Live plot refresh interval in seconds",
)
parser.add_argument(
"--live-plot",
action="store_true",
help="Show a live plot while recording. This can reduce the serial sample rate.",
)
parser.add_argument(
"--no-live-plot",
action="store_true",
help="Deprecated; live plot is disabled by default",
)
parser.add_argument(
"--no-data-warning-sec",
type=float,
default=5.0,
help="Seconds before warning that no valid sample has arrived",
)
parser.add_argument(
"--show-raw",
action="store_true",
help="Print every non-sample serial line for troubleshooting",
)
parser.add_argument(
"--show-invalid",
action="store_true",
help="Print serial lines that are not valid CSV samples",
)
args = parser.parse_args()
model = load_subjective_model(args.model)
if args.from_csv is not None:
samples = load_csv(args.from_csv)
if not samples:
print(f"No samples found in {args.from_csv}", file=sys.stderr)
return 1
print_sample_rate_warning(samples, args.expected_hz)
image_path = args.image_path or args.from_csv.with_name(
f"{args.from_csv.stem}_peaks.png"
)
analyses = save_plot(
samples,
image_path,
args.peak_field,
args.peak_threshold,
args.peak_min_interval_sec,
args.max_peaks,
args.rotation_field,
args.peak_neighbor_gap_sec,
)
print(f"Saved punch evaluation plot to {image_path}")
summary_path = image_path.with_name(f"{image_path.stem}_summary.csv")
save_analysis_csv(analyses, summary_path, model)
print(f"Saved punch summary to {summary_path}")
print_analysis(analyses, args.rotation_field, model)
return 0
port = args.port or auto_detect_port()
if not port:
print("No serial port found. Specify one with --port COMx.", file=sys.stderr)
return 2
output_dir = Path(args.output_dir)
csv_path, image_path = build_paths(output_dir)
samples: list[Sample] = []
first_device_ms: int | None = None
last_plot_time = 0.0
last_status_time = 0.0
last_warning_time = time.monotonic()
fig = None
ax = None
if args.live_plot and not args.no_live_plot:
fig, ax = prepare_live_plot()
print(f"Opening {port} at {args.baud} bps.")
print("Recording IMU data. Press Ctrl+C to save CSV and waveform PNG.")
try:
with serial.Serial(port, args.baud, timeout=1) as ser:
while True:
raw_line = ser.readline()
if not raw_line:
now = time.monotonic()
if (
not samples
and now - last_warning_time >= args.no_data_warning_sec
):
print(
"\nNo valid samples yet. Check that the sensor is connected, "
"the firmware is uploaded, and no other Serial Monitor is open."
)
last_warning_time = now
continue
line = raw_line.decode("utf-8", errors="replace")
sample, first_device_ms = parse_sample(line, first_device_ms)
if sample is None:
text = line.strip()
if text and (
args.show_raw
or args.show_invalid
or text.startswith("#")
or text.startswith("CSV_HEADER")
):
print(text)
continue
samples.append(sample)
last_warning_time = time.monotonic()
now = time.monotonic()
if now - last_status_time >= 0.5:
print(
f"\rSamples: {len(samples)} "
f"t={sample.time_sec:8.3f}s "
f"ax={sample.ax_g: .3f} ay={sample.ay_g: .3f} az={sample.az_g: .3f}",
end="",
flush=True,
)
last_status_time = now
if ax is not None and now - last_plot_time >= args.plot_interval:
update_plot(ax, samples, args.window_sec)
last_plot_time = now
except KeyboardInterrupt:
print()
finally:
if fig is not None:
plt.close(fig)
if not samples:
print("No samples were recorded; nothing was saved.")
print(
"Try: python scripts/read_imu.py --baud 115200 --show-raw\n"
"If that shows CSV data, upload the latest firmware or keep using 115200."
)
return 1
print_sample_rate_warning(samples, args.expected_hz)
save_csv(samples, csv_path)
analyses = save_plot(
samples,
image_path,
args.peak_field,
args.peak_threshold,
args.peak_min_interval_sec,
args.max_peaks,
args.rotation_field,
args.peak_neighbor_gap_sec,
)
print(f"Saved {len(samples)} samples to {csv_path}")
print(f"Saved waveform plot to {image_path}")
summary_path = image_path.with_name(f"{image_path.stem}_summary.csv")
save_analysis_csv(analyses, summary_path, model)
print(f"Saved punch summary to {summary_path}")
print_analysis(analyses, args.rotation_field, model)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Personalized Score Calibration
Python#!/usr/bin/env python3
"""Build and apply a simple personal punch score from labeled summaries."""
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
RAW_FEATURES = [
"dynamic_peak_g",
"accel_rise_g",
"rotation_peak_dps",
]
MODEL_FEATURES = [
*RAW_FEATURES,
"timing_closeness",
"rotation_per_dynamic",
"rotation_speed_balance",
"coordinated_rotation",
]
FEATURE_WEIGHT_MULTIPLIERS = {
"dynamic_peak_g": 1.45,
"accel_rise_g": 1.45,
"rotation_peak_dps": 0.85,
"timing_closeness": 1.10,
"rotation_per_dynamic": 0.55,
"rotation_speed_balance": 1.00,
"coordinated_rotation": 0.80,
}
def row_float(row: dict[str, str], name: str) -> float:
return float(row[name])
def row_float_optional(row: dict[str, str], name: str, fallback: float) -> float:
value = row.get(name, "")
if value is None or str(value).strip() == "":
return fallback
return float(value)
def timing_closeness(row: dict[str, str]) -> float:
fallback_offset = row_float(row, "timing_offset_sec")
decel_offset = row_float_optional(row, "decel_offset_sec", fallback_offset)
offset = abs(row_float_optional(row, "arrival_offset_sec", decel_offset))
return max(0.0, 1.0 - offset / 0.20)
def feature_value(row: dict[str, str], name: str) -> float:
if name == "timing_closeness":
return timing_closeness(row)
if name == "rotation_per_dynamic":
return row_float(row, "rotation_peak_dps") / max(
row_float(row, "dynamic_peak_g"), 0.001
)
if name == "rotation_speed_balance":
return 0.0
if name == "coordinated_rotation":
return 0.0
return row_float(row, name)
def valid_labeled_row(row: dict[str, str]) -> bool:
return row.get("user_score", "").strip() in {"1", "2", "3", "4", "5"}
def read_rows(paths: list[Path], labeled_only: bool) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for path in paths:
with path.open(newline="", encoding="utf-8-sig") as fp:
reader = csv.DictReader(fp)
for row in reader:
if labeled_only and not valid_labeled_row(row):
continue
row["source_file"] = path.name
rows.append(row)
return rows
def pearson(xs: list[float], ys: list[float]) -> float:
if len(xs) < 2:
return 0.0
mx = sum(xs) / len(xs)
my = sum(ys) / len(ys)
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
den_x = math.sqrt(sum((x - mx) ** 2 for x in xs))
den_y = math.sqrt(sum((y - my) ** 2 for y in ys))
if den_x == 0 or den_y == 0:
return 0.0
return num / (den_x * den_y)
def normalize(value: float, low: float, high: float) -> float:
if high <= low:
return 0.5
return max(0.0, min(1.0, (value - low) / (high - low)))
def build_model(rows: list[dict[str, str]]) -> dict:
user_scores = [row_float(row, "user_score") for row in rows]
feature_stats = {}
raw_weights = {}
dynamic_values = [row_float(row, "dynamic_peak_g") for row in rows]
rotation_values = [row_float(row, "rotation_peak_dps") for row in rows]
dynamic_min = min(dynamic_values)
dynamic_max = max(dynamic_values)
rotation_min = min(rotation_values)
rotation_max = max(rotation_values)
def derived_value(row: dict[str, str], feature: str) -> float:
dynamic_norm = normalize(
row_float(row, "dynamic_peak_g"),
dynamic_min,
dynamic_max,
)
rotation_norm = normalize(
row_float(row, "rotation_peak_dps"),
rotation_min,
rotation_max,
)
close = timing_closeness(row)
if feature == "rotation_speed_balance":
return 1.0 - abs(rotation_norm - dynamic_norm)
if feature == "coordinated_rotation":
return math.sqrt(dynamic_norm * rotation_norm) * close
return feature_value(row, feature)
for feature in MODEL_FEATURES:
values = [derived_value(row, feature) for row in rows]
corr = pearson(values, user_scores)
raw_weights[feature] = max(0.0, corr) * FEATURE_WEIGHT_MULTIPLIERS[feature]
feature_stats[feature] = {
"min": min(values),
"max": max(values),
"correlation": corr,
"weight_multiplier": FEATURE_WEIGHT_MULTIPLIERS[feature],
}
feature_stats["_normalization"] = {
"dynamic_peak_g": {"min": dynamic_min, "max": dynamic_max},
"rotation_peak_dps": {"min": rotation_min, "max": rotation_max},
}
total_weight = sum(raw_weights.values())
if total_weight <= 0:
weights = {feature: 1.0 / len(MODEL_FEATURES) for feature in MODEL_FEATURES}
else:
weights = {
feature: raw_weights[feature] / total_weight for feature in MODEL_FEATURES
}
return {
"version": 3,
"score_min": 1.0,
"score_max": 5.0,
"features": feature_stats,
"weights": weights,
}
def predict(row: dict[str, str], model: dict) -> float:
total = 0.0
for feature, weight in model["weights"].items():
stats = model["features"][feature]
value = model_feature_value(row, feature, model)
total += weight * normalize(value, stats["min"], stats["max"])
return 100.0 * total
def model_feature_value(row: dict[str, str], feature: str, model: dict) -> float:
normalization = model["features"].get("_normalization", {})
dynamic_stats = normalization.get("dynamic_peak_g", {})
rotation_stats = normalization.get("rotation_peak_dps", {})
dynamic_norm = normalize(
row_float(row, "dynamic_peak_g"),
dynamic_stats.get("min", 0.0),
dynamic_stats.get("max", 1.0),
)
rotation_norm = normalize(
row_float(row, "rotation_peak_dps"),
rotation_stats.get("min", 0.0),
rotation_stats.get("max", 1.0),
)
close = timing_closeness(row)
if feature == "rotation_speed_balance":
return 1.0 - abs(rotation_norm - dynamic_norm)
if feature == "coordinated_rotation":
return math.sqrt(dynamic_norm * rotation_norm) * close
return feature_value(row, feature)
def score_to_label(score: float) -> str:
if score >= 85:
return "S"
if score >= 70:
return "A"
if score >= 50:
return "B"
if score >= 30:
return "C"
return "D"
def write_scored_rows(rows: list[dict[str, str]], model: dict, output: Path) -> None:
if not rows:
return
fieldnames: list[str] = []
for row in rows:
for name in row.keys():
if name != "score" and name not in fieldnames:
fieldnames.append(name)
for name in ["learned_score", "learned_label"]:
if name not in fieldnames:
fieldnames.append(name)
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w", newline="", encoding="utf-8") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
learned_score = predict(row, model)
row = dict(row)
row.pop("score", None)
row["learned_score"] = f"{learned_score:.1f}"
row["learned_label"] = score_to_label(learned_score)
writer.writerow(row)
def print_model(model: dict) -> None:
print("Learned feature correlations and weights:")
for feature in MODEL_FEATURES:
stats = model["features"][feature]
weight = model["weights"][feature]
print(
f" {feature:>18}: corr={stats['correlation']: .3f} "
f"weight={weight: .3f} range={stats['min']:.3f}..{stats['max']:.3f}"
)
def expand_paths(paths: list[Path]) -> list[Path]:
expanded: list[Path] = []
for path in paths:
text = str(path)
if any(char in text for char in "*?[]"):
matches = sorted(Path().glob(text))
expanded.extend(matches)
else:
expanded.append(path)
return expanded
def main() -> int:
parser = argparse.ArgumentParser(
description="Calibrate a personal punch score from user_score labels."
)
parser.add_argument(
"paths",
nargs="*",
type=Path,
help="Labeled summary CSV files. Defaults to data/*_summary.csv",
)
parser.add_argument(
"--model",
type=Path,
default=Path("data/subjective_score_config.json"),
help="Output model JSON path",
)
parser.add_argument(
"--scored-output",
type=Path,
default=Path("data/labeled_punches_scored.csv"),
help="Output CSV with learned_score added",
)
args = parser.parse_args()
paths = expand_paths(args.paths) if args.paths else sorted(Path("data").glob("*_summary.csv"))
if not paths:
print("No summary CSV files found.")
return 1
labeled_rows = read_rows(paths, labeled_only=True)
if len(labeled_rows) < 10:
print("Need at least 10 labeled punches to calibrate a rough score.")
return 1
model = build_model(labeled_rows)
args.model.parent.mkdir(parents=True, exist_ok=True)
args.model.write_text(json.dumps(model, indent=2), encoding="utf-8")
write_scored_rows(labeled_rows, model, args.scored_output)
print(f"Labeled punches used: {len(labeled_rows)}")
print_model(model)
print(f"Saved model to {args.model}")
print(f"Saved scored rows to {args.scored_output}")
if len(labeled_rows) < 40:
print("Tip: this is still a rough calibration. Add 40-100 labeled punches.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Real-Time Personalized Punch Scoring
Python#!/usr/bin/env python3
"""Realtime punch evaluation using the current subjective score calibration."""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
try:
import serial
except ImportError as exc:
raise SystemExit(
"pyserial is required. Install it with: python -m pip install pyserial"
) from exc
from calibrate_subjective_score import build_model, predict, read_rows, score_to_label
from read_imu import (
Sample,
analyze_punches,
auto_detect_port,
detect_peaks,
parse_sample,
print_sample_rate_warning,
save_csv,
save_plot,
)
G_TO_MS2 = 9.80665
def load_or_build_model(model_path: Path, summary_glob: str) -> dict | None:
if model_path.exists():
return json.loads(model_path.read_text(encoding="utf-8-sig"))
rows = read_rows(sorted(Path("data").glob(summary_glob)), labeled_only=True)
if len(rows) < 10:
return None
model = build_model(rows)
model_path.parent.mkdir(parents=True, exist_ok=True)
model_path.write_text(json.dumps(model, indent=2), encoding="utf-8")
return model
def analysis_row(analysis) -> dict[str, str]:
return {
"score": f"{analysis.score}",
"dynamic_peak_g": f"{analysis.peak.value}",
"accel_rise_g": f"{analysis.accel_rise_g}",
"rotation_peak_dps": f"{analysis.rotation_peak_dps}",
"timing_offset_sec": f"{analysis.timing_offset_sec}",
"decel_offset_sec": f"{analysis.decel_offset_sec}",
"arrival_offset_sec": f"{analysis.arrival_offset_sec}",
}
def learned_score_for(analysis, model: dict | None) -> tuple[float, str]:
if model is None:
return analysis.score, score_to_label(analysis.score)
learned_score = predict(analysis_row(analysis), model)
return learned_score, score_to_label(learned_score)
def send_display_score(
ser: serial.Serial, score: float, label: str, accel_ms2: float
) -> None:
command = f"DISPLAY_SCORE,{score:.1f},{label},{accel_ms2:.1f}\n"
ser.write(command.encode("ascii"))
def latest_finalized_analyses(
samples: list[Sample],
args: argparse.Namespace,
finalized_before_sec: float,
) -> list:
finalized = [
sample for sample in samples if sample.time_sec <= finalized_before_sec
]
if len(finalized) < 3:
return []
peaks = detect_peaks(
finalized,
args.peak_field,
args.peak_threshold,
args.peak_min_interval_sec,
args.max_peaks,
args.peak_neighbor_gap_sec,
)
return analyze_punches(finalized, peaks, args.rotation_field)
def recent_samples(samples: list[Sample], latest_time_sec: float, window_sec: float) -> list[Sample]:
start_sec = max(0.0, latest_time_sec - window_sec)
return [sample for sample in samples if sample.time_sec >= start_sec]
def main() -> int:
parser = argparse.ArgumentParser(
description="Read IMU serial CSV and print realtime personal punch scores."
)
parser.add_argument("-p", "--port", help="Serial port, for example COM10")
parser.add_argument("-b", "--baud", type=int, default=921600)
parser.add_argument("-o", "--output-dir", default="data")
parser.add_argument("--model", type=Path, default=Path("data/subjective_score_config.json"))
parser.add_argument("--summary-glob", default="*_summary.csv")
parser.add_argument("--peak-field", choices=["dynamic_g", "mag_g"], default="dynamic_g")
parser.add_argument("--rotation-field", choices=["gx_dps", "gy_dps", "gz_dps", "rotation_dps"], default="rotation_dps")
parser.add_argument("--peak-threshold", type=float, default=1.5)
parser.add_argument("--peak-min-interval-sec", type=float, default=0.6)
parser.add_argument("--peak-neighbor-gap-sec", type=float, default=0.25)
parser.add_argument("--max-peaks", type=int, default=100)
parser.add_argument("--settle-sec", type=float, default=0.35)
parser.add_argument("--analysis-window-sec", type=float, default=8.0)
parser.add_argument("--expected-hz", type=float, default=100.0)
parser.add_argument("--no-data-warning-sec", type=float, default=5.0)
parser.add_argument("--save", action="store_true", help="Save CSV/PNG when stopped")
args = parser.parse_args()
model = load_or_build_model(args.model, args.summary_glob)
if model is None:
print(
"No subjective model yet. Realtime output will use the old sensor score.\n"
"Add labels to summary CSV files, then run calibrate_subjective_score.py."
)
else:
print(f"Using subjective model: {args.model}")
port = args.port or auto_detect_port()
if not port:
print("No serial port found. Specify one with --port COMx.", file=sys.stderr)
return 2
output_dir = Path(args.output_dir)
samples: list[Sample] = []
first_device_ms: int | None = None
printed_peak_times: set[float] = set()
last_status_time = 0.0
last_sample_wall_time = time.monotonic()
print(f"Opening {port} at {args.baud} bps.")
print("Realtime scoring. Press Ctrl+C to stop.")
print(f"Analysis window: latest {args.analysis_window_sec:g}s")
print(
"No. time[s] learned label old dynamic[g] rise[g] "
"rotation[dps] peak_offset[s] decel_offset[s] arrival_offset[s]"
)
try:
with serial.Serial(port, args.baud, timeout=1) as ser:
while True:
raw_line = ser.readline()
if not raw_line:
now = time.monotonic()
if now - last_sample_wall_time >= args.no_data_warning_sec:
print(
"\nNo valid samples recently. Check the M5Stack connection, "
"Serial Monitor, and firmware.",
flush=True,
)
last_sample_wall_time = now
continue
line = raw_line.decode("utf-8", errors="replace")
sample, first_device_ms = parse_sample(line, first_device_ms)
if sample is None:
text = line.strip()
if text.startswith("#"):
print(text)
continue
samples.append(sample)
last_sample_wall_time = time.monotonic()
latest_time = sample.time_sec
finalized_before = latest_time - args.settle_sec
analysis_samples = recent_samples(
samples, latest_time, args.analysis_window_sec
)
analyses = latest_finalized_analyses(
analysis_samples, args, finalized_before
)
for analysis in analyses:
peak_time = round(analysis.peak.sample.time_sec, 3)
if peak_time in printed_peak_times:
continue
printed_peak_times.add(peak_time)
learned_score, learned_label = learned_score_for(analysis, model)
peak_accel_ms2 = analysis.peak.value * G_TO_MS2
send_display_score(ser, learned_score, learned_label, peak_accel_ms2)
print(
f"{len(printed_peak_times):>2} "
f"{analysis.peak.sample.time_sec:>7.3f} "
f"{learned_score:>7.1f} "
f"{learned_label:>5} "
f"{analysis.score:>5.1f} "
f"{analysis.peak.value:>10.3f} "
f"{analysis.accel_rise_g:>7.3f} "
f"{analysis.rotation_peak_dps:>13.1f} "
f"{analysis.timing_offset_sec:>14.3f} "
f"{analysis.decel_offset_sec:>15.3f} "
f"{analysis.arrival_offset_sec:>17.3f}"
)
now = time.monotonic()
if now - last_status_time >= 1.0:
print(
f"\rSamples: {len(samples)} t={latest_time:7.3f}s",
end="",
flush=True,
)
last_status_time = now
except KeyboardInterrupt:
print()
if not samples:
print("No samples were recorded.")
return 1
print_sample_rate_warning(samples, args.expected_hz)
if args.save:
stamp = time.strftime("%Y%m%d_%H%M%S")
csv_path = output_dir / f"realtime_{stamp}.csv"
image_path = output_dir / f"realtime_{stamp}.png"
save_csv(samples, csv_path)
save_plot(
samples,
image_path,
args.peak_field,
args.peak_threshold,
args.peak_min_interval_sec,
args.max_peaks,
args.rotation_field,
args.peak_neighbor_gap_sec,
)
print(f"Saved {csv_path}")
print(f"Saved {image_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[platformio]
data_dir = spiffs_data
[env:m5stack-core2]
platform = espressif32
board = m5stack-core2
framework = arduino
monitor_speed = 921600
lib_deps =
m5stack/M5Unified
https://github.com/m5stack/M5Unit-IMU-Pro-Mini.git
https://github.com/adafruit/Adafruit_BMP280_Library.git

















Comments