mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-19 05:10:13 -05:00
Merge PR #2144 (aepod) into release integration
This commit is contained in:
commit
e223d86ba2
7 files changed
+952
-1
No files matched your search
@@ -0,0 +1,150 @@
|
||||
//! Label RuView recordings with occupancy from radar logs.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p ruview-groundtruth --example radar_autolabel -- \
|
||||
//! --session mon data/recordings/mon.csi.jsonl radar/mon.jsonl \
|
||||
//! --session tue data/recordings/tue.csi.jsonl radar/tue.jsonl \
|
||||
//! --test-session tue --offset-ms 0 --out labels.jsonl
|
||||
//! ```
|
||||
//!
|
||||
//! Writes one labelled window per line (to `--out`, or stdout) and a JSON
|
||||
//! summary to stderr: per-session kept/skipped counts and, when
|
||||
//! `--test-session` is given, the session-disjoint majority/mean baseline.
|
||||
//! `--offset-ms` is the recording clock minus the radar clock and applies to
|
||||
//! every session. `--allow-unverified` accepts radar windows whose source is
|
||||
//! not flagged as verified hardware.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use ruview_groundtruth::{
|
||||
auto_label, majority_baseline, parse_radar_jsonl, parse_recording_jsonl,
|
||||
session_disjoint_split, AutoLabelConfig, LabelledWindow, RadarAdapterConfig, RadarLabels,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
struct Args {
|
||||
sessions: Vec<(String, String, String)>,
|
||||
test_sessions: Vec<String>,
|
||||
out: Option<String>,
|
||||
label: AutoLabelConfig,
|
||||
radar: RadarAdapterConfig,
|
||||
}
|
||||
|
||||
fn next(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
|
||||
it.next().ok_or_else(|| format!("{flag} needs a value"))
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut a = Args {
|
||||
sessions: Vec::new(),
|
||||
test_sessions: Vec::new(),
|
||||
out: None,
|
||||
label: AutoLabelConfig::default(),
|
||||
radar: RadarAdapterConfig::default(),
|
||||
};
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(flag) = it.next() {
|
||||
match flag.as_str() {
|
||||
"--session" => {
|
||||
let name = next(&mut it, "--session")?;
|
||||
let rec = next(&mut it, "--session")?;
|
||||
let radar = next(&mut it, "--session")?;
|
||||
a.sessions.push((name, rec, radar));
|
||||
}
|
||||
"--test-session" => a.test_sessions.push(next(&mut it, &flag)?),
|
||||
"--out" => a.out = Some(next(&mut it, &flag)?),
|
||||
"--offset-ms" => {
|
||||
a.label.radar_offset_ms = next(&mut it, &flag)?
|
||||
.parse()
|
||||
.map_err(|e| format!("--offset-ms: {e}"))?;
|
||||
}
|
||||
"--min-frames" => {
|
||||
a.label.min_csi_frames = next(&mut it, &flag)?
|
||||
.parse()
|
||||
.map_err(|e| format!("--min-frames: {e}"))?;
|
||||
}
|
||||
"--empty-hold-ms" => {
|
||||
a.label.empty_hold_ms = next(&mut it, &flag)?
|
||||
.parse()
|
||||
.map_err(|e| format!("--empty-hold-ms: {e}"))?;
|
||||
}
|
||||
"--allow-unverified" => a.radar.require_verified = false,
|
||||
other => return Err(format!("unknown argument {other}")),
|
||||
}
|
||||
}
|
||||
if a.sessions.is_empty() {
|
||||
return Err("at least one --session NAME RECORDING RADAR is required".into());
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args = parse_args()?;
|
||||
let mut all: Vec<LabelledWindow> = Vec::new();
|
||||
let mut per_session = Vec::new();
|
||||
|
||||
for (name, rec_path, radar_path) in &args.sessions {
|
||||
let rec = fs::read_to_string(rec_path).map_err(|e| format!("{rec_path}: {e}"))?;
|
||||
let rad = fs::read_to_string(radar_path).map_err(|e| format!("{radar_path}: {e}"))?;
|
||||
let frames = parse_recording_jsonl(&rec).map_err(|e| format!("{rec_path}: {e}"))?;
|
||||
let windows = parse_radar_jsonl(&rad).map_err(|e| format!("{radar_path}: {e}"))?;
|
||||
let labels = RadarLabels::from_windows(name, "radar", &windows, args.radar)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let out = auto_label(name, &frames, &labels, args.label).map_err(|e| e.to_string())?;
|
||||
per_session.push(json!({
|
||||
"session": name,
|
||||
"csi_frames": frames.len(),
|
||||
"radar_windows": windows.len(),
|
||||
"kept": out.windows.len(),
|
||||
"skipped": out.skipped,
|
||||
"radar_unknown": out.radar_unknown,
|
||||
}));
|
||||
all.extend(out.windows);
|
||||
}
|
||||
|
||||
let mut sink: Box<dyn Write> = match &args.out {
|
||||
Some(p) => Box::new(fs::File::create(p).map_err(|e| format!("{p}: {e}"))?),
|
||||
None => Box::new(std::io::stdout().lock()),
|
||||
};
|
||||
for w in &all {
|
||||
let line = serde_json::to_string(w).map_err(|e| e.to_string())?;
|
||||
writeln!(sink, "{line}").map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let baseline = if args.test_sessions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let names: Vec<&str> = args.test_sessions.iter().map(String::as_str).collect();
|
||||
let split = session_disjoint_split(&all, &names).map_err(|e| e.to_string())?;
|
||||
let b = majority_baseline(&split.train, &split.test).map_err(|e| e.to_string())?;
|
||||
Some(json!({
|
||||
"train_sessions": split.train_sessions,
|
||||
"test_sessions": split.test_sessions,
|
||||
"baseline": b,
|
||||
}))
|
||||
};
|
||||
let summary = json!({
|
||||
"offset_ms": args.label.radar_offset_ms,
|
||||
"label_config": args.label,
|
||||
"radar_config": args.radar,
|
||||
"sessions": per_session,
|
||||
"split": baseline,
|
||||
});
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary).map_err(|e| e.to_string())?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("radar_autolabel: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
//! Occupancy auto-labelling from a RuView recording plus a radar log.
|
||||
//!
|
||||
//! A RuView recording (`.csi.jsonl`, one line per CSI frame in the
|
||||
//! `RecordedFrame` shape of the sensing server's `recording.rs`) and a radar log (see [`crate::radar`]) cover the same session. This
|
||||
//! module cuts the recording at the radar's window boundaries and gives each
|
||||
//! cut an occupancy label (empty / occupied) and a person count taken from the
|
||||
//! radar. The output is training and evaluation data for CSI occupancy models;
|
||||
//! the radar never becomes an input to them.
|
||||
//!
|
||||
//! The radar and the recording are stamped by different clocks. The offset
|
||||
//! between them is a required, reported parameter, never estimated silently.
|
||||
//!
|
||||
//! Windows are dropped, with a counted reason, when the radar window has no
|
||||
//! label, when too few CSI frames fall inside it, or when it says "empty" too
|
||||
//! soon after an occupied window (a tracker can lose a person who stops
|
||||
//! moving, so a short gap is more likely a miss than an exit).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{check_bound, check_nonempty, GroundTruthError};
|
||||
use crate::radar::{LabelStatus, RadarLabels, UnknownReason};
|
||||
use crate::series::MAX_SAMPLES;
|
||||
|
||||
/// The largest recording line accepted, in bytes, bounding allocation.
|
||||
pub const MAX_RECORDING_LINE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RecordedFrameLine {
|
||||
timestamp: f64,
|
||||
#[serde(default)]
|
||||
subcarriers: Vec<f64>,
|
||||
#[serde(default)]
|
||||
rssi: Option<f64>,
|
||||
}
|
||||
|
||||
/// One CSI frame from a recording, reduced to what windowing needs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiFrame {
|
||||
/// Frame time on the recording clock, Unix milliseconds.
|
||||
pub at_unix_ms: i64,
|
||||
/// Received signal strength, dBm, when recorded.
|
||||
pub rssi: Option<f64>,
|
||||
/// Mean subcarrier amplitude of the frame, when it has subcarriers.
|
||||
pub amplitude_mean: Option<f64>,
|
||||
}
|
||||
|
||||
/// Parse a RuView `.csi.jsonl` recording. Blank lines are skipped; a malformed
|
||||
/// line or a non-finite timestamp is a row-numbered error.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidRow`] or [`GroundTruthError::TooManySamples`].
|
||||
pub fn parse_recording_jsonl(text: &str) -> Result<Vec<CsiFrame>, GroundTruthError> {
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let row = i + 1;
|
||||
if line.len() > MAX_RECORDING_LINE_BYTES {
|
||||
return Err(GroundTruthError::InvalidRow {
|
||||
row,
|
||||
reason: format!(
|
||||
"line is {} bytes, max {MAX_RECORDING_LINE_BYTES}",
|
||||
line.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
let f: RecordedFrameLine =
|
||||
serde_json::from_str(line).map_err(|e| GroundTruthError::InvalidRow {
|
||||
row,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if !f.timestamp.is_finite() || f.timestamp < 0.0 {
|
||||
return Err(GroundTruthError::InvalidRow {
|
||||
row,
|
||||
reason: "timestamp must be finite, non-negative Unix seconds".into(),
|
||||
});
|
||||
}
|
||||
let finite: Vec<f64> = f
|
||||
.subcarriers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|v| v.is_finite())
|
||||
.collect();
|
||||
out.push(CsiFrame {
|
||||
at_unix_ms: (f.timestamp * 1000.0).round() as i64,
|
||||
rssi: f.rssi.filter(|r| r.is_finite()),
|
||||
amplitude_mean: (!finite.is_empty())
|
||||
.then(|| finite.iter().map(|v| v.abs()).sum::<f64>() / finite.len() as f64),
|
||||
});
|
||||
if out.len() > MAX_SAMPLES {
|
||||
return Err(GroundTruthError::TooManySamples {
|
||||
len: out.len(),
|
||||
max: MAX_SAMPLES,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Occupancy class of a window.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Occupancy {
|
||||
/// The radar saw nobody.
|
||||
Empty,
|
||||
/// The radar saw at least one person.
|
||||
Occupied,
|
||||
}
|
||||
|
||||
/// A compact summary of the CSI frames inside one window.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiWindowSummary {
|
||||
/// Frames in the window.
|
||||
pub n_frames: usize,
|
||||
/// Mean RSSI over frames that recorded one.
|
||||
pub rssi_mean: Option<f64>,
|
||||
/// Mean of per-frame mean amplitude.
|
||||
pub amplitude_mean: Option<f64>,
|
||||
/// Population variance of per-frame mean amplitude, a crude motion proxy.
|
||||
pub amplitude_var: Option<f64>,
|
||||
}
|
||||
|
||||
fn mean_var(xs: &[f64]) -> (Option<f64>, Option<f64>) {
|
||||
if xs.is_empty() {
|
||||
return (None, None);
|
||||
}
|
||||
let n = xs.len() as f64;
|
||||
let m = xs.iter().sum::<f64>() / n;
|
||||
let v = xs.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / n;
|
||||
(Some(m), Some(v))
|
||||
}
|
||||
|
||||
fn summarize(frames: &[CsiFrame]) -> CsiWindowSummary {
|
||||
let rssi: Vec<f64> = frames.iter().filter_map(|f| f.rssi).collect();
|
||||
let amp: Vec<f64> = frames.iter().filter_map(|f| f.amplitude_mean).collect();
|
||||
let (amplitude_mean, amplitude_var) = mean_var(&);
|
||||
CsiWindowSummary {
|
||||
n_frames: frames.len(),
|
||||
rssi_mean: mean_var(&rssi).0,
|
||||
amplitude_mean,
|
||||
amplitude_var,
|
||||
}
|
||||
}
|
||||
|
||||
/// One labelled window of a recording.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LabelledWindow {
|
||||
/// Session (recording) name; the unit of a disjoint split.
|
||||
pub session: String,
|
||||
/// Window start on the recording clock, Unix milliseconds.
|
||||
pub start_ms: i64,
|
||||
/// Window end on the recording clock, Unix milliseconds.
|
||||
pub end_ms: i64,
|
||||
/// Occupancy label.
|
||||
pub occupancy: Occupancy,
|
||||
/// Person count, when the radar reported one.
|
||||
pub person_count: Option<u32>,
|
||||
/// Lowest count the radar window carried.
|
||||
pub count_low: Option<u32>,
|
||||
/// Highest count the radar window carried.
|
||||
pub count_high: Option<u32>,
|
||||
/// Index of the radar window in its log.
|
||||
pub radar_window_index: usize,
|
||||
/// Summary of the CSI frames in the window.
|
||||
pub csi: CsiWindowSummary,
|
||||
}
|
||||
|
||||
/// Why a radar window produced no labelled window.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkipReason {
|
||||
/// The radar window had no label.
|
||||
RadarUnknown,
|
||||
/// Fewer CSI frames than `min_csi_frames` fell inside the window.
|
||||
TooFewCsiFrames,
|
||||
/// An empty label within `empty_hold_ms` of an occupied one.
|
||||
RecentlyOccupied,
|
||||
}
|
||||
|
||||
/// How a session is labelled.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AutoLabelConfig {
|
||||
/// Added to radar timestamps to put them on the recording clock,
|
||||
/// milliseconds (recording clock minus radar clock).
|
||||
pub radar_offset_ms: i64,
|
||||
/// Fewest CSI frames a window needs to be kept.
|
||||
pub min_csi_frames: usize,
|
||||
/// An empty window starting within this long after an occupied window
|
||||
/// ends is dropped, milliseconds. `0` keeps every empty window.
|
||||
pub empty_hold_ms: i64,
|
||||
}
|
||||
|
||||
impl Default for AutoLabelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
radar_offset_ms: 0,
|
||||
min_csi_frames: 5,
|
||||
empty_hold_ms: 5_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The labelled windows of one session plus what was dropped and why.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AutoLabelOutput {
|
||||
/// Session name.
|
||||
pub session: String,
|
||||
/// The configuration used, including the clock offset.
|
||||
pub config: AutoLabelConfig,
|
||||
/// Kept windows, in time order.
|
||||
pub windows: Vec<LabelledWindow>,
|
||||
/// Dropped radar windows by reason.
|
||||
pub skipped: BTreeMap<SkipReason, usize>,
|
||||
/// Unlabelled radar windows by the radar adapter's reason.
|
||||
pub radar_unknown: BTreeMap<UnknownReason, usize>,
|
||||
}
|
||||
|
||||
/// Label one session.
|
||||
///
|
||||
/// `frames` need not be sorted. Radar labels are processed in time order.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::EmptyField`]/[`GroundTruthError::TooLong`] for a bad
|
||||
/// session name, or [`GroundTruthError::InvalidConfig`] for a negative hold.
|
||||
pub fn auto_label(
|
||||
session: &str,
|
||||
frames: &[CsiFrame],
|
||||
radar: &RadarLabels,
|
||||
cfg: AutoLabelConfig,
|
||||
) -> Result<AutoLabelOutput, GroundTruthError> {
|
||||
check_nonempty("session", session)?;
|
||||
check_bound("session", session)?;
|
||||
if cfg.empty_hold_ms < 0 {
|
||||
return Err(GroundTruthError::InvalidConfig {
|
||||
reason: "empty_hold_ms must be non-negative",
|
||||
});
|
||||
}
|
||||
let mut frames = frames.to_vec();
|
||||
frames.sort_by_key(|f| f.at_unix_ms);
|
||||
|
||||
let mut labels: Vec<_> = radar.labels.iter().collect();
|
||||
labels.sort_by_key(|l| (l.window_start_ms, l.window_end_ms));
|
||||
|
||||
let mut out = AutoLabelOutput {
|
||||
session: session.to_owned(),
|
||||
config: cfg,
|
||||
windows: Vec::new(),
|
||||
skipped: BTreeMap::new(),
|
||||
radar_unknown: BTreeMap::new(),
|
||||
};
|
||||
let mut last_occupied_end: Option<i64> = None;
|
||||
|
||||
for l in labels {
|
||||
if let LabelStatus::Unknown(r) = l.status {
|
||||
*out.radar_unknown.entry(r).or_default() += 1;
|
||||
*out.skipped.entry(SkipReason::RadarUnknown).or_default() += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(present) = l.presence else {
|
||||
*out.skipped.entry(SkipReason::RadarUnknown).or_default() += 1;
|
||||
continue;
|
||||
};
|
||||
let start = l.window_start_ms.saturating_add(cfg.radar_offset_ms);
|
||||
let end = l.window_end_ms.saturating_add(cfg.radar_offset_ms);
|
||||
let occupancy = if present {
|
||||
Occupancy::Occupied
|
||||
} else {
|
||||
Occupancy::Empty
|
||||
};
|
||||
|
||||
if occupancy == Occupancy::Occupied {
|
||||
last_occupied_end = Some(end);
|
||||
} else if matches!(last_occupied_end, Some(e) if start.saturating_sub(e) < cfg.empty_hold_ms)
|
||||
{
|
||||
*out.skipped.entry(SkipReason::RecentlyOccupied).or_default() += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Half-open [start, end); a zero-length window takes the frames at `start`.
|
||||
let lo = frames.partition_point(|f| f.at_unix_ms < start);
|
||||
let hi = if end > start {
|
||||
frames.partition_point(|f| f.at_unix_ms < end)
|
||||
} else {
|
||||
frames.partition_point(|f| f.at_unix_ms <= start)
|
||||
};
|
||||
let inside = &frames[lo..hi];
|
||||
if inside.len() < cfg.min_csi_frames.max(1) {
|
||||
*out.skipped.entry(SkipReason::TooFewCsiFrames).or_default() += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.windows.push(LabelledWindow {
|
||||
session: session.to_owned(),
|
||||
start_ms: start,
|
||||
end_ms: end,
|
||||
occupancy,
|
||||
person_count: l.person_count,
|
||||
count_low: l.uncertainty.count_low,
|
||||
count_high: l.uncertainty.count_high,
|
||||
radar_window_index: l.window_index,
|
||||
csi: summarize(inside),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::radar::{RadarAdapterConfig, RadarWindow, RadarWindowSource};
|
||||
|
||||
/// Synthetic CSI at 10 Hz from `t0` for `secs` seconds.
|
||||
fn csi(t0_ms: i64, secs: i64) -> Vec<CsiFrame> {
|
||||
(0..secs * 10)
|
||||
.map(|i| CsiFrame {
|
||||
at_unix_ms: t0_ms + i * 100,
|
||||
rssi: Some(-50.0),
|
||||
amplitude_mean: Some(10.0 + (i % 3) as f64),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn radar(t0_ms: i64, counts: &[Option<u32>]) -> RadarLabels {
|
||||
let windows: Vec<RadarWindow> = counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| RadarWindow {
|
||||
timestamp_ms: t0_ms + (i as i64 + 1) * 1000,
|
||||
window_start_ms: Some(t0_ms + i as i64 * 1000),
|
||||
health: Some("ok".into()),
|
||||
quality: Some(1.0),
|
||||
source: Some(RadarWindowSource {
|
||||
verified: c.is_some(),
|
||||
simulated: false,
|
||||
}),
|
||||
presence: None,
|
||||
target_count: Some(c.unwrap_or(0)),
|
||||
max_target_count: None,
|
||||
distance_cm: None,
|
||||
targets: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
RadarLabels::from_windows("radar-1", "ld2450", &windows, RadarAdapterConfig::default())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_recording_lines() {
|
||||
let text = r#"{"timestamp":1700000000.25,"subcarriers":[1.0,-3.0],"rssi":-48.0,"noise_floor":-90.0,"features":{}}
|
||||
|
||||
{"timestamp":1700000000.35,"subcarriers":[],"rssi":-49.0,"noise_floor":-90.0,"features":{}}"#;
|
||||
let f = parse_recording_jsonl(text).unwrap();
|
||||
assert_eq!(f.len(), 2);
|
||||
assert_eq!(f[0].at_unix_ms, 1_700_000_000_250);
|
||||
assert_eq!(f[0].amplitude_mean, Some(2.0));
|
||||
assert_eq!(f[1].amplitude_mean, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_recording_rows_are_numbered() {
|
||||
let err = parse_recording_jsonl("{\"timestamp\":1.0}\nnope").unwrap_err();
|
||||
assert!(matches!(err, GroundTruthError::InvalidRow { row: 2, .. }));
|
||||
let err = parse_recording_jsonl("{\"timestamp\":-1.0}").unwrap_err();
|
||||
assert!(matches!(err, GroundTruthError::InvalidRow { row: 1, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_follow_radar_windows_and_count_frames() {
|
||||
let r = radar(0, &[Some(0), Some(1), Some(2)]);
|
||||
let cfg = AutoLabelConfig {
|
||||
empty_hold_ms: 0,
|
||||
..AutoLabelConfig::default()
|
||||
};
|
||||
let out = auto_label("s1", &csi(0, 3), &r, cfg).unwrap();
|
||||
assert_eq!(out.windows.len(), 3);
|
||||
assert_eq!(out.windows[0].occupancy, Occupancy::Empty);
|
||||
assert_eq!(out.windows[2].person_count, Some(2));
|
||||
assert!(out.windows.iter().all(|w| w.csi.n_frames == 10));
|
||||
assert_eq!(out.windows[1].csi.rssi_mean, Some(-50.0));
|
||||
assert!(out.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_offset_moves_radar_onto_the_recording() {
|
||||
// Radar clock runs 60 s behind the recording.
|
||||
let r = radar(0, &[Some(1), Some(1)]);
|
||||
let frames = csi(60_000, 2);
|
||||
let none = auto_label("s1", &frames, &r, AutoLabelConfig::default()).unwrap();
|
||||
assert_eq!(none.windows.len(), 0);
|
||||
assert_eq!(none.skipped.get(&SkipReason::TooFewCsiFrames), Some(&2));
|
||||
|
||||
let cfg = AutoLabelConfig {
|
||||
radar_offset_ms: 60_000,
|
||||
..AutoLabelConfig::default()
|
||||
};
|
||||
let out = auto_label("s1", &frames, &r, cfg).unwrap();
|
||||
assert_eq!(out.windows.len(), 2);
|
||||
assert_eq!(out.windows[0].start_ms, 60_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_empty_gap_after_occupancy_is_dropped() {
|
||||
let r = radar(
|
||||
0,
|
||||
&[
|
||||
Some(1),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
],
|
||||
);
|
||||
let cfg = AutoLabelConfig {
|
||||
empty_hold_ms: 3_000,
|
||||
..AutoLabelConfig::default()
|
||||
};
|
||||
let out = auto_label("s1", &csi(0, 7), &r, cfg).unwrap();
|
||||
// Occupied ends at 1000; empties starting at 1000, 2000, 3000 are within 3 s.
|
||||
assert_eq!(out.skipped.get(&SkipReason::RecentlyOccupied), Some(&3));
|
||||
let kept: Vec<_> = out
|
||||
.windows
|
||||
.iter()
|
||||
.map(|w| (w.start_ms, w.occupancy))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kept,
|
||||
[
|
||||
(0, Occupancy::Occupied),
|
||||
(4000, Occupancy::Empty),
|
||||
(5000, Occupancy::Empty),
|
||||
(6000, Occupancy::Empty)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_radar_windows_are_counted_by_reason() {
|
||||
let r = radar(0, &[Some(1), None, Some(1)]);
|
||||
let out = auto_label("s1", &csi(0, 3), &r, AutoLabelConfig::default()).unwrap();
|
||||
assert_eq!(out.windows.len(), 2);
|
||||
assert_eq!(out.skipped.get(&SkipReason::RadarUnknown), Some(&1));
|
||||
assert_eq!(out.radar_unknown.get(&UnknownReason::Unverified), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_inputs_are_rejected() {
|
||||
let r = radar(0, &[Some(1)]);
|
||||
assert!(auto_label("", &[], &r, AutoLabelConfig::default()).is_err());
|
||||
let cfg = AutoLabelConfig {
|
||||
empty_hold_ms: -1,
|
||||
..AutoLabelConfig::default()
|
||||
};
|
||||
assert!(auto_label("s", &[], &r, cfg).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Session-disjoint splits and the trivial baseline for occupancy labels.
|
||||
//!
|
||||
//! Windows from one session are strongly correlated, so a split that puts
|
||||
//! windows of the same session on both sides leaks. [`session_disjoint_split`]
|
||||
//! splits by session only and refuses a split that cannot be disjoint.
|
||||
//!
|
||||
//! An occupancy or count accuracy means little without the score of a model
|
||||
//! that ignores the CSI entirely. [`majority_baseline`] fits that model on the
|
||||
//! training side (majority occupancy class, mean person count) and scores it
|
||||
//! on the test side. Report a model's numbers next to this baseline.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::autolabel::{LabelledWindow, Occupancy};
|
||||
use crate::error::GroundTruthError;
|
||||
|
||||
/// Train and test windows with no session on both sides.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionSplit {
|
||||
/// Sessions on the training side, sorted.
|
||||
pub train_sessions: Vec<String>,
|
||||
/// Sessions on the test side, sorted.
|
||||
pub test_sessions: Vec<String>,
|
||||
/// Training windows.
|
||||
pub train: Vec<LabelledWindow>,
|
||||
/// Test windows.
|
||||
pub test: Vec<LabelledWindow>,
|
||||
}
|
||||
|
||||
/// Split windows by session: sessions named in `test_sessions` go to the test
|
||||
/// side, every other session to training.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidSplit`] when a named test session has no
|
||||
/// windows, or when either side would be empty.
|
||||
pub fn session_disjoint_split(
|
||||
windows: &[LabelledWindow],
|
||||
test_sessions: &[&str],
|
||||
) -> Result<SessionSplit, GroundTruthError> {
|
||||
let present: BTreeSet<&str> = windows.iter().map(|w| w.session.as_str()).collect();
|
||||
let test: BTreeSet<&str> = test_sessions.iter().copied().collect();
|
||||
if test.iter().any(|s| !present.contains(s)) {
|
||||
return Err(GroundTruthError::InvalidSplit {
|
||||
reason: "a test session has no labelled windows",
|
||||
});
|
||||
}
|
||||
let (te, tr): (Vec<_>, Vec<_>) = windows
|
||||
.iter()
|
||||
.cloned()
|
||||
.partition(|w| test.contains(w.session.as_str()));
|
||||
if te.is_empty() || tr.is_empty() {
|
||||
return Err(GroundTruthError::InvalidSplit {
|
||||
reason: "both sides of a session split need at least one session",
|
||||
});
|
||||
}
|
||||
Ok(SessionSplit {
|
||||
train_sessions: present.difference(&test).map(|s| (*s).to_owned()).collect(),
|
||||
test_sessions: test.iter().map(|s| (*s).to_owned()).collect(),
|
||||
train: tr,
|
||||
test: te,
|
||||
})
|
||||
}
|
||||
|
||||
/// The no-CSI baseline, fitted on training windows and scored on test windows.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BaselineReport {
|
||||
/// Training windows.
|
||||
pub train_windows: usize,
|
||||
/// Test windows.
|
||||
pub test_windows: usize,
|
||||
/// Test windows per occupancy class.
|
||||
pub test_class_counts: BTreeMap<Occupancy, usize>,
|
||||
/// The training majority class (ties go to `Empty`).
|
||||
pub majority_occupancy: Occupancy,
|
||||
/// Accuracy of always predicting the majority class on the test side.
|
||||
pub majority_accuracy: f64,
|
||||
/// Mean training person count, when any training window has a count.
|
||||
pub mean_count: Option<f64>,
|
||||
/// Mean absolute error of predicting `mean_count` on test windows with a
|
||||
/// count.
|
||||
pub count_mae: Option<f64>,
|
||||
/// Test windows with a count.
|
||||
pub count_test_windows: usize,
|
||||
}
|
||||
|
||||
/// Fit and score the majority-class / mean-count baseline.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidSplit`] when either side is empty.
|
||||
pub fn majority_baseline(
|
||||
train: &[LabelledWindow],
|
||||
test: &[LabelledWindow],
|
||||
) -> Result<BaselineReport, GroundTruthError> {
|
||||
if train.is_empty() || test.is_empty() {
|
||||
return Err(GroundTruthError::InvalidSplit {
|
||||
reason: "baseline needs training and test windows",
|
||||
});
|
||||
}
|
||||
let occupied = train
|
||||
.iter()
|
||||
.filter(|w| w.occupancy == Occupancy::Occupied)
|
||||
.count();
|
||||
let majority_occupancy = if occupied * 2 > train.len() {
|
||||
Occupancy::Occupied
|
||||
} else {
|
||||
Occupancy::Empty
|
||||
};
|
||||
|
||||
let mut test_class_counts = BTreeMap::new();
|
||||
for w in test {
|
||||
*test_class_counts.entry(w.occupancy).or_insert(0usize) += 1;
|
||||
}
|
||||
let hits = test_class_counts
|
||||
.get(&majority_occupancy)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
let train_counts: Vec<f64> = train
|
||||
.iter()
|
||||
.filter_map(|w| w.person_count)
|
||||
.map(f64::from)
|
||||
.collect();
|
||||
let mean_count = (!train_counts.is_empty())
|
||||
.then(|| train_counts.iter().sum::<f64>() / train_counts.len() as f64);
|
||||
let test_counts: Vec<f64> = test
|
||||
.iter()
|
||||
.filter_map(|w| w.person_count)
|
||||
.map(f64::from)
|
||||
.collect();
|
||||
let count_mae = match mean_count {
|
||||
Some(m) if !test_counts.is_empty() => {
|
||||
Some(test_counts.iter().map(|c| (c - m).abs()).sum::<f64>() / test_counts.len() as f64)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(BaselineReport {
|
||||
train_windows: train.len(),
|
||||
test_windows: test.len(),
|
||||
test_class_counts,
|
||||
majority_occupancy,
|
||||
majority_accuracy: hits as f64 / test.len() as f64,
|
||||
mean_count,
|
||||
count_mae,
|
||||
count_test_windows: test_counts.len(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::autolabel::CsiWindowSummary;
|
||||
|
||||
fn w(session: &str, i: i64, count: u32) -> LabelledWindow {
|
||||
LabelledWindow {
|
||||
session: session.into(),
|
||||
start_ms: i * 1000,
|
||||
end_ms: i * 1000 + 1000,
|
||||
occupancy: if count > 0 {
|
||||
Occupancy::Occupied
|
||||
} else {
|
||||
Occupancy::Empty
|
||||
},
|
||||
person_count: Some(count),
|
||||
count_low: Some(count),
|
||||
count_high: Some(count),
|
||||
radar_window_index: i as usize,
|
||||
csi: CsiWindowSummary {
|
||||
n_frames: 10,
|
||||
rssi_mean: None,
|
||||
amplitude_mean: None,
|
||||
amplitude_var: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn windows() -> Vec<LabelledWindow> {
|
||||
let mut v = Vec::new();
|
||||
for (i, c) in [0, 1, 1, 2].iter().enumerate() {
|
||||
v.push(w("mon", i as i64, *c));
|
||||
}
|
||||
for (i, c) in [0, 0, 1].iter().enumerate() {
|
||||
v.push(w("tue", i as i64, *c));
|
||||
}
|
||||
for (i, c) in [1, 0].iter().enumerate() {
|
||||
v.push(w("wed", i as i64, *c));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_sessions_on_one_side() {
|
||||
let s = session_disjoint_split(&windows(), &["tue"]).unwrap();
|
||||
assert_eq!(s.train_sessions, ["mon", "wed"]);
|
||||
assert_eq!(s.test_sessions, ["tue"]);
|
||||
assert_eq!((s.train.len(), s.test.len()), (6, 3));
|
||||
let train: BTreeSet<_> = s.train.iter().map(|w| &w.session).collect();
|
||||
assert!(s.test.iter().all(|w| !train.contains(&w.session)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impossible_splits_are_refused() {
|
||||
let all = windows();
|
||||
assert!(session_disjoint_split(&all, &[]).is_err());
|
||||
assert!(session_disjoint_split(&all, &["mon", "tue", "wed"]).is_err());
|
||||
assert!(session_disjoint_split(&all, &["thu"]).is_err());
|
||||
let one: Vec<_> = all.into_iter().filter(|w| w.session == "mon").collect();
|
||||
assert!(session_disjoint_split(&one, &["mon"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_matches_hand_computed_fixture() {
|
||||
let s = session_disjoint_split(&windows(), &["tue"]).unwrap();
|
||||
// Train: mon [0,1,1,2] + wed [1,0] -> 4 occupied of 6, mean count 5/6.
|
||||
// Test: tue [0,0,1] -> majority "occupied" is right once in three.
|
||||
let b = majority_baseline(&s.train, &s.test).unwrap();
|
||||
assert_eq!(b.majority_occupancy, Occupancy::Occupied);
|
||||
assert!((b.majority_accuracy - 1.0 / 3.0).abs() < 1e-12);
|
||||
let m = 5.0 / 6.0;
|
||||
assert!((b.mean_count.unwrap() - m).abs() < 1e-12);
|
||||
let mae = (m + m + (1.0 - m)) / 3.0;
|
||||
assert!((b.count_mae.unwrap() - mae).abs() < 1e-12);
|
||||
assert_eq!(b.count_test_windows, 3);
|
||||
assert_eq!(b.test_class_counts.get(&Occupancy::Empty), Some(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tie_goes_to_empty_and_missing_counts_are_none() {
|
||||
let mut train = vec![w("a", 0, 0), w("a", 1, 1)];
|
||||
for t in &mut train {
|
||||
t.person_count = None;
|
||||
}
|
||||
let test = vec![w("b", 0, 0)];
|
||||
let b = majority_baseline(&train, &test).unwrap();
|
||||
assert_eq!(b.majority_occupancy, Occupancy::Empty);
|
||||
assert_eq!(b.majority_accuracy, 1.0);
|
||||
assert_eq!((b.mean_count, b.count_mae), (None, None));
|
||||
assert!(majority_baseline(&[], &test).is_err());
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,13 @@ pub enum GroundTruthError {
|
||||
/// Human-readable reason.
|
||||
reason: String,
|
||||
},
|
||||
/// A train/test split could not be made session-disjoint, or a side was
|
||||
/// empty.
|
||||
#[error("invalid split: {reason}")]
|
||||
InvalidSplit {
|
||||
/// Human-readable reason.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// A failure raised by the [`ruview_evidence`] ledger boundary when
|
||||
/// emitting a record.
|
||||
#[error(transparent)]
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
//! Radar reference logs (one JSON summary per window) enter through
|
||||
//! [`RadarLabels`], which turns each window into presence, count and range
|
||||
//! labels with an uncertainty and a reason when a window cannot be labelled.
|
||||
//! [`auto_label`] pairs those labels with a RuView recording to make
|
||||
//! occupancy training windows; [`session_disjoint_split`] and
|
||||
//! [`majority_baseline`] give the split and the baseline any accuracy built
|
||||
//! on them must be reported against (see `examples/radar_autolabel.rs`).
|
||||
//!
|
||||
//! ## Honesty and determinism
|
||||
//!
|
||||
@@ -52,6 +56,8 @@
|
||||
|
||||
mod agreement;
|
||||
mod align;
|
||||
mod autolabel;
|
||||
mod baseline;
|
||||
mod error;
|
||||
mod model;
|
||||
mod radar;
|
||||
@@ -63,6 +69,11 @@ pub use agreement::{AgreementMetrics, AgreementReport, EvidenceGrade, GradingPol
|
||||
pub use align::{
|
||||
estimate_alignment, Alignment, AlignmentConfig, MAX_GRID_POINTS, MAX_LAG_STEPS,
|
||||
};
|
||||
pub use autolabel::{
|
||||
auto_label, parse_recording_jsonl, AutoLabelConfig, AutoLabelOutput, CsiFrame,
|
||||
CsiWindowSummary, LabelledWindow, Occupancy, SkipReason, MAX_RECORDING_LINE_BYTES,
|
||||
};
|
||||
pub use baseline::{majority_baseline, session_disjoint_split, BaselineReport, SessionSplit};
|
||||
pub use error::{GroundTruthError, MAX_STR_LEN};
|
||||
pub use model::{DataProvenance, Measurand, Reading, ReadingKind};
|
||||
pub use radar::{
|
||||
|
||||
@@ -221,7 +221,7 @@ impl RadarAdapterConfig {
|
||||
}
|
||||
|
||||
/// Why a window produced no label.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UnknownReason {
|
||||
/// The reader had no sensor data for the window.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! End-to-end on SYNTHETIC data: recording + radar JSONL text through
|
||||
//! labelling, a session-disjoint split and the baseline.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use ruview_groundtruth::{
|
||||
auto_label, majority_baseline, parse_radar_jsonl, parse_recording_jsonl,
|
||||
session_disjoint_split, AutoLabelConfig, LabelledWindow, Occupancy, RadarAdapterConfig,
|
||||
RadarLabels,
|
||||
};
|
||||
|
||||
/// A session: 10 Hz CSI and one 1 s radar window per entry of `counts`. The
|
||||
/// radar clock runs `skew_ms` behind the recording.
|
||||
fn session(t0_ms: i64, skew_ms: i64, counts: &[u32]) -> (String, String) {
|
||||
let mut rec = String::new();
|
||||
for i in 0..counts.len() as i64 * 10 {
|
||||
let t = (t0_ms + i * 100) as f64 / 1000.0;
|
||||
rec.push_str(&format!(
|
||||
"{{\"timestamp\":{t},\"subcarriers\":[1.0,{}],\"rssi\":-55.0,\"noise_floor\":-90.0,\"features\":{{}}}}\n",
|
||||
1 + i % 3
|
||||
));
|
||||
}
|
||||
let mut radar = String::new();
|
||||
for (i, c) in counts.iter().enumerate() {
|
||||
let start = t0_ms - skew_ms + i as i64 * 1000;
|
||||
let targets: Vec<String> = (0..*c)
|
||||
.map(|k| format!("{{\"x_m\":0.2,\"y_m\":{}}}", 1 + k))
|
||||
.collect();
|
||||
radar.push_str(&format!(
|
||||
"{{\"timestamp_ms\":{},\"window_start_ms\":{start},\"health\":\"ok\",\"source\":{{\"verified\":true}},\"target_count\":{c},\"targets\":[{}]}}\n",
|
||||
start + 1000,
|
||||
targets.join(",")
|
||||
));
|
||||
}
|
||||
(rec, radar)
|
||||
}
|
||||
|
||||
fn label(name: &str, t0_ms: i64, counts: &[u32]) -> Vec<LabelledWindow> {
|
||||
let skew = 2_500;
|
||||
let (rec, radar) = session(t0_ms, skew, counts);
|
||||
let frames = parse_recording_jsonl(&rec).unwrap();
|
||||
let windows = parse_radar_jsonl(&radar).unwrap();
|
||||
let labels =
|
||||
RadarLabels::from_windows(name, "ld2450", &windows, RadarAdapterConfig::default()).unwrap();
|
||||
let cfg = AutoLabelConfig {
|
||||
radar_offset_ms: skew,
|
||||
min_csi_frames: 5,
|
||||
empty_hold_ms: 0,
|
||||
};
|
||||
let out = auto_label(name, &frames, &labels, cfg).unwrap();
|
||||
assert_eq!(out.windows.len(), counts.len(), "{name}: {:?}", out.skipped);
|
||||
out.windows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_sessions_label_split_and_baseline() {
|
||||
let mut all = label("a", 1_000_000, &[0, 1, 1, 2, 0]);
|
||||
all.extend(label("b", 2_000_000, &[0, 0, 0, 1]));
|
||||
all.extend(label("c", 3_000_000, &[1, 1, 0]));
|
||||
|
||||
// The clock offset lands each label on its own second of CSI.
|
||||
assert!(all.iter().all(|w| w.csi.n_frames == 10));
|
||||
assert_eq!(all[3].person_count, Some(2));
|
||||
assert_eq!(all[0].occupancy, Occupancy::Empty);
|
||||
|
||||
let split = session_disjoint_split(&all, &["b"]).unwrap();
|
||||
let train: BTreeSet<_> = split.train.iter().map(|w| w.session.clone()).collect();
|
||||
let test: BTreeSet<_> = split.test.iter().map(|w| w.session.clone()).collect();
|
||||
assert!(train.is_disjoint(&test));
|
||||
|
||||
// Train a+c: occupied 5 of 8 -> majority occupied; test b is 1 of 4 occupied.
|
||||
let b = majority_baseline(&split.train, &split.test).unwrap();
|
||||
assert_eq!(b.majority_occupancy, Occupancy::Occupied);
|
||||
assert!((b.majority_accuracy - 0.25).abs() < 1e-12);
|
||||
assert!((b.mean_count.unwrap() - 6.0 / 8.0).abs() < 1e-12);
|
||||
|
||||
// Same inputs, same outputs.
|
||||
let again = majority_baseline(&split.train, &split.test).unwrap();
|
||||
assert_eq!(b, again);
|
||||
}
|
||||
Reference in new issue
Block a user