Marine radar small-target range-gate localization on the IPIX Dartmouth 1993 dataset. Single self-contained pipeline: builds the dataset from raw CDF files, trains an HH-polarization Vision Transformer, and evaluates it against a classical STDBA baseline with honest, leakage-controlled time-block splits.
pip install -r requirements.txt
python radarphi.py --step build # CDF → RD maps in dataset_v2/
python radarphi.py --step train # trains model, saves to results_v2/checkpoints/
python radarphi.py --step evaluate # metrics + plots → results_v2/
python radarphi.py # all three in sequence
python radarphi.py --loocv 19931110_001635 # leave-one-file-out honest test
python test_matfile.py --mat path/to/file.mat # OOD inference on external .matMac M1: pin numpy<2 and uninstall tensorflow-metal — see macOS M1 TensorFlow Fix below.
radarphi.py ← main pipeline (build / train / evaluate)
test_matfile.py ← OOD inference on external .mat files
requirements.txt
Dataset/ ← put the .cdf files here (2.cdf … 15.cdf is fine)
dataset_v2/ ← generated RD maps, labels.csv, meta.json
results_v2/ ← test_metrics.json, plots, checkpoints
tools/ ← analysis & visualisation scripts
reports/ ← PDF reports
Only two things are strictly required: radarphi.py and the IPIX .cdf files.
Labels are read from inside each file via Data_collection_date, so filenames can be anything.
| Path | Contents |
|---|---|
dataset_v2/labels.csv |
filename, split, det, primary, g0…g13 per RD map |
dataset_v2/meta.json |
global norm stats, det counts, pos_cell_rate |
results_v2/test_metrics.json |
all evaluation numbers |
results_v2/checkpoints/best.weights.h5 |
best checkpoint |
results_v2/roc_curve.png |
ROC curve |
results_v2/training_history.png |
loss/AUC curves |
- per-cell AUC (Transformer head) — target gate vs clutter gate within the same recording. This is the headline scientific metric.
- map-level AUC —
max(loc_tr)over 14 gates; trivially high (~1.0) because 11/12 usable files are target-bearing. Not the headline number. - within-1-gate localization (91.7%) — more informative than primary-gate hit (41.5%) because the model sometimes peaks at a documented secondary gate.
- naive baseline (81.8%) — always predicts the most common target gate. The model must beat this to show it learned something real.
| Script | Purpose |
|---|---|
tools/target_check.py |
RD map + per-gate amplitude bar chart for any set of files |
tools/make_comparison.py |
Side-by-side Python vs MATLAB Figure 2 comparison |
tools/matlab_match.py |
Full Python replication of mentor's MATLAB cdf.m pipeline |
tools/preview_rd_maps.py |
Jet/parula RD map previews for all 14 CDF files |
tools/generate_report.py |
Generates full PDF target analysis report |
python tools/target_check.py # default 4-file comparison
python tools/target_check.py --files 135603 001635 # specific files
python tools/make_comparison.py --file Dataset/19931108_213827_starea.cdf
python tools/matlab_match.py --file Dataset/19931110_001635_starea.cdf --n 105
python tools/preview_rd_maps.py --cmap turbo
python tools/generate_report.pyIPIX_TARGETS in radarphi.py is the ground-truth label database, keyed by YYYYMMDD_HHMMSS timestamp (read from CDF metadata, not filename). 11 of 14 stare files contain the 1 m floating-sphere target: primary cell 7 for Nov 8–18 files, cell 9 for Nov 7 files. Two files (19931108_213827, 19931118_035737) are excluded pending verification. One file (19931107_145028) and the surveillance file are genuine negatives.
This section documents the known failure modes of v1 and the design decisions made in v2 to fix them.
The headline 98.8% / AUC 0.998 in v1 was not target detection. The model was learning "which recording did this map come from". Two bugs caused this:
- Mislabeled files. Files the code marked "no target" actually contain the target in a documented range cell.
- Temporal split leakage. Windows were split randomly; at PRF 1000 Hz, windows 0.256 s apart are highly correlated.
Positives came from exactly 2 files, negatives from 12 others, all labeled at the file level. The optimizer's easiest path was to fingerprint the two positive recordings — their noise floor, DC offset, sea state — none of which is "the target."
Fix: per-gate labels. Within a target file the target cell(s) = positive; the other ~11 cells = negative. Positives and negatives now come from the same recording, so file-fingerprinting is useless.
Windows were shuffled randomly across train/val/test. Window k (train) and k+1 (test) are 0.256 s apart and highly correlated.
Fix: contiguous time-block split per file (first 70% → train, next 15% → val, last 15% → test). Never shuffle temporally adjacent windows across splits.
The augmentation rolled the RD map but not the label vector, so the model could ignore the augmented positions.
Fix: augment_pair rolls both the RD map and the 14-gate label vector by the same amount. This coupling must be preserved.
Input (512, 14, 4)
→ CNN backbone: 4× [Conv2D → BN → ReLU → MaxPool(Doppler only)]
Doppler 512→8, Range kept at 14, Channels →128
→ Permute+Reshape → 14 tokens of dim 1024
├── det_cnn: Dense(64,GELU)→Dense(1,sigmoid) per gate [no cross-gate]
└── loc_tr: Dense(128)→RangePosEmbed→4×EncoderBlock→Dense(1,sigmoid) [Transformer]
The Transformer head uses self-attention across range tokens — a cell looks like a target relative to its neighbours, which is exactly what amplitude-fluctuation statistics measure.
- Per-gate labels (not per-file).
IPIX_TARGETSmaps each file to a primary + secondary gate vector. - Contiguous time-block splits. Windows assigned by temporal position, never randomly.
- Range-shift augmentation moves the label.
augment_pairrolls both map andgvecby the same amount.
Short answer: Yes. The "only 2 targets" came from a hand-written assumption in v1's label table. Three independent lines of evidence confirm 11 files contain the target.
The only target-relevant global attribute is Field_dataset_name — it is just an experiment path index, not a target flag. Your v1 code mapped a few path names to label=0 by hand. That is the sole origin of "2 targets."
| CDF file | Field_dataset_name |
shape |
|---|---|---|
| 19931106_183151_surv | /data/Nov6/surv1 | (8000,184,4) — surveillance |
| 19931107_135603_starea | /data/Nov7/starea1 | (131072,2,14,4) |
| 19931107_141630_starea | /data/Nov7/starea2 | (131072,2,14,4) |
| 19931107_145028_starea | /data/Nov7/starea3 | (131072,2,14,4) |
| 19931108_213827_starea | /data/Nov8/starea0 | (131072,2,14,4) |
| 19931108_220902_starea | /data/Nov8/starea0 | (131072,2,14,4) |
| 19931109_191449_starea | /data/Nov9/starea1 | (131072,2,14,4) |
| 19931109_202217_starea | /data/Nov9/starea2 | (131072,2,14,4) |
| 19931110_001635_starea | /data/Nov9/starea5 | (131072,2,14,4) |
| 19931111_163625_starea | /data/Nov11/starea0 | (131072,2,14,4) |
| 19931118_023604_stareC | /data/Nov17/stareC2 | (131072,2,14,4) |
| 19931118_035737_stareC | /data/Nov17/stareC4 | (131072,2,14,4) |
| 19931118_162155_stareC | /data/Nov18/stareC0 | (131072,2,14,4) |
| 19931118_162658_stareC | /data/Nov18/stareC1 | (131072,2,14,4) |
| 19931118_174259_stareC | /data/Nov18A/stareC2 | (131072,2,14,4) |
Note: two different files share /data/Nov8/starea0 — this field was not kept unique, confirming it cannot serve as a target label.
A floating sphere bobbing on the swell makes the backscattering amplitude fluctuate more in the target cell. Per-gate coefficient of variation: std(|HH|)/mean(|HH|) over all 131,072 pulses.
| File | Lit. primary cell | STDBA argmax | within ±1? | contrast ratio |
|---|---|---|---|---|
| 135603 | 9 | 9 | ✅ | 9.0× |
| 141630 | 9 | 9 | ✅ | 4.7× |
| 220902 | 7 | 7 | ✅ | 2.1× |
| 191449 | 7 | 6 | ✅ | 1.3× |
| 202217 | 7 | 7 | ✅ | 3.3× |
| 001635 | 7 | 7 | ✅ | 3.3× |
| 163625 | 7 | 8 | ✅ | 3.3× |
| 023604 | 7 | 8 | ✅ | 1.1× |
| 162155 | 7 | 7 | ✅ | 1.6× |
| 162658 | 7 | 7 | ✅ | 4.5× |
| 174259 | 7 | 7 | ✅ | 4.3× |
| Match | 11/11 |
In all 11 files the amplitude-fluctuation peak falls within one cell of the published target cell. For pure clutter the value is flat; it is not — it spikes exactly where the literature says the target is.
Caveats: 191449 (1.3×) and 023604 (1.1×) have weak HH contrast; HV/VH polarisations are stronger for these. Labels are correct but these are the hardest examples.
| Source | Files confirmed |
|---|---|
| McMaster IPIX Dartmouth database (Bakker & Currie, 2001) | All canonical files; defines primary cell 7 / cell 9 for Nov 7 |
| Zhou et al., arXiv:2009.04185 — Table I lists TCR per file | 135603, 220902, 191449, 202217, 001635, 163625, 023604, 162155, 162658, 174259 |
| Sensors 2022 (PMC9740436) — Table 1 target cells | IPIX_01=191449→cell 7; IPIX_02=220902→cell 7; IPIX_03=141630→cell 9 |
| Shui, Li & Xu, IEEE T-AES 50(2), 2014 | Primary cell 7 (Nov 8–18), cell 9 (Nov 7) |
The decisive citation is Zhou et al., Table I: it explicitly tabulates the files v1 called "clutter" as target recordings with measured TCR values.
python3 - <<'PY'
from scipy.io import netcdf_file; import numpy as np
ds = netcdf_file("Dataset/19931110_001635_starea.cdf", 'r', mmap=False)
adc = ds.variables['adc_data'].data; ds.close()
amp = np.abs(adc[:, 0, :, 0].astype(float) + 1j * adc[:, 0, :, 1].astype(float))
cv = amp.std(0) / (amp.mean(0) + 1e-9)
print("per-gate CV:", np.round(cv, 3), " peak gate (1-idx):", cv.argmax() + 1)
PYA segfault on M1 is almost never your Python code — it is a library mismatch. Do these steps top to bottom.
python3 -c "import platform; print(platform.machine())"
# expected: arm64
# if x86_64: install Miniforge arm64TensorFlow wheels are compiled against NumPy 1.x C ABI. NumPy 2.x breaks it.
pip install "numpy<2"
python3 -c "import numpy; print(numpy.__version__)" # must start with 1.pip uninstall -y tensorflow-metalThe code already forces CPU where needed. Add metal back only after CPU works.
pip install "numpy<2" scipy pandas scikit-learn matplotlib
pip install tensorflow==2.16.2
python3 -c "import tensorflow as tf; print(tf.__version__)"python3 -X faulthandler radarphi.py --step train
python3 radarphi.py --step build # pure NumPy/SciPy — should never segfault
python3 radarphi.py --step train # this is the TF partOne-line summary for your mentor: "The segfault was an Apple-Silicon library mismatch (NumPy 2.x ABI + metal GPU plugin), not a model bug. Pinning numpy<2, running on CPU, and removing py_function from the data pipeline fixes it."