Label schema

Every generated LabeledScene carries labels derived from the generated scene. This page describes the generic framework schema; an external package owns any dataset-specific vocabulary, mapping, or validation policy.

Labeling a generated record

The scene composer records one component label for each generated emitter. A component label identifies the signal class, its time/frequency extent, and the metadata needed to trace the generated component. Scene-level labels describe the complete receiver window and never replace component labels.

Field

Type

Meaning

class_name

str

Emitter-provided class identifier.

class_taxonomy

tuple[str, ...]

Optional hierarchical path associated with that identifier.

start_sample

int

Inclusive component start in the receiver window.

duration_samples

int

Component duration in samples.

realized_carrier_hz

float

Final carrier after scene placement.

bandwidth_hz

float

Emitter-reported bandwidth in Hz.

snr_db

float

Signal-to-noise ratio, capture-averaged over the full receiver band. See the frame note below.

generator_name

str

Emitter implementation identity.

extras

JSON-compatible mapping

Extension metadata with a package-owned schema.

The authoritative runtime fields are documented by SignalMetadata and LabeledScene. Do not treat this table as a separate serialized format.

snr_db is capture-averaged, and that matters for short bursts

This section describes scene.channel_application: scene, the default. The per_emitter mode is different and is covered at the end.

snr_db divides the component’s energy over the whole capture buffer by full-band receiver noise. A burst that occupies part of the capture is therefore diluted by its duty cycle:

snr_db = snr_db_occupied + 10 * log10(duration_samples / capture_samples)

A 10%-duty burst is labelled 10 dB below what a detector sees inside its box; a 1% burst, 20 dB below. Stratifying evaluation on snr_db, or filtering “hard” examples at snr_db < 0, therefore measures duty cycle as much as it measures SNR — two emitters with identical in-burst SNR land in different bins purely by burst length.

The in-burst ratios are recorded alongside, in extras:

Extra

Frame

snr_db_occupied

over the emitter’s own extent, full band — the ratio a detector experiences inside the box

sinr_db_occupied

as above, with co-channel interference in the denominator

snr_db_per_rx, sinr_db_per_rx

the per-receiver breakdown of the capture-averaged ratios

snr_db and sinr_db carry the worst receiver’s value when a scene has several; a consumer resolving one receiver should read that receiver’s entry from the _per_rx lists.

Under channel_application: per_emitter, none of the above applies. No _occupied extra is written, and the dilution relation does not hold: the noise term is calibrated from the same buffer-mean power as the numerator, so the dilution cancels and snr_db is simultaneously the capture-mean ratio and the in-burst one. sinr_db stays capture-mean in both modes, because in-burst interference power is declared nowhere and rescaling only the numerator would put SINR above SNR with no interferer present.

±inf on any of these is a measurement, not corruption: 10*log10 of a component with no noise, or with no power, is infinite, and every propagation backend except AWGN declares no SNR. A NaN is a defect and is refused at write time. See Non-finite metadata.

Time-frequency boxes

A bounding box is a label view over one generated component, derived entirely from declared metadata. No box is measured from I/Q.

Both axes are receiver-relative. This is the fact a consumer most needs and the one easiest to get wrong:

Column

Frame

Derivation

start_sample, duration_samples

receiver sample index, half-open [start, start + duration)

scene placement rescaled to the receiver’s sample_rate_hz

low_freq_hz, high_freq_hz

hertz offset from the receiver’s tuned centre, not absolute RF

realized_carrier_hz receiver_center_hz ± bandwidth_hz / 2

An emitter at 2.4005 GHz with 100 kHz of bandwidth, seen by a receiver centred at 2.4 GHz, has high_freq_hz = 2.4005e9 - 2.4e9 + 100e3 / 2 = 550000.0. Reading that as an absolute frequency puts a regression target 2.4 GHz wrong, and nothing downstream catches it: the target is self-consistent. realized_carrier_hz, in the emitter table above, is absolute; the two are not comparable.

Reading a class_id back to a class name

