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

bbox

structured 1-D, K rows

Time-frequency rectangles per emitter

segmentation

int16 [F, T] (single-label) or uint8 [C, F, T] (multi-label)

Per-bin class assignment

per_emitter

structured 1-D, K rows

Per-emitter metadata (class, SNR, channel, fingerprint, …)

scene

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

start_sample inclusive, stop_sample exclusive (numpy/COCO half-open)

Frequency

float64 Hz

low_freq_hzhigh_freq_hz, scene-frame absolute

Normalized time

float32 ∈ [0, 1]

YOLO cx, w normalized to scene duration

Normalized frequency

float32 ∈ [0, 1]

YOLO cy, h normalized to scene bandwidth

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_freqlow_freq_hz, start_in_samplesstart_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

n_fft

1024

drives frequency resolution

hop

256

typically n_fft / 4

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

emitter_idx

int32

Stable index, matches bbox.emitter_idx

class_name, class_id, taxonomy_path

str / int / list

Canonical label

source_label_raw

str

Verbatim source-dataset label (lossless)

source_dataset

str

"radioml_2018_01a", "sig53", "droneRF", "synthetic_native", …

source

enum

"synthetic" | "captured" | "hil"

sample_rate_hz, emitter_offset_hz, bandwidth_hz

float64

Position

start_sample, duration_samples

int64

Time placement

low_freq_hz, high_freq_hz

float64

Frequency placement

snr_db, power_dbm, noise_floor_dbm

float32

Power

device_fingerprint_id

str / null

sha256 of impairment params

channel_profile

str

"tdl-c-300", "rt:scene_munich/tx0/rx12", …

channel_params

compound

See below

aoa_deg, path_loss_db, delay_spread_rms_s, k_factor_db, doppler_hz

float32 / null

Channel realization

rx_snr_db

list[float32] / null

Per-RX SNR for multi-RX scenes

generator_name, generator_version

str

Plugin identity

extras

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

sample_id

str

hash(global_seed, shard_id, sample_idx)

bandwidth_hz, center_freq_hz, sample_rate_hz

float64

Scene RF parameters

duration_samples, duration_s

int64 / float64

Length

num_emitters, signal_density

int32 / float32

Density (emitters per MHz)

noise_floor_dbm

float32

AWGN floor

realized_snr_distribution

dict

{min, max, mean, p10, p50, p90}

realized_class_counts

dict[str, int]

Histogram

multi_rx, num_rx, rx_geometry

bool / int32 / dict

Multi-RX

scene_preset_name, config_hash, scene_seed

str / str / int64

Reproducibility

framework_version, torchsig_version, sionna_version

str

Versions

runtime_executor_pool, runtime_seconds

str / float32

Cost attribution

schema_version

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_count for 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

n_fft

1024

Frequency resolution sample_rate / n_fft

hop

256

n_fft / 4

Window length

n_fft

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

dominant_snr (default)

Higher SNR wins

lower_idx

Lower emitter_idx wins (TorchSig-compatible)

multi_label

Switch mode globally to [C, F, T]

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

taxonomy_path

BPSK

["comms", "psk", "bpsk"]

QPSK

["comms", "psk", "qpsk"]

8PSK

["comms", "psk", "8psk"]

16PSK

["comms", "psk", "16psk"]

32PSK

["comms", "psk", "32psk"]

OQPSK

["comms", "psk", "oqpsk"]

4ASK

["comms", "ask", "4ask"]

8ASK

["comms", "ask", "8ask"]

OOK

["comms", "ask", "ook"]

16QAM

["comms", "qam", "16qam"]

32QAM

["comms", "qam", "32qam"]

64QAM

["comms", "qam", "64qam"]

128QAM

["comms", "qam", "128qam"]

256QAM

["comms", "qam", "256qam"]

16APSK

["comms", "apsk", "16apsk"]

32APSK

["comms", "apsk", "32apsk"]

64APSK

["comms", "apsk", "64apsk"]

128APSK

["comms", "apsk", "128apsk"]

GFSK

["comms", "fsk", "gfsk"]

OFDM

["comms", "ofdm"]

AM-DSB-WC

["comms", "am", "dsb-wc"]

AM-DSB-SC

["comms", "am", "dsb-sc"]

AM-SSB-WC

["comms", "am", "ssb-wc"]

AM-SSB-SC

["comms", "am", "ssb-sc"]

FM

["comms", "fm"]

(24 modulations total, all from RadioML 2018.01a.)

RadioML 2016.10a to taxonomy

Source raw label

taxonomy_path

BPSK

["comms", "psk", "bpsk"]

QPSK

["comms", "psk", "qpsk"]

8PSK

["comms", "psk", "8psk"]

PAM4

["comms", "pam", "pam4"]

QAM16

["comms", "qam", "16qam"]

QAM64

["comms", "qam", "64qam"]

GFSK

["comms", "fsk", "gfsk"]

CPFSK

["comms", "fsk", "cpfsk"]

WBFM

["comms", "fm", "wb"]

AM-DSB

["comms", "am", "dsb-fc"]

AM-SSB

["comms", "am", "ssb"]

