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 |
|---|---|---|
|
|
Emitter-provided class identifier. |
|
|
Optional hierarchical path associated with that identifier. |
|
|
Inclusive component start in the receiver window. |
|
|
Component duration in samples. |
|
|
Final carrier after scene placement. |
|
|
Emitter-reported bandwidth in Hz. |
|
|
Signal-to-noise ratio, capture-averaged over the full receiver band. See the frame note below. |
|
|
Emitter implementation identity. |
|
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 |
|---|---|
|
over the emitter’s own extent, full band — the ratio a detector experiences inside the box |
|
as above, with co-channel interference in the denominator |
|
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 |
|---|---|---|
|
receiver sample index, half-open |
scene placement rescaled to the receiver’s |
|
hertz offset from the receiver’s tuned centre, not absolute RF |
|
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/identitycarriesclass_id_labels, for example{"4082": "bpsk", "21211": "qpsk", "12319": "16qam", "11364": "64qam"}.labels/segmentationcarries the same key, plusclass_plane_idsgiving the plane order underMULTI_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 |
|---|---|
|
which prefixes this record actually carries, sorted |
|
one named family |
|
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 |
|
Bin 0 |
|
Bin width |
|
Time cells |
|
Cell duration |
|
Cell |
|
Occupied when |
the box covers at least 50% of that cell’s own area — measured against the clipped width for the last cell |
|
|
Rank |
|
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 |
|
1024 |
8 |
|
1024 |
4 |
|
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.