A class_id is a CRC-32 of the class name reduced modulo 32767, so it is stable across shards and workers without a shared counter — and opaque on its own. Do not recompute it. Both label fields publish the mapping for the classes they carry, in field metadata:

  • labels/boxes/identity carries class_id_labels, for example {"4082": "bpsk", "21211": "qpsk", "12319": "16qam", "11364": "64qam"}.

  • labels/segmentation carries the same key, plus class_plane_ids giving the plane order under MULTI_LABEL.

The mapping lists only the classes present in that record, which is what makes it exact: a dataset-wide table would have to be assembled by the consumer, and a record that named classes it does not contain would be lying.

A record may carry more than one box family

Box columns are written under a field-name prefix. ground_truth.boxes is the default and, for most corpora, the only one. It is not the only one a reader may find.

A scene whose entities are not all produced the same way emits more than one family. A satellite-positioning corpus draws its satellites from a kind: repeat and its victim and its jammer from fixed nodes, and writes the second group under a prefix of its own. Both families are real ground truth and a consumer wants their union.

Read them with the accessors, not by assuming a prefix:

Function

Answers

box_families(record)

which prefixes this record actually carries, sorted

detection_boxes(record, prefix=...)

one named family

all_detection_boxes(record)

every family, in family-name order

Both rfgen.nodes.label.projection (in-memory values) and rfgen.storage.sds.accessors (persisted SDS records) publish all three. rfgen inspect, annotation projection, and YOLO export read every family.

A prefix is recognised as a family when it carries the start_sample column; the remaining columns are then required, so a half-written family raises rather than silently not existing.

A family may be cut into any number of rows. Box columns are ragged, and how they are partitioned is a fact about how the record was assembled, not about what it means: a family emitted by a bounded repeat carries one row per entity, and a family emitted by a single label node carries one row for all of them. They describe the same boxes either way, and both readers accept both. What is still checked is everything that carries meaning — boundaries that start at zero, do not decrease, and span exactly the values present — and that every column of one family shares one partition, since they describe the same boxes.

emitter_index is scoped to its entity group

An emitter_index does not identify an entity on its own, and never did. Every entity group indexes from zero, so a record declaring three groups carries three different entities all reporting emitter_index == 0. Reading the column as a record-wide identity conflates them: it reports one entity where there are three, and computes per-entity statistics — occupancy, co-channel overlap, SNR quantiles — over the wrong partition.

The identity is the pair (emitter_group_id, emitter_index).

emitter_group_id is a code into a vocabulary the scene declares and core does not own. One checked EntityGroupFramePlan owns the ordered catalog and collision-free emitter population ranges. A framed detection_ground_truth row consumes that exact frame plus the exact checked TransmitterEntityRef and its SourceProvenanceFacts; the label cannot author a group code or reorder a private catalog. It projects the canonical frame once as emitter_group_frame, while each row carries the derived group code and global emitter index. Crossed transmitter/provenance authorities, mixed framed and unframed rows, and copied catalogs from an unmarked plugin refuse before generation.

A wholly unframed legacy label authors no frame binding. Its emitter_group_id column is not written at all, because column presence follows the declared schema and not the values in hand. A corpus written before the frame existed therefore decodes as group 0 — one record-global population, which is what that legacy schema represented.

Multiple detection events may cite the same physical emitter when their time/frequency support or other event evidence differs. Two exactly duplicate realized events refuse; entity uniqueness and event multiplicity are separate contracts.

At the SDS read boundary, the scalar frame and every flat ragged row’s embedded enum catalog must agree exactly. Annotation and inspection join boxes, SNR, and entity declarations by (emitter_group_id, emitter_index) and verify that the code resolves to the matching SDS entity-group prefix. A missing or partial frame, an unknown version/role/code, duplicate scalar catalogs, or a crossed row catalog refuses. The legacy bare-index accessor is intentionally unavailable for framed records; it cannot express the identity without ambiguity.

