actRecog / src /phyphox_app_block.py
Fola-lad's picture
Fix: use Acceleration (with g) for Phyphox recording, pipeline needs total acc including gravity
2d25c7f
Raw History Blame
8.33 kB
"""Streamlit UI block for Tab 2: Phyphox live sensor upload.
Call from streamlit_app.py:
from phyphox_app_block import render_phyphox_tab
with tab2:
render_phyphox_tab(ffn_model, ffn_status, cnn_model, cnn_status)
"""
import json
import os
import numpy as np
import pandas as pd
import streamlit as st
from phyphox_pipeline import process_phyphox_files, FS, WINDOW, STEP
# ── Constants ─────────────────────────────────────────────────────────────────
LABEL_MAP = {
0: "WALKING",
1: "WALKING_UPSTAIRS",
2: "WALKING_DOWNSTAIRS",
3: "SITTING",
4: "STANDING",
5: "LAYING",
}
EXPLANATIONS = {
"LAYING": "Minimal movement detected across all axes: consistent with a stationary horizontal posture.",
"SITTING": "Low dynamic acceleration with stable gravity: stationary upright posture.",
"STANDING": "Similar to sitting with slight postural micro-movements.",
"WALKING": "Rhythmic periodic acceleration on the vertical axis: level walking at normal cadence.",
"WALKING_DOWNSTAIRS": "Downward gravitational shift with higher impact peaks: descending stairs.",
"WALKING_UPSTAIRS": "Elevated vertical acceleration effort: climbing stairs.",
}
# ── Normalisation ─────────────────────────────────────────────────────────────
@st.cache_resource
def _load_norm_params(norm_path: str):
"""Load per-feature min/max from norm_params.json (keys are string indices 0-560)."""
with open(norm_path) as f:
d = json.load(f)
min_vals = np.array([d[str(i)]["min"] for i in range(561)], dtype=np.float32)
max_vals = np.array([d[str(i)]["max"] for i in range(561)], dtype=np.float32)
return min_vals, max_vals
def _normalize(features: np.ndarray, min_vals: np.ndarray, max_vals: np.ndarray) -> np.ndarray:
"""Best-effort feature-level min-max scaling to [-1, 1].
Uses per-feature min/max observed in the UCI HAR training set. This is
an approximation: the UCI pipeline normalises raw signals before feature
extraction, so physical-unit features may fall outside the training range.
Values are clipped before scaling to keep outputs bounded.
"""
rng = max_vals - min_vals
rng = np.where(rng < 1e-8, 1.0, rng) # avoid div-by-zero
clipped = np.clip(features, min_vals, max_vals)
return 2.0 * (clipped - min_vals) / rng - 1.0
# ── Public render function ────────────────────────────────────────────────────
def render_phyphox_tab(
ffn_model, ffn_status: str,
cnn_model, cnn_status: str,
norm_params_path: str,
) -> None:
st.subheader("Upload Phyphox sensor recording")
st.markdown("""
**How to record your own data:**
1. Install [Phyphox](https://phyphox.org/) on your phone
2. Open **Acceleration (with g)** and **Gyroscope**: record simultaneously
3. Hold the phone at your waist (same position as the UCI dataset)
4. Record at least 3 seconds of a single activity
5. Export both experiments as CSV and upload below
""")
if "phyphox_run" not in st.session_state:
st.session_state.phyphox_run = 0
col1, col2 = st.columns(2)
with col1:
acc_file = st.file_uploader(
"Accelerometer CSV",
type=["csv"],
key=f"acc_upload_{st.session_state.phyphox_run}",
help="Columns: Time (s), X (m/sΒ²), Y (m/sΒ²), Z (m/sΒ²)",
)
with col2:
gyro_file = st.file_uploader(
"Gyroscope CSV",
type=["csv"],
key=f"gyro_upload_{st.session_state.phyphox_run}",
help="Columns: Time (s), X (rad/s), Y (rad/s), Z (rad/s)",
)
if acc_file is not None or gyro_file is not None:
if st.button("Clear / new recording"):
st.session_state.phyphox_run += 1
st.rerun()
if acc_file is None or gyro_file is None:
st.info("Upload both files to continue.")
return
# ── Feature extraction ────────────────────────────────────────────────────
try:
with st.spinner("Extracting 561 features from sensor data…"):
features, pipeline_warnings = process_phyphox_files(acc_file, gyro_file)
except ValueError as err:
st.error(str(err))
return
except Exception as err:
st.error(f"Unexpected error during feature extraction: {err}")
return
for w in pipeline_warnings:
st.warning(w)
n_windows = len(features)
duration_s = (n_windows - 1) * (STEP / FS) + (WINDOW / FS)
c1, c2, c3 = st.columns(3)
c1.metric("Windows extracted", n_windows)
c2.metric("Approx. duration", f"{duration_s:.1f} s")
c3.metric("Features per window", 561)
st.caption(
f"Each window = {WINDOW / FS:.2f} s at {FS} Hz Β· "
f"50% overlap ({STEP / FS:.2f} s hop)"
)
# ── Normalisation ─────────────────────────────────────────────────────────
if os.path.exists(norm_params_path):
min_vals, max_vals = _load_norm_params(norm_params_path)
features = _normalize(features, min_vals, max_vals)
st.caption(
"Accelerometer converted from m/sΒ² to g units to match UCI training data. "
"Features scaled to [βˆ’1, 1] using physical-unit min/max computed from the "
"UCI HAR training set raw inertial signals."
)
else:
st.warning(
"norm_params.json not found: features are in physical units. "
"Predictions will be unreliable until normalisation is applied."
)
# ── Predictions ───────────────────────────────────────────────────────────
if ffn_status != "ready" and cnn_status != "ready":
st.warning("Models not loaded: cannot predict yet.")
return
st.markdown("---")
st.subheader("Model comparison")
left, right = st.columns(2)
def _render_model_col(col, model, status, name):
with col:
st.markdown(f"#### {name}")
if status != "ready":
st.error(f"Model not loaded: {status}")
return
probs_all = model.predict(features, verbose=0) # (n_windows, 6)
pred_labels = [LABEL_MAP[int(np.argmax(p))] for p in probs_all]
from collections import Counter
vote = Counter(pred_labels).most_common(1)[0][0]
avg_conf = float(np.mean(np.max(probs_all, axis=1))) * 100
st.success(f"**{vote}** Β· {avg_conf:.1f}% avg confidence")
st.markdown(f"_{EXPLANATIONS[vote]}_")
if n_windows > 1:
with st.expander(f"Per-window breakdown ({n_windows} windows)"):
rows = []
for i, (p, label) in enumerate(zip(probs_all, pred_labels)):
t_start = i * STEP / FS
rows.append({
"Window": i + 1,
"Time (s)": f"{t_start:.1f}–{t_start + WINDOW/FS:.1f}",
"Prediction": label,
"Confidence": f"{float(np.max(p))*100:.1f}%",
})
st.dataframe(pd.DataFrame(rows), use_container_width=True)
mean_probs = probs_all.mean(axis=0)
st.markdown("**Average confidence across all classes**")
st.bar_chart(pd.DataFrame(
{"Confidence (%)": [float(mean_probs[i]) * 100 for i in range(6)]},
index=[LABEL_MAP[i] for i in range(6)],
))
_render_model_col(left, ffn_model, ffn_status, "Feedforward Network")
_render_model_col(right, cnn_model, cnn_status, "1D Convolutional Network")