Sig53 to taxonomy

Sig53’s 53 classes (a superset of RadioML 18). Notable additions:

Source raw label

taxonomy_path

ofdm-256

["comms", "ofdm", "fft-256"]

ofdm-512

["comms", "ofdm", "fft-512"]

ofdm-1024

["comms", "ofdm", "fft-1024"]

ofdm-2048

["comms", "ofdm", "fft-2048"]

chirpss

["comms", "chirp", "css"]

lfm-up

["comms", "chirp", "lfm-up"]

lfm-down

["comms", "chirp", "lfm-down"]

gmsk

["comms", "fsk", "gmsk"]

4fsk

["comms", "fsk", "4fsk"]

8fsk

["comms", "fsk", "8fsk"]

16fsk

["comms", "fsk", "16fsk"]

(full 53-entry table at rfgen/labels/taxonomy.py)

HisarMod 2019.1 to taxonomy

Source raw label

taxonomy_path

V_OFDM

["comms", "ofdm", "v-ofdm"]

H_OFDM

["comms", "ofdm", "h-ofdm"]

BPSK_DOUBLE_SIDEBAND

["comms", "psk", "bpsk", "dsb"]

(… 26 entries)

DroneRF to taxonomy

Source raw label

taxonomy_path

DJI_Phantom4_Pro_RC

["drone", "ocusync", "phantom4-pro", "rc"]

DJI_Phantom4_Pro_video

["drone", "ocusync", "phantom4-pro", "video"]

DJI_Mavic_Pro_RC

["drone", "ocusync", "mavic-pro", "rc"]

DJI_Inspire1_RC

["drone", "lightbridge", "inspire1", "rc"]

BG_3CH_RC

["drone", "fpv", "analog", "3ch"]

(… full DroneRF model list)

RFUAV to taxonomy

Source raw label

taxonomy_path

DJI_Mavic3_OcuSync3_video

["drone", "ocusync", "v3", "mavic3", "video"]

DJI_Mavic3_OcuSync3_rc

["drone", "ocusync", "v3", "mavic3", "rc"]

DJI_Air2S_OcuSync_video

["drone", "ocusync", "air2s", "video"]

Autel_EVO_II_video

["drone", "autel", "evo2", "video"]

(… 37 UAVs total)

CardRF to taxonomy

Source raw label

taxonomy_path

DJI_Inspire2_OcuSync_RC

["drone", "ocusync", "inspire2", "rc"]

DJI_Phantom4_Pro_OcuSync_video

["drone", "ocusync", "phantom4-pro", "video"]

Bluetooth_inband

["iot", "ble", "gfsk", "1m"]

WiFi_inband_2_4ghz

["comms", "wifi", "11g"]

(… full CardRF list)

WiSig to taxonomy

Source raw label

taxonomy_path

device_001_router_A

["comms", "wifi", "11g", "device-001"]

device_002_router_A

["comms", "wifi", "11g", "device-002"]

(… 174 devices total)

SMoRFFI to taxonomy

Source raw label

taxonomy_path

dev_042_apa1_ses3

["comms", "wifi", "11g", "device-042"]

dev_087_apa2_ses1

["comms", "wifi", "11g", "device-087"]

(… 123 devices total)

Daytona ADS-B to taxonomy

Source raw label

taxonomy_path

DF11_all_call_reply

["aviation", "adsb", "df11", "all_call_reply"]

DF17_airborne_position

["aviation", "adsb", "df17", "airborne_position"]

DF17_airborne_velocity

["aviation", "adsb", "df17", "airborne_velocity"]

DF17_identification

["aviation", "adsb", "df17", "identification"]

DF17_surface_position

["aviation", "adsb", "df17", "surface_position"]

DF18_TIS_B

["aviation", "adsb", "df18", "tis_b"]

NIST CBRS to taxonomy

Source raw label

taxonomy_path

radar_type_1

["radar", "cbrs", "type-1"]

radar_type_2

["radar", "cbrs", "type-2"]

radar_type_3

["radar", "cbrs", "type-3"]

lte_uplink

["cellular", "lte", "fdd", "ul"]

lte_downlink

["cellular", "lte", "fdd", "dl"]

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.

  1. IQ ↔ bbox. Bboxes lie within scene bounds; YOLO normalized within [0, 1]; YOLO derived from absolute to within 1 ULP.

  2. Bbox ↔ segmentation. Cells marked with class_id lie within the bbox rectangle expanded by ±1 STFT hop / freq bin (tolerance for the binary-at-0.5 rule).

  3. Bbox ↔ per_emitter. class_name, class_id, taxonomy_path, time/freq bounds match exactly.

  4. per_emitter ↔ scene. len(per_emitter) == scene.num_emitters; histograms and SNR distributions reproduce.

Schema versioning

schema_version follows SemVer:

  • Patch (1.0.01.0.1): bug fix in label computation, no schema change.

  • Minor (1.0.01.1.0): additive only (new optional field, new taxonomy node). Old loaders keep working.

  • Major (1.x2.0.0): breaking (field rename, dtype change, taxonomy path rename). Ships with a migration script under scripts/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.