Boxes are suitable for a detector only when the downstream task defines how to interpret overlaps and thresholding. RFGen records generation ground truth; it does not prescribe a training target or evaluation metric.

The segmentation grid

A mask rasterizes the declared boxes onto a fixed grid. It is not an STFT of the signal, and its grid is not the grid torch.stft produces for the same n_fft and hop — pairing the two naively misaligns both axes.

Value

Frequency bins

seg_n_fft — the full two-sided grid, not seg_n_fft // 2 + 1

Bin 0

-sample_rate_hz / 2, the most negative offset, not DC

Bin width

sample_rate_hz / seg_n_fft

Time cells

ceil(N / seg_hop) — the cells tile the whole capture

Cell duration

seg_hop samples, not seg_n_fft. The last cell is clipped to the end of the capture and is shorter whenever N is not a multiple of seg_hop.

Cell k starts at

k * seg_hop

Occupied when

the box covers at least 50% of that cell’s own area — measured against the clipped width for the last cell

int16 background

-1, not 0

Rank

(frequency, time) for one receiver; a leading receiver axis is prepended when the scene has more than one. Multi-label inserts a class axis before frequency in both cases.

The time axis is published as explicit offsets rather than a uniform step, precisely because the last cell is short. Read it from the axis.

It is not a torch.stft grid

Overlaying one naively misaligns the time axis. At the defaults (seg_n_fft: 1024, seg_hop: 256) on a 2000-sample capture:

Frequency bins

Time frames

the mask

1024

8

torch.stft(iq, 1024, 256) on the stored complex IQ

1024

8

torch.stft(..., center=False)

1024

4

torch.stft on a real tensor

513

8

The frequency axes agree for complex input, which is what the store holds — the two-sided 1024-bin grid is chosen for exactly that reason. Feed a real tensor and you get 513 bins instead, and the overlay is wrong by roughly a factor of two in frequency.

Time is the axis that does not line up. center=True pads by n_fft // 2 = 512 samples, so frame k is centred where mask cell k starts; center=False drops to 4 frames against the mask’s 8. And a mask cell spans 256 samples while the STFT frame whose energy you see spans 1024, so a burst’s visible energy smears up to 768 samples — three cells — past the labelled one.

-1 background is the other trap: an int16 mask fed to CrossEntropyLoss without ignore_index=-1 trains on a class index that does not exist.

A short burst may not be rasterized

The 50%-area rule is a threshold, not a floor, so a burst can be published with a correct bounding box and no raster support at all. Measured at the defaults (seg_hop: 256), sweeping start offsets across a 2000-sample capture:

Burst length

Fraction of cell

Unrasterized

under 128 samples

under 0.5

always

128–255 samples

0.5 to 1.0

depends on alignment — roughly a fifth to a half

256 samples or more

1.0 or more

never

A burst shorter than half a cell can never reach the threshold. Between half a cell and a full cell it depends on where the burst falls relative to the cell edges: straddling two cells splits its area and can leave both below the line. At one full cell or longer, some cell is always at least half covered.

This is the rule working as specified rather than a defect, but the consequence is worth stating plainly: train on the raster alone and you will not see short bursts. Use the boxes when they matter, or lower seg_hop so a burst spans a whole cell — seg_hop is the cell width, so raising it makes short bursts less likely to be rasterized, not more.

The labels/segmentation field carries frequency_coordinate, frequency_cell_width_hz, time_cell_duration_s, final_time_cell_duration_s, and receiver_frequency_references in its metadata. Read the grid from there rather than reconstructing it from the labeler parameters.

Annotation overlays

An annotation is published as a dense set beside the snapshot, keyed to each record. It changes neither the generated I/Q nor the component provenance.

See Labels for the conceptual model and Annotations for the overlay lifecycle.

Extension boundary

An external package may define a fixed vocabulary, source-label mapping, dataset-specific provenance fields, or task-specific validation alongside its own configuration and entry point. It must preserve the generic record fields above and keep its additional schema in package-owned extras; RFGen core does not reserve names for application-specific datasets.