Detect targets and build point clouds
Run CFAR detection on a range-Doppler map, turn detections into a point cloud with angle estimates, and hand timestamped frames to a tracker.
A typical mmWave pipeline ends in a sparse point cloud: one point per detected range-Doppler cell, with an angle estimate. Frame.points() does this in one call. This page shows the one-call route, the same steps done by hand, and the hand-off to a tracker.
One call#
frame = result.frame(0)
cloud = frame.points(pfa=1e-4, guard_cells=(2, 4), training_cells=(4, 8), max_points=64)
print(len(cloud), cloud.xyz.shape, cloud.velocity_mps.shape)
columns = cloud.as_columns() # [N, 6]: x, y, z, velocity_mps, energy, range_mFrame.points runs, in order: range_doppler(window=...), combine_coherent, ca_cfar(pfa, guard_cells, training_cells) and point_cloud(route, fft_size, max_points). Its arguments and defaults:
| Argument | Default | Meaning |
|---|---|---|
pfa | 1e-4 | Design false-alarm probability of the CA-CFAR threshold |
guard_cells | (2, 4) | Guard cells on each side, as (doppler, range) |
training_cells | (4, 8) | Training cells on each side beyond the guard, as (doppler, range) |
route | "phase_comparison" | Angle estimator: "phase_comparison" or "fft2" |
fft_size | 64 | Angle FFT size |
max_points | None | Keep only the strongest cells. None keeps every detection |
window | "hann" | Slow-time window of the range-Doppler stage |
The same steps by hand#
Use the functions when you want a different detector, a range gate, or only approaching targets:
from witwin.radar.processing import ca_cfar, combine_coherent, os_cfar, point_cloud
rd = frame.range_doppler(window="hann")
magnitude = combine_coherent(rd.data) # real [doppler, range]
detections = os_cfar(magnitude, guard_cells=(2, 3), training_cells=(4, 6), rank_fraction=0.75, pfa=1e-3)
cloud = point_cloud(
detections, rd, frame.array(),
route="phase_comparison", fft_size=64, # "fft2" needs num_tx >= 4
range_gate_m=(0.5, 6.0), # keep 0.5 m <= range < 6 m
positive_velocity_only=True, # only closing targets
max_points=128,
)ca_cfaraverages the training cells.os_cfaruses an ordered statistic, which is robust when a second target sits in the training ring. Foros_cfar,pfais the cell-averaging design constant and the achieved rate is lower, so it errs on the conservative side.ca_cfar_1d(profile, guard_cells=2, training_cells=8, pfa=1e-3)detects along range only.detections.maskis a boolean map anddetections.thresholdthe level each cell had to beat.detections.countis a device tensor, so reading it as a Python number synchronizes the GPU.point_cloudneeds a 2-D mask over[doppler, range]and the full[TX, RX, doppler, range]map.
Angle estimators#
| Route | Needs | Accuracy |
|---|---|---|
"phase_comparison" | A virtual array with an elevation sub-aperture, such as the 3-TX layout in the quickstart | Coarse elevation: corrects elevation with the azimuth FFT peak bin |
"fft2" | A planar virtual array with num_tx of at least 4 | Reads both angles from one 2-D FFT |
Both apply TDM phase compensation for the measured velocity before estimating the angle.
Point cloud frame#
Coordinates are metres in the array's local frame: x along the array's horizontal axis, y along the boresight, z in elevation. To get world coordinates, rotate by the radar pose: local array (x, y, z) maps to the pose's right, forward (look direction) and up axes.
velocity_mps is closing-positive. energy is in dB relative to one unit of the map's amplitude, so levels compare across waveforms. Cells whose boresight component is zero are dropped.
Hand off to a tracker#
from witwin.radar.processing import DetectionFrame, TrackHandoff
handoff = TrackHandoff() # uses nearest_neighbour_associator by default
for k, result in enumerate(radar.stream(scene, targets, times=times)):
cloud = result.frame(0).points(max_points=64)
track_ids = handoff.push(DetectionFrame.from_point_cloud(cloud, time_s=times[k], frame_index=k))
print(handoff.track_count, handoff.track(0)[:3])TrackHandoff stores frames and calls an associator. It has no motion model, gate or track lifetime of its own. The default nearest_neighbour_associator (gate 1 m) predicts each detection forward along its radial velocity and matches greedily; it is a reference implementation, not a production tracker. Pass your own associator(previous, current, elapsed_s) that returns, for each current detection, the index of the matched previous detection or -1 for a new track. Frame times must increase.
For a learned model, DetectionFrame.as_fixed_size(size, generator=...) returns a [size, 6] tensor: zeros for an empty frame, resampling with replacement when there are fewer points than size, and sampling without replacement when there are more.
Note
Detection, point cloud, angle estimation and tracking are not differentiable. They refuse inputs that carry a gradient. Write losses on the range-Doppler map instead.