Label Schema¶
Warning
Pre-implementation. This page describes proposed contracts. Class signatures, parameter types, config field names, and behavior are subject to change before code lands. Once implementation exists, content here will be regenerated from docstrings or sourced from running tests.
The on-disk format for joint labels (bbox + segmentation + per-emitter metadata + scene-level metadata). This page is normative; implementations must produce labels matching this schema or bump the schema version.
At a glance¶
Every sample carries four label modalities:
Modality |
Shape / type |
Purpose |
|---|---|---|
|
structured 1-D, K rows |
Time-frequency rectangles per emitter |
|
|
Per-bin class assignment |
|
structured 1-D, K rows |
Per-emitter metadata (class, SNR, channel, fingerprint, …) |
|
JSON / attrs |
Scene-level metadata (BW, duration, density, …) |
All four are computed from the same Signal object; the labeler is a pure function with no I/O and no randomness. Two calls with the same input produce byte-identical output.
Coordinate conventions¶
Axis |
Type |
Convention |
|---|---|---|
Time |
int64 samples |
|
Frequency |
float64 Hz |
|
Normalized time |
float32 ∈ [0, 1] |
YOLO |
Normalized frequency |
float32 ∈ [0, 1] |
YOLO |
Both absolute and normalized coordinates are stored. Normalized is derived from absolute at write time; never inverted.
Bounding box record¶
class BBox(TypedDict):
emitter_idx: int # FK into per_emitter[]
class_name: str # canonical taxonomy label, e.g. "comms.wifi.11g.ofdm-20mhz"
class_id: int # dataset-local integer alias
taxonomy_path: list[str] # ["comms", "wifi", "11g", "ofdm-20mhz"]
abs: BBoxAbsolute # {start_sample, stop_sample, low_freq_hz, high_freq_hz}
yolo: BBoxYolo # {cx, cy, w, h} normalized to scene
confidence: float # 1.0 for synthetic, <1 for HIL re-labels
HDF5 / Zarr compound dtype¶
bbox_dtype = np.dtype([
("emitter_idx", "<i4"),
("class_id", "<i4"),
("class_name", h5py.string_dtype()),
("taxonomy_path", h5py.string_dtype()), # JSON-serialized list
("start_sample", "<i8"),
("stop_sample", "<i8"),
("low_freq_hz", "<f8"),
("high_freq_hz", "<f8"),
("cx", "<f4"),
("cy", "<f4"),
("w", "<f4"),
("h", "<f4"),
("confidence", "<f4"),
])
Adopts TorchSig v2.x field names verbatim (lower_freq ≡ low_freq_hz, start_in_samples ≡ start_sample) so TorchSig-trained detectors load these unchanged.
Segmentation mask¶
Rasterized onto an STFT grid with configurable params:
Parameter |
Default |
Notes |
|---|---|---|
Window |
Hann |
matches TorchSig / ZoomSpec |
|
1024 |
drives frequency resolution |
|
256 |
typically |
Center |
True |
scipy/torch convention |
Shape: [F, T] where F = n_fft (full bilateral, signed frequencies), T = ceil((duration - n_fft) / hop) + 1.
Modes:
single_label(default,int16): one class per cell, dominant emitter wins overlap.-1= noise floor. ~50–200 KB per sample after blosc-zstd.multi_label(opt-in,uint8):[C, F, T]binary planes per class. Captures overlap. ~1–3 MB per sample after compression.
Anti-alias rule: a cell is assigned to an emitter iff ≥ 50 % of the cell’s TF rectangle falls inside the bbox. Ties break toward lower emitter_idx. No soft labels; the mask is discrete by construction.
Per-emitter metadata¶
Canonical fields (see full dtype block below):
Field |
Type |
Meaning |
|---|---|---|
|
int32 |
Stable index, matches |
|
str / int / list |
Canonical label |
|
str |
Verbatim source-dataset label (lossless) |
|
str |
|
|
enum |
|
|
float64 |
Position |
|
int64 |
Time placement |
|
float64 |
Frequency placement |
|
float32 |
Power |
|
str / null |
sha256 of impairment params |
|
str |
|
|
compound |
See below |
|
float32 / null |
Channel realization |
|
list[float32] / null |
Per-RX SNR for multi-RX scenes |
|
str |
Plugin identity |
|
JSON |
Free-form, plugin-specific |
channel_params compound¶
channel_params_dtype = np.dtype([
("backend", h5py.string_dtype()), # "torchsig" | "sionna_tdl" | "sionna_rt" | ...
("profile", h5py.string_dtype()), # "C" | "A" | "B" | "" for non-3GPP
("delay_spread_ns", "<f4"),
("k_factor_db", "<f4"),
("max_doppler_hz", "<f4"),
("num_taps", "<i4"),
("scene_geometry_hash", h5py.string_dtype()),
("realization_seed", "<i8"),
])
Full per-tap delays/powers live in extras["channel_taps"] to keep the compound a fixed small size.
Scene-level metadata¶
Field |
Type |
Meaning |
|---|---|---|
|
str |
|
|
float64 |
Scene RF parameters |
|
int64 / float64 |
Length |
|
int32 / float32 |
Density (emitters per MHz) |
|
float32 |
AWGN floor |
|
dict |
|
|
dict[str, int] |
Histogram |
|
bool / int32 / dict |
Multi-RX |
|
str / str / int64 |
Reproducibility |
|
str |
Versions |
|
str / float32 |
Cost attribution |
|
str |
semver |
Hierarchical taxonomy¶
Eight top-level categories: comms, radar, iot, drone, aviation, cellular, satellite, unknown.
Variable depth; leaves go as deep as needed:
comms.psk.bpsk
comms.qam.16qam
comms.ofdm.fft-256.cp-1-4
comms.wifi.11g.ofdm-20mhz.r-54mbps
iot.lora.css.sf7.bw125.cr-4-5
iot.ble.gfsk.1m.adv
radar.pulsed.barker.b13.pri-1ms
radar.fmcw.up.bw-10mhz.dur-100us
drone.ocusync.shell.bw-20mhz
aviation.adsb.df17.airborne_position
cellular.lte.fdd.bw-10mhz
cellular.nr.dl.bw-100mhz.scs-30khz
Source-dataset → taxonomy mapping table lives in rfgen/labels/taxonomy.py and is versioned with the schema. Adding a new source dataset is a minor schema bump (additive); renaming a path is a major bump.
The full mapping table is in the Source-dataset → taxonomy mapping section below.
Bbox derivation algorithm¶
The bbox is derived as a pure function of Signal and the per-emitter component metadata. Spectral-occupancy rule: smallest contiguous frequency band containing -20 dB power around the peak, and smallest contiguous time window over which the emitter’s energy is non-trivial.
def derive_bbox(component_signal: Signal, scene: SceneMeta,
emitter_idx: int, emitter_meta: SignalMetadata) -> BBox:
"""Pure function. Same Signal + same scene → same bbox to within 1 ULP."""
# Time bounds: from the composer's planned start/duration.
start_sample = emitter_meta.t_start * scene.sample_rate_hz
stop_sample = emitter_meta.t_stop * scene.sample_rate_hz
# Frequency bounds: spectral occupancy on the emitter's IQ before mixing.
iq_baseband = component_signal.iq_at_baseband # before frequency shift
bw_occupied_hz = _spectral_occupancy(
iq_baseband, scene.sample_rate_hz, threshold_db=-20.0,
)
# Center frequency = where the composer placed it (per-emitter offset)
center_freq_hz = scene.center_freq_hz + emitter_meta.emitter_offset_hz
low_freq_hz = center_freq_hz - bw_occupied_hz / 2
high_freq_hz = center_freq_hz + bw_occupied_hz / 2
# Derive YOLO from absolute (always one direction; never inverse)
scene_low = scene.center_freq_hz - scene.bandwidth_hz / 2
cx = (start_sample + stop_sample) / 2 / scene.duration_samples
cy = ((low_freq_hz + high_freq_hz) / 2 - scene_low) / scene.bandwidth_hz
w = (stop_sample - start_sample) / scene.duration_samples
h = (high_freq_hz - low_freq_hz) / scene.bandwidth_hz
return BBox(
emitter_idx=emitter_idx,
class_name=emitter_meta.class_name,
class_id=_class_id(emitter_meta.class_name),
taxonomy_path=emitter_meta.class_taxonomy,
abs=BBoxAbsolute(
start_sample=int(start_sample), stop_sample=int(stop_sample),
low_freq_hz=float(low_freq_hz), high_freq_hz=float(high_freq_hz),
),
yolo=BBoxYolo(cx=float(cx), cy=float(cy), w=float(w), h=float(h)),
confidence=1.0,
)
def _spectral_occupancy(iq: torch.Tensor, sample_rate: float,
threshold_db: float = -20.0) -> float:
"""Smallest contiguous frequency band containing power above (peak + threshold_db).
Uses a Welch PSD estimate to reduce variance.
"""
spec, freqs = _welch_psd(iq, sample_rate, n_perseg=1024, noverlap=512)
spec_db = 10 * torch.log10(spec / spec.max() + 1e-12)
above = spec_db > threshold_db
bins = torch.nonzero(above).flatten()
if len(bins) == 0:
return 0.0
f_low_idx, f_high_idx = bins[0].item(), bins[-1].item()
return float(freqs[f_high_idx] - freqs[f_low_idx])
Threshold choice. −20 dB matches TorchSig’s update_signal_snr_bandwidth convention and the wideband-detection literature (WRIST 2021, ZoomSpec 2026). Configurable via label.bbox.occupancy_threshold_db.
Edge cases:
Zero-energy slot (rare; could happen if an emitter is gated off):
bw_occupied_hz = 0, bbox skipped,bbox_countfor the scene decremented.DC-leakage emitters (e.g., AM at zero baseband): the spectral-occupancy estimator handles DC peaks correctly; no special case.
Frequency-hopping emitters (FH radar, OcuSync): the bbox covers the union of hop frequencies, the smallest contiguous band containing all hops above threshold. This is conservative; per-hop sub-bboxes are recorded in
extras["sub_bboxes"].
Segmentation rasterization algorithm¶
Per-bin class assignment on an STFT grid.
STFT parameters (defaults)¶
Parameter |
Default |
Notes |
|---|---|---|
Window |
Hann |
Matches TorchSig / ZoomSpec |
|
1024 |
Frequency resolution |
|
256 |
|
Window length |
|
Locked |
Center |
True |
scipy/torch convention |
Padding |
reflect |
Edge handling |
Normalization |
none |
Mask is class IDs, not power |
Output grid shape¶
n_freq_bins = n_fft: full bilateral; we keep negative frequencies because the scene is centered at zero baseband and emitters can sit anywhere in[−fs/2, +fs/2].n_time_bins = ceil((duration_samples − n_fft) / hop) + 1: standard centered-STFT convention.
Frequency bin k maps to absolute frequency scene.center_freq_hz + (k − n_fft/2) * fs / n_fft. Time bin t maps to absolute sample t * hop + n_fft/2.
Algorithm¶
def rasterize_segmentation(
bboxes: list[BBox],
scene: SceneMeta,
n_fft: int = 1024,
hop: int = 256,
mode: Literal["single_label", "multi_label"] = "single_label",
) -> torch.Tensor:
"""Build the segmentation tensor by computing per-cell occupancy.
Single-label: int16 [F, T], -1 = noise floor, integer class_id otherwise.
Multi-label: uint8 [C, F, T], binary planes per class.
"""
F = n_fft
T = (scene.duration_samples - n_fft) // hop + 1
if mode == "single_label":
mask = torch.full((F, T), -1, dtype=torch.int16)
else:
# Pre-allocate; trim to active classes after
n_classes = _num_classes_in_dataset()
mask = torch.zeros((n_classes, F, T), dtype=torch.uint8)
# Per-cell time-frequency rectangle
df = scene.sample_rate_hz / n_fft # freq bin width Hz
dt = hop / scene.sample_rate_hz # time bin width s
f0 = scene.center_freq_hz - scene.sample_rate_hz / 2
for bbox in bboxes:
# Convert bbox to (t_idx_lo, t_idx_hi, f_idx_lo, f_idx_hi) ranges
t_lo = max(0, (bbox.abs.start_sample - n_fft // 2) // hop)
t_hi = min(T, (bbox.abs.stop_sample - n_fft // 2) // hop + 1)
f_lo_abs = bbox.abs.low_freq_hz
f_hi_abs = bbox.abs.high_freq_hz
f_lo_idx = max(0, int((f_lo_abs - f0) / df))
f_hi_idx = min(F, int((f_hi_abs - f0) / df) + 1)
# 50%-occupancy rule per cell
for f_idx in range(f_lo_idx, f_hi_idx):
cell_f_lo = f0 + f_idx * df
cell_f_hi = cell_f_lo + df
# Fraction of cell inside bbox in frequency
f_overlap = (min(cell_f_hi, f_hi_abs) - max(cell_f_lo, f_lo_abs)) / df
if f_overlap < 0.5:
continue
for t_idx in range(t_lo, t_hi):
cell_t_lo = (t_idx * hop + n_fft // 2) / scene.sample_rate_hz
cell_t_hi = cell_t_lo + dt
t_overlap = (min(cell_t_hi, bbox.abs.stop_sample / scene.sample_rate_hz)
- max(cell_t_lo, bbox.abs.start_sample / scene.sample_rate_hz)) / dt
if t_overlap < 0.5:
continue
if mode == "single_label":
# Dominant emitter wins overlap (compare per-cell SNR via emitter_idx ordering)
if mask[f_idx, t_idx] == -1:
mask[f_idx, t_idx] = bbox.class_id
elif _per_cell_snr(bbox, f_idx, t_idx) > _per_cell_snr_existing(...):
mask[f_idx, t_idx] = bbox.class_id
else:
mask[bbox.class_id, f_idx, t_idx] = 1
return mask
Anti-alias rule¶
A cell is assigned to an emitter iff at least 50 % of the cell’s TF rectangle falls inside the emitter’s bbox. Same convention as COCO instance masks and ZoomSpec STFT. Ties (exact 50 / 50) break toward the lower emitter_idx for determinism.
For multi-label masks: per-class plane.
We do NOT anti-alias to a continuous occupancy fraction. The mask is discrete by construction. Models that want soft labels can compute them from the bbox at training time.
Single-label tie-breaking on overlap¶
When two bboxes overlap a cell, the higher per-cell SNR wins. Per-cell SNR is computed as emitter.snr_db - 10*log10(overlap_count) (penalizes shared cells). If SNRs are equal, the lower emitter_idx wins.
The overlap-tie strategy is configurable via label.segmentation.overlap_strategy:
Strategy |
Behavior |
|---|---|
|
Higher SNR wins |
|
Lower |
|
Switch mode globally to |
Edge cells (boundary handling)¶
Cells that straddle scene boundaries (first / last n_fft/2 samples) are zero-padded by the STFT (reflect-padded by default). The mask treats these cells like any other.
For frequency edges (DC and Nyquist bins): the bilateral grid [−fs/2, +fs/2] includes both. Wraparound is not modeled; emitters near +fs/2 do not “wrap” to −fs/2. The scene composer’s bandwidth check ensures emitters fit within ±bandwidth_hz/2.
Source-dataset to taxonomy mapping¶
The full SOURCE_LABEL_MAP table from rfgen/labels/taxonomy.py. Every raw label in every source dataset maps to a canonical taxonomy_path. Versioned with the label schema; new datasets are minor bumps.
RadioML 2018.01a to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(24 modulations total, all from RadioML 2018.01a.)
RadioML 2016.10a to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Sig53 to taxonomy¶
Sig53’s 53 classes (a superset of RadioML 18). Notable additions:
Source raw label |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(full 53-entry table at |
HisarMod 2019.1 to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
|
|
(… 26 entries) |
DroneRF to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
(… full DroneRF model list) |
RFUAV to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
|
|
|
|
(… 37 UAVs total) |
CardRF to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
|
|
|
|
(… full CardRF list) |
WiSig to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
(… 174 devices total) |
SMoRFFI to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
(… 123 devices total) |
Daytona ADS-B to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
NIST CBRS to taxonomy¶
Source raw label |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
Synthetic native¶
For synthetic samples produced by rfgen directly (most of the dataset), source_dataset = "synthetic_native" and source_label_raw = class_name; the canonical taxonomy leaf is the source label.
Cross-modality consistency¶
The labeler validates four invariants per sample, before returning. Failures raise LabelInconsistencyError; the orchestrator excludes the offending sample but continues the run. > 0.1 % failure rate rejects the entire run.
IQ ↔ bbox. Bboxes lie within scene bounds; YOLO normalized within
[0, 1]; YOLO derived from absolute to within 1 ULP.Bbox ↔ segmentation. Cells marked with
class_idlie within the bbox rectangle expanded by ±1 STFT hop / freq bin (tolerance for the binary-at-0.5 rule).Bbox ↔ per_emitter.
class_name,class_id,taxonomy_path, time/freq bounds match exactly.per_emitter ↔ scene.
len(per_emitter) == scene.num_emitters; histograms and SNR distributions reproduce.
Schema versioning¶
schema_version follows SemVer:
Patch (
1.0.0→1.0.1): bug fix in label computation, no schema change.Minor (
1.0.0→1.1.0): additive only (new optional field, new taxonomy node). Old loaders keep working.Major (
1.x→2.0.0): breaking (field rename, dtype change, taxonomy path rename). Ships with a migration script underscripts/migrations/v{from}_to_v{to}.py.
Loaders check manifest.json["label_schema_version"] against their supported range; mismatches raise with a clear pointer to the migration script. Initial public release: 1.0.0, frozen at framework 0.5.0.
Open questions¶
Unresolved questions for this surface are tracked in Background / Open Questions.
See Reference / TorchSig Interop for round-trip converter specs.