# Signal Identification

Pycom Radio Controller helps you detect and identify signals visible on the waterfall display. The system has two layers:

1. **Signal Detection** — automatically finds signals that stand out above the noise, using statistical analysis (no machine learning required)
2. **Signal Identification (AI)** — classifies detected signals by type (CW, FT8, FM, etc.), using a trained neural network model

---

## Signal Detection

Signal detection is **off by default**. When enabled in the Signal Detection configuration tab, the app continuously analyzes each spectrum frame and finds peaks that stand out from the surrounding noise floor. This runs automatically while you operate — no training or configuration beyond selecting a preset or adjusting sensitivity parameters is needed.

### What You See on the Waterfall

Colored markers appear next to detected signals:

| Marker Color | Meaning |
|---|---|
| **Red** | Strong signal (peak-to-noise ratio ≥ 6.0) |
| **Orange** | Moderate signal (peak-to-noise ratio 3.0–5.9) |
| **Light blue** | Weak or marginal signal (peak-to-noise ratio < 3.0) |

Each marker also shows a **frequency label** in MHz.

Detected signals that temporarily vanish from the spectrum are kept on the display for about 30 frames before fading out. This prevents markers from blinking on and off for intermittent or drifting signals.

### How Detection Works

The detector processes each spectrum frame in two stages:

1. **Contrast z-score detection** — finds contiguous groups of bins where the local contrast z-score exceeds the configured threshold. The detection spectrum is built from a configurable percentile of the scope history (for example, the 85th percentile), which preserves keyed signals that appear in a fraction of frames while suppressing single-frame noise spikes. Local background is estimated using both a mean filter and morphological opening (the lower of the two at each bin), and the effective standard deviation is the maximum of spatial and temporal noise estimates, preventing false positives in flat-noise regions.

2. **Wideband energy detection** — when the spectrum is nearly flat (P90/P25 ratio below 1.4, indicating many overlapping signals), this mode divides the spectrum into blocks and flags regions where energy exceeds the surrounding baseline by more than 12% with temporal stability above 55%. This catches spread-spectrum and AFSK-like signals that do not produce sharp contrast peaks.

Results from both modes are merged (duplicates with >50% overlap removed), scored, and deduplicated.

### Sensitivity Presets

You can choose a detection preset from the Signal Detection configuration tab:

| Preset | Best For | Key Values |
|---|---|---|
| **Strong Only** | Clear, dominant signals | z=1.5, score=8.0, percentile=95, avg=3 |
| **Normal** | General use | z=1.5, score=4.0, percentile=85, avg=1 |
| **Sensitive** | Marginally visible signals (FT8, JT65, CW) | z=1.0, score=2.5, percentile=75, avg=1 |

Each preset sets all detection parameters at once. Selecting a preset fills in the parameter fields, and you can still adjust individual values afterward.

### Detection Parameters

If you want finer control, adjust individual detection parameters. Parameters are listed in the order the detector processes each spectrum frame:

