Versal ACAP AI Engine WiFi CSI nexmon PetaLinux Edge AI

Seeing Through Walls: WiFi Human-Activity Recognition on the Versal AI Engine

A weekend hardware/software co-design that streams WiFi Channel-State-Information (CSI) from a Raspberry Pi into an AMD Versal VCK190, parses the UDP/CSI packets in the Programmable Logic, and runs the STFT + activity / breathing / fall feature extraction on the AI Engine array — turning ordinary WiFi into a device-free room sensor. Validated bit-accurate on real silicon.

5.96e-08
max abs err vs golden
3-branch
AIE feature graph
L = 256
sample window
4 ILAs
on the live datapath
10+
related publications
▶ See it live GitHub Repository Read the Thesis →
Act 1 — Origins
It started as a thesis

My ECE Master's at Mississippi State University was in software-defined-radio (SDR) based sensing. One project asked a deceptively simple question: can an ordinary WiFi signal tell what a person is doing in a room?

The answer is yes — and it comes from Channel-State-Information (CSI). Every OFDM WiFi receiver estimates how the channel distorted each subcarrier. When a body moves, the multipath reflections change, and those changes carry a signature of the motion. I built a WiFi-CSI human-activity recognition (HAR) pipeline, benchmarked it against a radar sensing baseline for accuracy, and published the results across several papers (see Publications).

The visual heart of that work is the spectrogram: take the Short-Time Fourier Transform of the CSI time series and each activity paints a distinct time–frequency fingerprint. These are real CSI spectrograms from the dataset — one per activity class:

Act 2 — A gentle challenge
"You could try to do this in an FPGA"

When I presented that thesis to a former manager, they gently suggested that the whole pipeline could, one day, run in an FPGA — the packet parsing in fabric, the heavy DSP and inference on an accelerator. It was said kindly, almost in passing. But it lodged in my head as a personal challenge I promised myself I'd take on someday.

"Someday" meant: get the CSI off the CPU entirely — parse the UDP packet in the Programmable Logic, and let a Versal AI Engine do the STFT and the activity inference.

The napkin sketch of that idea looked like this:

flowchart LR PI["WiFi CSI source"] -->|UDP over Ethernet| FPGA subgraph FPGA["FPGA — do it all here"] PL["PL: parse packets"] --> ACC["AI Engine: STFT + inference"] end ACC --> OUT["activity / breathing / fall"]
Act 3 — Picking it back up
From the shelf to silicon

The idea sat on the shelf while life moved on. What finally unblocked it was hardware and time: a VCK190 on the bench and a run of weekends and evenings. Modern tooling — and, honestly, LLMs for grinding through the deep Versal tool-flow issues — made a solo build of this scope tractable.

The origin

Master's thesis

  • WiFi-CSI human-activity recognition
  • benchmarked against radar
  • published papers
The nudge

The challenge

  • a gentle "do it in an FPGA"
  • filed away as a someday goal
The pause

Shelved

  • other priorities
  • idea kept warm
The rebirth

VCK190

  • PL UDP/CSI parser
  • AIE feature graph
  • bit-accurate on silicon
Act 4 — The rebirth
The thesis, now implemented in an FPGA

The pipeline now runs on real hardware. A Raspberry Pi running the nexmon_csi firmware patch turns its WiFi chip into a CSI sensor and packetizes CSI as UDP over Ethernet. That stream enters the VCK190 over an SFP link; the PL parses the UDP/CSI packet and streams the I/Q samples straight into the AI Engine, which computes the motion, breathing and phase-variance features. Only the compact results cross into Linux on the Arm cores, where an on-board web dashboard renders what's happening in the room.

AMD Versal VCK190 evaluation board
The AMD Versal VCK190 — Arm Cortex-A72 PS, Programmable Logic, and the AI Engine array on one ACAP.

Why this device is the right fit:

  • PL streams and parses the Ethernet/UDP packet at line rate.
  • The AI Engine is a vector-VLIW array built for FFTs and MAC-heavy DSP — exactly the CSI workload.
  • The NoC + DDR and Arm PS handle results and visualization.
  • It's a single-chip edge sensor: no PC in the loop.
Act 5 — How it actually works
End-to-end dataflow

