If your first CSI capture looked like static, you are not doing anything wrong. Raw CSI is noisy by nature, because it reacts to every tiny change in the radio channel plus a stack of hardware quirks. Turning it into something a detector or a model can use is a fixed sequence of steps, and once you know the order it is routine. Here is that order.
01Why cleaning matters
Raw CSI is a blend of several things, and only one of them is useful. There is the real channel, which carries the movement you want. On top of it sit empty subcarriers that carry nothing, sharp outliers from radio glitches, phase that is scrambled by clock errors, and slow drift as the channel wanders over minutes. Feed that blend straight into a threshold or a neural network and the noise buries the signal.
Cleaning is about stripping away everything that is not information and stabilizing what remains, so that a genuine event, a person entering or a chest rising, stands clearly above a quiet background. The steps below are the ones almost every practical CSI project uses, in roughly this order. You do not always need all of them, but this is the full menu. For the raw capture that produces the input, see how to capture Wi-Fi CSI on an ESP32.
02Drop the null and pilot subcarriers
The first step is the cheapest and the most important. The channel contains subcarriers that carry no payload: the guard bands at the edges and the DC bin at the center are empty, and the pilot subcarriers carry fixed references rather than channel information. Their CSI values are meaningless for sensing, so keep the data subcarriers and discard the rest.
On an ESP32 20 MHz link, that usually means trimming a handful of bins from each end of the reported vector and ignoring the near-zero center. Exactly which indices to drop depends on the format and bandwidth, so inspect a quiet capture first and remove the subcarriers that sit flat near zero. The structure behind this is explained in CSI subcarriers and OFDM explained.
03A Hampel filter for outliers
Radios produce occasional sharp spikes, single samples that are wildly wrong. A plain moving average smears those spikes into their neighbors instead of removing them, so the standard tool here is a Hampel filter, a median-based outlier remover.
For each sample it looks at a small window around it, takes the median and the median absolute deviation of that window, and if the sample lies more than a few deviations from the median it is replaced by the median. Because it works on medians it is robust: one bad value cannot drag the estimate, and genuine motion, which is smooth and sustained, passes through untouched while isolated glitches vanish.
04Smoothing the result
After outliers are gone, the signal still has high-frequency jitter that no real body motion can explain. Human movement is slow in radio terms: walking, gestures and breathing all live below a few hertz. So a gentle low-pass filter or a short moving average over time removes the jitter above that band while keeping the movements you care about.
Keep the window modest. Too much smoothing and you blur the very events you are hunting; too little and the jitter survives. A window spanning a fraction of a second is a sensible starting point, which you then tune against real captures. For breathing specifically you can band-pass around the 0.1 to 0.5 Hz range to isolate the rhythm.
05Normalize, then learn a baseline
Two calibration steps make the signal comparable over time. First, amplitude normalization puts subcarriers and captures on a common scale, for example by dividing each subcarrier by its own mean or standard deviation, so that a naturally strong subcarrier does not dominate a naturally weak one.
Second, and most important for presence work, learn an empty-room baseline. Record CSI for a short period while the space is unoccupied, average it per subcarrier, and store that as the reference. From then on you compare live CSI against the baseline: subtract it, or divide by it, so that only departures from the empty room, meaning activity, remain. Without a baseline you are guessing what "quiet" looks like; with one, the quiet room is defined and anything above it is a candidate event. The honest limits of this approach are covered in our complete guide to Wi-Fi CSI.
06Phase sanitization, if you use phase
If your project only uses amplitude, you can skip this step. If you use phase, it is mandatory, because raw phase is dominated by carrier frequency offset, sampling frequency offset and a random per-packet offset. Sanitization unwraps the phase and then removes the linear component, the slope and offset the clock errors add, typically by fitting a straight line across the subcarriers and subtracting it. What survives is close to the true channel phase and safe to use for fine motion, distance or breathing. The reasoning is laid out in amplitude vs phase in Wi-Fi CSI.
07The pipeline in code
Here is the amplitude path as a short NumPy script: drop the edge subcarriers, remove outliers with a Hampel filter per subcarrier, learn a baseline from the first unoccupied seconds, and subtract it. It is deliberately small so you can read every line.
import numpy as np def hampel(x, k=5, n_sigma=3.0): # Replace spikes in a 1D series using a sliding median window. y = x.copy() scale = 1.4826 # MAD -> std for Gaussian noise for i in range(len(x)): lo, hi = max(0, i - k), min(len(x), i + k + 1) med = np.median(x[lo:hi]) mad = scale * np.median(np.abs(x[lo:hi] - med)) if mad > 0 and abs(x[i] - med) > n_sigma * mad: y[i] = med # outlier -> local median return y # amp: CSI amplitude, shape [packets, subcarriers] amp = amp[:, 6:-5] # 1) drop null/guard subcarriers amp = np.apply_along_axis(hampel, 0, amp) # 2) outliers, per subcarrier over time amp = amp / amp.std(axis=0, keepdims=True) # 3) normalize each subcarrier baseline = amp[:200].mean(axis=0) # 4) learn the empty room (first packets) clean = amp - baseline # 5) keep only departures from empty motion = np.abs(clean).mean(axis=1) # one motion value per packet print("motion range:", motion.min(), motion.max())
Add smoothing with a short moving average over the motion series, and for phase insert a sanitization step before step 3. For public captures to practice on, our roundup of Wi-Fi CSI datasets and tools lists sources you can load straight into a script like this one.
08FAQ
Why does raw Wi-Fi CSI need cleaning?
Raw CSI mixes the real channel with empty subcarriers, random outliers, hardware phase errors and slow drift. If you feed it straight into a detector or a model, the noise swamps the small movements you care about. Cleaning removes the parts that carry no information and stabilizes the rest, so a real change stands out from the background.
What is a Hampel filter and why use it on CSI?
A Hampel filter is a sliding-window outlier remover. For each sample it takes the median of a window around it and the median absolute deviation, and if the sample sits too many deviations from the median it is replaced by that median. It is good for CSI because radio glitches produce sharp spikes that a Hampel filter removes without blurring genuine motion the way a plain average would.
What is a CSI baseline and how do I learn one?
A baseline is the CSI pattern of the empty room, the reference the system compares against. You learn it by recording CSI for a short period while the space is unoccupied and averaging it per subcarrier. Afterwards you subtract or divide by this baseline so that only departures from the empty room, meaning activity, remain in the signal.
Do I have to clean CSI phase as well as amplitude?
Only if you use phase. Amplitude is robust and needs just outlier removal, smoothing and a baseline. Raw phase additionally carries carrier and sampling offsets that must be sanitized, usually by unwrapping and removing a linear fit across subcarriers. If your project uses amplitude alone, you can skip phase sanitization entirely.