| Parameter | Range | Default | What It Controls |
|---|---|---|---|
| Detection Percentile | 50–99 | 85 | Percentile of scope history frames used to build the detection spectrum. Higher values (95) preserve more signal energy but admit more noise; lower values (50) track the median spectrum, which is more stable but may underrepresent keyed signals. |
| Averaging Frames | 1–30 | 1 | Number of recent scope history frames used for temporal standard deviation and stability calculations. At 1, the percentile controls temporal filtering. Higher values smooth the spectrum but slow the detector's response to changes. Note: temporal noise estimation always uses at least 10 frames regardless of this setting. |
| Min Contrast Z | 0.5–3.0 | 1.5 | How many standard deviations a bin must exceed its local background to form a raw detection group. The denominator uses the maximum of spatial and temporal noise estimates, preventing false positives in flat-noise regions. Lower values produce more groups; higher values are stricter. |
| Merge Gap (Hz) | 0–5000 | 1000 | Maximum gap between adjacent raw groups that will be merged into one. Bridges small dips within a single signal (FSK tones, modulation valleys). Capped at 10% of the effective span to prevent merging separate signals at narrow spans. |
| Min Group Width (Hz) | 50–5000 | 500 | Minimum width for a merged group to be considered a real signal. Groups narrower than this are filtered out. The minimum is 2 bins, so narrow spans do not require unrealistically wide signals. |
| Min Score | 1.5–8.0 | 4.0 | Combined detection quality threshold. Score = max_z × (1 + log1p(mean_excess)) × (0.5 + 0.5 × stability). Candidates scoring below this are filtered out at the reporting stage. Scores at or above 8.0 bypass the persistence check entirely (fast-track). |
| Min Persistence (frames) | 1–20 | 2 | How many consecutive frames a candidate must appear before being reported. Higher values reject transient noise. Fast-tracked candidates (score ≥ 8.0) bypass this requirement. |
| Min Distance (Hz) | 25–5000 | 200 | Minimum frequency gap between two reported candidates. If two candidates are closer than this, the weaker one is removed. This is a final deduplication step and does not affect how groups are formed or scored. At most 15 candidates are reported per frame. |

All detection settings are saved to your configuration and restored on startup.

---

## Signal Identification (AI)

Detected signals can be classified by the AI signal identifier, which analyzes the shape and timing patterns visible around each signal on the waterfall. It also considers contextual information to make its determination.

### How It Works

The classifier examines each detected signal in two ways:

1. **Shape and timing** — a patch of the scope history around the signal, capturing its visual pattern over time (for example, the keying pattern of CW vs. the steady tone of FM)
2. **Context** — six numerical values: frequency span, reference level, noise floor, bandwidth, center frequency offset, and signal-to-noise ratio. These help distinguish similar-looking signal types at different bandwidths or noise conditions

The classifier outputs a confidence score for each signal type it knows. In the capture window, classification results below 50% confidence are displayed as **"Unknown"**. "Unknown" is not a trained category — it indicates the model is not confident enough to make a prediction. The model always returns its best-guess label; the 50% threshold is applied only for display purposes.

The default labels include 15 trained signal types plus a fallback "Unknown" label:

| | | | | |
|---|---|---|---|---|
| CW | FM | AM | SSB | RTTY |
| FT8 | FT4 | FT2 | Q65 | JT65 |
| AFSK | PSK | FSK | MFSK | MSK |

After fine-tuning with your own captures, you may have additional types. The exact list of classes comes from the model's labels file (`signal_labels.json`).

### The Bundled Model