The full path from WiFi chip to browser. Raw CSI (hundreds of KB/s) never touches the CPU in the target design — only the tiny feature/label vector is DMA'd to Linux. Click any diagram to open it fullscreen and zoom / pan.

flowchart LR subgraph PI["Raspberry Pi — nexmon_csi"] W["WiFi NIC
bcm43455c0"] --> CAP["CSI extractor"] --> UDP["UDP packetizer"] end UDP -->|"UDP/IP over 1000BASE-X"| SFP["SFP0"] subgraph VCK190["AMD Versal VCK190"] SFP --> GT["GTY + PCS/PMA"] --> MAC["Ethernet MAC"] MAC --> P["PL: csi_udp_parser
strip UDP → I/Q stream"] P --> MUX["csi_mux"] --> AIE subgraph AIE["AI Engine — feature_graph"] FIR["FIR band-pass"] --> ST["stats
mean / var / power"] DFT["windowed-DFT
STFT magnitude"] PH["phase-var
stats"] end AIE --> S2MM["s2mm DMA"] --> DDR[("DDR4 via NoC")] DDR --> A72["Arm A72 · PetaLinux"] A72 --> DASH["live_dashboard.py
SSE web UI"] end DASH --> BROWSER["any browser on the LAN"]
Why WiFi can see you

No camera, no wearable, often through a wall. The trick is that your body is part of the radio channel. Here is the intuition, in four steps.

📡

1. CSI is the channel's fingerprint

An OFDM receiver estimates, per subcarrier k, a complex response — amplitude and phase of how the signal arrived. nexmon_csi exposes this vector every packet.

$$H[k] = |H[k]|\,e^{\,j\angle H[k]}$$
🧍

2. You perturb the multipath

The receiver sums many rays: line-of-sight plus reflections off walls, furniture — and you. Move, and the reflected path length changes, rotating its phase and reshaping the summed H[k] over time.

🌊

3. Motion → Doppler

A body part moving at velocity v imparts a Doppler shift. Different activities have different velocity signatures, so the STFT separates walking from falling from sitting.

$$f_d = \frac{2v}{\lambda}, \quad \lambda \approx 12.5\,\text{cm at 2.4 GHz}$$
🫁

4. Breathing → slow phase tone

The chest wall moves millimeters at ~0.2–0.4 Hz. That appears as a narrow low-frequency tone in the CSI — the basis of the breathing branch.

Why STFT. Activities are non-stationary, so we take the Short-Time Fourier Transform of the CSI time series — window, DFT, magnitude — producing the time–frequency spectrograms you saw in Act 1. (This directly builds on my paper Effect of the Short-Time Fourier Transform on the Classification of Complex-Valued Mobile Signals.)

$$X[m,\,\omega] = \sum_{n} x[n]\,w[n-mR]\,e^{-j\omega n}$$
The windowed DFT: w is the analysis window, R the hop. The AI Engine computes the magnitude of this per window.

WiFi vs. radar — the thesis comparison

Radar is dedicated, high-bandwidth and precise, but it's extra hardware. WiFi CSI reuses ubiquitous infrastructure: it's device-free, works in the dark and through walls, and is privacy-preserving (no imagery). My thesis and our RadarConf 2024 paper comparing Wi-Fi-CSI and radar-based HAR quantified that accuracy trade-off — the motivation for pushing the WiFi pipeline all the way onto an edge accelerator here.

Parsing UDP/CSI in the fabric

In the target architecture the PL owns the packet. RX frames enter as an AXI4-Stream; a custom csi_udp_parser (Vitis HLS) filters the CSI UDP port, strips the Ethernet/IP/UDP headers, extracts the I/Q payload, and streams samples through a csi_mux straight into the AI Engine. This is the real Vivado block design that was built and run on the board:

Vivado block design: Ethernet → csi_udp_parser → csi_mux → AI Engine → DMA → NoC/DDR
Vivado IP-Integrator block design — Ethernet (GT + PCS/PMA + MAC) → csi_udp_parsercsi_muxai_engines2mm DMA → NoC → DDR4. Exported with write_bd_layout from the built project.

A simplified view of the same PL datapath, for reference:

flowchart LR MAC["Ethernet MAC"] --> RXW["rx_dwidth
(width conv.)"] RXW --> PAR["csi_udp_parser
(HLS)"] PAR --> MUX["csi_mux"] MUX --> SHIM["AIE ↔ PL shim
(v++ co-generated)"] SHIM --> AIE["ai_engine_0"] AIE --> S2MM["s2mm"] --> NOC["NoC"] --> DDR[("DDR4")]
The feature graph, kernel by kernel

The AI Engine runs a three-branch feature graph. Each branch maps directly to a piece of the physics above, and every kernel is a small streaming function connected window-to-window over PLIO:

flowchart TB IN["PLIO_in
CSI window (BLOCK)"] --> FIR["kfir: FIR band-pass"] --> STA["kstats: mean/var/power"] --> O1["PLIO_out
motion {mean,var,power}"] BIN["PLIO_brt_in
N samples"] --> DFT["kdft: windowed-DFT magnitude"] --> O2["PLIO_brt_out
NB breathing bins"] PIN["PLIO_phase_in
L phase samples"] --> PHA["kphase: stats"] --> O3["PLIO_phase_out
phase-var {mean,var,power}"]
BranchKernel chainOutputPhysical meaning
MotionFIR band-pass → stats{mean, var, power}band-limit to the human-motion Doppler band, then measure energy → presence / activity intensity
Breathingwindowed-DFT magnitudeNB binsSTFT bin near 0.2–0.4 Hz → respiration rate
Phase-varstats{mean, var, power}phase variance → micro-motion / stability, feeds fall detection

The graph wiring is pure ADF — kernels connected stream-to-stream on the array:

aie/src/graph.cpp — the three-branch feature graph
// motion branch: FIR band-pass -> stats (kernel-to-kernel)
connect<>(in.out[0],    kfir.in[0]);
connect<>(kfir.out[0],  kstats.in[0]);
connect<>(kstats.out[0], out.in[0]);
// breathing branch: windowed-DFT magnitude
connect<>(brt_in.out[0], kdft.in[0]);
connect<>(kdft.out[0],   brt_out.in[0]);
// phase-variance branch: stats -> variance is the phase-var feature
connect<>(phase_in.out[0], kphase.in[0]);
connect<>(kphase.out[0],   phase_out.in[0]);
aie/src/stats_core.h — two-pass mean / variance / power over one window
static inline void stats_core(const float *x, float *out) {
    float s = 0.0f;
    for (int i = 0; i < L; i++) s += x[i];
    float mean = s / (float)L;
    float sd = 0.0f, s2 = 0.0f;
    for (int i = 0; i < L; i++) {
        float d = x[i] - mean;
        sd += d * d;
        s2 += x[i] * x[i];
    }
    out[0] = mean;          // DC / bias
    out[1] = sd / (float)L; // variance  == motion-band / phase-var energy
    out[2] = s2 / (float)L; // power
}

The compact feature vector then feeds a tiny pretrained MLP for the activity label. The design does hand-written AIE kernels here; the vector-VLIW array chews through the FIR taps and DFT butterflies far faster than the scalar A72 could — which is the whole point of the offload.

The subtle bug that cost the most: the AIE ↔ PL shim

A plain Vivado block design that wires a PL stream into the AI Engine's S00_AXIS stalls on siliconTREADY never asserts. The reason: a hand-built BD does not emit the paired AIE↔PL shim solution that the graph's PLIO placement requires. The binding must be co-generated by v++ --link. The fix inserts the PL stream on v++'s own post-SysLink block design via an overlay hook, leaving ai_engine_0 untouched so the correct shim solution is preserved.

Integrated Logic Analyzer captures

Talk is cheap — here is the datapath caught in the act on a real VCK190 (board chanterelle10). Four ILA taps were inserted on the live pipeline via the Versal debug automation: ETH_RX (nexmon UDP bytes), CSI_PARSED (parser output), AIE_IN (mux → AI Engine) and AIE_OUT (AIE features → DMA).

ILA tap map across the four datapath probes
Where each ILA tap carries valid data during a capture. AIE_IN and AIE_OUT are active; ETH_RX / CSI_PARSED light up on the live-Pi path.
256-sample WiFi CSI window entering the AI Engine, captured on ILA
The AIE_IN tap: 256 real WiFi-CSI samples streaming into the AI Engine, one AXI4-Stream beat each (float32, e.g. 0x3f0ec77a = 0.558).
AI Engine feature output matching the golden model
The AIE_OUT tap: the three feature values the AI Engine computed for that window — bit-identical to the software golden model.

The end-to-end numerical check, on hardware:

AIE result: mean=-0.002065 var=0.302189 power=0.302193 golden: mean=-0.002065 var=0.302189 power=0.302193 max_abs_err=5.96e-08 -> PASS
Act 6 — See it live
A room, sensed in real time

The VCK190 serves its own dashboard (pure-Python, standard-library only, no CDN — it runs on a bare PetaLinux rootfs). It consumes the AI Engine feature groups and derives presence, activity, breathing rate and fall alerts — and renders a live CSI spectrogram by stacking the streamed brt[33] windowed-DFT magnitude vectors over time (the same STFT view from Act 1, now updating in real time). Below is that dashboard's logic, replaying a representative feature stream right in your browser — empty room → someone walks in → sits and breathes → a fall → stillness:

loading live replay…

The widget above is a representative feature-stream replay running the same metrics and thresholds as the on-board dashboard — it is not a live capture. On the board, these tiles update from live AI-Engine features.

Live demo video
COMING SOON

A full live demo — Raspberry Pi streaming CSI into the VCK190, the room being sensed in real time — is being recorded. Until then, the interactive dashboard above is the teaser.

War-stories: the bugs worth remembering

The gap between "works in simulation" and "works on silicon" was where the real engineering happened. A few that cost days:

The AIE ↔ PL shim must be co-generated by v++
A hand-built Vivado BD wiring a PL stream into S00_AXIS stalls — TREADY never asserts — because it lacks the v++-generated shim solution (aieshim_solution.aiesol / aie_pl_intf.json) the PLIO placement requires. Fix: insert the PL stream on v++'s own post-SysLink BD through an overlay hook, leaving ai_engine_0 untouched. The resulting XSA carries the full shim solution and the DDR → mm2s → AIE → s2mm → DDR stream is bit-accurate on silicon.
The debug build crashed the placer — an unplaced GT quad
Adding the ILA debug cores crashed place_design in "Phase 5.1 Post Place Optimization". Root cause wasn't the ILAs alone: the co-generated Ethernet GT quad was left unplaced (the external SFP ports aren't top-level accessible under v++, and the OOC gt_quad can't be LOC'd from a top XDC). Fix: an implementation OPT_DESIGN.TCL.POST hook (gtloc_hook.tcl) LOCs the leaf cells after link — quad_inst → GTY_QUAD_X0Y5, IBUFDS_GTE5 → GTY_REFCLK_X0Y10. With both placed, place + route + write_device_image all pass.
Three integration fixes to make the AIE actually run under Linux
The SDT/PetaLinux flow doesn't wire everything the AI Engine needs, so three patches were required: (1) add interrupts-extended (the CU IRQs) to the zocl / zyxclmm_drm device-tree node; (2) bake the aie_image graph CDO into BOOT.BIN; (3) drop graph.wait() in the host, because the packaged CDO free-runs the graph — you drain s2mm instead.
The Ethernet image boots — it was a timing race, not a dead board
The larger Ethernet PDI appeared to hang PS Linux, but u-boot was actually fine: the failure was a race where u-boot auto-booted before the ~251 MB initramfs finished downloading over JTAG. Deterministic fix: interrupt u-boot autoboot over the console, download Image / boot.scr / dtb / rootfs, then booti. With that, the eth image boots Linux 6.12.40 and its ILAs capture live data end-to-end.
Technology & Tools
LayerTechnologyRole
CSI sourceRaspberry Pi + nexmon_csiWiFi NIC → per-packet CSI → UDP
Link1000BASE-X SFPPi Ethernet → VCK190 GTY
DeviceAMD Versal VCK190PS (A72) + PL + AI Engine ACAP
Packet parseVitis HLS csi_udp_parserUDP/CSI depacketize in PL
DSP + inferenceAI Engine (ADF graph)FIR / DFT / stats feature extraction
Data movementmm2s / s2mm + NoC + DDR4PLIO ↔ DDR, results to PS
ToolsVivado / Vitis 2025.2synth, AIE compiler, v++ link
Embedded OSPetaLinux 2025.2 + XRTA72 Linux, board host app
VisualizationPython stdlib SSE dashboardon-board live web UI
Publications

The research this build stands on. Full profile on Google Scholar.

Software Defined Radio (SDR) Based Sensing — Master's Thesis
Ajaya Dahal · Advisor: Ali C. Gurbuz
M.S. Electrical & Computer Engineering, Mississippi State University · 2024
SDRWiFi CSIRadarHAR
Comparison Between Wi-Fi-CSI and Radar-Based HAR
A. Dahal, S. Biswas, S. Z. Gurbuz, A. C. Gurbuz
IEEE International Radar Conference (RadarConf) · 2024 · doi:10.1109/RadarConf2458775.2024.10548515
WiFi vs RadarHARCSI
Robustness Analysis of Wi-Fi-Based Human Activity Recognition
A. Dahal, S. Biswas, S. Z. Gurbuz, A. C. Gurbuz
Proc. SPIE 13036, Defense + Commercial Sensing · 2024 · doi:10.1117/12.3014010
HARWiFi CSIRobustness
Wi-Fi Fingerprinting Based Room-Level Classification: Combining STFT and Imbalanced Learning
F. Islam, J. Farmer, A. Dahal, B. Tang, J. E. Ball, M. Young
Proc. SPIE 12122, Defense + Commercial Sensing · 2022 · doi:10.1117/12.2618717
STFTLocalizationML
Effect of the Short-Time Fourier Transform on the Classification of Complex-Valued Mobile Signals
L. Smith, N. Smith, S. Kodipaka, A. Dahal, B. Tang, J. E. Ball, M. Young
Proc. SPIE 11756, Defense + Commercial Sensing · 2021 · doi:10.1117/12.2587664
STFTComplex-valued
Real-Time Location Fingerprinting for Mobile Devices in an Indoor Setting
N. Smith, L. Smith, S. Kodipaka, A. Dahal, B. Tang, J. E. Ball, M. Young
Proc. SPIE 11756, Defense + Commercial Sensing · 2021 · doi:10.1117/12.2587679
LocalizationFingerprinting
Automatic Waveform Recognition from Complex RF Data with Filter-Based Deep Learning — B. Hicks, S. Biswas, A. Dahal, V. González, A. C. Gurbuz · NAECON 2024
Software Radio with MATLAB Toolbox for 5G NR Waveform Generation — W. AlQwider, A. Dahal, V. Marojevic · DCOSS 2022
Open-Source Software Radio Platform for Research on Cellular Networked UAVs: It Works! — V. Marojevic et al. · IEEE Communications Magazine 2022
RGB Pixel-Block Point-Cloud Fusion for Object Detection — J. E. Ball, A. Dahal et al. · Proc. SPIE 11748 2021
Adversarial Indoor Signal Detection — S. Kodipaka, A. Dahal, L. Smith, N. Smith, B. Tang, J. E. Ball, M. Young · Proc. SPIE 11756 2021
Build it yourself

The whole project is open source. I've deliberately made it turnkey — a single project_top.tcl rebuilds the Vivado design end-to-end, and the tracked petalinux/ project directory has everything you need to build the Linux image for the board.

Fully reproducible builds — a standard I hold on every reference design

Reproducibility isn't an afterthought here; it's how I ship. Across all of my reference designs the entire Vivado project rebuilds from a single tracked TCL script and the PetaLinux image from a tracked project-spec — no GUI click-throughs, no undocumented state, so anyone can clone the repo and rebuild it bit-for-bit. You'll find the exact same discipline in my Versal Ethernet and ZCU102 Ethernet reference designs. It's the same production-grade, source-controlled methodology I apply to the AMD reference designs I author professionally.

🧩

One-command Vivado build

hw/scripts/project_top.tcl conveniently builds the inline Vivado project (Ethernet → csi_udp_parser → AI Engine → DMA) into an XSA end-to-end — no manual block-design wiring.

🐧

Ready-to-build PetaLinux

The petalinux/ directory ships the full project-spec/ (recipes + configs), the SDT flow, and the boot / rootfs bring-up scripts — everything needed to build and boot this project on the VCK190.

⚙️

AIE graph + HLS parser

The AI Engine feature graph (aie/), the Vitis HLS UDP/CSI parser (hls/), the v++ co-generation flow and the on-board dashboard (live/) are all in the repo, with a phase-by-phase build log in docs/.