Synthetic aperture radar image formation from raw phase history: LFM pulse compression, time-domain backprojection, phase gradient autofocus, and impulse response analysis checked against the analytic resolution and sidelobe figures.
A SAR image is a coherent sum over an aperture the platform flew rather than one the antenna has. Everything interesting follows from that: range resolution comes from bandwidth and has nothing to do with pulse length, cross-range resolution comes from how much the viewing angle changed and has nothing to do with the antenna, and both are wrong the moment you don't know where the antenna was to a few millimetres.
pip install -r requirements-dev.txt
pytest # 27 tests
python bench/benchmark.py # every table below
python bench/benchmark.py --quick # a faster sanity checkNot "it produces plausible images" — plausible is easy and unfalsifiable. Every number below is checked against a value that was known before the code existed:
| quantity | analytic value |
|---|---|
| range resolution (−3 dB IRW) | 0.886 · c / 2B |
| azimuth resolution (−3 dB IRW) | 0.886 · λR / 2L |
| unweighted peak sidelobe ratio | −13.26 dB |
| Hamming PSLR / broadening | ≈ −42.8 dB / ≈ 1.47x |
The 0.886 matters. c/2B is the Rayleigh figure; the measured −3 dB width of
an unweighted sinc is 11% narrower. Checking against the wrong one turns an 11%
error into an apparent success, which is a mistake I made before the tests
caught it.
Sweeping bandwidth and aperture independently. Range should track 1/B and be
indifferent to aperture; azimuth should track 1/L and be indifferent to
bandwidth. That double dissociation is the real test — a single matching number
could be a coincidence.
| B MHz | aperture m | range IRW | theory | err | azim IRW | theory | err |
|---|---|---|---|---|---|---|---|
| 100 | 100 | 1.6609 | 1.6601 | +0.05% | 0.6631 | 0.6663 | −0.47% |
| 100 | 200 | 1.6620 | 1.6601 | +0.11% | 0.3315 | 0.3326 | −0.33% |
| 200 | 200 | 0.8302 | 0.8301 | +0.02% | 0.3315 | 0.3326 | −0.33% |
| 400 | 200 | 0.4149 | 0.4150 | −0.02% | 0.3315 | 0.3326 | −0.34% |
| 200 | 400 | 0.8299 | 0.8301 | −0.02% | 0.1658 | 0.1661 | −0.22% |
Everything lands inside 0.5%, and the two axes move only with the parameter they should. Doubling bandwidth halves the range cell with azimuth unchanged; doubling the aperture halves the azimuth cell with range unchanged.
Backprojection samples the compressed history at arbitrary non-integer delays, so it has to interpolate. Linear interpolation seemed fine — until the sidelobes came out better than physically possible:
| range oversampling | azim PSLR | range PSLR | azim IRW err |
|---|---|---|---|
| 1 (linear interp on raw samples) | −15.55 | −16.23 | +2.29% |
| 2 | −13.62 | −14.26 | −0.03% |
| 4 | −13.31 | −13.44 | −0.29% |
| 8 | −13.27 | −13.31 | −0.33% |
| 16 | −13.26 | −13.27 | −0.34% |
| theory | −13.26 | −13.26 | — |
An unweighted aperture cannot do better than −13.26 dB. Measuring −16.23 dB meant something was filtering the data, and that something was the interpolator: a triangular interpolation kernel is a sinc² taper in the frequency domain, so linear interpolation was applying an amplitude weighting by accident — broadening the main lobe ~2% while suppressing the sidelobes.
Band-limited oversampling before backprojection removes it, and the sidelobes rise to meet theory from below. A result that looks too good is a bug report; this one would have been invisible without a number to check against.
The classic trade, measured on both sides at once:
| window | range IRW m | broadening | PSLR dB | ISLR dB |
|---|---|---|---|---|
| none | 0.8302 | 1.000x | −13.31 | −10.72 |
| Hann | 1.3493 | 1.626x | −31.55 | −33.01 |
| Hamming | 1.2208 | 1.471x | −42.74 | −40.31 |
| Taylor (n̄=4, −35 dB) | 1.1092 | 1.336x | −35.07 | −30.99 |
Hamming's −42.74 dB against a textbook −42.8 dB, at 1.471x broadening against a textbook ~1.47x. Taylor does what it is designed to do: hit a specified sidelobe level (−35 dB requested, −35.07 measured) for the least broadening that buys.
Phase gradient autofocus estimates the aperture phase error from the data. It works because a bright scatterer is a known signal — its response should be a clean impulse, so any phase structure on it is error. Doing that on the brightest scatterer in every range bin and averaging is what makes it robust.
| phase error | IRW before | IRW after | vs limit | recovery | entropy before | after |
|---|---|---|---|---|---|---|
| none | 0.3312 | 0.3312 | −0.4% | 1.00x | 7.826 | 7.826 |
| quadratic 2 rad | 0.3687 | 0.3331 | +0.2% | 1.11x | 8.388 | 7.871 |
| quadratic 4 rad | 1.1587 | 0.3317 | −0.3% | 3.49x | 8.931 | 8.184 |
| quadratic 8 rad | 0.8627 | 0.3360 | +1.0% | 2.57x | 9.440 | 7.956 |
| quadratic 12 rad | 0.9376 | 0.3581 | +7.7% | 2.62x | 9.739 | 8.615 |
| random 1.5 rad rms | 0.3847 | 0.3713 | +11.7% | 1.04x | 9.875 | 9.582 |
| random 3.0 rad rms | 0.4663 | 0.4082 | +22.8% | 1.14x | 10.096 | 9.867 |
| random 5.0 rad rms | 0.3096 | 0.3461 | +4.1% | 0.89x | 10.103 | 9.917 |
On low-order defocus it recovers the diffraction limit almost exactly — a 3.5x blur removed to within 0.3% of the theoretical width.
Two honest failures in that table.
High-order random error barely improves. Recovery is 1.04x–1.14x, and at 5 rad rms the IRW worsens. PGA estimates a phase gradient and integrates it; that is well conditioned for a smooth low-order error and poorly conditioned for one with structure across the aperture. Entropy still falls in every row, so it is finding something — just not enough, and not the right thing.
The IRW metric itself stops being meaningful there. Look at the "before" column: 12 rad quadratic reads 0.9376 but 4 rad reads 1.1587, and random 5 rad reads 0.3096, which is below the diffraction limit. That is not a sharper image. Once a response breaks into multiple lobes, the width measured at the brightest pixel describes whichever fragment happened to win, not the target. Entropy is monotone across the whole sweep and IRW is not, which is the reason both are reported.
PGA run for a fixed iteration count degrades an image that is already focused. On clean data, six iterations raised entropy 7.826 → 8.55 and drove the apparent IRW below the diffraction limit. With few bright scatterers most range bins hold sidelobe ridges rather than targets, and the estimator faithfully fits those.
Two fixes, both in sar/autofocus.py: rank range bins by
peak-to-mean and use only the strongest, and return the best-cost iterate
rather than the last one. The none row above is the regression test — PGA on
focused data is now exactly a no-op.
The sign convention was also wrong initially, and wrong in the worst way: the
correction is exp(+jφ), and with exp(−jφ) every iteration made the image
measurably worse while the code looked entirely reasonable. Image entropy
moving the wrong way is what caught it.
Backprojection is O(pulses × pixels) and makes no apology for it:
| image | pixels | pulses | seconds | Mpix-pulse/s |
|---|---|---|---|---|
| 81×81 | 6,561 | 600 | 0.40 | 9.9 |
| 161×161 | 25,921 | 600 | 1.01 | 15.4 |
| 321×321 | 103,041 | 600 | 3.30 | 18.8 |
| 641×641 | 410,881 | 600 | 18.94 | 13.0 |
Linear in the product, as promised. This is the algorithm's whole trade: it assumes nothing about the geometry — not a straight track, not a flat scene, not separable range migration — and pays for that generality per pixel. The frequency-domain methods buy back orders of magnitude by assuming exactly the things backprojection refuses to.
| file | what |
|---|---|
sar/waveform.py |
LFM chirp, matched filter, windows |
sar/geometry.py |
Platform track, range histories, resolution formulas |
sar/simulate.py |
Exact phase-history generation, phase-error models |
sar/focus.py |
Backprojection, band-limited range oversampling |
sar/autofocus.py |
PGA: centre, window, LUMV estimator, integrate |
sar/metrics.py |
IRW / PSLR / ISLR, entropy, contrast |
- The data is simulated. This is the real limitation. The simulator evaluates exact two-way delays with no approximations, so range migration appears because the geometry produces it — but image formation is still being checked against physics I also wrote. It pins down resolution, sidelobes and autofocus behaviour, which have analytic answers. It says nothing about calibration, real motion measurement error, antenna patterns, or clutter statistics. Running this against Sandia or Capella collects is the obvious next step and is not done.
- No frequency-domain image formation. Range-Doppler and ω-k are what you actually field; backprojection is the reference they get checked against. Only the reference is here.
- Stripmap, broadside, straight and level. No spotlight, no squint, no motion compensation for a track that wanders.
- Point scatterers only. No distributed targets, no speckle, so nothing here exercises the statistics that dominate a real scene.
- No polarimetry, no interferometry.
MIT licensed.