The model shipped with the app is a starter model trained on a small dataset. It works out of the box for common signal types, but you can improve its accuracy and add new types by retraining on your own labeled captures. See [Model Training](#model-training) for instructions.

---

## Capture Window

The **Capture** button (camera icon) on the waterfall display opens a capture window with a frozen snapshot of the current waterfall and equalizer. This is where you review, label, and save signals for training.

### What You See

- The waterfall and equalizer are frozen in place
- Detected signals appear as **orange vertical lines** on the waterfall
- The bottom 30 rows of the waterfall show a "Not used for training" overlay — these rows fall outside the scope history the model uses

### Interactions

- **Drag on the waterfall** to manually add a signal candidate at a specific frequency range. The drag selects a region, and the app estimates frequency, bandwidth, and signal strength from the spectrum data in that region. The AI classifier then attempts to identify the new candidate automatically.
- Each candidate has a **label dropdown** containing all model classes plus "Unknown". You can also remove a candidate with the **X** button beside its dropdown.
- When the capture window opens, the **AI auto-classifies** all detected candidates that do not already have an AI label.
- **Save** stores the capture as a `.npz` file in `~/Pycom/signals/captures/`

### What a Capture Contains

Each saved capture is a single `.npz` file that includes:

- The spectrum data (475 amplitude values)
- The waterfall image (RGB snapshot)
- The equalizer image (RGB snapshot)
- The scope history (raw amplitude data the AI classifier uses)
- Metadata: frequency, span, mode, detected candidates, labels, and notes

### How Labels Work

Labels are stored in capture metadata. During training, the script reads each capture's metadata and chooses the label in this priority order:

1. **`user_label`** — if you manually selected a label in the capture window, that label is always used
2. **`ai_label`** — if you did not set a label manually, but the AI classified the signal with sufficiently high confidence, that classification is used. The threshold defaults to 90% confidence when fine-tuning (`--fine-tune`) and 50% when training from scratch, to prevent low-confidence AI labels from creating self-reinforcing misclassification loops
3. **`label`** — for captures without candidate-level labels, the overall capture label is used

Candidates without any of these labels are skipped during training. The quality of your training data depends on how carefully you label your captures.

### Browsing Saved Captures

| Button | Action |
|---|---|
| **Prev / Next** | Navigate through previously saved captures |
| **Update** | Save label or note changes to an existing capture (preserves the original scope history) |
| **Delete** | Permanently remove a capture from disk |

---

## Model Training

To improve signal classification accuracy or add new signal types, train the model on your own labeled captures. The more labeled captures you have across different signal types, the better the classifier performs.

### Prerequisites

- **Python 3.10+**
- **Training dependencies**: Run `pip install torch onnx onnxscript torchvision` before training
   - `onnxscript` is required by PyTorch's ONNX exporter (since torch 2.6+)
   - `onnxruntime` is only needed by the app at runtime, not for training

### Getting the Training Scripts

1. Open **Configuration → Signal Detection** tab
2. Click **Download Training Resources**
3. Existing scripts will be overwritten — back up any custom changes first
4. Scripts are deployed to `~/Pycom/signals/scripts/`

### Training from Scratch

Train a new model with random initial weights when you have no existing trained model or when the current one is performing poorly:

**macOS/Linux:**

```bash
cd ~/Pycom/signals/scripts
python3 retrain_model.py
```

**Windows:**

```cmd
cd %USERPROFILE%\Pycom\signals\scripts
python retrain_model.py
```

Custom training options:

```bash
python3 retrain_model.py --epochs 50 --lr 0.001
```

This creates a model with random initial weights, trains on all labeled captures in `~/Pycom/signals/captures/`, and exports the result to ONNX format.

### Fine-Tuning from the Base Model

Fine-tune when you want to add your signal captures to the distributed base model. This preserves the base model's existing knowledge while adapting it for your signals.

**macOS/Linux:**

```bash
cd ~/Pycom/signals/scripts
python3 retrain_model.py --fine-tune --freeze-classifier-input
```

**Windows:**

```cmd
cd %USERPROFILE%\Pycom\signals\scripts
python retrain_model.py --fine-tune --freeze-classifier-input
```

**For larger datasets where broader adaptation is desired:**

```bash
python3 retrain_model.py --fine-tune --freeze-features
```

`--fine-tune` always loads from the protected `.default` model (the distributed base), never from the active model. This ensures consistent results regardless of any previous training.

#### Fine-Tuning Flags

These flags apply to `retrain_model.py`. The underlying `train_signal_classifier.py` accepts additional flags (run with `--help` for full list).

| Flag | What It Does |
|---|---|
| `--fine-tune` | Fine-tune from the distributed base model. Always loads the protected `.default` model and labels. |
| `--freeze-classifier-input` | Freezes nearly all of the model, training only the new-class rows of the final classification layer. Old-class rows are frozen via gradient hooks to prevent corruption. Best when you have a small number of new captures. Preserves existing signal type recognition. |
| `--freeze-features` | Freezes only the early visual analysis layers. The rest of the model remains trainable. Use only with large datasets, since more of the model will change. |
| `--checkpoint` | Saves an additional `.pt` file alongside the `.onnx` model, which can be used for future `--fine-tune` sessions. |
| `--epochs N` | Training epochs (default: 30). |
| `--lr VALUE` | Learning rate override. By default: 0.001 for from-scratch training, 0.0005 for `--fine-tune` without a freeze flag, and 0.0003 for `--fine-tune --freeze-classifier-input`. |

**Advanced flags** (only when calling `train_signal_classifier.py` directly):

| Flag | What It Does |
|---|---|
| `--resume PATH` | Path to an existing ONNX or `.pt` model to fine-tune from. |
| `--labels PATH` | Path to the labels JSON for the resume model. |
| `--ai-label-threshold VALUE` | Minimum AI confidence to use an AI label as training ground truth. Defaults to 0.9 for `--resume`, 0.5 for from-scratch. Lower values risk self-reinforcing misclassification loops. |
| `--batch-size N` | Training batch size (default: 16). |

#### Adding New Signal Types

When fine-tuning, the training script scans your captures for labels that are not in the existing model. New types are added before "Unknown" in the label list, and the model's output is expanded to accommodate them. Existing label assignments are never changed.

For example, if your existing model has the 15 default classes and you add captures labeled "OLIVIA" and "CONTEST", the merged label list becomes: `[CW, FM, ..., MSK, OLIVIA, CONTEST, Unknown]`.

#### Backups

When you use `--fine-tune`, the training script automatically backs up your existing model and labels:

- `signal_classifier.onnx` is copied to `signal_classifier.onnx.bak`
- `signal_labels.json` is copied to `signal_labels.json.bak`

If the fine-tuned model performs worse, you can revert by renaming the `.bak` files back to their original names.

#### Protected Default Model

When the app first deploys the bundled default model to `~/Pycom/signals/model/`, it also creates protected copies of both the model and its labels file:

- `signal_classifier.onnx.default`
- `signal_labels.json.default`

These copies are **never overwritten by training scripts**. Since the model and labels are an inseparable pair, both must be protected together. The `--fine-tune` flag always loads from these protected copies, so you can always recover from a bad from-scratch retrain by running `--fine-tune`.

The `.default` files are updated only when you click **Download Training Resources** in the app (which explicitly replaces the deployed model with the latest bundled version).

#### Quality Warnings

The retrain script checks your training data against the existing model's metadata and warns about:

- **Sample regression**: fine-tuning with far fewer samples than the base model was trained on
- **Low class coverage**: fewer than 33% of classes have training data (strong warning) or 50% (moderate)
- **Class imbalance**: any single class exceeds 50% of total training samples
- **Weak base model**: the current model was trained from scratch with very few samples — consider `--fine-tune` instead

These are warnings only — training proceeds regardless. Pay attention to them to avoid degrading your model.

#### Training Metadata

After training, the `signal_labels.json` file includes a `training_metadata` section that records how the model was produced:

| Field | Description |
|---|---|
| `date` | UTC timestamp of training |
| `mode` | `"scratch"` or `"fine-tune"` |
| `base_model_path` | Path to the model used as base (empty for scratch) |
| `base_model_hash` | SHA-256 prefix of the base model file |
| `total_samples` | Total labeled samples across all captures |
| `train_samples` | Samples in the training split |
| `val_samples` | Samples in the validation split |
| `best_val_accuracy` | Best validation accuracy achieved (0.0–1.0) |
| `epochs` | Number of training epochs |
| `active_classes` | Dict of class name → sample count (only classes with data) |
| `frozen_layers` | Which layers were frozen: `"features"`, `"classifier.0-2"`, or `null` |
| `learning_rate` | Learning rate used |
| `torch_version` | PyTorch version used for export |

You can inspect this metadata with the verification script:

```bash
python3 verify_model.py --model-dir ~/Pycom/signals/model
```

#### Comparing Models

Use `verify_model.py --compare` to compare training metadata between two model directories:

```bash
python3 verify_model.py --compare ~/Pycom/signals/model path/to/project/model
```

This compares sample counts, accuracy, class coverage, and `aux_dim` compatibility. It reports regression warnings and returns exit code 1 if regression is detected, 0 otherwise.

#### Fine-Tuning Best Practices

- **Use `--fine-tune` over from-scratch** whenever possible. Fine-tuning preserves the base model's knowledge while adding your signals.
- **Fewer than 50 samples total** is unlikely to produce a useful model. Capture and label more signals first.
- **Aim for at least 10 samples per class**. Classes with only 1-2 samples will not be learned reliably.
- **Don't fine-tune repeatedly on the same small dataset**. Each run overwrites the model, and quality degrades.
- **After a from-scratch retrain, verify quality** with `verify_model.py --compare` before relying on the new model for live operation.

### What Training Does

Training follows these steps:

1. Loads all labeled captures from `~/Pycom/signals/captures/`
2. For each candidate with a user label or a high-confidence AI label, extracts a scope history patch and contextual features
3. Balances the training data so that rare signal types get proportional representation (underrepresented types are sampled more frequently)
4. Splits the data into training and validation sets, ensuring each class is proportionally represented in both
5. Trains the model for the specified number of epochs (default: 30)
6. Keeps the best checkpoint based on validation accuracy
7. Exports the final model to ONNX format with a matching `signal_labels.json`

The model is automatically loaded the next time the app starts. You can also click **Reload Model** in the Signal Detection configuration tab to load it without restarting.

If the model and labels files get out of sync (for example, the labels file lists a different number of classes than the model output), the app logs a warning. When swapping models, always replace both files together.

---

## Configuration

The **Signal Detection** tab in the Configuration window provides all signal detection and identification settings.

### Detection Settings

- **Enable Signal Detection** — toggle automatic detection on or off (off by default)
- **Show Detection Overlay** — show or hide all overlays (markers and labels)
- **Overlay Candidate Markers** — show or hide the colored signal markers
- **Overlay Frequency Labels** — show or hide the MHz labels next to markers

### Detector Parameters

- **Detection Percentile** (50–99) — percentile of scope history for the detection spectrum
- **Averaging Frames** (1–30) — number of recent frames for temporal noise estimation
- **Min Contrast Z** (0.5–3.0) — local contrast z-score threshold for raw detection groups
- **Merge Gap (Hz)** (0–5000) — maximum gap between adjacent groups that will be merged
- **Min Group Width (Hz)** (50–5000) — minimum width for a merged group to pass filtering
- **Min Score** (1.5–8.0) — combined detection quality threshold
- **Min Persistence (frames)** (1–20) — how many frames a candidate must persist before reporting
- **Min Distance (Hz)** (25–5000) — minimum frequency gap between reported candidates
- **Presets** — one-click settings for common detection scenarios

### Classification Settings

- **Classification Labels** — comma-separated list of signal labels shown in the capture dropdown
- The model's trained classes and "Unknown" are always present and cannot be removed
- You can add custom labels beyond those provided by the model

### Actions

- **Download Training Resources** — deploys training scripts, model definition, retrain script, and documentation to `~/Pycom/signals/` (overwrites existing scripts — back up first)
- **Reload Model** — reloads the ONNX model from disk after retraining, without restarting the app

All settings persist to your configuration file under the `[SignalDetection]` section.

---

## File Locations

| Item | Location | Purpose |
|---|---|---|
| AI model | `~/Pycom/signals/model/signal_classifier.onnx` | The trained model file |
| Labels + metadata | `~/Pycom/signals/model/signal_labels.json` | Signal type names, training metadata, and model config |
| Protected defaults | `~/Pycom/signals/model/*.default` | Factory default model + labels (never overwritten by training) |
| Captures | `~/Pycom/signals/captures/*.npz` | Your saved signal captures |
| Training scripts | `~/Pycom/signals/scripts/` | Training, model definition, and retrain scripts |
| Documentation | `~/Pycom/signals/docs/` | Copy of this documentation |

If no user-trained model exists, the app deploys the bundled default model on first run. User-trained models are **never overwritten** by this process — the bundled default is only placed if nothing is there already.