File size: 11,064 Bytes
6b7cf40
13747e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b7cf40
 
13747e9
6b7cf40
 
 
13747e9
 
 
 
286761c
13747e9
 
286761c
 
13747e9
 
 
 
 
 
 
6b7cf40
13747e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c37dadc
627ab26
c37dadc
627ab26
 
c37dadc
627ab26
 
 
 
 
 
c37dadc
 
 
627ab26
 
c37dadc
627ab26
 
c37dadc
627ab26
c37dadc
627ab26
 
c37dadc
 
627ab26
c37dadc
627ab26
c37dadc
627ab26
 
c37dadc
627ab26
13747e9
9782e77
 
 
13747e9
 
 
 
 
9782e77
13747e9
 
 
 
 
 
9782e77
13747e9
 
 
9782e77
 
 
 
 
13747e9
 
 
 
398aa27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0dd88b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398aa27
 
 
13747e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7dda26b
9782e77
 
13747e9
 
 
6b7cf40
13747e9
 
 
 
6b7cf40
13747e9
 
 
 
 
 
 
 
 
 
 
6b7cf40
13747e9
 
 
 
 
 
049d8fd
 
 
 
 
 
 
13747e9
 
 
 
 
 
 
 
 
049d8fd
13747e9
 
 
049d8fd
13747e9
 
 
049d8fd
 
13747e9
049d8fd
13747e9
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""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

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.",
}

@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


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")
    with st.expander("How to record and export your data - step by step", expanded=True):
        st.markdown("""
**Step 1 - Install Phyphox**
Download the free [Phyphox](https://phyphox.org/) app from the Play Store (Android) or App Store (iOS).

**Step 2 - Create a new experiment**
- Open the app and tap the **+** icon in the top-right corner
- Select **Add simple experiment**
- In the active sensors list, add **Accelerometer** and **Gyroscope**
- Set the **sensor rate to 50** (Hz) for both
- Give the experiment a title (e.g. "Walking") then tap **Proceed**

**Step 3 - Configure a timed run**
- Tap the **three-dot menu** in the top-right corner and select **Timed run**
- Set a **recording duration** (15-20 seconds recommended)
- Optionally set a **start delay** (e.g. 5 seconds) so you have time to get into position before recording begins

**Step 4 - Position the device**
Place your phone in your **trouser pocket** or attach it at your **waist**. This mirrors how the original UCI dataset was collected and gives the most accurate results.

**Step 5 - Record one activity**
- Press the **play button** to start
- Perform a **single activity continuously** - do not stop and restart mid-recording
- Stay in steady motion for the full duration before stopping

**Step 6 - Export the data**
- After the recording ends, tap the **three-dot menu** and select **Export data**
- Choose **CSV (comma separated values)** and confirm
- You will receive a **.zip file** - unzip it to find separate CSV files for the **Accelerometer** and **Gyroscope**

**Step 7 - Upload below**
Upload each CSV file into its corresponding field below, then the pipeline will extract features and classify your activity.

> ⚠️ **Important:** Each export is a fresh experiment. Do not reuse an old experiment - it will contain data from previous recordings stitched together, which will confuse the classifier.
        """)

    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

    try:
        import io as _io
        _raw = acc_file.read()
        acc_file.seek(0)
        _df = pd.read_csv(_io.StringIO(_raw.decode("utf-8") if isinstance(_raw, bytes) else _raw))
        _df.columns = [c.strip('"').strip() for c in _df.columns]
        _num_cols = [c for c in _df.columns if c != "Time (s)"]
        _mag = float((_df[_num_cols].apply(pd.to_numeric, errors="coerce") ** 2).sum(axis=1).mean() ** 0.5)
        if _mag < 3.0:
            st.error(
                f"Mean accelerometer magnitude is only {_mag:.2f} m/s² — this looks like "
                "'Acceleration **without** g'. Please re-record using **'Acceleration (with g)'** "
                "so gravity is included in the signal."
            )
            return
        else:
            st.caption(f"Signal check: mean magnitude = {_mag:.2f} m/s² (gravity present)")

        # Detect Phyphox pause/resume sessions: large gaps in the time column
        # mean different activities were stitched together in one CSV.
        _t = pd.to_numeric(_df["Time (s)"], errors="coerce").dropna().values
        _gaps = np.diff(_t)
        _expected_dt = np.median(_gaps)
        _session_breaks = int(np.sum(_gaps > _expected_dt * 20))
        if _session_breaks > 0:
            st.warning(
                f"**{_session_breaks} session break(s) detected** in this recording. "
                "Phyphox accumulates data across pause/resume cycles — your CSV contains "
                f"{_session_breaks + 1} separate recordings stitched together. "
                "Only windows from a single activity will predict correctly. "
                "To fix: tap the **trash icon** in Phyphox to clear data before each new recording."
            )
    except Exception:
        pass

    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)"
    )

    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."
        )

    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
            # Skip first and last window for the final vote: these are typically
            # contaminated by recording start/stop transients (person not yet
            # in full motion, or the gravity filter still warming up).
            core = probs_all[1:-1] if n_windows > 3 else probs_all
            core_labels = [LABEL_MAP[int(np.argmax(p))] for p in core]
            vote = Counter(core_labels).most_common(1)[0][0]
            avg_conf = float(np.mean(np.max(core, 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
                        is_edge = (i == 0 or i == n_windows - 1) and n_windows > 3
                        rows.append({
                            "Window": i + 1,
                            "Time (s)": f"{t_start:.1f}–{t_start + WINDOW/FS:.1f}",
                            "Prediction": label + (" *" if is_edge else ""),
                            "Confidence": f"{float(np.max(p))*100:.1f}%",
                        })
                    st.dataframe(pd.DataFrame(rows), use_container_width=True)
                    if n_windows > 3:
                        st.caption("* Edge windows excluded from overall vote (recording start/stop transient).")

            mean_probs = core.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")